1package modes
2
3import (
4 "strings"
5 "sync"
6 "sync/atomic"
7 "testing"
8
9 "github.com/bnema/zut/packages/core"
10 "github.com/bnema/zut/packages/provider"
11 "github.com/bnema/zut/packages/tui"
12)
13
14func TestLatestFrameSchedulerKeepsNewestRequest(t *testing.T) {
15 s := newLatestFrameScheduler()
16 started := make(chan struct{})
17 release := make(chan struct{})
18 type renderedFrame struct {
19 req renderRequest
20 version int64
21 }
22 frames := make(chan renderedFrame, 4)
23 done := make(chan struct{})
24 var startedOnce sync.Once
25 var latestVersion atomic.Int64
26 go func() {
27 s.run(func(req renderRequest) {
28 frames <- renderedFrame{req: req, version: latestVersion.Load()}
29 startedOnce.Do(func() { close(started) })
30 <-release
31 })
32 close(done)
33 }()
34
35 latestVersion.Store(1)
36 if !s.request(false, false) {
37 t.Fatal("initial render request was rejected")
38 }
39 first := <-frames
40 <-started
41 if first.req.clear {
42 t.Fatal("ordinary request unexpectedly requested a clear")
43 }
44 for n := 0; n < 1000; n++ {
45 latestVersion.Store(int64(n + 2))
46 if !s.request(false, false) {
47 t.Fatal("request rejected before shutdown")
48 }
49 }
50 close(release)
51 second := <-frames
52 if second.req.clear {
53 t.Fatal("coalesced ordinary request unexpectedly requested a clear")
54 }
55 if second.version != 1001 {
56 t.Fatalf("renderer did not observe newest state: got version %d", second.version)
57 }
58 s.stop()
59 <-done
60
61 select {
62 case <-frames:
63 t.Fatal("scheduler retained more than one pending frame")
64 default:
65 }
66}
67
68func TestToolEventBurstUsesThrottledInvalidationPath(t *testing.T) {
69 scheduler := newLatestFrameScheduler()
70 i := &Interactive{
71 dirty: make(chan struct{}, 1),
72 toolCalls: make(map[string]*tui.ToolCallView),
73 toolGate: make(map[string]int),
74 }
75 i.renderScheduler.Store(scheduler)
76
77 i.handleEventForPresentation(core.EvToolUseStart{ID: "call", Name: "bash"})
78 i.handleEventForPresentation(core.EvToolUseArgs{ID: "call", Delta: `{"command":"pwd"}`})
79 i.handleEventForPresentation(core.EvToolUseEnd{ID: "call"})
80
81 if got := len(scheduler.wake); got != 0 {
82 t.Fatalf("tool events bypassed the main-loop redraw throttle: %d direct request", got)
83 }
84 select {
85 case <-i.dirty:
86 default:
87 t.Fatal("tool events did not wake the throttled redraw path")
88 }
89 select {
90 case <-i.dirty:
91 t.Fatal("tool event burst queued more than one throttled redraw")
92 default:
93 }
94}
95
96func TestStableChatCacheTracksViewInvalidation(t *testing.T) {
97 i := &Interactive{
98 view: &tui.View{
99 Theme: tui.Dark,
100 Messages: []provider.Message{{
101 Role: provider.RoleUser,
102 Content: []provider.Content{provider.TextBlock{Text: "cache theme"}},
103 }},
104 MessagesRevision: 1,
105 },
106 }
107
108 i.mu.Lock()
109 before := strings.Join(i.stableChatRowsLocked(80), "\n")
110 i.view.Theme = tui.Light
111 i.view.InvalidateRenderCache()
112 after := strings.Join(i.stableChatRowsLocked(80), "\n")
113 i.mu.Unlock()
114
115 if before == after {
116 t.Fatal("stable chat cache reused rows after a theme invalidation")
117 }
118}
119
120func TestStableChatCacheRevealsMessageAfterStreamFlush(t *testing.T) {
121 const finalText = "final message revealed after paced flush"
122
123 agent := &core.Agent{}
124 agent.SetMessages([]provider.Message{
125 {
126 Role: provider.RoleUser,
127 Content: []provider.Content{provider.TextBlock{Text: "prompt"}},
128 },
129 {
130 Role: provider.RoleAssistant,
131 Content: []provider.Content{provider.TextBlock{Text: finalText}},
132 },
133 })
134 i := &Interactive{
135 agent: agent,
136 view: &tui.View{Theme: tui.Dark},
137 renderOutsideLock: true,
138 streamFlushPending: true,
139 }
140
141 revision := agent.Revision()
142 i.mu.Lock()
143 before := strings.Join(i.cachedChatLocked(80), "\n")
144 i.streamFlushPending = false
145 after := strings.Join(i.cachedChatLocked(80), "\n")
146 i.mu.Unlock()
147
148 if got := agent.Revision(); got != revision {
149 t.Fatalf("stream flush changed the transcript revision: got %d, want %d", got, revision)
150 }
151 if strings.Contains(before, finalText) {
152 t.Fatal("stable cache revealed the final message while stream flush was pending")
153 }
154 if !strings.Contains(after, finalText) {
155 t.Fatal("stable cache did not reveal the final message after stream flush")
156 }
157}
158
159func TestInteractiveToolProgressStormDoesNotInvalidate(t *testing.T) {
160 i := &Interactive{
161 dirty: make(chan struct{}, 1),
162 toolCalls: make(map[string]*tui.ToolCallView),
163 toolGate: make(map[string]int),
164 }
165 i.handleEventForPresentation(core.EvToolUseStart{ID: "storm", Name: "bash"})
166 select {
167 case <-i.dirty:
168 default:
169 t.Fatal("tool start did not invalidate the presentation")
170 }
171
172 const progressEvents = 4096
173 for n := 0; n < progressEvents; n++ {
174 i.handleEventForPresentation(core.EvToolProgress{
175 ID: "storm",
176 Text: strings.Repeat("progress payload ", 256),
177 })
178 }
179 select {
180 case <-i.dirty:
181 t.Fatal("invisible progress created presentation invalidation")
182 default:
183 }
184
185 result := strings.Repeat("completed tool output\n", 256)
186 i.handleEventForPresentation(core.EvToolResult{
187 ID: "storm",
188 Result: core.ToolResult{Content: []provider.Content{
189 provider.TextBlock{Text: result},
190 }},
191 })
192 select {
193 case <-i.dirty:
194 default:
195 t.Fatal("completed tool result did not invalidate the presentation")
196 }
197 if got := i.toolCalls["storm"].Result; got != result {
198 t.Fatalf("completed tool result was not retained: got %d bytes", len(got))
199 }
200 if got := i.toolCalls["storm"].Revision; got != 2 {
201 t.Fatalf("completed tool result revision: got %d, want 2", got)
202 }
203
204 view := &tui.View{Theme: tui.Dark, ExpandAll: false, ToolCalls: []tui.ToolCallView{*i.toolCalls["storm"]}}
205 rows := view.BuildLive(80)
206 if len(rows) == 0 {
207 t.Fatal("completed tool result produced no live frame rows")
208 }
209}