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:
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user