1package tui
2
3import (
4 "fmt"
5 "strings"
6 "testing"
7
8 "github.com/bnema/zut/packages/core"
9 "github.com/bnema/zut/packages/provider"
10)
11
12// planTestMessages builds the assistant tool_use + tool result pair a plan call
13// leaves in the transcript. Rendering now reads the checklist from the snapshot
14// map keyed by the call id, not from these arguments.
15func planTestMessages(callID, name, args string) []provider.Message {
16 return []provider.Message{
17 {Role: provider.RoleAssistant, Content: []provider.Content{
18 provider.ToolCallBlock{ID: callID, Name: name, Arguments: []byte(args)},
19 }},
20 {Role: provider.RoleTool, Content: []provider.Content{
21 provider.ToolResultBlock{CallID: callID, Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}}},
22 }},
23 }
24}
25
26func TestViewRendersPlanSnapshotAsChecklist(t *testing.T) {
27 callID := "plan-1"
28 explanation := "Finished discovery"
29 view := &View{
30 Theme: Dark,
31 Messages: planTestMessages(callID, "plan", `{"action":"set"}`),
32 PlanUpdates: map[string]core.PlanUpdate{
33 callID: {
34 Explanation: &explanation,
35 Plan: []core.PlanStep{
36 {Step: "Map the implementation", Status: core.PlanCompleted},
37 {Step: "Add the built-in tool", Status: core.PlanInProgress},
38 {Step: "Verify the behavior", Status: core.PlanPending},
39 },
40 },
41 },
42 }
43
44 plain := stripANSI(strings.Join(view.Build(60), "\n"))
45 for _, want := range []string{
46 "Updated Plan",
47 "Finished discovery",
48 "✓ Map the implementation",
49 "□ Add the built-in tool",
50 "□ Verify the behavior",
51 } {
52 if !strings.Contains(plain, want) {
53 t.Fatalf("rendered plan omitted %q:\n%s", want, plain)
54 }
55 }
56 if strings.Contains(plain, "plan ") || strings.Contains(plain, "Plan updated") {
57 t.Fatalf("rendered plan leaked generic tool chrome:\n%s", plain)
58 }
59}
60
61func TestViewRendersLegacyUpdatePlanSnapshotFromMap(t *testing.T) {
62 callID := "legacy-1"
63 view := &View{
64 Theme: Dark,
65 Messages: planTestMessages(callID, "update_plan", `{"plan":[{"step":"Implement","status":"in_progress"}]}`),
66 PlanUpdates: map[string]core.PlanUpdate{
67 callID: {Plan: []core.PlanStep{{Step: "Implement", Status: core.PlanInProgress}}},
68 },
69 }
70 plain := stripANSI(strings.Join(view.Build(60), "\n"))
71 if !strings.Contains(plain, "Updated Plan") || !strings.Contains(plain, "□ Implement") {
72 t.Fatalf("legacy plan rendering = %q", plain)
73 }
74}
75
76func TestViewRendersLivePlanFromSnapshot(t *testing.T) {
77 view := &View{
78 Theme: Dark,
79 PlanUpdates: map[string]core.PlanUpdate{
80 "plan-1": {Plan: []core.PlanStep{{Step: "Implement", Status: core.PlanInProgress}}},
81 },
82 }
83 plain := stripANSI(strings.Join(view.RenderToolCall(ToolCallView{
84 ID: "plan-1",
85 Name: "plan",
86 RawJSONBuf: `{"action":"update","index":1,"status":"in_progress"}`,
87 Done: true,
88 }, 50), "\n"))
89 if !strings.Contains(plain, "Updated Plan") || !strings.Contains(plain, "□ Implement") {
90 t.Fatalf("live plan rendering = %q", plain)
91 }
92 if strings.Contains(plain, "plan:") || strings.Contains(plain, "Plan updated") {
93 t.Fatalf("live plan leaked generic tool chrome: %q", plain)
94 }
95}
96
97// TestViewRendersCompactPlanLineWithoutSnapshot covers a plan call whose
98// arguments are still streaming, before execution materialized any checklist.
99func TestViewRendersCompactPlanLineWithoutSnapshot(t *testing.T) {
100 view := &View{Theme: Dark}
101 plain := stripANSI(strings.Join(view.RenderToolCall(ToolCallView{
102 ID: "plan-1",
103 Name: "plan",
104 RawJSONBuf: `{"action":"update"`,
105 }, 50), "\n"))
106 if !strings.Contains(plain, "plan: update") {
107 t.Fatalf("compact plan line = %q", plain)
108 }
109 if strings.Contains(plain, "Updated Plan") {
110 t.Fatalf("compact plan line rendered a checklist: %q", plain)
111 }
112}
113
114func TestViewRenderSnapshotsPreservePlanMetadata(t *testing.T) {
115 callID := "plan-1"
116 messages := planTestMessages(callID, "plan", `{"action":"set"}`)
117 seed := func() *View {
118 return &View{
119 Theme: Dark,
120 Messages: messages,
121 MessagesRevision: 7,
122 toolPathRevision: 7,
123 toolCallNames: map[string]string{callID: "plan"},
124 PlanUpdates: map[string]core.PlanUpdate{
125 callID: {Plan: []core.PlanStep{{Step: "Implement", Status: core.PlanInProgress}}},
126 },
127 }
128 }
129 assertChecklist := func(t *testing.T, view *View) {
130 t.Helper()
131 plain := stripANSI(strings.Join(view.Build(50), "\n"))
132 if !strings.Contains(plain, "Updated Plan") || strings.Contains(plain, "Plan updated") {
133 t.Fatalf("plan rendering = %q", plain)
134 }
135 }
136
137 original := seed()
138 assertChecklist(t, original.CloneForRender())
139
140 owner := &View{Theme: Dark, Messages: messages, MessagesRevision: 7}
141 owner.AdoptRenderCacheFrom(seed())
142 assertChecklist(t, owner)
143}
144
145// TestViewCloneForRenderCopiesPlanSnapshots pins the deep copy: the render
146// goroutine reads the clone while the live view keeps mutating its own map.
147func TestViewCloneForRenderCopiesPlanSnapshots(t *testing.T) {
148 callID := "plan-1"
149 view := &View{
150 Theme: Dark,
151 PlanUpdates: map[string]core.PlanUpdate{
152 callID: {Plan: []core.PlanStep{{Step: "one", Status: core.PlanPending}}},
153 },
154 }
155 clone := view.CloneForRender()
156
157 entry := view.PlanUpdates[callID]
158 entry.Plan[0].Step = "changed"
159 view.PlanUpdates[callID] = entry
160 view.PlanUpdates["plan-2"] = core.PlanUpdate{}
161
162 if got := clone.PlanUpdates[callID]; len(got.Plan) != 1 || got.Plan[0].Step != "one" {
163 t.Fatalf("clone shares a plan slice with the source: %#v", got)
164 }
165 if _, ok := clone.PlanUpdates["plan-2"]; ok {
166 t.Fatalf("clone observed a map insertion made after it was cloned")
167 }
168}
169
170// TestViewPlanSnapshotsNotSharedWithRenderGoroutine is the -race guard for the
171// render clone: the goroutine renders from its own copy of the map while the
172// live view writes new snapshots.
173func TestViewPlanSnapshotsNotSharedWithRenderGoroutine(t *testing.T) {
174 callID := "plan-1"
175 view := &View{
176 Theme: Dark,
177 Messages: planTestMessages(callID, "plan", `{"action":"set"}`),
178 PlanUpdates: map[string]core.PlanUpdate{callID: {Plan: []core.PlanStep{{Step: "one", Status: core.PlanPending}}}},
179 }
180 clone := view.CloneForRender()
181 done := make(chan struct{})
182 go func() {
183 defer close(done)
184 for i := 0; i < 25; i++ {
185 _ = clone.Build(60)
186 }
187 }()
188 for i := 0; i < 500; i++ {
189 view.PlanUpdates[callID] = core.PlanUpdate{
190 Plan: []core.PlanStep{{Step: fmt.Sprintf("step-%d", i), Status: core.PlanInProgress}},
191 }
192 }
193 <-done
194}
195
196// TestViewPlanWithoutSnapshotFallsBackToToolChrome covers a plan call that
197// produced no checklist: a failed call, or a read such as show.
198func TestViewPlanWithoutSnapshotFallsBackToToolChrome(t *testing.T) {
199 callID := "plan-1"
200 view := &View{Theme: Dark, Messages: planTestMessages(callID, "plan", `{"action":"show"}`)}
201 plain := stripANSI(strings.Join(view.Build(60), "\n"))
202 if strings.Contains(plain, "Updated Plan") || !strings.Contains(plain, "Plan updated") {
203 t.Fatalf("plan without a snapshot did not use generic fallback: %q", plain)
204 }
205}
206
207func TestViewRendersEmptyPlanSnapshot(t *testing.T) {
208 callID := "plan-1"
209 view := &View{
210 Theme: Dark,
211 Messages: planTestMessages(callID, "plan", `{"action":"clear"}`),
212 PlanUpdates: map[string]core.PlanUpdate{callID: {}},
213 }
214 plain := stripANSI(strings.Join(view.Build(40), "\n"))
215 if !strings.Contains(plain, "(no steps provided)") {
216 t.Fatalf("empty plan rendering = %q", plain)
217 }
218}
219
220// TestViewCachedPlanRowInvalidatesOnRevision guards GO-009: PlanRevision is part
221// of the render-cache key so a late or changed snapshot invalidates a message
222// row that already rendered a checklist. Without it the cached path serves the
223// old steps forever.
224func TestViewCachedPlanRowInvalidatesOnRevision(t *testing.T) {
225 callID := "plan-1"
226 view := &View{
227 Theme: Dark,
228 MessagesRevision: 1,
229 Messages: planTestMessages(callID, "plan", `{"action":"update"}`),
230 PlanUpdates: map[string]core.PlanUpdate{
231 callID: {Plan: []core.PlanStep{{Step: "old step", Status: core.PlanInProgress}}},
232 },
233 }
234
235 first := stripANSI(strings.Join(view.Build(60), "\n"))
236 if !strings.Contains(first, "old step") {
237 t.Fatalf("first render = %q", first)
238 }
239
240 view.PlanUpdates = map[string]core.PlanUpdate{
241 callID: {Plan: []core.PlanStep{{Step: "new step", Status: core.PlanInProgress}}},
242 }
243 view.PlanRevision++
244
245 second := stripANSI(strings.Join(view.Build(60), "\n"))
246 if strings.Contains(second, "old step") {
247 t.Fatalf("second render reused the cached checklist: %q", second)
248 }
249 if !strings.Contains(second, "new step") {
250 t.Fatalf("second render omitted the new checklist: %q", second)
251 }
252}