Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions internal/reporting/empty_state_goldens_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package reporting

import (
"bytes"
"flag"
"os"
"path/filepath"
"strings"
"testing"
)

// updateEmptyStateGoldens regenerates the golden files instead of
// asserting against them. Run with `-update-empty-state-goldens` after
// intentional empty-state copy changes; commit the resulting goldens
// in the same PR as the message change so reviewers see both.
var updateEmptyStateGoldens = flag.Bool("update-empty-state-goldens", false,
"regenerate empty-state golden files instead of asserting against them")

// TestEmptyState_Goldens is the Track 10.8 visual regression test for
// every shipped empty state. The contract: byte-identical output
// between `RenderEmptyState(EmptyXxx)` and the committed golden under
// internal/reporting/testdata/empty_state_goldens/<name>.txt.
//
// Empty-state copy is a high-leverage UX surface — first-run, clean
// repos, edge cases. Drift here means adopters experience subtle
// regressions in the messages that introduce them to the product.
// Locking the goldens in CI surfaces the drift immediately.
//
// To intentionally change a message:
// 1. Edit the string in EmptyStateFor (empty_states.go).
// 2. Run: go test ./internal/reporting/... -update-empty-state-goldens
// 3. Inspect the diff in the golden file.
// 4. Commit both the source change and the golden update together.
func TestEmptyState_Goldens(t *testing.T) {
cases := []struct {
name string
kind EmptyStateKind
}{
{"zero_findings", EmptyZeroFindings},
{"no_ai_surfaces", EmptyNoAISurfaces},
{"no_policy_file", EmptyNoPolicyFile},
{"first_run", EmptyFirstRun},
{"no_impact", EmptyNoImpact},
{"no_test_selection", EmptyNoTestSelection},
{"no_migration_candidates", EmptyNoMigrationCandidates},
}

for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
RenderEmptyState(&buf, tc.kind)
got := buf.String()

path := filepath.Join("testdata", "empty_state_goldens", tc.name+".txt")

if *updateEmptyStateGoldens {
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
t.Fatalf("write golden %s: %v", path, err)
}
return
}

want, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read golden %s: %v (run with -update-empty-state-goldens to create it)",
path, err)
}

if got != string(want) {
t.Errorf("empty-state %s drift:\n--- want (%s) ---\n%s\n--- got ---\n%s",
tc.name, path, string(want), got)
}
})
}
}

// TestEmptyState_GoldensCoverEveryKind is the drift gate: the goldens
// directory must contain one .txt per shipped EmptyStateKind. Adding
// a new kind without a golden surfaces the gap in CI.
func TestEmptyState_GoldensCoverEveryKind(t *testing.T) {
t.Parallel()
allKinds := []EmptyStateKind{
EmptyZeroFindings,
EmptyNoAISurfaces,
EmptyNoPolicyFile,
EmptyFirstRun,
EmptyNoImpact,
EmptyNoTestSelection,
EmptyNoMigrationCandidates,
}

entries, err := os.ReadDir(filepath.Join("testdata", "empty_state_goldens"))
if err != nil {
t.Fatalf("read goldens dir: %v", err)
}
files := map[string]bool{}
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".txt") {
files[strings.TrimSuffix(e.Name(), ".txt")] = true
}
}

if len(allKinds) != len(files) {
t.Errorf("kinds=%d goldens=%d — every shipped kind needs a golden, every golden needs a corresponding kind. Files found: %v",
len(allKinds), len(files), keys(files))
}
}

func keys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
166 changes: 166 additions & 0 deletions internal/reporting/empty_states.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package reporting

import (
"fmt"
"io"
"strings"
)

// EmptyStateKind identifies which empty-state path is being rendered.
// Track 10.6 of the 0.2.0 release plan calls for every list-producing
// command to have a *designed* empty-state path — a clear next-move
// nudge instead of silence — so first-run / clean-repo experiences
// don't read as broken output.
//
// One enum value per distinct empty case keeps the wiring tight: the
// renderer asks "which kind?" and the helper produces a stable,
// designed string. Adding a new kind requires updating one switch in
// RenderEmptyState below; tests in empty_states_test.go lock the
// strings.
type EmptyStateKind int

const (
// EmptyZeroFindings — analyze / insights / posture ran cleanly
// and produced zero findings. Most repos will never see this
// state, but those that do should feel rewarded, not confused.
EmptyZeroFindings EmptyStateKind = iota

// EmptyNoAISurfaces — the AI surface inventory pass found no
// detectable AI surfaces (no models, no prompts, no eval
// frameworks). The AI Risk Review section should be skipped
// entirely with a single explanatory line so adopters know
// it's deliberate, not a bug.
EmptyNoAISurfaces

// EmptyNoPolicyFile — `terrain policy check` ran but no
// `.terrain/policy.yaml` is present. The right next move is
// pointing at `terrain init`, not silently exiting 0.
EmptyNoPolicyFile

// EmptyFirstRun — the binary appears to be running on a
// repo that has never been analyzed before (no
// .terrain/snapshots/, no terrain.yaml). A single warm
// greeting that suggests the next command beats no output.
EmptyFirstRun

// EmptyNoImpact — `terrain report impact` ran but the change
// scope produced zero impacted units (tiny doc change, etc.).
// The right next move is "merge with confidence", not blank
// output that reads as "Terrain failed."
EmptyNoImpact

// EmptyNoTestSelection — `terrain report select-tests` ran
// but no tests were selected. Often the right answer (the
// change has no test impact) but adopters need to see that
// it's deliberate.
EmptyNoTestSelection

// EmptyNoMigrationCandidates — `terrain migrate readiness`
// found no convertible files. Right when the repo is already
// on the framework of record; otherwise a possible
// detection bug.
EmptyNoMigrationCandidates
)

// EmptyState is the rendered shape of an empty-state path: a one-line
// header (designed, not blank) plus an optional next-move nudge.
//
// We keep the data here rather than emitting strings inline so that
// callers can render to terminal-text, JSON envelopes, or markdown
// without each callsite reinventing the message. JSON consumers
// receive {empty: true, kind: "...", header: "...", nextMove: "..."}.
type EmptyState struct {
Kind EmptyStateKind `json:"-"`
Header string `json:"header"`
NextMove string `json:"nextMove,omitempty"`
}

// EmptyStateFor returns the canonical EmptyState for a given kind.
// The strings are deliberately short — first sentence is the header,
// next-move nudge is one short imperative. No exclamation marks
// (jarring on terminal); no emojis (out-of-vocabulary in the design
// system); plain English voice consistent with Track 10.7.
func EmptyStateFor(kind EmptyStateKind) EmptyState {
switch kind {
case EmptyZeroFindings:
return EmptyState{
Kind: kind,
Header: "Nothing to flag — your test system looks healthy.",
NextMove: "Run `terrain compare` over time to track posture; this clean state is the bar to hold.",
}
case EmptyNoAISurfaces:
return EmptyState{
Kind: kind,
Header: "No AI surfaces detected in this repo.",
NextMove: "Skipping AI risk review. Run `terrain ai list` to confirm if you expected AI surfaces.",
}
case EmptyNoPolicyFile:
return EmptyState{
Kind: kind,
Header: "No policy file found.",
NextMove: "Run `terrain init` to scaffold `.terrain/policy.yaml`, then re-run policy check.",
}
case EmptyFirstRun:
return EmptyState{
Kind: kind,
Header: "First time here? Welcome.",
NextMove: "Try `terrain analyze` to map your test terrain — typical service repos finish in 5–15 seconds.",
}
case EmptyNoImpact:
return EmptyState{
Kind: kind,
Header: "This change has no impact on the test system.",
NextMove: "Merge with confidence — no impacted units, no protection gaps introduced. Run `terrain analyze` to confirm overall posture is unchanged.",
}
case EmptyNoTestSelection:
return EmptyState{
Kind: kind,
Header: "No tests selected for this change.",
NextMove: "Either the change is purely structural (docs, config) or its impact graph is empty. Re-run with `--explain-selection` to see why.",
}
case EmptyNoMigrationCandidates:
return EmptyState{
Kind: kind,
Header: "No migration candidates detected.",
NextMove: "Either the repo is already on the framework of record, or none of the supported source frameworks are in use. Run `terrain migrate list` to see what's supported.",
}
default:
// Unknown kind — return empty so the renderer skips. Keeps
// the contract: only designed kinds render anything.
return EmptyState{Kind: kind}
}
}

// RenderEmptyState writes an empty-state to a terminal-text writer.
// Format is two lines: header, indented next-move (when present).
// Trailing blank line is the caller's responsibility — keeps the
// helper symmetric with renderFindingCard and friends in
// internal/changescope/render.go.
func RenderEmptyState(w io.Writer, kind EmptyStateKind) {
es := EmptyStateFor(kind)
if es.Header == "" {
return
}
fmt.Fprintln(w, es.Header)
if es.NextMove != "" {
fmt.Fprintln(w, " → "+es.NextMove)
}
}

// EmptyStateMarkdown renders an empty-state for inclusion in PR-comment
// markdown output. Uses a blockquote callout for the header (renders
// as a tinted callout on GitHub) plus an italicized next-move line.
// Designed to fit the same visual rhythm as the populated stanzas in
// internal/changescope/render.go.
func EmptyStateMarkdown(kind EmptyStateKind) string {
es := EmptyStateFor(kind)
if es.Header == "" {
return ""
}
var b strings.Builder
fmt.Fprintf(&b, "> %s\n", es.Header)
if es.NextMove != "" {
fmt.Fprintf(&b, "\n*%s*\n", es.NextMove)
}
return b.String()
}
Loading
Loading