test(tui-go): per-event render-matrix golden tests (E §2)

Table-driven render assertions (37 cases over 42 protocol Type* constants), driven
through applyServer and asserted ANSI-stripped on stable defining text. Coverage
guard parses every Type* from protocol.go and fails if a kind is neither covered
nor explicitly classified non-rendering. Pins width/theme/timestamp; no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 16:30:25 +00:00
parent 1f7a5d96a7
commit 2a99e15383
@@ -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
}