detached 07e48e3

test(copy): make lifecycle checks deterministic

commit
07e48e3d55b1b31b53c3c2442b835cac0d1b1a25
author
brice · · 2026-07-15 10:19 UTC
committer
brice · · 2026-07-15 10:19 UTC
parent
1f016aa
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +78 -28
M internal/usecase/daemon/config_test.go +41 -11
@@ -51,18 +51,48 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
5151 }
5252
5353 func TestApplyConfigPublishesCopyConfig(t *testing.T) {
54- d := newTestDaemon(t, nil, stubClock{})
55- require.Equal(t, domain.CopyConfig{WordSeparators: domain.DefaultWordSeparators}, d.currentCopyConfig())
56-
57- configured := domain.Defaults()
58- configured.Copy = domain.CopyConfig{WordSeparators: "/:"}
59- d.ApplyConfig(configured)
60- require.Equal(t, domain.CopyConfig{WordSeparators: "/:"}, d.currentCopyConfig())
54+ tests := []struct {
55+ name string
56+ apply func(*Daemon)
57+ want domain.CopyConfig
58+ }{
59+ {
60+ name: "default",
61+ want: domain.CopyConfig{WordSeparators: domain.DefaultWordSeparators},
62+ },
63+ {
64+ name: "custom",
65+ apply: func(d *Daemon) {
66+ cfg := domain.Defaults()
67+ cfg.Copy = domain.CopyConfig{WordSeparators: "/:"}
68+ d.ApplyConfig(cfg)
69+ },
70+ want: domain.CopyConfig{WordSeparators: "/:"},
71+ },
72+ {
73+ name: "reload empty",
74+ apply: func(d *Daemon) {
75+ configured := domain.Defaults()
76+ configured.Copy = domain.CopyConfig{WordSeparators: "/:"}
77+ d.ApplyConfig(configured)
78+
79+ reloaded := domain.Defaults()
80+ reloaded.Copy = domain.CopyConfig{}
81+ d.ApplyConfig(reloaded)
82+ },
83+ want: domain.CopyConfig{},
84+ },
85+ }
6186
62- reloaded := domain.Defaults()
63- reloaded.Copy = domain.CopyConfig{}
64- d.ApplyConfig(reloaded)
65- require.Equal(t, domain.CopyConfig{}, d.currentCopyConfig())
87+ for _, tt := range tests {
88+ t.Run(tt.name, func(t *testing.T) {
89+ d := newTestDaemon(t, nil, stubClock{})
90+ if tt.apply != nil {
91+ tt.apply(d)
92+ }
93+ require.Equal(t, tt.want, d.currentCopyConfig())
94+ })
95+ }
6696 }
6797
6898 func TestApplyConfigPublishesImmutablePaletteSnapshot(t *testing.T) {
@@ -51,18 +51,48 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
51 }51 }
52 52
53 func TestApplyConfigPublishesCopyConfig(t *testing.T) {53 func TestApplyConfigPublishesCopyConfig(t *testing.T) {
54- d := newTestDaemon(t, nil, stubClock{})54+ tests := []struct {
55- require.Equal(t, domain.CopyConfig{WordSeparators: domain.DefaultWordSeparators}, d.currentCopyConfig())55+ name string
56-56+ apply func(*Daemon)
57- configured := domain.Defaults()57+ want domain.CopyConfig
58- configured.Copy = domain.CopyConfig{WordSeparators: "/:"}58+ }{
59- d.ApplyConfig(configured)59+ {
60- require.Equal(t, domain.CopyConfig{WordSeparators: "/:"}, d.currentCopyConfig())60+ name: "default",
61+ want: domain.CopyConfig{WordSeparators: domain.DefaultWordSeparators},
62+ },
63+ {
64+ name: "custom",
65+ apply: func(d *Daemon) {
66+ cfg := domain.Defaults()
67+ cfg.Copy = domain.CopyConfig{WordSeparators: "/:"}
68+ d.ApplyConfig(cfg)
69+ },
70+ want: domain.CopyConfig{WordSeparators: "/:"},
71+ },
72+ {
73+ name: "reload empty",
74+ apply: func(d *Daemon) {
75+ configured := domain.Defaults()
76+ configured.Copy = domain.CopyConfig{WordSeparators: "/:"}
77+ d.ApplyConfig(configured)
78+
79+ reloaded := domain.Defaults()
80+ reloaded.Copy = domain.CopyConfig{}
81+ d.ApplyConfig(reloaded)
82+ },
83+ want: domain.CopyConfig{},
84+ },
85+ }
61 86
62- reloaded := domain.Defaults()87+ for _, tt := range tests {
63- reloaded.Copy = domain.CopyConfig{}88+ t.Run(tt.name, func(t *testing.T) {
64- d.ApplyConfig(reloaded)89+ d := newTestDaemon(t, nil, stubClock{})
65- require.Equal(t, domain.CopyConfig{}, d.currentCopyConfig())90+ if tt.apply != nil {
91+ tt.apply(d)
92+ }
93+ require.Equal(t, tt.want, d.currentCopyConfig())
94+ })
95+ }
66 }96 }
67 97
68 func TestApplyConfigPublishesImmutablePaletteSnapshot(t *testing.T) {98 func TestApplyConfigPublishesImmutablePaletteSnapshot(t *testing.T) {
M internal/usecase/daemon/copymode_lifecycle_test.go +37 -17
@@ -5,7 +5,6 @@ import (
55 "testing"
66 "time"
77
8- "github.com/stretchr/testify/assert"
98 "github.com/stretchr/testify/require"
109
1110 "github.com/bnema/vev/internal/domain"
@@ -117,31 +116,52 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
117116 document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators)
118117 p.mu.Unlock()
119118
120- // Hold session validation after publication and make the captured target
121- // stale. A render snapshot taken in this window must not expose the
122- // candidate document.
123- sess.mu.Lock()
124- sess.active = 1
119+ // Pause after candidate publication, then make the captured target stale.
120+ // A render snapshot while paused must not expose the candidate document.
121+ reached := make(chan struct{})
122+ resume := make(chan struct{})
123+ done := make(chan struct{})
124+ d.beforeCopyModeRevalidate = func() {
125+ close(reached)
126+ <-resume
127+ }
128+ resumed := false
129+ defer func() {
130+ if !resumed {
131+ close(resume)
132+ }
133+ select {
134+ case <-done:
135+ d.beforeCopyModeRevalidate = nil
136+ case <-time.After(time.Second):
137+ t.Error("copy mode publication did not finish after revalidation gate was released")
138+ }
139+ }()
140+
125141 result := make(chan bool, 1)
126142 go func() {
143+ defer close(done)
127144 result <- d.publishCopyMode(sess, ac, tb, p, document, nil, nil)
128145 }()
129- published := assert.Eventually(t, func() bool {
130- ac.overlays.copyMu.Lock()
131- defer ac.overlays.copyMu.Unlock()
132- return ac.overlays.copyPane == p && ac.overlays.copyDocument != nil
133- }, time.Second, time.Millisecond)
134- if !published {
135- sess.mu.Unlock()
136- <-result
137- return
146+ select {
147+ case <-reached:
148+ case <-time.After(time.Second):
149+ t.Fatal("copy mode publication did not reach revalidation gate")
138150 }
151+
139152 renderSnapshot := ac.overlays.SnapshotForRender()
140153 candidateWasRenderable := renderSnapshot.copyActive
141154 renderSnapshot.Unlock()
142- sess.mu.Unlock()
155+ require.True(t, sess.switchTab(1))
156+ close(resume)
157+ resumed = true
143158
144- require.False(t, <-result)
159+ select {
160+ case published := <-result:
161+ require.False(t, published)
162+ case <-time.After(time.Second):
163+ t.Fatal("copy mode publication did not finish after revalidation gate was released")
164+ }
145165 require.False(t, candidateWasRenderable)
146166 require.False(t, ac.overlays.copyActive())
147167 require.Nil(t, copyTargetPane(ac.overlays))
@@ -5,7 +5,6 @@ import (
5 "testing"5 "testing"
6 "time"6 "time"
7 7
8- "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
10 9
11 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
@@ -117,31 +116,52 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
117 document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators)116 document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators)
118 p.mu.Unlock()117 p.mu.Unlock()
119 118
120- // Hold session validation after publication and make the captured target119+ // Pause after candidate publication, then make the captured target stale.
121- // stale. A render snapshot taken in this window must not expose the120+ // A render snapshot while paused must not expose the candidate document.
122- // candidate document.121+ reached := make(chan struct{})
123- sess.mu.Lock()122+ resume := make(chan struct{})
124- sess.active = 1123+ done := make(chan struct{})
124+ d.beforeCopyModeRevalidate = func() {
125+ close(reached)
126+ <-resume
127+ }
128+ resumed := false
129+ defer func() {
130+ if !resumed {
131+ close(resume)
132+ }
133+ select {
134+ case <-done:
135+ d.beforeCopyModeRevalidate = nil
136+ case <-time.After(time.Second):
137+ t.Error("copy mode publication did not finish after revalidation gate was released")
138+ }
139+ }()
140+
125 result := make(chan bool, 1)141 result := make(chan bool, 1)
126 go func() {142 go func() {
143+ defer close(done)
127 result <- d.publishCopyMode(sess, ac, tb, p, document, nil, nil)144 result <- d.publishCopyMode(sess, ac, tb, p, document, nil, nil)
128 }()145 }()
129- published := assert.Eventually(t, func() bool {146+ select {
130- ac.overlays.copyMu.Lock()147+ case <-reached:
131- defer ac.overlays.copyMu.Unlock()148+ case <-time.After(time.Second):
132- return ac.overlays.copyPane == p && ac.overlays.copyDocument != nil149+ t.Fatal("copy mode publication did not reach revalidation gate")
133- }, time.Second, time.Millisecond)
134- if !published {
135- sess.mu.Unlock()
136- <-result
137- return
138 }150 }
151+
139 renderSnapshot := ac.overlays.SnapshotForRender()152 renderSnapshot := ac.overlays.SnapshotForRender()
140 candidateWasRenderable := renderSnapshot.copyActive153 candidateWasRenderable := renderSnapshot.copyActive
141 renderSnapshot.Unlock()154 renderSnapshot.Unlock()
142- sess.mu.Unlock()155+ require.True(t, sess.switchTab(1))
156+ close(resume)
157+ resumed = true
143 158
144- require.False(t, <-result)159+ select {
160+ case published := <-result:
161+ require.False(t, published)
162+ case <-time.After(time.Second):
163+ t.Fatal("copy mode publication did not finish after revalidation gate was released")
164+ }
145 require.False(t, candidateWasRenderable)165 require.False(t, candidateWasRenderable)
146 require.False(t, ac.overlays.copyActive())166 require.False(t, ac.overlays.copyActive())
147 require.Nil(t, copyTargetPane(ac.overlays))167 require.Nil(t, copyTargetPane(ac.overlays))

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