detached 7a79ec3

test(daemon): exercise render coordinator behavior

Replace the source-scanning RED placeholders with deterministic fake-clock
behavioral tests: cap-1 wake coalescing with latest state, sticky FullRedraw,
urgent <=2ms and adaptive 8-16ms deadlines, ACK block/single-wake, synchronized
output completion/watchdog, preview subscription teardown, stale
detach/replace/park/session-teardown callbacks, monotonic resize metadata
epochs (duplicates and displaced attachments), retained PR #71
timer/generation/cancel dispatch into coordinator invalidation, and one
exercised production transition per direct-paint producer including the
backSession fallback. Source scanning survives only as a passing inventory
guard. All coordinator tests fail on the inert contract until GREEN.
commit
7a79ec3897e9037d9aa36f6c0b9ef2622201268a
author
brice · · 2026-07-11 14:49 UTC
parent
65114be
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +671 -57
M internal/usecase/daemon/input_test.go +39 -0
@@ -466,6 +466,45 @@ func TestStaleBackSessionClearPreservesConcurrentTarget(t *testing.T) {
466466 require.Same(t, updated, ac.previousSession.Get())
467467 }
468468
469+// backSession's invalid-target fallback is a render producer: it must publish
470+// exactly one coordinator invalidation instead of composing directly, while
471+// the valid-target hand-off (TestPaletteBackSessionTogglesPreviousSession) and
472+// the guarded stale clear (TestStaleBackSessionClearPreservesConcurrentTarget)
473+// keep their PR #73 behavior unchanged.
474+func TestBackSessionInvalidTargetsFallBackThroughOneInvalidation(t *testing.T) {
475+ for _, tc := range []struct {
476+ name string
477+ prepare func(current *session, ac *attachedClient)
478+ }{
479+ {name: "no previous target", prepare: func(*session, *attachedClient) {}},
480+ {name: "stale previous target", prepare: func(_ *session, ac *attachedClient) {
481+ ac.previousSession.Set(&session{id: "gone"})
482+ }},
483+ {name: "previous target equals current", prepare: func(current *session, ac *attachedClient) {
484+ ac.previousSession.Set(current)
485+ }},
486+ } {
487+ t.Run(tc.name, func(t *testing.T) {
488+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
489+ defer releaseAll(releases)
490+ invs := make(chan renderInvalidation, 4)
491+ current.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{
492+ clock: d.clock,
493+ wake: func(renderWake) {},
494+ onInvalidate: func(inv renderInvalidation) { invs <- inv },
495+ }))
496+ tc.prepare(current, ac)
497+
498+ d.backSession(current, ac)
499+
500+ require.Same(t, current, ac.currentSession(), "an invalid target must not move the attachment")
501+ awaitInvalidation(t, invs)
502+ requireNoInvalidation(t, invs)
503+ requireNoOutputFrame(t, sends)
504+ })
505+ }
506+}
507+
469508 func TestSwitchSourcePreviousSessionContracts(t *testing.T) {
470509 for _, tc := range []struct {
471510 name string
@@ -466,6 +466,45 @@ func TestStaleBackSessionClearPreservesConcurrentTarget(t *testing.T) {
466 require.Same(t, updated, ac.previousSession.Get())466 require.Same(t, updated, ac.previousSession.Get())
467 }467 }
468 468
469+// backSession's invalid-target fallback is a render producer: it must publish
470+// exactly one coordinator invalidation instead of composing directly, while
471+// the valid-target hand-off (TestPaletteBackSessionTogglesPreviousSession) and
472+// the guarded stale clear (TestStaleBackSessionClearPreservesConcurrentTarget)
473+// keep their PR #73 behavior unchanged.
474+func TestBackSessionInvalidTargetsFallBackThroughOneInvalidation(t *testing.T) {
475+ for _, tc := range []struct {
476+ name string
477+ prepare func(current *session, ac *attachedClient)
478+ }{
479+ {name: "no previous target", prepare: func(*session, *attachedClient) {}},
480+ {name: "stale previous target", prepare: func(_ *session, ac *attachedClient) {
481+ ac.previousSession.Set(&session{id: "gone"})
482+ }},
483+ {name: "previous target equals current", prepare: func(current *session, ac *attachedClient) {
484+ ac.previousSession.Set(current)
485+ }},
486+ } {
487+ t.Run(tc.name, func(t *testing.T) {
488+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
489+ defer releaseAll(releases)
490+ invs := make(chan renderInvalidation, 4)
491+ current.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{
492+ clock: d.clock,
493+ wake: func(renderWake) {},
494+ onInvalidate: func(inv renderInvalidation) { invs <- inv },
495+ }))
496+ tc.prepare(current, ac)
497+
498+ d.backSession(current, ac)
499+
500+ require.Same(t, current, ac.currentSession(), "an invalid target must not move the attachment")
501+ awaitInvalidation(t, invs)
502+ requireNoInvalidation(t, invs)
503+ requireNoOutputFrame(t, sends)
504+ })
505+ }
506+}
507+
469 func TestSwitchSourcePreviousSessionContracts(t *testing.T) {508 func TestSwitchSourcePreviousSessionContracts(t *testing.T) {
470 for _, tc := range []struct {509 for _, tc := range []struct {
471 name string510 name string
M internal/usecase/daemon/render_coordinator_test.go +632 -57
@@ -3,94 +3,669 @@ package daemon
33 import (
44 "os"
55 "path/filepath"
6- "strings"
6+ "sync/atomic"
77 "testing"
8+ "time"
89
910 "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/usecase/keys"
15+ "github.com/bnema/vev/internal/usecase/layout"
1016 )
1117
12-// These are deliberately source-level RED contracts. S1 has no coordinator
13-// yet, so exercising the production transitions would either require test-only
14-// production shims or fail to compile. Keeping the contract in the daemon
15-// package makes the missing fan-in and retained PR #71 hand-off explicit while
16-// the implementation is introduced in the following GREEN slice.
17-func TestRenderCoordinatorProducerFanIn(t *testing.T) {
18- producers := []string{
19- "attention.go", "client.go", "copymode.go", "floating.go", "input.go",
20- "palette.go", "pane_actions.go", "picker.go", "prompt.go", "render.go",
21- "session.go", "session_back.go",
22- }
23-
24- for _, name := range producers {
25- t.Run(name, func(t *testing.T) {
26- body := daemonSource(t, name)
27- require.NotContainsf(t, body, "d.paint(", "%s must invalidate the session coordinator rather than compose/send directly", name)
28- require.Containsf(t, body, "invalidate(", "%s must publish its state transition to the render coordinator", name)
18+// --- coordinator harness ------------------------------------------------------
19+
20+// coordinatorHarness wires one coordinator to a fake clock and recording
21+// hooks. Every assertion is channel- or counter-based; nothing sleeps.
22+type coordinatorHarness struct {
23+ clk *signalClock
24+ wakes chan renderWake
25+ previews chan renderWake
26+ ackReady atomic.Bool
27+ syncActive atomic.Bool
28+ rc *renderCoordinator
29+}
30+
31+func newCoordinatorHarness(t *testing.T) *coordinatorHarness {
32+ t.Helper()
33+ h := &coordinatorHarness{
34+ clk: &signalClock{timers: make(chan *signalTimer, 16)},
35+ wakes: make(chan renderWake, 16),
36+ previews: make(chan renderWake, 16),
37+ }
38+ h.ackReady.Store(true)
39+ h.rc = newRenderCoordinator(renderCoordinatorOptions{
40+ clock: h.clk,
41+ wake: func(w renderWake) { h.wakes <- w },
42+ ackReady: func() bool { return h.ackReady.Load() },
43+ syncActive: func() bool { return h.syncActive.Load() },
44+ })
45+ return h
46+}
47+
48+// armedTimers drains every deadline armed so far. Arming happens
49+// synchronously inside the coordinator call, so a non-blocking drain is
50+// deterministic.
51+func (h *coordinatorHarness) armedTimers(t *testing.T) []*signalTimer {
52+ t.Helper()
53+ var timers []*signalTimer
54+ for {
55+ select {
56+ case tm := <-h.clk.timers:
57+ timers = append(timers, tm)
58+ default:
59+ return timers
60+ }
61+ }
62+}
63+
64+func awaitWake(t *testing.T, ch chan renderWake) renderWake {
65+ t.Helper()
66+ select {
67+ case w := <-ch:
68+ return w
69+ case <-time.After(2 * time.Second):
70+ t.Fatal("timed out waiting for a coordinator wake")
71+ return renderWake{}
72+ }
73+}
74+
75+func requireNoWake(t *testing.T, ch chan renderWake) {
76+ t.Helper()
77+ select {
78+ case w := <-ch:
79+ t.Fatalf("unexpected coordinator wake: %+v", w)
80+ case <-time.After(50 * time.Millisecond):
81+ }
82+}
83+
84+func awaitInvalidation(t *testing.T, ch chan renderInvalidation) renderInvalidation {
85+ t.Helper()
86+ select {
87+ case inv := <-ch:
88+ return inv
89+ case <-time.After(2 * time.Second):
90+ t.Fatal("timed out waiting for a coordinator invalidation")
91+ return renderInvalidation{}
92+ }
93+}
94+
95+func requireNoInvalidation(t *testing.T, ch chan renderInvalidation) {
96+ t.Helper()
97+ select {
98+ case inv := <-ch:
99+ t.Fatalf("unexpected coordinator invalidation: %+v", inv)
100+ case <-time.After(50 * time.Millisecond):
101+ }
102+}
103+
104+func awaitScheduledTimer(t *testing.T, clk *signalClock) *signalTimer {
105+ t.Helper()
106+ select {
107+ case tm := <-clk.timers:
108+ return tm
109+ case <-time.After(2 * time.Second):
110+ t.Fatal("timed out waiting for a scheduled timer")
111+ return nil
112+ }
113+}
114+
115+// --- wake coalescing and deadlines --------------------------------------------
116+
117+func TestRenderCoordinatorCoalescesWakesUnderOneDeadline(t *testing.T) {
118+ cases := []struct {
119+ name string
120+ invalidations []renderInvalidation
121+ wantTimers int
122+ wantMin time.Duration
123+ wantMax time.Duration
124+ wantWake renderWake
125+ }{
126+ {
127+ name: "urgent transition wakes within two milliseconds",
128+ invalidations: []renderInvalidation{{class: invalidateUrgent, reset: true, producer: "input.go"}},
129+ wantTimers: 1,
130+ wantMax: urgentRenderDeadline,
131+ wantWake: renderWake{reset: true, urgent: true, coalesced: 1},
132+ },
133+ {
134+ name: "output burst coalesces into one adaptive deadline",
135+ invalidations: []renderInvalidation{
136+ {class: invalidateOutput, producer: "render.go"},
137+ {class: invalidateOutput, producer: "render.go"},
138+ {class: invalidateOutput, producer: "render.go"},
139+ },
140+ wantTimers: 1,
141+ wantMin: minOutputRenderDeadline,
142+ wantMax: maxOutputRenderDeadline,
143+ wantWake: renderWake{coalesced: 3},
144+ },
145+ {
146+ name: "full redraw stays sticky across later incremental transitions",
147+ invalidations: []renderInvalidation{
148+ {class: invalidateOutput},
149+ {class: invalidateOutput, reset: true},
150+ {class: invalidateOutput},
151+ },
152+ wantTimers: 1,
153+ wantMin: minOutputRenderDeadline,
154+ wantMax: maxOutputRenderDeadline,
155+ wantWake: renderWake{reset: true, coalesced: 3},
156+ },
157+ {
158+ name: "urgent transition tightens a pending output deadline",
159+ invalidations: []renderInvalidation{
160+ {class: invalidateOutput},
161+ {class: invalidateUrgent},
162+ },
163+ wantTimers: 2,
164+ wantMax: urgentRenderDeadline,
165+ wantWake: renderWake{urgent: true, coalesced: 2},
166+ },
167+ }
168+ for _, tc := range cases {
169+ t.Run(tc.name, func(t *testing.T) {
170+ h := newCoordinatorHarness(t)
171+ for _, inv := range tc.invalidations {
172+ h.rc.invalidate(inv)
173+ }
174+ timers := h.armedTimers(t)
175+ require.Lenf(t, timers, tc.wantTimers,
176+ "cap-1 coalescing must arm exactly %d deadline timer(s) for %d invalidations",
177+ tc.wantTimers, len(tc.invalidations))
178+ last := timers[len(timers)-1]
179+ require.GreaterOrEqual(t, last.duration, tc.wantMin)
180+ require.LessOrEqual(t, last.duration, tc.wantMax)
181+
182+ last.ch <- time.Time{}
183+ require.Equal(t, tc.wantWake, awaitWake(t, h.wakes),
184+ "one wake must deliver the latest coalesced state")
185+ requireNoWake(t, h.wakes)
186+ })
187+ }
188+}
189+
190+// --- ACK gating ----------------------------------------------------------------
191+
192+func TestRenderCoordinatorAckGateBlocksCompositionUntilAck(t *testing.T) {
193+ h := newCoordinatorHarness(t)
194+ h.ackReady.Store(false)
195+
196+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
197+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true})
198+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
199+ for _, timer := range h.armedTimers(t) {
200+ timer.ch <- time.Time{}
201+ }
202+ requireNoWake(t, h.wakes)
203+
204+ h.ackReady.Store(true)
205+ h.rc.notifyAck()
206+ w := awaitWake(t, h.wakes)
207+ require.True(t, w.reset, "the deferred full redraw must stay sticky through the ack gate")
208+ require.Equal(t, 3, w.coalesced, "the post-ack wake must carry every deferred transition")
209+ requireNoWake(t, h.wakes)
210+
211+ h.rc.notifyAck()
212+ requireNoWake(t, h.wakes)
213+}
214+
215+// --- synchronized output ---------------------------------------------------------
216+
217+func TestRenderCoordinatorSynchronizedOutput(t *testing.T) {
218+ t.Run("completion flushes pending state in one wake", func(t *testing.T) {
219+ h := newCoordinatorHarness(t)
220+ h.syncActive.Store(true)
221+ h.rc.noteSyncBegin(1)
222+ watchdogs := h.armedTimers(t)
223+ require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")
224+ require.Equal(t, maxSyncUpdateDuration, watchdogs[len(watchdogs)-1].duration)
225+
226+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true})
227+ for _, timer := range h.armedTimers(t) {
228+ timer.ch <- time.Time{}
229+ }
230+ requireNoWake(t, h.wakes)
231+
232+ h.syncActive.Store(false)
233+ h.rc.noteSyncEnd(1)
234+ w := awaitWake(t, h.wakes)
235+ require.True(t, w.reset)
236+ requireNoWake(t, h.wakes)
237+ })
238+
239+ t.Run("watchdog forces a flush for a wedged batch", func(t *testing.T) {
240+ h := newCoordinatorHarness(t)
241+ h.syncActive.Store(true)
242+ h.rc.noteSyncBegin(7)
243+ watchdogs := h.armedTimers(t)
244+ require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")
245+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
246+
247+ watchdogs[0].ch <- time.Time{}
248+ w := awaitWake(t, h.wakes)
249+ require.True(t, w.watchdog, "a wedged synchronized batch must be force-flushed by the watchdog")
250+ requireNoWake(t, h.wakes)
251+ })
252+
253+ t.Run("stale watchdog generation cannot wake a completed batch", func(t *testing.T) {
254+ h := newCoordinatorHarness(t)
255+ h.syncActive.Store(true)
256+ h.rc.noteSyncBegin(1)
257+ watchdogs := h.armedTimers(t)
258+ require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")
259+ h.syncActive.Store(false)
260+ h.rc.noteSyncEnd(1)
261+
262+ watchdogs[0].ch <- time.Time{}
263+ requireNoWake(t, h.wakes)
264+ })
265+}
266+
267+// --- preview subscription --------------------------------------------------------
268+
269+func TestRenderCoordinatorPreviewSubscription(t *testing.T) {
270+ h := newCoordinatorHarness(t)
271+ h.rc.subscribePreview(func(w renderWake) { h.previews <- w })
272+
273+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
274+ timers := h.armedTimers(t)
275+ require.NotEmpty(t, timers, "invalidate must arm a deadline timer")
276+ timers[len(timers)-1].ch <- time.Time{}
277+ owner := awaitWake(t, h.wakes)
278+ preview := awaitWake(t, h.previews)
279+ require.Equal(t, owner, preview, "a subscribed preview observes the same coalesced wake")
280+ requireNoWake(t, h.previews)
281+
282+ h.rc.teardownPreview()
283+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
284+ timers = h.armedTimers(t)
285+ require.NotEmpty(t, timers, "invalidate must arm a deadline timer")
286+ timers[len(timers)-1].ch <- time.Time{}
287+ awaitWake(t, h.wakes)
288+ requireNoWake(t, h.previews)
289+}
290+
291+// --- lifecycle and stale callbacks -----------------------------------------------
292+
293+func TestRenderCoordinatorLifecycleDropsStaleWakes(t *testing.T) {
294+ cases := []struct {
295+ name string
296+ teardown func(rc *renderCoordinator, owner *attachedClient)
297+ }{
298+ {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},
299+ {"park", func(rc *renderCoordinator, owner *attachedClient) { rc.notePark(owner) }},
300+ {"session teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.noteSessionTeardown() }},
301+ }
302+ for _, tc := range cases {
303+ t.Run(tc.name, func(t *testing.T) {
304+ h := newCoordinatorHarness(t)
305+ owner := &attachedClient{}
306+ h.rc.attach(owner)
307+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
308+ stale := h.armedTimers(t)
309+ require.NotEmpty(t, stale, "invalidate must arm a deadline timer")
310+
311+ tc.teardown(h.rc, owner)
312+ for _, timer := range stale {
313+ timer.ch <- time.Time{}
314+ }
315+ requireNoWake(t, h.wakes)
316+
317+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
318+ for _, timer := range h.armedTimers(t) {
319+ timer.ch <- time.Time{}
320+ }
321+ requireNoWake(t, h.wakes)
322+ })
323+ }
324+
325+ t.Run("replacement attachment wakes independently", func(t *testing.T) {
326+ h := newCoordinatorHarness(t)
327+ owner := &attachedClient{}
328+ replacement := &attachedClient{}
329+ h.rc.attach(owner)
330+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
331+ stale := h.armedTimers(t)
332+ require.NotEmpty(t, stale, "invalidate must arm a deadline timer")
333+
334+ h.rc.noteReplace(owner, replacement)
335+ h.rc.attach(replacement)
336+ for _, timer := range stale {
337+ timer.ch <- time.Time{}
338+ }
339+ requireNoWake(t, h.wakes)
340+
341+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true})
342+ fresh := h.armedTimers(t)
343+ require.NotEmpty(t, fresh, "a replacement attachment must arm its own deadline")
344+ fresh[len(fresh)-1].ch <- time.Time{}
345+ w := awaitWake(t, h.wakes)
346+ require.True(t, w.reset, "the replacement starts from an independent full state")
347+ requireNoWake(t, h.wakes)
348+ })
349+}
350+
351+// --- resize metadata ownership -----------------------------------------------------
352+
353+func TestRenderCoordinatorResizeMetadata(t *testing.T) {
354+ sz := func(cols, rows int) domain.Size { return domain.Size{Cols: cols, Rows: rows} }
355+ cases := []struct {
356+ name string
357+ run func(t *testing.T, rc *renderCoordinator, owner, replacement *attachedClient)
358+ }{
359+ {
360+ name: "latest request wins with strictly increasing epochs",
361+ run: func(t *testing.T, rc *renderCoordinator, owner, _ *attachedClient) {
362+ require.Equal(t, uint64(1), rc.recordResizeRequest(sz(100, 30), owner))
363+ require.Equal(t, uint64(2), rc.recordResizeRequest(sz(110, 32), owner))
364+ require.Equal(t, uint64(3), rc.recordResizeRequest(sz(120, 40), owner))
365+ snap := rc.resizeSnapshot()
366+ require.Equal(t, sz(120, 40), snap.size)
367+ require.Same(t, owner, snap.source)
368+ require.Equal(t, uint64(3), snap.epoch)
369+
370+ // Metadata ownership is independent of the retained PR #71
371+ // attachment dispatch state.
372+ owner.sendMu.Lock()
373+ generation, pending := owner.resizePaintGeneration, owner.resizePaintPending
374+ owner.sendMu.Unlock()
375+ require.Zero(t, generation, "recording metadata must not touch the PR #71 generation")
376+ require.False(t, pending, "recording metadata must not arm the PR #71 dispatch")
377+ },
378+ },
379+ {
380+ name: "duplicate sizes still advance the epoch deterministically",
381+ run: func(t *testing.T, rc *renderCoordinator, owner, _ *attachedClient) {
382+ require.Equal(t, uint64(1), rc.recordResizeRequest(sz(120, 40), owner))
383+ require.Equal(t, uint64(2), rc.recordResizeRequest(sz(120, 40), owner))
384+ snap := rc.resizeSnapshot()
385+ require.Equal(t, sz(120, 40), snap.size)
386+ require.Equal(t, uint64(2), snap.epoch)
387+ },
388+ },
389+ {
390+ name: "a displaced attachment cannot overwrite current metadata",
391+ run: func(t *testing.T, rc *renderCoordinator, owner, replacement *attachedClient) {
392+ require.Equal(t, uint64(1), rc.recordResizeRequest(sz(120, 40), owner))
393+ rc.noteReplace(owner, replacement)
394+ rc.attach(replacement)
395+
396+ require.Zero(t, rc.recordResizeRequest(sz(80, 20), owner),
397+ "a stale attachment must not advance the resize epoch")
398+ snap := rc.resizeSnapshot()
399+ require.Equal(t, sz(120, 40), snap.size)
400+ require.Equal(t, uint64(1), snap.epoch)
401+
402+ require.Equal(t, uint64(2), rc.recordResizeRequest(sz(100, 50), replacement))
403+ snap = rc.resizeSnapshot()
404+ require.Equal(t, sz(100, 50), snap.size)
405+ require.Same(t, replacement, snap.source)
406+ },
407+ },
408+ {
409+ name: "detach cannot erase committed metadata",
410+ run: func(t *testing.T, rc *renderCoordinator, owner, _ *attachedClient) {
411+ require.Equal(t, uint64(1), rc.recordResizeRequest(sz(120, 40), owner))
412+ rc.noteDetach(owner)
413+ snap := rc.resizeSnapshot()
414+ require.Equal(t, sz(120, 40), snap.size)
415+ require.Equal(t, uint64(1), snap.epoch)
416+ },
417+ },
418+ }
419+ for _, tc := range cases {
420+ t.Run(tc.name, func(t *testing.T) {
421+ h := newCoordinatorHarness(t)
422+ owner := &attachedClient{}
423+ replacement := &attachedClient{}
424+ h.rc.attach(owner)
425+ tc.run(t, h.rc, owner, replacement)
29426 })
30427 }
31428 }
32429
33-func TestRenderCoordinatorContract(t *testing.T) {
34- coordinator := daemonSource(t, "render_coordinator.go")
430+// --- producer fan-in ------------------------------------------------------------
431+
432+// producerFiles is the current production direct-paint inventory. Every file
433+// gets exactly one exercised state transition in TestProducerInvalidations.
434+var producerFiles = []string{
435+ "attention.go", "client.go", "copymode.go", "floating.go", "input.go",
436+ "palette.go", "pane_actions.go", "picker.go", "prompt.go", "render.go",
437+ "session.go", "session_back.go",
438+}
35439
36- for _, contract := range []struct {
440+func TestProducerInvalidations(t *testing.T) {
441+ cases := []struct {
442+ file string
37443 name string
38- want []string
444+ tabs int
445+ run func(t *testing.T, d *Daemon, sess *session, ac *attachedClient)
39446 }{
40447 {
41- name: "cap-one wake and sticky reset",
42- want: []string{"type renderCoordinator", "invalidate(", "wake", "reset"},
448+ file: "attention.go",
449+ name: "attention repaint",
450+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
451+ d.repaintAttachedClients(sess)
452+ },
43453 },
44454 {
45- name: "urgent and adaptive fake-clock deadlines",
46- want: []string{"minDebounceInterval", "maxDebounceInterval", "ports.Clock"},
455+ file: "client.go",
456+ name: "client theme application",
457+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
458+ d.applyTheme(sess, ac, ports.Theme{TrueColor: true})
459+ },
47460 },
48461 {
49- name: "ack gated composition",
50- want: []string{"output", "deferIfAtCapacity", "ack"},
462+ file: "copymode.go",
463+ name: "copy mode entry",
464+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
465+ d.enterCopyMode(sess, ac)
466+ },
51467 },
52468 {
53- name: "sync completion and watchdog",
54- want: []string{"sync", "watchdog"},
469+ file: "floating.go",
470+ name: "floating toggle to visible",
471+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
472+ fp := newPaneWithStableID(layout.PaneID("floating"), "float-producer", newQuietPTY(), domain.Size{Cols: 40, Rows: 10})
473+ tb := sess.activeTab()
474+ tb.mu.Lock()
475+ tb.floating.pane = fp
476+ tb.floating.state = floatingHidden
477+ tb.floating.generation = 1
478+ tb.mu.Unlock()
479+ require.NoError(t, d.toggleFloating(sess, ac))
480+ },
55481 },
56482 {
57- name: "preview and lifecycle teardown",
58- want: []string{"preview", "detach", "replace", "park", "teardown"},
483+ file: "input.go",
484+ name: "switch tab key action",
485+ tabs: 2,
486+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
487+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2)
488+ require.Equal(t, 1, activeTabIndex(sess))
489+ },
59490 },
60491 {
61- name: "latest resize metadata has monotonic epoch",
62- want: []string{"domain.Size", "resizeEpoch", "resize", "snapshot"},
492+ file: "palette.go",
493+ name: "palette entry",
494+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
495+ d.enterPalette(sess, ac)
496+ },
63497 },
64- } {
65- t.Run(contract.name, func(t *testing.T) {
66- for _, token := range contract.want {
67- require.Containsf(t, coordinator, token, "render coordinator must own %s", contract.name)
498+ {
499+ file: "pane_actions.go",
500+ name: "pane split",
501+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
502+ require.NoError(t, d.splitPane(sess, ac, layout.Right))
503+ },
504+ },
505+ {
506+ file: "picker.go",
507+ name: "picker entry",
508+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
509+ d.enterPicker(sess, ac)
510+ },
511+ },
512+ {
513+ file: "prompt.go",
514+ name: "prompt entry",
515+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
516+ d.enterPrompt(sess, ac, "rename", "", func(string) error { return nil })
517+ },
518+ },
519+ {
520+ file: "render.go",
521+ name: "retained resize dispatch",
522+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
523+ clk := &signalClock{timers: make(chan *signalTimer, 2)}
524+ d.clock = clk
525+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 26})
526+ awaitScheduledTimer(t, clk).ch <- time.Time{}
527+ },
528+ },
529+ {
530+ file: "session.go",
531+ name: "tab close repaint",
532+ tabs: 2,
533+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
534+ sess.mu.Lock()
535+ tb := sess.tabs[1]
536+ sess.mu.Unlock()
537+ d.closeTab(sess, tb, true)
538+ },
539+ },
540+ {
541+ file: "session_back.go",
542+ name: "back session fallback without a target",
543+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
544+ d.backSession(sess, ac)
545+ require.Same(t, sess, ac.currentSession())
546+ },
547+ },
548+ }
549+
550+ exercised := make([]string, 0, len(cases))
551+ for _, tc := range cases {
552+ exercised = append(exercised, tc.file)
553+ }
554+ require.ElementsMatch(t, producerFiles, exercised,
555+ "every current direct-paint producer file needs exactly one exercised transition")
556+
557+ for _, tc := range cases {
558+ t.Run(tc.file+"/"+tc.name, func(t *testing.T) {
559+ tabs := tc.tabs
560+ if tabs == 0 {
561+ tabs = 1
68562 }
563+ d, sess, ac, sends, releases := newManualTabSession(t, tabs)
564+ defer releaseAll(releases)
565+ invs := make(chan renderInvalidation, 8)
566+ sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{
567+ clock: d.clock,
568+ wake: func(renderWake) {},
569+ onInvalidate: func(inv renderInvalidation) { invs <- inv },
570+ }))
571+
572+ tc.run(t, d, sess, ac)
573+
574+ awaitInvalidation(t, invs)
575+ requireNoInvalidation(t, invs)
576+ requireNoOutputFrame(t, sends)
69577 })
70578 }
71579 }
72580
73-// PR #71 remains the sole resize timer/generation owner in S1. Its callback
74-// must only request coordinator work; it must not become a second compositor.
581+// TestProducerInvalidationInventory is a supplementary inventory guard only:
582+// it keeps producerFiles aligned with the daemon source layout so the
583+// behavioral table above cannot drift silently. It is never acceptance
584+// evidence for coordinator behavior.
585+func TestProducerInvalidationInventory(t *testing.T) {
586+ for _, name := range producerFiles {
587+ _, err := os.Stat(filepath.Join(".", name))
588+ require.NoErrorf(t, err, "producer file %s is gone; update TestProducerInvalidations", name)
589+ }
590+}
591+
592+// --- retained PR #71 resize dispatch ----------------------------------------------
593+
75594 func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {
76- body := daemonSource(t, "render.go")
77- for _, symbol := range []string{
78- "scheduleResizePaintLocked", "paintForResizeGeneration", "resizePaintGeneration",
79- "resizePaintPending", "cancelResizePaint",
80- } {
81- require.Contains(t, body+daemonSource(t, "client.go"), symbol)
595+ newResizeFixture := func(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, *signalClock, chan renderInvalidation) {
596+ t.Helper()
597+ p, releasePTY := newBlockingPTY(t)
598+ t.Cleanup(releasePTY)
599+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
600+ clk := &signalClock{timers: make(chan *signalTimer, 4)}
601+ d.clock = clk
602+ invs := make(chan renderInvalidation, 4)
603+ sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{
604+ clock: clk,
605+ wake: func(renderWake) {},
606+ onInvalidate: func(inv renderInvalidation) { invs <- inv },
607+ }))
608+ return d, sess, ac, sends, clk, invs
82609 }
83610
84- start := strings.Index(body, "func (d *Daemon) paintForResizeGeneration")
85- require.NotEqual(t, -1, start)
86- callback := body[start:]
87- require.Contains(t, callback, "invalidate(", "the retained PR #71 callback must enter coordinator invalidation")
88- require.NotContains(t, callback, "composeClientFrame", "the retained PR #71 callback must not compose directly")
89-}
611+ t.Run("bounded timer records metadata and dispatches through the coordinator", func(t *testing.T) {
612+ d, sess, ac, sends, clk, invs := newResizeFixture(t)
90613
91-func daemonSource(t *testing.T, name string) string {
92- t.Helper()
93- body, err := os.ReadFile(filepath.Join(".", name))
94- require.NoError(t, err)
95- return string(body)
614+ d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
615+ timer := awaitScheduledTimer(t, clk)
616+ require.GreaterOrEqual(t, timer.duration, minOutputRenderDeadline)
617+ require.LessOrEqual(t, timer.duration, maxOutputRenderDeadline)
618+ ac.sendMu.Lock()
619+ generation, pending := ac.resizePaintGeneration, ac.resizePaintPending
620+ ac.sendMu.Unlock()
621+ require.Equal(t, uint64(1), generation, "PR #71 generation ownership must stay with the attachment")
622+ require.True(t, pending, "PR #71 pending dispatch must remain armed")
623+
624+ snap := sess.renderCoordinator().resizeSnapshot()
625+ require.Equal(t, domain.Size{Cols: 120, Rows: 24}, snap.size,
626+ "the coordinator must record the latest requested resize before delegating")
627+ require.Same(t, ac, snap.source)
628+ require.Equal(t, uint64(1), snap.epoch)
629+ requireNoOutputFrame(t, sends)
630+
631+ timer.ch <- time.Time{}
632+ inv := awaitInvalidation(t, invs)
633+ require.True(t, inv.reset, "the resize dispatch must request a full-redraw invalidation")
634+ requireNoInvalidation(t, invs)
635+ requireNoOutputFrame(t, sends)
636+ })
637+
638+ t.Run("stale generations stay rejected", func(t *testing.T) {
639+ d, sess, ac, sends, clk, invs := newResizeFixture(t)
640+
641+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 24})
642+ first := awaitScheduledTimer(t, clk)
643+ d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
644+ latest := awaitScheduledTimer(t, clk)
645+ require.Equal(t, uint64(2), sess.renderCoordinator().resizeSnapshot().epoch,
646+ "every resize request must advance the coordinator epoch")
647+
648+ first.ch <- time.Time{}
649+ requireNoInvalidation(t, invs)
650+ requireNoOutputFrame(t, sends)
651+
652+ latest.ch <- time.Time{}
653+ inv := awaitInvalidation(t, invs)
654+ require.True(t, inv.reset)
655+ requireNoInvalidation(t, invs)
656+ requireNoOutputFrame(t, sends)
657+ require.Equal(t, domain.Size{Cols: 120, Rows: 24}, sess.renderCoordinator().resizeSnapshot().size)
658+ })
659+
660+ t.Run("cancellation still drops the pending dispatch", func(t *testing.T) {
661+ d, sess, ac, sends, clk, invs := newResizeFixture(t)
662+
663+ d.resize(sess, ac, domain.Size{Cols: 90, Rows: 30})
664+ timer := awaitScheduledTimer(t, clk)
665+ ac.cancelResizePaint()
666+
667+ timer.ch <- time.Time{}
668+ requireNoInvalidation(t, invs)
669+ requireNoOutputFrame(t, sends)
670+ })
96671 }
@@ -3,94 +3,669 @@ package daemon
3 import (3 import (
4 "os"4 "os"
5 "path/filepath"5 "path/filepath"
6- "strings"6+ "sync/atomic"
7 "testing"7 "testing"
8+ "time"
8 9
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/usecase/keys"
15+ "github.com/bnema/vev/internal/usecase/layout"
10 )16 )
11 17
12-// These are deliberately source-level RED contracts. S1 has no coordinator18+// --- coordinator harness ------------------------------------------------------
13-// yet, so exercising the production transitions would either require test-only19+
14-// production shims or fail to compile. Keeping the contract in the daemon20+// coordinatorHarness wires one coordinator to a fake clock and recording
15-// package makes the missing fan-in and retained PR #71 hand-off explicit while21+// hooks. Every assertion is channel- or counter-based; nothing sleeps.
16-// the implementation is introduced in the following GREEN slice.22+type coordinatorHarness struct {
17-func TestRenderCoordinatorProducerFanIn(t *testing.T) {23+ clk *signalClock
18- producers := []string{24+ wakes chan renderWake
19- "attention.go", "client.go", "copymode.go", "floating.go", "input.go",25+ previews chan renderWake
20- "palette.go", "pane_actions.go", "picker.go", "prompt.go", "render.go",26+ ackReady atomic.Bool
21- "session.go", "session_back.go",27+ syncActive atomic.Bool
22- }28+ rc *renderCoordinator
23-29+}
24- for _, name := range producers {30+
25- t.Run(name, func(t *testing.T) {31+func newCoordinatorHarness(t *testing.T) *coordinatorHarness {
26- body := daemonSource(t, name)32+ t.Helper()
27- require.NotContainsf(t, body, "d.paint(", "%s must invalidate the session coordinator rather than compose/send directly", name)33+ h := &coordinatorHarness{
28- require.Containsf(t, body, "invalidate(", "%s must publish its state transition to the render coordinator", name)34+ clk: &signalClock{timers: make(chan *signalTimer, 16)},
35+ wakes: make(chan renderWake, 16),
36+ previews: make(chan renderWake, 16),
37+ }
38+ h.ackReady.Store(true)
39+ h.rc = newRenderCoordinator(renderCoordinatorOptions{
40+ clock: h.clk,
41+ wake: func(w renderWake) { h.wakes <- w },
42+ ackReady: func() bool { return h.ackReady.Load() },
43+ syncActive: func() bool { return h.syncActive.Load() },
44+ })
45+ return h
46+}
47+
48+// armedTimers drains every deadline armed so far. Arming happens
49+// synchronously inside the coordinator call, so a non-blocking drain is
50+// deterministic.
51+func (h *coordinatorHarness) armedTimers(t *testing.T) []*signalTimer {
52+ t.Helper()
53+ var timers []*signalTimer
54+ for {
55+ select {
56+ case tm := <-h.clk.timers:
57+ timers = append(timers, tm)
58+ default:
59+ return timers
60+ }
61+ }
62+}
63+
64+func awaitWake(t *testing.T, ch chan renderWake) renderWake {
65+ t.Helper()
66+ select {
67+ case w := <-ch:
68+ return w
69+ case <-time.After(2 * time.Second):
70+ t.Fatal("timed out waiting for a coordinator wake")
71+ return renderWake{}
72+ }
73+}
74+
75+func requireNoWake(t *testing.T, ch chan renderWake) {
76+ t.Helper()
77+ select {
78+ case w := <-ch:
79+ t.Fatalf("unexpected coordinator wake: %+v", w)
80+ case <-time.After(50 * time.Millisecond):
81+ }
82+}
83+
84+func awaitInvalidation(t *testing.T, ch chan renderInvalidation) renderInvalidation {
85+ t.Helper()
86+ select {
87+ case inv := <-ch:
88+ return inv
89+ case <-time.After(2 * time.Second):
90+ t.Fatal("timed out waiting for a coordinator invalidation")
91+ return renderInvalidation{}
92+ }
93+}
94+
95+func requireNoInvalidation(t *testing.T, ch chan renderInvalidation) {
96+ t.Helper()
97+ select {
98+ case inv := <-ch:
99+ t.Fatalf("unexpected coordinator invalidation: %+v", inv)
100+ case <-time.After(50 * time.Millisecond):
101+ }
102+}
103+
104+func awaitScheduledTimer(t *testing.T, clk *signalClock) *signalTimer {
105+ t.Helper()
106+ select {
107+ case tm := <-clk.timers:
108+ return tm
109+ case <-time.After(2 * time.Second):
110+ t.Fatal("timed out waiting for a scheduled timer")
111+ return nil
112+ }
113+}
114+
115+// --- wake coalescing and deadlines --------------------------------------------
116+
117+func TestRenderCoordinatorCoalescesWakesUnderOneDeadline(t *testing.T) {
118+ cases := []struct {
119+ name string
120+ invalidations []renderInvalidation
121+ wantTimers int
122+ wantMin time.Duration
123+ wantMax time.Duration
124+ wantWake renderWake
125+ }{
126+ {
127+ name: "urgent transition wakes within two milliseconds",
128+ invalidations: []renderInvalidation{{class: invalidateUrgent, reset: true, producer: "input.go"}},
129+ wantTimers: 1,
130+ wantMax: urgentRenderDeadline,
131+ wantWake: renderWake{reset: true, urgent: true, coalesced: 1},
132+ },
133+ {
134+ name: "output burst coalesces into one adaptive deadline",
135+ invalidations: []renderInvalidation{
136+ {class: invalidateOutput, producer: "render.go"},
137+ {class: invalidateOutput, producer: "render.go"},
138+ {class: invalidateOutput, producer: "render.go"},
139+ },
140+ wantTimers: 1,
141+ wantMin: minOutputRenderDeadline,
142+ wantMax: maxOutputRenderDeadline,
143+ wantWake: renderWake{coalesced: 3},
144+ },
145+ {
146+ name: "full redraw stays sticky across later incremental transitions",
147+ invalidations: []renderInvalidation{
148+ {class: invalidateOutput},
149+ {class: invalidateOutput, reset: true},
150+ {class: invalidateOutput},
151+ },
152+ wantTimers: 1,
153+ wantMin: minOutputRenderDeadline,
154+ wantMax: maxOutputRenderDeadline,
155+ wantWake: renderWake{reset: true, coalesced: 3},
156+ },
157+ {
158+ name: "urgent transition tightens a pending output deadline",
159+ invalidations: []renderInvalidation{
160+ {class: invalidateOutput},
161+ {class: invalidateUrgent},
162+ },
163+ wantTimers: 2,
164+ wantMax: urgentRenderDeadline,
165+ wantWake: renderWake{urgent: true, coalesced: 2},
166+ },
167+ }
168+ for _, tc := range cases {
169+ t.Run(tc.name, func(t *testing.T) {
170+ h := newCoordinatorHarness(t)
171+ for _, inv := range tc.invalidations {
172+ h.rc.invalidate(inv)
173+ }
174+ timers := h.armedTimers(t)
175+ require.Lenf(t, timers, tc.wantTimers,
176+ "cap-1 coalescing must arm exactly %d deadline timer(s) for %d invalidations",
177+ tc.wantTimers, len(tc.invalidations))
178+ last := timers[len(timers)-1]
179+ require.GreaterOrEqual(t, last.duration, tc.wantMin)
180+ require.LessOrEqual(t, last.duration, tc.wantMax)
181+
182+ last.ch <- time.Time{}
183+ require.Equal(t, tc.wantWake, awaitWake(t, h.wakes),
184+ "one wake must deliver the latest coalesced state")
185+ requireNoWake(t, h.wakes)
186+ })
187+ }
188+}
189+
190+// --- ACK gating ----------------------------------------------------------------
191+
192+func TestRenderCoordinatorAckGateBlocksCompositionUntilAck(t *testing.T) {
193+ h := newCoordinatorHarness(t)
194+ h.ackReady.Store(false)
195+
196+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
197+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true})
198+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
199+ for _, timer := range h.armedTimers(t) {
200+ timer.ch <- time.Time{}
201+ }
202+ requireNoWake(t, h.wakes)
203+
204+ h.ackReady.Store(true)
205+ h.rc.notifyAck()
206+ w := awaitWake(t, h.wakes)
207+ require.True(t, w.reset, "the deferred full redraw must stay sticky through the ack gate")
208+ require.Equal(t, 3, w.coalesced, "the post-ack wake must carry every deferred transition")
209+ requireNoWake(t, h.wakes)
210+
211+ h.rc.notifyAck()
212+ requireNoWake(t, h.wakes)
213+}
214+
215+// --- synchronized output ---------------------------------------------------------
216+
217+func TestRenderCoordinatorSynchronizedOutput(t *testing.T) {
218+ t.Run("completion flushes pending state in one wake", func(t *testing.T) {
219+ h := newCoordinatorHarness(t)
220+ h.syncActive.Store(true)
221+ h.rc.noteSyncBegin(1)
222+ watchdogs := h.armedTimers(t)
223+ require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")
224+ require.Equal(t, maxSyncUpdateDuration, watchdogs[len(watchdogs)-1].duration)
225+
226+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true})
227+ for _, timer := range h.armedTimers(t) {
228+ timer.ch <- time.Time{}
229+ }
230+ requireNoWake(t, h.wakes)
231+
232+ h.syncActive.Store(false)
233+ h.rc.noteSyncEnd(1)
234+ w := awaitWake(t, h.wakes)
235+ require.True(t, w.reset)
236+ requireNoWake(t, h.wakes)
237+ })
238+
239+ t.Run("watchdog forces a flush for a wedged batch", func(t *testing.T) {
240+ h := newCoordinatorHarness(t)
241+ h.syncActive.Store(true)
242+ h.rc.noteSyncBegin(7)
243+ watchdogs := h.armedTimers(t)
244+ require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")
245+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
246+
247+ watchdogs[0].ch <- time.Time{}
248+ w := awaitWake(t, h.wakes)
249+ require.True(t, w.watchdog, "a wedged synchronized batch must be force-flushed by the watchdog")
250+ requireNoWake(t, h.wakes)
251+ })
252+
253+ t.Run("stale watchdog generation cannot wake a completed batch", func(t *testing.T) {
254+ h := newCoordinatorHarness(t)
255+ h.syncActive.Store(true)
256+ h.rc.noteSyncBegin(1)
257+ watchdogs := h.armedTimers(t)
258+ require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")
259+ h.syncActive.Store(false)
260+ h.rc.noteSyncEnd(1)
261+
262+ watchdogs[0].ch <- time.Time{}
263+ requireNoWake(t, h.wakes)
264+ })
265+}
266+
267+// --- preview subscription --------------------------------------------------------
268+
269+func TestRenderCoordinatorPreviewSubscription(t *testing.T) {
270+ h := newCoordinatorHarness(t)
271+ h.rc.subscribePreview(func(w renderWake) { h.previews <- w })
272+
273+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
274+ timers := h.armedTimers(t)
275+ require.NotEmpty(t, timers, "invalidate must arm a deadline timer")
276+ timers[len(timers)-1].ch <- time.Time{}
277+ owner := awaitWake(t, h.wakes)
278+ preview := awaitWake(t, h.previews)
279+ require.Equal(t, owner, preview, "a subscribed preview observes the same coalesced wake")
280+ requireNoWake(t, h.previews)
281+
282+ h.rc.teardownPreview()
283+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
284+ timers = h.armedTimers(t)
285+ require.NotEmpty(t, timers, "invalidate must arm a deadline timer")
286+ timers[len(timers)-1].ch <- time.Time{}
287+ awaitWake(t, h.wakes)
288+ requireNoWake(t, h.previews)
289+}
290+
291+// --- lifecycle and stale callbacks -----------------------------------------------
292+
293+func TestRenderCoordinatorLifecycleDropsStaleWakes(t *testing.T) {
294+ cases := []struct {
295+ name string
296+ teardown func(rc *renderCoordinator, owner *attachedClient)
297+ }{
298+ {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},
299+ {"park", func(rc *renderCoordinator, owner *attachedClient) { rc.notePark(owner) }},
300+ {"session teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.noteSessionTeardown() }},
301+ }
302+ for _, tc := range cases {
303+ t.Run(tc.name, func(t *testing.T) {
304+ h := newCoordinatorHarness(t)
305+ owner := &attachedClient{}
306+ h.rc.attach(owner)
307+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
308+ stale := h.armedTimers(t)
309+ require.NotEmpty(t, stale, "invalidate must arm a deadline timer")
310+
311+ tc.teardown(h.rc, owner)
312+ for _, timer := range stale {
313+ timer.ch <- time.Time{}
314+ }
315+ requireNoWake(t, h.wakes)
316+
317+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
318+ for _, timer := range h.armedTimers(t) {
319+ timer.ch <- time.Time{}
320+ }
321+ requireNoWake(t, h.wakes)
322+ })
323+ }
324+
325+ t.Run("replacement attachment wakes independently", func(t *testing.T) {
326+ h := newCoordinatorHarness(t)
327+ owner := &attachedClient{}
328+ replacement := &attachedClient{}
329+ h.rc.attach(owner)
330+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
331+ stale := h.armedTimers(t)
332+ require.NotEmpty(t, stale, "invalidate must arm a deadline timer")
333+
334+ h.rc.noteReplace(owner, replacement)
335+ h.rc.attach(replacement)
336+ for _, timer := range stale {
337+ timer.ch <- time.Time{}
338+ }
339+ requireNoWake(t, h.wakes)
340+
341+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true})
342+ fresh := h.armedTimers(t)
343+ require.NotEmpty(t, fresh, "a replacement attachment must arm its own deadline")
344+ fresh[len(fresh)-1].ch <- time.Time{}
345+ w := awaitWake(t, h.wakes)
346+ require.True(t, w.reset, "the replacement starts from an independent full state")
347+ requireNoWake(t, h.wakes)
348+ })
349+}
350+
351+// --- resize metadata ownership -----------------------------------------------------
352+
353+func TestRenderCoordinatorResizeMetadata(t *testing.T) {
354+ sz := func(cols, rows int) domain.Size { return domain.Size{Cols: cols, Rows: rows} }
355+ cases := []struct {
356+ name string
357+ run func(t *testing.T, rc *renderCoordinator, owner, replacement *attachedClient)
358+ }{
359+ {
360+ name: "latest request wins with strictly increasing epochs",
361+ run: func(t *testing.T, rc *renderCoordinator, owner, _ *attachedClient) {
362+ require.Equal(t, uint64(1), rc.recordResizeRequest(sz(100, 30), owner))
363+ require.Equal(t, uint64(2), rc.recordResizeRequest(sz(110, 32), owner))
364+ require.Equal(t, uint64(3), rc.recordResizeRequest(sz(120, 40), owner))
365+ snap := rc.resizeSnapshot()
366+ require.Equal(t, sz(120, 40), snap.size)
367+ require.Same(t, owner, snap.source)
368+ require.Equal(t, uint64(3), snap.epoch)
369+
370+ // Metadata ownership is independent of the retained PR #71
371+ // attachment dispatch state.
372+ owner.sendMu.Lock()
373+ generation, pending := owner.resizePaintGeneration, owner.resizePaintPending
374+ owner.sendMu.Unlock()
375+ require.Zero(t, generation, "recording metadata must not touch the PR #71 generation")
376+ require.False(t, pending, "recording metadata must not arm the PR #71 dispatch")
377+ },
378+ },
379+ {
380+ name: "duplicate sizes still advance the epoch deterministically",
381+ run: func(t *testing.T, rc *renderCoordinator, owner, _ *attachedClient) {
382+ require.Equal(t, uint64(1), rc.recordResizeRequest(sz(120, 40), owner))
383+ require.Equal(t, uint64(2), rc.recordResizeRequest(sz(120, 40), owner))
384+ snap := rc.resizeSnapshot()
385+ require.Equal(t, sz(120, 40), snap.size)
386+ require.Equal(t, uint64(2), snap.epoch)
387+ },
388+ },
389+ {
390+ name: "a displaced attachment cannot overwrite current metadata",
391+ run: func(t *testing.T, rc *renderCoordinator, owner, replacement *attachedClient) {
392+ require.Equal(t, uint64(1), rc.recordResizeRequest(sz(120, 40), owner))
393+ rc.noteReplace(owner, replacement)
394+ rc.attach(replacement)
395+
396+ require.Zero(t, rc.recordResizeRequest(sz(80, 20), owner),
397+ "a stale attachment must not advance the resize epoch")
398+ snap := rc.resizeSnapshot()
399+ require.Equal(t, sz(120, 40), snap.size)
400+ require.Equal(t, uint64(1), snap.epoch)
401+
402+ require.Equal(t, uint64(2), rc.recordResizeRequest(sz(100, 50), replacement))
403+ snap = rc.resizeSnapshot()
404+ require.Equal(t, sz(100, 50), snap.size)
405+ require.Same(t, replacement, snap.source)
406+ },
407+ },
408+ {
409+ name: "detach cannot erase committed metadata",
410+ run: func(t *testing.T, rc *renderCoordinator, owner, _ *attachedClient) {
411+ require.Equal(t, uint64(1), rc.recordResizeRequest(sz(120, 40), owner))
412+ rc.noteDetach(owner)
413+ snap := rc.resizeSnapshot()
414+ require.Equal(t, sz(120, 40), snap.size)
415+ require.Equal(t, uint64(1), snap.epoch)
416+ },
417+ },
418+ }
419+ for _, tc := range cases {
420+ t.Run(tc.name, func(t *testing.T) {
421+ h := newCoordinatorHarness(t)
422+ owner := &attachedClient{}
423+ replacement := &attachedClient{}
424+ h.rc.attach(owner)
425+ tc.run(t, h.rc, owner, replacement)
29 })426 })
30 }427 }
31 }428 }
32 429
33-func TestRenderCoordinatorContract(t *testing.T) {430+// --- producer fan-in ------------------------------------------------------------
34- coordinator := daemonSource(t, "render_coordinator.go")431+
432+// producerFiles is the current production direct-paint inventory. Every file
433+// gets exactly one exercised state transition in TestProducerInvalidations.
434+var producerFiles = []string{
435+ "attention.go", "client.go", "copymode.go", "floating.go", "input.go",
436+ "palette.go", "pane_actions.go", "picker.go", "prompt.go", "render.go",
437+ "session.go", "session_back.go",
438+}
35 439
36- for _, contract := range []struct {440+func TestProducerInvalidations(t *testing.T) {
441+ cases := []struct {
442+ file string
37 name string443 name string
38- want []string444+ tabs int
445+ run func(t *testing.T, d *Daemon, sess *session, ac *attachedClient)
39 }{446 }{
40 {447 {
41- name: "cap-one wake and sticky reset",448+ file: "attention.go",
42- want: []string{"type renderCoordinator", "invalidate(", "wake", "reset"},449+ name: "attention repaint",
450+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
451+ d.repaintAttachedClients(sess)
452+ },
43 },453 },
44 {454 {
45- name: "urgent and adaptive fake-clock deadlines",455+ file: "client.go",
46- want: []string{"minDebounceInterval", "maxDebounceInterval", "ports.Clock"},456+ name: "client theme application",
457+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
458+ d.applyTheme(sess, ac, ports.Theme{TrueColor: true})
459+ },
47 },460 },
48 {461 {
49- name: "ack gated composition",462+ file: "copymode.go",
50- want: []string{"output", "deferIfAtCapacity", "ack"},463+ name: "copy mode entry",
464+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
465+ d.enterCopyMode(sess, ac)
466+ },
51 },467 },
52 {468 {
53- name: "sync completion and watchdog",469+ file: "floating.go",
54- want: []string{"sync", "watchdog"},470+ name: "floating toggle to visible",
471+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
472+ fp := newPaneWithStableID(layout.PaneID("floating"), "float-producer", newQuietPTY(), domain.Size{Cols: 40, Rows: 10})
473+ tb := sess.activeTab()
474+ tb.mu.Lock()
475+ tb.floating.pane = fp
476+ tb.floating.state = floatingHidden
477+ tb.floating.generation = 1
478+ tb.mu.Unlock()
479+ require.NoError(t, d.toggleFloating(sess, ac))
480+ },
55 },481 },
56 {482 {
57- name: "preview and lifecycle teardown",483+ file: "input.go",
58- want: []string{"preview", "detach", "replace", "park", "teardown"},484+ name: "switch tab key action",
485+ tabs: 2,
486+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
487+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2)
488+ require.Equal(t, 1, activeTabIndex(sess))
489+ },
59 },490 },
60 {491 {
61- name: "latest resize metadata has monotonic epoch",492+ file: "palette.go",
62- want: []string{"domain.Size", "resizeEpoch", "resize", "snapshot"},493+ name: "palette entry",
494+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
495+ d.enterPalette(sess, ac)
496+ },
63 },497 },
64- } {498+ {
65- t.Run(contract.name, func(t *testing.T) {499+ file: "pane_actions.go",
66- for _, token := range contract.want {500+ name: "pane split",
67- require.Containsf(t, coordinator, token, "render coordinator must own %s", contract.name)501+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
502+ require.NoError(t, d.splitPane(sess, ac, layout.Right))
503+ },
504+ },
505+ {
506+ file: "picker.go",
507+ name: "picker entry",
508+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
509+ d.enterPicker(sess, ac)
510+ },
511+ },
512+ {
513+ file: "prompt.go",
514+ name: "prompt entry",
515+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
516+ d.enterPrompt(sess, ac, "rename", "", func(string) error { return nil })
517+ },
518+ },
519+ {
520+ file: "render.go",
521+ name: "retained resize dispatch",
522+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
523+ clk := &signalClock{timers: make(chan *signalTimer, 2)}
524+ d.clock = clk
525+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 26})
526+ awaitScheduledTimer(t, clk).ch <- time.Time{}
527+ },
528+ },
529+ {
530+ file: "session.go",
531+ name: "tab close repaint",
532+ tabs: 2,
533+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
534+ sess.mu.Lock()
535+ tb := sess.tabs[1]
536+ sess.mu.Unlock()
537+ d.closeTab(sess, tb, true)
538+ },
539+ },
540+ {
541+ file: "session_back.go",
542+ name: "back session fallback without a target",
543+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
544+ d.backSession(sess, ac)
545+ require.Same(t, sess, ac.currentSession())
546+ },
547+ },
548+ }
549+
550+ exercised := make([]string, 0, len(cases))
551+ for _, tc := range cases {
552+ exercised = append(exercised, tc.file)
553+ }
554+ require.ElementsMatch(t, producerFiles, exercised,
555+ "every current direct-paint producer file needs exactly one exercised transition")
556+
557+ for _, tc := range cases {
558+ t.Run(tc.file+"/"+tc.name, func(t *testing.T) {
559+ tabs := tc.tabs
560+ if tabs == 0 {
561+ tabs = 1
68 }562 }
563+ d, sess, ac, sends, releases := newManualTabSession(t, tabs)
564+ defer releaseAll(releases)
565+ invs := make(chan renderInvalidation, 8)
566+ sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{
567+ clock: d.clock,
568+ wake: func(renderWake) {},
569+ onInvalidate: func(inv renderInvalidation) { invs <- inv },
570+ }))
571+
572+ tc.run(t, d, sess, ac)
573+
574+ awaitInvalidation(t, invs)
575+ requireNoInvalidation(t, invs)
576+ requireNoOutputFrame(t, sends)
69 })577 })
70 }578 }
71 }579 }
72 580
73-// PR #71 remains the sole resize timer/generation owner in S1. Its callback581+// TestProducerInvalidationInventory is a supplementary inventory guard only:
74-// must only request coordinator work; it must not become a second compositor.582+// it keeps producerFiles aligned with the daemon source layout so the
583+// behavioral table above cannot drift silently. It is never acceptance
584+// evidence for coordinator behavior.
585+func TestProducerInvalidationInventory(t *testing.T) {
586+ for _, name := range producerFiles {
587+ _, err := os.Stat(filepath.Join(".", name))
588+ require.NoErrorf(t, err, "producer file %s is gone; update TestProducerInvalidations", name)
589+ }
590+}
591+
592+// --- retained PR #71 resize dispatch ----------------------------------------------
593+
75 func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {594 func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {
76- body := daemonSource(t, "render.go")595+ newResizeFixture := func(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, *signalClock, chan renderInvalidation) {
77- for _, symbol := range []string{596+ t.Helper()
78- "scheduleResizePaintLocked", "paintForResizeGeneration", "resizePaintGeneration",597+ p, releasePTY := newBlockingPTY(t)
79- "resizePaintPending", "cancelResizePaint",598+ t.Cleanup(releasePTY)
80- } {599+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
81- require.Contains(t, body+daemonSource(t, "client.go"), symbol)600+ clk := &signalClock{timers: make(chan *signalTimer, 4)}
601+ d.clock = clk
602+ invs := make(chan renderInvalidation, 4)
603+ sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{
604+ clock: clk,
605+ wake: func(renderWake) {},
606+ onInvalidate: func(inv renderInvalidation) { invs <- inv },
607+ }))
608+ return d, sess, ac, sends, clk, invs
82 }609 }
83 610
84- start := strings.Index(body, "func (d *Daemon) paintForResizeGeneration")611+ t.Run("bounded timer records metadata and dispatches through the coordinator", func(t *testing.T) {
85- require.NotEqual(t, -1, start)612+ d, sess, ac, sends, clk, invs := newResizeFixture(t)
86- callback := body[start:]
87- require.Contains(t, callback, "invalidate(", "the retained PR #71 callback must enter coordinator invalidation")
88- require.NotContains(t, callback, "composeClientFrame", "the retained PR #71 callback must not compose directly")
89-}
90 613
91-func daemonSource(t *testing.T, name string) string {614+ d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
92- t.Helper()615+ timer := awaitScheduledTimer(t, clk)
93- body, err := os.ReadFile(filepath.Join(".", name))616+ require.GreaterOrEqual(t, timer.duration, minOutputRenderDeadline)
94- require.NoError(t, err)617+ require.LessOrEqual(t, timer.duration, maxOutputRenderDeadline)
95- return string(body)618+ ac.sendMu.Lock()
619+ generation, pending := ac.resizePaintGeneration, ac.resizePaintPending
620+ ac.sendMu.Unlock()
621+ require.Equal(t, uint64(1), generation, "PR #71 generation ownership must stay with the attachment")
622+ require.True(t, pending, "PR #71 pending dispatch must remain armed")
623+
624+ snap := sess.renderCoordinator().resizeSnapshot()
625+ require.Equal(t, domain.Size{Cols: 120, Rows: 24}, snap.size,
626+ "the coordinator must record the latest requested resize before delegating")
627+ require.Same(t, ac, snap.source)
628+ require.Equal(t, uint64(1), snap.epoch)
629+ requireNoOutputFrame(t, sends)
630+
631+ timer.ch <- time.Time{}
632+ inv := awaitInvalidation(t, invs)
633+ require.True(t, inv.reset, "the resize dispatch must request a full-redraw invalidation")
634+ requireNoInvalidation(t, invs)
635+ requireNoOutputFrame(t, sends)
636+ })
637+
638+ t.Run("stale generations stay rejected", func(t *testing.T) {
639+ d, sess, ac, sends, clk, invs := newResizeFixture(t)
640+
641+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 24})
642+ first := awaitScheduledTimer(t, clk)
643+ d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
644+ latest := awaitScheduledTimer(t, clk)
645+ require.Equal(t, uint64(2), sess.renderCoordinator().resizeSnapshot().epoch,
646+ "every resize request must advance the coordinator epoch")
647+
648+ first.ch <- time.Time{}
649+ requireNoInvalidation(t, invs)
650+ requireNoOutputFrame(t, sends)
651+
652+ latest.ch <- time.Time{}
653+ inv := awaitInvalidation(t, invs)
654+ require.True(t, inv.reset)
655+ requireNoInvalidation(t, invs)
656+ requireNoOutputFrame(t, sends)
657+ require.Equal(t, domain.Size{Cols: 120, Rows: 24}, sess.renderCoordinator().resizeSnapshot().size)
658+ })
659+
660+ t.Run("cancellation still drops the pending dispatch", func(t *testing.T) {
661+ d, sess, ac, sends, clk, invs := newResizeFixture(t)
662+
663+ d.resize(sess, ac, domain.Size{Cols: 90, Rows: 30})
664+ timer := awaitScheduledTimer(t, clk)
665+ ac.cancelResizePaint()
666+
667+ timer.ch <- time.Time{}
668+ requireNoInvalidation(t, invs)
669+ requireNoOutputFrame(t, sends)
670+ })
96 }671 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close