diff --git a/apps/tui-go/internal/app/render_matrix_test.go b/apps/tui-go/internal/app/render_matrix_test.go new file mode 100644 index 00000000..82c8b72b --- /dev/null +++ b/apps/tui-go/internal/app/render_matrix_test.go @@ -0,0 +1,668 @@ +package app + +import ( + "bufio" + "os" + "regexp" + "strings" + "testing" + + "github.com/correx/tui-go/internal/protocol" + "github.com/correx/tui-go/internal/ws" +) + +// render_matrix_test.go is the per-event-type golden render matrix (BACKLOG §E §2): +// one case per server event/entry kind the TUI handles. Each case drives a +// representative protocol.ServerMessage through the *production* path (applyServer), +// renders the surface it lands on (View / a card / a modal / the router transcript), +// strips ANSI, and asserts the render carries that kind's defining, stable text. +// +// Goldens are inline expected substrings rather than golden files: it matches the +// module's existing render-test convention (clarcard_test, ideacard_test, …), needs no +// -update dance, and pins the *meaning* of each render (the tool name, stage id, status +// label, card title) without coupling to exact lipgloss styling or timestamps. +// +// The coverage guard (TestRenderMatrixCoversEveryEventType) is the load-bearing part: +// it parses every protocol.Type* constant and fails if a new event kind is added +// without either a matrix case or an explicit non-rendering classification. + +// matrixSurface selects which render surface a case asserts against — each is a real +// production render entry point, fed off the same Model that applyServer mutated. +type matrixSurface int + +const ( + surfaceView matrixSurface = iota // full immediate-mode frame, View() + surfaceEvents // right-panel event-stream rows (eventRows) + surfaceRouter // left transcript rows (routerRows) + surfaceApprovalBand // docked approval band (renderApprovalBand) + surfaceClarModal // clarification modal + surfaceProposeModal // workflow-propose modal + surfaceIdeasModal // idea board overlay + surfaceStatsModal // session-stats overlay + surfaceConfigModal // config editor overlay + surfaceArtifactsModal // artifact viewer overlay + surfaceModelsModal // model swap overlay + surfaceStatusBar // top status bar (renderStatus) +) + +// fixedNow is a deterministic timestamp used for every event carrying OccurredAt, so +// formatTime output is stable across runs/machines. (Events that fall back to +// nowMillis() are asserted only on their type/detail text, never the clock.) +const fixedNow int64 = 1_700_000_000_000 + +// matrixCase is one event/entry kind in the matrix. +type matrixCase struct { + name string // human label / subtest name + kinds []string // protocol.Type* values this case exercises + build func() protocol.ServerMessage // a representative frame + surface matrixSurface // where its render lands + // prep mutates the model before applyServer (e.g. select + enter a session so an + // in-session surface is reachable, or open the overlay an *.list reply fills). + prep func(m *Model) + want []string // ANSI-stripped substrings the render must contain +} + +// newMatrixModel builds a deterministic Model: fixed width/theme, an entered session, +// an unconnected ws client (Send buffers, never dials). +func newMatrixModel() Model { + m := NewModel(ws.New("", 0)) + m.width, m.height = 120, 40 + m.theme = NewTheme(SoftBlue) + m.connected = true + m.selectedID = "s1" + m.sessionEntered = true + m.sessions = []Session{{ID: "s1", Status: "ACTIVE", Name: "healthcheck", LastEventAt: fixedNow}} + return m +} + +// renderSurface renders the requested surface off m and strips ANSI. +func renderSurface(m Model, s matrixSurface) string { + var raw string + switch s { + case surfaceView: + raw = m.View() + case surfaceEvents: + // Render the event-stream rows at full panel width so a row's detail isn't + // clipped at the slim right-panel edge (the production builder, just wide). + raw = strings.Join(m.eventRows(110, 40), "\n") + case surfaceRouter: + raw = strings.Join(m.routerRows(100, 30), "\n") + case surfaceApprovalBand: + raw = m.renderApprovalBand(m.approvalBandHeight()) + case surfaceClarModal: + raw = m.clarificationModal() + case surfaceProposeModal: + raw = m.proposeModal() + case surfaceIdeasModal: + raw = m.ideasModal() + case surfaceStatsModal: + raw = m.statsModal() + case surfaceConfigModal: + raw = m.configModal() + case surfaceArtifactsModal: + raw = m.artifactsModal() + case surfaceModelsModal: + raw = m.modelsModal() + case surfaceStatusBar: + raw = m.renderStatus() + } + return stripANSI(raw) +} + +// matrixCases is the matrix: one entry per event/entry kind the renderer handles. +func matrixCases() []matrixCase { + str := func(s string) *string { return &s } + return []matrixCase{ + // --- session lifecycle (status bar + narration feed) --- + { + name: "session.announced", + kinds: []string{protocol.TypeSessionAnnounced}, + surface: surfaceStatusBar, + build: func() protocol.ServerMessage { return msg(protocol.TypeSessionAnnounced, withWF("healthcheck")) }, + want: []string{"healthcheck", "ACTIVE"}, + }, + { + name: "session.paused", + kinds: []string{protocol.TypeSessionPaused}, + surface: surfaceRouter, + build: func() protocol.ServerMessage { return msg(protocol.TypeSessionPaused) }, + want: []string{"paused"}, + }, + { + name: "session.resumed", + kinds: []string{protocol.TypeSessionResumed}, + surface: surfaceRouter, + build: func() protocol.ServerMessage { return msg(protocol.TypeSessionResumed) }, + want: []string{"resumed"}, + }, + { + name: "session.completed", + kinds: []string{protocol.TypeSessionCompleted}, + surface: surfaceRouter, + build: func() protocol.ServerMessage { return msg(protocol.TypeSessionCompleted) }, + want: []string{"workflow complete"}, + }, + { + name: "session.failed", + kinds: []string{protocol.TypeSessionFailed}, + surface: surfaceRouter, + build: func() protocol.ServerMessage { return msg(protocol.TypeSessionFailed, withReason("schema mismatch")) }, + want: []string{"workflow failed", "schema mismatch"}, + }, + + // --- chat / router transcript --- + { + name: "chat.turn (router)", + kinds: []string{protocol.TypeChatTurn}, + surface: surfaceRouter, + build: func() protocol.ServerMessage { + return msg(protocol.TypeChatTurn, func(m *protocol.ServerMessage) { + m.Role, m.Content = "ROUTER", "running the healthcheck now" + }) + }, + want: []string{"running the healthcheck now"}, + }, + { + name: "router.narration", + kinds: []string{protocol.TypeRouterNarration}, + surface: surfaceRouter, + build: func() protocol.ServerMessage { + return msg(protocol.TypeRouterNarration, func(m *protocol.ServerMessage) { + m.Content = "moving on to validation" + }) + }, + want: []string{"moving on to validation", "◆"}, + }, + + // --- stage lifecycle (event stream rows) --- + { + name: "stage.started", + kinds: []string{protocol.TypeStageStarted}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { return msg(protocol.TypeStageStarted, withStage("write_script")) }, + want: []string{"StageStarted", "write_script"}, + }, + { + name: "stage.completed", + kinds: []string{protocol.TypeStageCompleted}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { return msg(protocol.TypeStageCompleted, withStage("write_script")) }, + want: []string{"StageCompleted", "write_script"}, + }, + { + name: "stage.failed", + kinds: []string{protocol.TypeStageFailed}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { return msg(protocol.TypeStageFailed, withStage("generate")) }, + want: []string{"StageFailed", "generate"}, + }, + + // --- inference lifecycle (event stream rows) --- + { + name: "inference.started", + kinds: []string{protocol.TypeInferenceStarted}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { return msg(protocol.TypeInferenceStarted, withStage("plan")) }, + want: []string{"InferenceStarted", "plan"}, + }, + { + name: "inference.completed", + kinds: []string{protocol.TypeInferenceDone}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return msg(protocol.TypeInferenceDone, withStage("plan"), func(m *protocol.ServerMessage) { m.Summary = "ok" }) + }, + want: []string{"InferenceCompleted", "plan"}, + }, + { + name: "inference.timed_out", + kinds: []string{protocol.TypeInferenceTimeout}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { return msg(protocol.TypeInferenceTimeout, withStage("plan")) }, + want: []string{"InferenceTimedOut", "plan"}, + }, + { + name: "inference.failed", + kinds: []string{protocol.TypeInferenceFailed}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return msg(protocol.TypeInferenceFailed, withStage("plan"), withReason("oom")) + }, + want: []string{"InferenceFailed", "plan", "oom"}, + }, + { + name: "inference.retry", + kinds: []string{protocol.TypeInferenceRetry}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return msg(protocol.TypeInferenceRetry, withStage("plan"), func(m *protocol.ServerMessage) { + m.AttemptNumber, m.MaxAttempts, m.FailureReason = 2, 3, "rate limited" + }) + }, + want: []string{"RetryAttempted", "plan", "2/3", "rate limited"}, + }, + + // --- tools --- + { + name: "tool.started", + kinds: []string{protocol.TypeToolStarted}, + surface: surfaceStatusBar, // started shows as the active spinner; record lands in s.Tools + build: func() protocol.ServerMessage { return msg(protocol.TypeToolStarted, withTool("file_write")) }, + want: []string{"active"}, + }, + { + name: "tool.completed", + kinds: []string{protocol.TypeToolCompleted}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return msg(protocol.TypeToolCompleted, withTool("file_write"), func(m *protocol.ServerMessage) { m.Summary = "wrote 2 files" }) + }, + want: []string{"ToolCompleted", "file_write"}, + }, + { + name: "tool.failed", + kinds: []string{protocol.TypeToolFailed}, + surface: surfaceView, + prep: func(m *Model) { + // failed marks an already-started tool record; seed one so the status flips. + s := m.session("s1") + s.Tools = []ToolRecord{{Name: "file_write", Status: ToolStarted}} + }, + build: func() protocol.ServerMessage { return msg(protocol.TypeToolFailed, withTool("file_write")) }, + // tool.failed is a state-only mutation (no event row, no card); assert it + // renders the in-session frame without crashing and keeps the session header. + want: []string{"healthcheck"}, + }, + { + name: "tool.rejected", + kinds: []string{protocol.TypeToolRejected}, + surface: surfaceView, + prep: func(m *Model) { + s := m.session("s1") + s.Tools = []ToolRecord{{Name: "shell_exec", Status: ToolStarted}} + }, + build: func() protocol.ServerMessage { return msg(protocol.TypeToolRejected, withTool("shell_exec")) }, + want: []string{"healthcheck"}, + }, + { + name: "tool.assessed", + kinds: []string{protocol.TypeToolAssessed}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return msg(protocol.TypeToolAssessed, withTool("file_write"), func(m *protocol.ServerMessage) { + m.Disposition = "BLOCK" + m.AssessedIssues = []protocol.AssessedIssueDto{{Code: "PATH_ESCAPE", Message: "outside workspace", Severity: "HIGH"}} + }) + }, + want: []string{"ToolAssessed", "BLOCK", "PATH_ESCAPE"}, + }, + + // --- artifacts / plan (event stream rows) --- + { + name: "artifact.created", + kinds: []string{protocol.TypeArtifactCreated}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return msg(protocol.TypeArtifactCreated, func(m *protocol.ServerMessage) { m.ArtifactID = "art-7" }) + }, + want: []string{"ArtifactCreated", "art-7"}, + }, + { + name: "artifact.validated", + kinds: []string{protocol.TypeArtifactValid}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return msg(protocol.TypeArtifactValid, func(m *protocol.ServerMessage) { m.ArtifactID = "art-7" }) + }, + want: []string{"ArtifactValidated", "art-7"}, + }, + { + name: "plan.locked", + kinds: []string{protocol.TypePlanLocked}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return msg(protocol.TypePlanLocked, func(m *protocol.ServerMessage) { m.StageIDs = []string{"plan", "build", "verify"} }) + }, + want: []string{"PlanLocked", "plan", "build", "verify"}, + }, + + // --- workspace bind (status bar) --- + { + name: "session.workspace_bound", + kinds: []string{protocol.TypeWorkspaceBound}, + surface: surfaceStatusBar, + build: func() protocol.ServerMessage { + return msg(protocol.TypeWorkspaceBound, func(m *protocol.ServerMessage) { m.WorkspaceRoot = "/tmp/corx-ws" }) + }, + want: []string{"corx-ws"}, + }, + + // --- approval gate (docked band card) --- + { + name: "approval.required", + kinds: []string{protocol.TypeApprovalRequired}, + surface: surfaceApprovalBand, + build: func() protocol.ServerMessage { + return msg(protocol.TypeApprovalRequired, withTool("file_write"), func(m *protocol.ServerMessage) { + m.RequestID, m.Tier = "req-1", "T3" + m.Preview = str("--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+new\n") + m.RiskSummary = &protocol.RiskSummaryDto{Level: "HIGH", Rationale: []string{"[PATH_OUTSIDE_WORKSPACE] /etc/hosts"}} + }) + }, + want: []string{"file_write", "T3", "HIGH", "PATH_OUTSIDE_WORKSPACE"}, + }, + { + name: "approval.resolved", + kinds: []string{protocol.TypeApprovalResolved}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return msg(protocol.TypeApprovalResolved, func(m *protocol.ServerMessage) { m.Outcome, m.Reason = "APPROVED", "operator ok" }) + }, + want: []string{"ApprovalResolved", "APPROVED", "operator ok"}, + }, + + // --- clarification (modal card) --- + { + name: "clarification.required", + kinds: []string{protocol.TypeClarification}, + surface: surfaceClarModal, + build: func() protocol.ServerMessage { + return msg(protocol.TypeClarification, withStage("analyst"), func(m *protocol.ServerMessage) { + m.RequestID = "req-1" + m.Questions = []protocol.ClarificationQuestionDto{ + {ID: "stack", Prompt: "Which stack?", Options: []string{"React", "Vue"}, Header: "Stack"}, + } + }) + }, + want: []string{"clarifying questions", "Which stack?", "React", "Vue", "Stack"}, + }, + + // --- workflow proposal (modal card) --- + { + name: "workflow.proposed", + kinds: []string{protocol.TypeWorkflowProposed}, + surface: surfaceProposeModal, + build: func() protocol.ServerMessage { + return msg(protocol.TypeWorkflowProposed, func(m *protocol.ServerMessage) { + m.ProposalID, m.Prompt, m.OriginalRequest = "prop-1", "Run one of these?", "scan the repo" + m.Candidates = []protocol.ProposedWorkflowDto{ + {WorkflowID: "research", Reason: "gather + report"}, + {WorkflowID: "role_pipeline", Reason: "full build"}, + } + }) + }, + want: []string{"workflow suggestion", "Run one of these?", "research", "gather + report", "role_pipeline"}, + }, + + // --- idea board (overlay; cross-session, no session scope) --- + { + name: "idea.list", + kinds: []string{protocol.TypeIdeaList}, + surface: surfaceIdeasModal, + prep: func(m *Model) { m.overlay = OverlayIdeas }, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: protocol.TypeIdeaList, Ideas: []protocol.IdeaDto{ + {IdeaID: "i1", Text: "cache the repo map", CapturedAtMs: 1000}, + }} + }, + want: []string{"ideas", "cache the repo map"}, + }, + + // --- session stats (overlay) --- + { + name: "session.stats", + kinds: []string{protocol.TypeSessionStats}, + surface: surfaceStatsModal, + prep: func(m *Model) { m.overlay = OverlayStats; m.statsFor = "s1" }, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: protocol.TypeSessionStats, SessionID: "s1", Stats: sampleStats()} + }, + want: []string{"file_write", "read_file"}, + }, + + // --- config snapshot (overlay) --- + { + name: "config.snapshot", + kinds: []string{protocol.TypeConfigSnapshot}, + surface: surfaceConfigModal, + prep: func(m *Model) { m.overlay = OverlayConfig }, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: protocol.TypeConfigSnapshot, ConfigFields: []protocol.ConfigFieldDto{ + {Key: "router.model", Type: "string", Value: "qwen2.5-coder"}, + }} + }, + want: []string{"router.model", "qwen2.5-coder"}, + }, + + // --- artifact list (overlay) --- + { + name: "artifact.list", + kinds: []string{protocol.TypeArtifactList}, + surface: surfaceArtifactsModal, + prep: func(m *Model) { m.overlay = OverlayArtifacts; m.artifactsFor = "s1" }, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: protocol.TypeArtifactList, SessionID: "s1", Artifacts: []protocol.ArtifactDto{ + {ArtifactID: "art-7", StageID: "plan", SchemaVersion: 1, Phase: "PLAN", Content: str("{\"ok\":true}")}, + }} + }, + want: []string{"art-7"}, + }, + + // --- model list / changed (overlay + status chrome) --- + { + name: "model.list", + kinds: []string{protocol.TypeModelList}, + surface: surfaceModelsModal, + prep: func(m *Model) { m.overlay = OverlayModels }, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: protocol.TypeModelList, Models: []string{"qwen2.5-coder", "llama3"}, Current: "qwen2.5-coder"} + }, + want: []string{"qwen2.5-coder", "llama3"}, + }, + { + name: "model.changed", + kinds: []string{protocol.TypeModelChanged}, + surface: surfaceStatusBar, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: protocol.TypeModelChanged, ModelID: "llama3-swapped", Loaded: true} + }, + want: []string{"llama3-swapped"}, + }, + + // --- provider status (status bar model + locality) --- + { + name: "provider.status_changed", + kinds: []string{protocol.TypeProviderStatus}, + surface: surfaceStatusBar, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: protocol.TypeProviderStatus, ProviderID: "llama-cpp:qwen"} + }, + want: []string{"llama-cpp:qwen", "local"}, + }, + + // --- resource gauge (status bar) --- + { + name: "resource.status", + kinds: []string{protocol.TypeResourceStatus}, + surface: surfaceStatusBar, + prep: func(m *Model) { m.selectedID = ""; m.sessionEntered = false }, // gauge shows on the idle bar + build: func() protocol.ServerMessage { + used, total, util := int64(4096), int64(8192), 42 + return protocol.ServerMessage{Type: protocol.TypeResourceStatus, + GpuMemoryUsedMb: &used, GpuMemoryTotalMb: &total, GpuUtilizationPct: &util} + }, + want: []string{"VRAM 4096/8192M", "GPU 42%"}, + }, + + // --- workflow list (idle left pane) --- + { + name: "workflow.list", + kinds: []string{protocol.TypeWorkflowList}, + surface: surfaceView, + prep: func(m *Model) { m.selectedID = ""; m.sessionEntered = false; m.wfVisible = true; m.wfIndex = 0 }, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: protocol.TypeWorkflowList, Workflows: []protocol.WorkflowDto{ + {WorkflowID: "healthcheck", Description: "kick the tires"}, + }} + }, + want: []string{"healthcheck", "kick the tires"}, + }, + + // --- session snapshot (reopen: rebuilds a session from the recent-events tail) --- + { + name: "session_snapshot", + kinds: []string{protocol.TypeSessionSnapshot}, + surface: surfaceEvents, + prep: func(m *Model) { m.sessions = nil; m.selectedID = "snap1"; m.sessionEntered = true }, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{ + Type: protocol.TypeSessionSnapshot, SessionID: "snap1", WorkflowID: "refactor", + State: &protocol.SessionStateDto{Status: "PAUSED"}, + RecentEvents: []protocol.EventEntryDto{{Timestamp: fixedNow, Type: "StageStarted", Detail: "plan"}}, + } + }, + want: []string{"StageStarted", "plan"}, + }, + + // --- stage tool manifest (declared, not-yet-run tools — state only) --- + { + name: "stage.tool_manifest", + kinds: []string{protocol.TypeStageManifest}, + surface: surfaceView, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: protocol.TypeStageManifest, SessionID: "s1", Stages: []protocol.StageToolDecl{ + {StageID: "plan", Tools: []protocol.ToolDecl{{Name: "read_file", Tier: 1}}}, + }} + }, + // manifest seeds s.ToolsByStage (surfaced by the tool palette, not the main + // frame); assert the in-session frame still renders cleanly with the session. + want: []string{"healthcheck"}, + }, + + // --- unknown / future event: raw fallback row, never dropped (§2) --- + { + name: "unknown event (raw fallback)", + kinds: []string{"some.future.event"}, + surface: surfaceEvents, + build: func() protocol.ServerMessage { + return protocol.ServerMessage{Type: "some.future.event", SessionID: "s1", OccurredAt: fixedNow} + }, + want: []string{"some.future.event", "raw"}, + }, + } +} + +// --- frame builders (keep cases terse) --- + +type msgOpt func(*protocol.ServerMessage) + +func msg(typ string, opts ...msgOpt) protocol.ServerMessage { + m := protocol.ServerMessage{Type: typ, SessionID: "s1", OccurredAt: fixedNow} + for _, o := range opts { + o(&m) + } + return m +} + +func withWF(id string) msgOpt { return func(m *protocol.ServerMessage) { m.WorkflowID = id } } +func withStage(id string) msgOpt { return func(m *protocol.ServerMessage) { m.StageID = id } } +func withTool(name string) msgOpt { return func(m *protocol.ServerMessage) { m.ToolName = name } } +func withReason(r string) msgOpt { return func(m *protocol.ServerMessage) { m.Reason = r } } + +// TestRenderMatrix is the table-driven golden render matrix: every event/entry kind, +// driven through applyServer, asserts its render carries its defining stable text. +func TestRenderMatrix(t *testing.T) { + for _, c := range matrixCases() { + t.Run(c.name, func(t *testing.T) { + m := newMatrixModel() + if c.prep != nil { + c.prep(&m) + } + m.applyServer(c.build()) + out := renderSurface(m, c.surface) + for _, want := range c.want { + if !strings.Contains(out, want) { + t.Fatalf("render for %q missing %q\n--- visible ---\n%s", c.name, want, out) + } + } + }) + } +} + +// nonRenderingEventTypes are protocol.Type* constants the TUI handles but that have no +// per-kind render of their own — they are explicitly classified here so the coverage +// guard stays exhaustive. Adding a new Type* constant forces a choice: give it a matrix +// case, or list it here with a reason. Either way the guard can't silently pass. +var nonRenderingEventTypes = map[string]string{ + protocol.TypeSnapshotComplete: "control frame: ends the snapshot-buffering phase; no render", + protocol.TypeRouterResponse: "recognized no-op: superseded by chat.turn on the global stream", + protocol.TypeProtocolError: "transport diagnostic: explicit no-op, not surfaced", +} + +// TestRenderMatrixCoversEveryEventType is the load-bearing matrix guarantee: it scans +// every protocol.Type* constant declared in protocol.go and fails if any is neither +// covered by a matrix case nor explicitly classified as non-rendering. A new event kind +// added to the protocol without a matching render assertion breaks this test. +func TestRenderMatrixCoversEveryEventType(t *testing.T) { + declared := declaredEventTypes(t) + if len(declared) < 30 { + t.Fatalf("expected to scan ~40 protocol Type* constants, found only %d — parser drift?", len(declared)) + } + + covered := map[string]bool{} + for _, c := range matrixCases() { + for _, k := range c.kinds { + covered[k] = true + } + } + + var missing []string + for typ := range declared { + if covered[typ] { + continue + } + if _, ok := nonRenderingEventTypes[typ]; ok { + continue + } + missing = append(missing, typ) + } + if len(missing) > 0 { + t.Fatalf("event types with no matrix case and no non-rendering classification: %v\n"+ + "add a case to matrixCases() (preferred) or list it in nonRenderingEventTypes with a reason", missing) + } + + // Reverse guard: every classified non-rendering type must still be a real constant + // (catches a typo or a removed constant rotting the allowlist). + for typ := range nonRenderingEventTypes { + if !declared[typ] { + t.Errorf("nonRenderingEventTypes lists %q which is not a declared protocol Type* value", typ) + } + } +} + +// declaredEventTypes parses protocol.go and returns the value of every Type* string +// constant (e.g. "stage.started"). Deriving the authoritative set from source — rather +// than a hand-kept list — is what makes the coverage guard catch *new* event kinds. +func declaredEventTypes(t *testing.T) map[string]bool { + t.Helper() + const src = "../protocol/protocol.go" + f, err := os.Open(src) + if err != nil { + t.Fatalf("open %s: %v", src, err) + } + defer f.Close() + + // Matches lines like: TypeStageStarted = "stage.started" + re := regexp.MustCompile(`^\s*Type[A-Za-z0-9]+\s*=\s*"([^"]+)"`) + out := map[string]bool{} + sc := bufio.NewScanner(f) + for sc.Scan() { + if mm := re.FindStringSubmatch(sc.Text()); mm != nil { + out[mm[1]] = true + } + } + if err := sc.Err(); err != nil { + t.Fatalf("scan %s: %v", src, err) + } + return out +}