detached d74f5ba

test(daemon): complete transactional resize red matrix

commit
d74f5ba24321f09dccac4d816daa6c16520ac6de
author
brice · · 2026-07-12 07:56 UTC
parent
e24215f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +117 -30
M internal/usecase/daemon/transactional_resize_test.go +117 -30
@@ -5,11 +5,14 @@ import (
55 "io"
66 "sync"
77 "testing"
8+ "time"
89
910 "github.com/stretchr/testify/require"
1011
1112 "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
1214 "github.com/bnema/vev/internal/usecase/layout"
15+ "github.com/bnema/vev/internal/usecase/visualsearch"
1316 )
1417
1518 // transactionalResizePTY is deliberately channel-scripted: a test can stop a
@@ -104,24 +107,94 @@ func TestTransactionalResizePartialFailureCommitsOnlySuccessfulPTYState(t *testi
104107 require.NotEqual(t, oldFailedRect, second.rect, "failed PTY rectangle still follows the committed layout")
105108 }
106109
110+// S3 acceptance: a failed member is retried only for the newest committed
111+// epoch. An intervening request supersedes the old retry, and a successful
112+// retry must resize its VT before the forced full-frame emission.
113+func TestTransactionalResizeRetryTargetsNewestCommittedEpoch(t *testing.T) {
114+ ok := &transactionalResizePTY{}
115+ failed := &transactionalResizePTY{errs: []error{errors.New("first epoch fails"), nil}}
116+ d, sess, ac, _ := newManualSessionWithPTYs(t, ok)
117+ tb := sess.activeTab()
118+ retry := newPane("retry", failed, domain.Size{Cols: 80, Rows: 23})
119+ tb.mu.Lock()
120+ tb.panes[retry.id] = retry
121+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("retry")}}, Focus: "pane-1"}
122+ tb.mu.Unlock()
123+
124+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30}) // failed epoch
125+ d.resize(sess, ac, domain.Size{Cols: 120, Rows: 34}) // supersedes it
126+
127+ // The failed pane must not be retried at 49x28 from the obsolete epoch.
128+ // S3 retries 59x32, then commits the parser/screen and requests a reset.
129+ require.Equal(t, []domain.Size{{Cols: 59, Rows: 32}}, failed.requested())
130+ require.Equal(t, domain.Size{Cols: 59, Rows: 32}, domain.Size{Cols: retry.screen.Frame.Width, Rows: retry.screen.Frame.Height})
131+ ac.sendMu.Lock()
132+ pending := ac.resizePaintPending
133+ ac.sendMu.Unlock()
134+ require.True(t, pending, "successful retry forces a final full frame")
135+}
136+
107137 // Stale lifecycle callbacks may never advance a transaction owned by a
108138 // replacement attachment. The coordinator test clock drives callbacks through
109139 // channels elsewhere in this package; this ownership assertion is deliberately
110140 // synchronous so it cannot hide a race behind a sleep.
111141 func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) {
112- rc := newRenderCoordinator(renderCoordinatorOptions{})
113- old, replacement := &attachedClient{}, &attachedClient{}
114- rc.attach(old)
115- require.Equal(t, uint64(1), rc.recordResizeRequest(domain.Size{Cols: 90, Rows: 24}, old))
116- require.Equal(t, uint64(2), rc.recordResizeRequest(domain.Size{Cols: 100, Rows: 26}, old))
117- require.Equal(t, uint64(3), rc.recordResizeRequest(domain.Size{Cols: 120, Rows: 30}, old))
118- rc.noteReplace(old, replacement)
119- rc.attach(replacement)
120- require.Zero(t, rc.recordResizeRequest(domain.Size{Cols: 20, Rows: 5}, old),
121- "obsolete attach/replace/detach/park/resume callbacks cannot publish")
122- snap := rc.resizeSnapshot()
123- require.Equal(t, uint64(3), snap.epoch)
124- require.Equal(t, domain.Size{Cols: 120, Rows: 30}, snap.size)
142+ // Each closure is a callback captured before its lifecycle transition. The
143+ // coordinator must reject it, rather than merely coalescing its paint.
144+ for _, transition := range []struct {
145+ name string
146+ run func(*renderCoordinator, *attachedClient, *attachedClient)
147+ }{
148+ {"replace", func(rc *renderCoordinator, old, next *attachedClient) { rc.noteReplace(old, next) }},
149+ {"detach", func(rc *renderCoordinator, old, _ *attachedClient) { rc.noteDetach(old) }},
150+ {"park", func(rc *renderCoordinator, old, _ *attachedClient) { rc.notePark(old) }},
151+ {"resume", func(rc *renderCoordinator, old, next *attachedClient) { rc.notePark(old); rc.attach(next) }},
152+ } {
153+ t.Run(transition.name, func(t *testing.T) {
154+ rc := newRenderCoordinator(renderCoordinatorOptions{})
155+ old, next := &attachedClient{}, &attachedClient{}
156+ rc.attach(old)
157+ require.Equal(t, uint64(1), rc.recordResizeRequest(domain.Size{Cols: 90, Rows: 24}, old))
158+ stale := func() uint64 { return rc.recordResizeRequest(domain.Size{Cols: 20, Rows: 5}, old) }
159+ transition.run(rc, old, next)
160+ require.Zero(t, stale(), "a stale %s callback must not publish", transition.name)
161+ })
162+ }
163+}
164+
165+// S3 acceptance: three callbacks can be delivered even after their timers were
166+// stopped. Only the newest prepared epoch may apply, commit, and emit one full
167+// frame. coordinatorMockClock is a generated MockClock/MockTimer harness; its
168+// channels make callback order explicit and do not use wall-clock waits.
169+func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testing.T) {
170+ clock := newCoordinatorMockClock(t, 8)
171+ pty := &transactionalResizePTY{}
172+ d, sess, ac, frames := newManualSessionWithPTYs(t, pty)
173+ d.clock = clock.clock
174+
175+ for _, size := range []domain.Size{{Cols: 90, Rows: 24}, {Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}} {
176+ d.resize(sess, ac, size)
177+ }
178+ // Current PR #71 applies each resize before its debounce callback. S3 must
179+ // leave this empty until the final callback's prepare/apply/commit epoch.
180+ require.Empty(t, pty.requested(), "obsolete requests must not resize the PTY")
181+
182+ var timers []*coordinatorMockTimer
183+ for len(timers) < 3 {
184+ timers = append(timers, <-clock.timers)
185+ }
186+ for _, timer := range timers {
187+ timer.ch <- time.Time{}
188+ }
189+ require.Equal(t, []domain.Size{{Cols: 120, Rows: 30}}, pty.requested())
190+ require.Equal(t, uint64(3), sess.renderCoordinator().resizeSnapshot().epoch)
191+ frame := <-frames
192+ require.Equal(t, ports.MsgOutput, frame.Type, "the committed epoch emits a full frame")
193+ select {
194+ case extra := <-frames:
195+ t.Fatalf("obsolete epoch emitted an additional frame: %#v", extra)
196+ default:
197+ }
125198 }
126199
127200 // Entering copy mode is invalidated at transaction prepare time, while a
@@ -129,21 +202,35 @@ func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) {
129202 // composition. Channels, rather than a delay, make the publication boundary
130203 // deterministic.
131204 func TestTransactionalResizeFloatingMouseCopyAndSearchContract(t *testing.T) {
132- entered, release := make(chan struct{}), make(chan struct{})
133- pty := &transactionalResizePTY{onResize: func() { close(entered); <-release }}
134- d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
135- tb := sess.activeTab()
136- p := tb.focusedPane()
137- old := p.rect
138- d.enterCopyMode(sess, ac)
139- done := make(chan struct{})
140- go func() {
141- d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
142- close(done)
143- }()
144- <-entered
145- require.False(t, ac.overlays.copyActive(), "prepare invalidates copy mode and visual search")
146- require.Equal(t, old, p.rect, "mouse uses the old committed geometry before publication")
147- close(release)
148- <-done
205+ for _, tc := range []struct {
206+ name string
207+ errs []error
208+ }{
209+ {name: "success"},
210+ {name: "partial failure", errs: []error{errors.New("resize failed")}},
211+ } {
212+ t.Run(tc.name, func(t *testing.T) {
213+ entered, release := make(chan struct{}), make(chan struct{})
214+ pty := &transactionalResizePTY{errs: tc.errs, onResize: func() { close(entered); <-release }}
215+ d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
216+ tb := sess.activeTab()
217+ p := tb.focusedPane()
218+ old := p.rect
219+ d.enterCopyMode(sess, ac)
220+ ac.overlays.copyMu.Lock()
221+ ac.overlays.copySearch = &visualsearch.Model{}
222+ ac.overlays.copyMu.Unlock()
223+ done := make(chan struct{})
224+ go func() {
225+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
226+ close(done)
227+ }()
228+ <-entered
229+ require.False(t, ac.overlays.copyActive(), "prepare invalidates copy mode")
230+ require.False(t, ac.overlays.copySearchActive(), "prepare invalidates visual search")
231+ require.Equal(t, old, p.rect, "mouse uses old committed geometry before publication")
232+ close(release)
233+ <-done
234+ })
235+ }
149236 }
@@ -5,11 +5,14 @@ import (
5 "io"5 "io"
6 "sync"6 "sync"
7 "testing"7 "testing"
8+ "time"
8 9
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
10 11
11 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
12 "github.com/bnema/vev/internal/usecase/layout"14 "github.com/bnema/vev/internal/usecase/layout"
15+ "github.com/bnema/vev/internal/usecase/visualsearch"
13 )16 )
14 17
15 // transactionalResizePTY is deliberately channel-scripted: a test can stop a18 // transactionalResizePTY is deliberately channel-scripted: a test can stop a
@@ -104,24 +107,94 @@ func TestTransactionalResizePartialFailureCommitsOnlySuccessfulPTYState(t *testi
104 require.NotEqual(t, oldFailedRect, second.rect, "failed PTY rectangle still follows the committed layout")107 require.NotEqual(t, oldFailedRect, second.rect, "failed PTY rectangle still follows the committed layout")
105 }108 }
106 109
110+// S3 acceptance: a failed member is retried only for the newest committed
111+// epoch. An intervening request supersedes the old retry, and a successful
112+// retry must resize its VT before the forced full-frame emission.
113+func TestTransactionalResizeRetryTargetsNewestCommittedEpoch(t *testing.T) {
114+ ok := &transactionalResizePTY{}
115+ failed := &transactionalResizePTY{errs: []error{errors.New("first epoch fails"), nil}}
116+ d, sess, ac, _ := newManualSessionWithPTYs(t, ok)
117+ tb := sess.activeTab()
118+ retry := newPane("retry", failed, domain.Size{Cols: 80, Rows: 23})
119+ tb.mu.Lock()
120+ tb.panes[retry.id] = retry
121+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("retry")}}, Focus: "pane-1"}
122+ tb.mu.Unlock()
123+
124+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30}) // failed epoch
125+ d.resize(sess, ac, domain.Size{Cols: 120, Rows: 34}) // supersedes it
126+
127+ // The failed pane must not be retried at 49x28 from the obsolete epoch.
128+ // S3 retries 59x32, then commits the parser/screen and requests a reset.
129+ require.Equal(t, []domain.Size{{Cols: 59, Rows: 32}}, failed.requested())
130+ require.Equal(t, domain.Size{Cols: 59, Rows: 32}, domain.Size{Cols: retry.screen.Frame.Width, Rows: retry.screen.Frame.Height})
131+ ac.sendMu.Lock()
132+ pending := ac.resizePaintPending
133+ ac.sendMu.Unlock()
134+ require.True(t, pending, "successful retry forces a final full frame")
135+}
136+
107 // Stale lifecycle callbacks may never advance a transaction owned by a137 // Stale lifecycle callbacks may never advance a transaction owned by a
108 // replacement attachment. The coordinator test clock drives callbacks through138 // replacement attachment. The coordinator test clock drives callbacks through
109 // channels elsewhere in this package; this ownership assertion is deliberately139 // channels elsewhere in this package; this ownership assertion is deliberately
110 // synchronous so it cannot hide a race behind a sleep.140 // synchronous so it cannot hide a race behind a sleep.
111 func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) {141 func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) {
112- rc := newRenderCoordinator(renderCoordinatorOptions{})142+ // Each closure is a callback captured before its lifecycle transition. The
113- old, replacement := &attachedClient{}, &attachedClient{}143+ // coordinator must reject it, rather than merely coalescing its paint.
114- rc.attach(old)144+ for _, transition := range []struct {
115- require.Equal(t, uint64(1), rc.recordResizeRequest(domain.Size{Cols: 90, Rows: 24}, old))145+ name string
116- require.Equal(t, uint64(2), rc.recordResizeRequest(domain.Size{Cols: 100, Rows: 26}, old))146+ run func(*renderCoordinator, *attachedClient, *attachedClient)
117- require.Equal(t, uint64(3), rc.recordResizeRequest(domain.Size{Cols: 120, Rows: 30}, old))147+ }{
118- rc.noteReplace(old, replacement)148+ {"replace", func(rc *renderCoordinator, old, next *attachedClient) { rc.noteReplace(old, next) }},
119- rc.attach(replacement)149+ {"detach", func(rc *renderCoordinator, old, _ *attachedClient) { rc.noteDetach(old) }},
120- require.Zero(t, rc.recordResizeRequest(domain.Size{Cols: 20, Rows: 5}, old),150+ {"park", func(rc *renderCoordinator, old, _ *attachedClient) { rc.notePark(old) }},
121- "obsolete attach/replace/detach/park/resume callbacks cannot publish")151+ {"resume", func(rc *renderCoordinator, old, next *attachedClient) { rc.notePark(old); rc.attach(next) }},
122- snap := rc.resizeSnapshot()152+ } {
123- require.Equal(t, uint64(3), snap.epoch)153+ t.Run(transition.name, func(t *testing.T) {
124- require.Equal(t, domain.Size{Cols: 120, Rows: 30}, snap.size)154+ rc := newRenderCoordinator(renderCoordinatorOptions{})
155+ old, next := &attachedClient{}, &attachedClient{}
156+ rc.attach(old)
157+ require.Equal(t, uint64(1), rc.recordResizeRequest(domain.Size{Cols: 90, Rows: 24}, old))
158+ stale := func() uint64 { return rc.recordResizeRequest(domain.Size{Cols: 20, Rows: 5}, old) }
159+ transition.run(rc, old, next)
160+ require.Zero(t, stale(), "a stale %s callback must not publish", transition.name)
161+ })
162+ }
163+}
164+
165+// S3 acceptance: three callbacks can be delivered even after their timers were
166+// stopped. Only the newest prepared epoch may apply, commit, and emit one full
167+// frame. coordinatorMockClock is a generated MockClock/MockTimer harness; its
168+// channels make callback order explicit and do not use wall-clock waits.
169+func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testing.T) {
170+ clock := newCoordinatorMockClock(t, 8)
171+ pty := &transactionalResizePTY{}
172+ d, sess, ac, frames := newManualSessionWithPTYs(t, pty)
173+ d.clock = clock.clock
174+
175+ for _, size := range []domain.Size{{Cols: 90, Rows: 24}, {Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}} {
176+ d.resize(sess, ac, size)
177+ }
178+ // Current PR #71 applies each resize before its debounce callback. S3 must
179+ // leave this empty until the final callback's prepare/apply/commit epoch.
180+ require.Empty(t, pty.requested(), "obsolete requests must not resize the PTY")
181+
182+ var timers []*coordinatorMockTimer
183+ for len(timers) < 3 {
184+ timers = append(timers, <-clock.timers)
185+ }
186+ for _, timer := range timers {
187+ timer.ch <- time.Time{}
188+ }
189+ require.Equal(t, []domain.Size{{Cols: 120, Rows: 30}}, pty.requested())
190+ require.Equal(t, uint64(3), sess.renderCoordinator().resizeSnapshot().epoch)
191+ frame := <-frames
192+ require.Equal(t, ports.MsgOutput, frame.Type, "the committed epoch emits a full frame")
193+ select {
194+ case extra := <-frames:
195+ t.Fatalf("obsolete epoch emitted an additional frame: %#v", extra)
196+ default:
197+ }
125 }198 }
126 199
127 // Entering copy mode is invalidated at transaction prepare time, while a200 // Entering copy mode is invalidated at transaction prepare time, while a
@@ -129,21 +202,35 @@ func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) {
129 // composition. Channels, rather than a delay, make the publication boundary202 // composition. Channels, rather than a delay, make the publication boundary
130 // deterministic.203 // deterministic.
131 func TestTransactionalResizeFloatingMouseCopyAndSearchContract(t *testing.T) {204 func TestTransactionalResizeFloatingMouseCopyAndSearchContract(t *testing.T) {
132- entered, release := make(chan struct{}), make(chan struct{})205+ for _, tc := range []struct {
133- pty := &transactionalResizePTY{onResize: func() { close(entered); <-release }}206+ name string
134- d, sess, ac, _ := newManualSessionWithPTYs(t, pty)207+ errs []error
135- tb := sess.activeTab()208+ }{
136- p := tb.focusedPane()209+ {name: "success"},
137- old := p.rect210+ {name: "partial failure", errs: []error{errors.New("resize failed")}},
138- d.enterCopyMode(sess, ac)211+ } {
139- done := make(chan struct{})212+ t.Run(tc.name, func(t *testing.T) {
140- go func() {213+ entered, release := make(chan struct{}), make(chan struct{})
141- d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})214+ pty := &transactionalResizePTY{errs: tc.errs, onResize: func() { close(entered); <-release }}
142- close(done)215+ d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
143- }()216+ tb := sess.activeTab()
144- <-entered217+ p := tb.focusedPane()
145- require.False(t, ac.overlays.copyActive(), "prepare invalidates copy mode and visual search")218+ old := p.rect
146- require.Equal(t, old, p.rect, "mouse uses the old committed geometry before publication")219+ d.enterCopyMode(sess, ac)
147- close(release)220+ ac.overlays.copyMu.Lock()
148- <-done221+ ac.overlays.copySearch = &visualsearch.Model{}
222+ ac.overlays.copyMu.Unlock()
223+ done := make(chan struct{})
224+ go func() {
225+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
226+ close(done)
227+ }()
228+ <-entered
229+ require.False(t, ac.overlays.copyActive(), "prepare invalidates copy mode")
230+ require.False(t, ac.overlays.copySearchActive(), "prepare invalidates visual search")
231+ require.Equal(t, old, p.rect, "mouse uses old committed geometry before publication")
232+ close(release)
233+ <-done
234+ })
235+ }
149 }236 }

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