1package tui
2
3import (
4 "encoding/json"
5 "strings"
6 "testing"
7
8 "github.com/bnema/zut/packages/provider"
9)
10
11func TestViewRendersUpdatePlanAsChecklist(t *testing.T) {
12 callID := "plan-1"
13 args := json.RawMessage(`{
14 "explanation":"Finished discovery",
15 "plan":[
16 {"step":"Map the implementation","status":"completed"},
17 {"step":"Add the built-in tool","status":"in_progress"},
18 {"step":"Verify the behavior","status":"pending"}
19 ]
20 }`)
21 view := &View{Theme: Dark, Messages: []provider.Message{
22 {Role: provider.RoleAssistant, Content: []provider.Content{
23 provider.ToolCallBlock{ID: callID, Name: "update_plan", Arguments: args},
24 }},
25 {Role: provider.RoleTool, Content: []provider.Content{
26 provider.ToolResultBlock{CallID: callID, Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}}},
27 }},
28 }}
29
30 plain := stripANSI(strings.Join(view.Build(60), "\n"))
31 for _, want := range []string{
32 "Updated Plan",
33 "Finished discovery",
34 "✓ Map the implementation",
35 "□ Add the built-in tool",
36 "□ Verify the behavior",
37 } {
38 if !strings.Contains(plain, want) {
39 t.Fatalf("rendered plan omitted %q:\n%s", want, plain)
40 }
41 }
42 if strings.Contains(plain, "update_plan") || strings.Contains(plain, "Plan updated") {
43 t.Fatalf("rendered plan leaked generic tool chrome:\n%s", plain)
44 }
45}
46
47func TestViewRendersLiveUpdatePlanAsChecklist(t *testing.T) {
48 args := json.RawMessage(`{"plan":[{"step":"Implement","status":"in_progress"}]}`)
49 view := &View{Theme: Dark}
50 plain := stripANSI(strings.Join(view.RenderToolCall(ToolCallView{
51 ID: "plan-1",
52 Name: "update_plan",
53 RawJSONBuf: string(args),
54 Result: "Plan updated",
55 Done: true,
56 }, 50), "\n"))
57 if !strings.Contains(plain, "Updated Plan") || !strings.Contains(plain, "□ Implement") {
58 t.Fatalf("live plan rendering = %q", plain)
59 }
60 if strings.Contains(plain, "update_plan") || strings.Contains(plain, "Plan updated") {
61 t.Fatalf("live plan leaked generic tool chrome: %q", plain)
62 }
63}
64
65func TestViewRenderSnapshotsPreservePlanMetadata(t *testing.T) {
66 callID := "plan-1"
67 args := json.RawMessage(`{"plan":[{"step":"Implement","status":"in_progress"}]}`)
68 messages := []provider.Message{
69 {Role: provider.RoleAssistant, Content: []provider.Content{
70 provider.ToolCallBlock{ID: callID, Name: "update_plan", Arguments: args},
71 }},
72 {Role: provider.RoleTool, Content: []provider.Content{
73 provider.ToolResultBlock{CallID: callID, Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}}},
74 }},
75 }
76 seed := func() *View {
77 return &View{
78 Theme: Dark,
79 Messages: messages,
80 MessagesRevision: 7,
81 toolPathRevision: 7,
82 toolCallNames: map[string]string{callID: "update_plan"},
83 toolCallArgs: map[string]json.RawMessage{callID: args},
84 toolCallLabels: map[string]string{callID: "update_plan"},
85 toolPaths: map[string]string{},
86 toolStartLines: map[string]int{},
87 }
88 }
89 assertChecklist := func(t *testing.T, view *View) {
90 t.Helper()
91 plain := stripANSI(strings.Join(view.Build(50), "\n"))
92 if !strings.Contains(plain, "Updated Plan") || strings.Contains(plain, "Plan updated") {
93 t.Fatalf("plan rendering = %q", plain)
94 }
95 }
96
97 original := seed()
98 assertChecklist(t, original.CloneForRender())
99
100 owner := &View{Theme: Dark, Messages: messages, MessagesRevision: 7}
101 owner.AdoptRenderCacheFrom(seed())
102 assertChecklist(t, owner)
103}
104
105func TestViewFallsBackForMalformedUpdatePlan(t *testing.T) {
106 cases := []string{
107 `{"plan":[{"status":"pending"}]}`,
108 `{"plan":[{"step":"work"}]}`,
109 `{"plan":[{"step":"work","status":"unknown"}]}`,
110 `{"plan":[{"step":"work","status":"pending","extra":true}]}`,
111 `{"plan":[],"extra":true}`,
112 `{"plan":[]} trailing`,
113 }
114 for _, raw := range cases {
115 t.Run(raw, func(t *testing.T) {
116 callID := "plan-1"
117 view := &View{Theme: Dark, Messages: []provider.Message{
118 {Role: provider.RoleAssistant, Content: []provider.Content{
119 provider.ToolCallBlock{ID: callID, Name: "update_plan", Arguments: json.RawMessage(raw)},
120 }},
121 {Role: provider.RoleTool, Content: []provider.Content{
122 provider.ToolResultBlock{CallID: callID, Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}}},
123 }},
124 }}
125 plain := stripANSI(strings.Join(view.Build(50), "\n"))
126 if strings.Contains(plain, "Updated Plan") || !strings.Contains(plain, "Plan updated") {
127 t.Fatalf("malformed plan did not use generic fallback: %q", plain)
128 }
129 })
130 }
131}
132
133func TestViewRendersEmptyUpdatePlan(t *testing.T) {
134 callID := "plan-1"
135 view := &View{Theme: Dark, Messages: []provider.Message{
136 {Role: provider.RoleAssistant, Content: []provider.Content{
137 provider.ToolCallBlock{ID: callID, Name: "update_plan", Arguments: json.RawMessage(`{"plan":[]}`)},
138 }},
139 {Role: provider.RoleTool, Content: []provider.Content{
140 provider.ToolResultBlock{CallID: callID, Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}}},
141 }},
142 }}
143
144 plain := stripANSI(strings.Join(view.Build(40), "\n"))
145 if !strings.Contains(plain, "(no steps provided)") {
146 t.Fatalf("empty plan rendering = %q", plain)
147 }
148}