detached ece7682

toast_damage_test.go RawHistoryFolder

1package daemon
2
3import (
4	"testing"
5
6	vt "github.com/bnema/vev-vt"
7	renderer "github.com/bnema/vev-vt/ansi"
8	"github.com/bnema/vev/internal/domain"
9	"github.com/bnema/vev/internal/domain/layout"
10	"github.com/stretchr/testify/require"
11)
12
13func TestComposeFrameToastDamageIsIncrementalAndRestoresOldFootprints(t *testing.T) {
14	baseState := toastDamageState(nil, nil, true)
15	base := composeFrame(baseState, composeCacheInput{})
16	stream := newOutputStateStream()
17	terminal := vt.NewScreen(base.frame.Width, base.frame.Height)
18	replayToastFrame(t, stream, terminal, base)
19
20	one := domain.Notification{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}
21	shownState := toastDamageState([]domain.Notification{one}, nil, false)
22	shown := composeFrame(shownState, base.cache)
23	requireNoFullRedraw(t, shown.damage)
24	requireToastFootprintsDamaged(t, shown.damage, shown.cache.toastFootprints)
25	replayToastFrame(t, stream, terminal, shown)
26
27	// A one-cell PTY update while the toast is stable redraws only the pane cell
28	// and toast footprint, never the whole terminal.
29	updatedState := toastDamageState([]domain.Notification{one}, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false)
30	updatedState.panes[0].frame.Set(0, 0, renderer.Cell{Rune: 'X', Style: renderer.DefaultStyle()})
31	updated := composeFrame(updatedState, shown.cache)
32	requireNoFullRedraw(t, updated.damage)
33	require.Contains(t, updated.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 1, Height: 1})
34	requireToastFootprintsDamaged(t, updated.damage, shown.cache.toastFootprints)
35	requireToastFootprintsDamaged(t, updated.damage, updated.cache.toastFootprints)
36	incremental, err := stream.render(updated.frame, updated.damage, updated.reset)
37	require.NoError(t, err)
38	fullRenderer := renderer.New(renderer.Capabilities{})
39	full, err := fullRenderer.Draw(updated.frame, []renderer.Damage{renderer.FullRedraw()})
40	require.NoError(t, err)
41	require.Less(t, len(incremental)*2, len(full), "one-cell update with a stable toast must stay bounded below full-screen output")
42	terminal.Write(incremental)
43	require.Equal(t, frameRows(updated.frame), frameRows(terminal))
44
45	// Stacking and overflow stay visually identical while their complete old and
46	// new coverage remains incremental damage.
47	two := domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeWarn, Message: "second", Count: 1}
48	three := domain.Notification{Code: domain.NoticeConfigReload, Severity: domain.NoticeError, Message: "third", Count: 1}
49	stackedState := toastDamageState([]domain.Notification{two, one, three}, nil, false)
50	stackedState.overlays.noticeOverflow = 2
51	stacked := composeFrame(stackedState, updated.cache)
52	requireNoFullRedraw(t, stacked.damage)
53	requireToastFootprintsDamaged(t, stacked.damage, updated.cache.toastFootprints)
54	requireToastFootprintsDamaged(t, stacked.damage, stacked.cache.toastFootprints)
55	replayToastFrame(t, stream, terminal, stacked)
56
57	// Coalescing changes the title count in place. Both old and new coverage is
58	// damaged even where their geometry happens to be identical.
59	coalescedTwo := two
60	coalescedTwo.Count = 12
61	coalescedState := toastDamageState([]domain.Notification{coalescedTwo, one, three}, nil, false)
62	coalescedState.overlays.noticeOverflow = 2
63	coalesced := composeFrame(coalescedState, stacked.cache)
64	requireNoFullRedraw(t, coalesced.damage)
65	requireToastFootprintsDamaged(t, coalesced.damage, stacked.cache.toastFootprints)
66	requireToastFootprintsDamaged(t, coalesced.damage, coalesced.cache.toastFootprints)
67	replayToastFrame(t, stream, terminal, coalesced)
68
69	// Dismissal and expiry have the same rendering transition: no current toast
70	// exists, so the previous footprint alone must restore every exposed cell.
71	for _, tt := range []struct {
72		name string
73		from composedRenderFrame
74	}{
75		{name: "dismiss", from: coalesced},
76		{name: "expiry", from: shown},
77	} {
78		t.Run(tt.name, func(t *testing.T) {
79			cleared := composeFrame(toastDamageState(nil, nil, false), tt.from.cache)
80			requireNoFullRedraw(t, cleared.damage)
81			requireToastFootprintsDamaged(t, cleared.damage, tt.from.cache.toastFootprints)
82			require.Empty(t, cleared.cache.toastFootprints)
83		})
84	}
85
86	cleared := composeFrame(toastDamageState(nil, nil, false), coalesced.cache)
87	replayToastFrame(t, stream, terminal, cleared)
88	require.Equal(t, frameRows(coalesced.cache.frame), frameRows(cleared.frame), "dismissal must restore the toast-free base without artifacts")
89}
90
91func BenchmarkComposeFrameStableToastOneCellUpdate(b *testing.B) {
92	base := composeFrame(toastDamageState(nil, nil, true), composeCacheInput{})
93	notice := domain.Notification{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}
94	shown := composeFrame(toastDamageState([]domain.Notification{notice}, nil, false), base.cache, composeCacheInput{})
95	cache, scratch := shown.cache, base.cache
96	state := toastDamageState([]domain.Notification{notice}, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false)
97
98	b.ReportAllocs()
99	for i := 0; b.Loop(); i++ {
100		cell := renderer.Cell{Rune: rune('A' + i%26), Style: renderer.DefaultStyle()}
101		state.panes[0].frame.Set(0, 0, cell)
102		out := composeFrame(state, cache, scratch)
103		if hasFullRedraw(out.damage) {
104			b.Fatal("stable toast forced a full redraw")
105		}
106		scratch, cache = cache, out.cache
107	}
108}
109
110func toastDamageState(notices []domain.Notification, damage []renderer.Damage, reset bool) capturedRenderState {
111	paneFrame := renderer.NewFrame(80, 20)
112	for y := 0; y < paneFrame.Height; y++ {
113		for x := 0; x < paneFrame.Width; x++ {
114			paneFrame.Set(x, y, renderer.Cell{Rune: 'a', Style: renderer.DefaultStyle()})
115		}
116	}
117	placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: 80, Height: 20}}
118	return capturedRenderState{
119		reset:    reset,
120		layout:   capturedTabLayout{area: domain.Rect{Width: 80, Height: 20}, focus: "pane", placements: []layout.Placement{placement}, fingerprint: "toast-damage", valid: true},
121		panes:    []capturedPaneRenderState{{id: "pane", frame: paneFrame, placement: placement, focused: true, damage: damage}},
122		overlays: capturedOverlayRenderState{notices: notices},
123		styles:   resolveStyles(nil),
124	}
125}
126
127func replayToastFrame(t *testing.T, stream *attachmentOutput, terminal *vt.Screen, composed composedRenderFrame) {
128	t.Helper()
129	data, err := stream.render(composed.frame, composed.damage, composed.reset)
130	require.NoError(t, err)
131	terminal.Write(data)
132	require.Equal(t, frameRows(composed.frame), frameRows(terminal))
133}
134
135func requireNoFullRedraw(t *testing.T, damage []renderer.Damage) {
136	t.Helper()
137	for _, d := range damage {
138		require.NotEqual(t, renderer.DamageFullRedraw, d.Kind)
139	}
140}
141
142func requireToastFootprintsDamaged(t *testing.T, damage []renderer.Damage, footprints []domain.Rect) {
143	t.Helper()
144	for _, footprint := range footprints {
145		require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: footprint.X, Y: footprint.Y, Width: footprint.Width, Height: footprint.Height})
146	}
147}
148
149func hasFullRedraw(damage []renderer.Damage) bool {
150	for _, d := range damage {
151		if d.Kind == renderer.DamageFullRedraw {
152			return true
153		}
154	}
155	return false
156}

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