1package ui
2
3import (
4 "fmt"
5 "testing"
6 "time"
7
8 renderer "github.com/bnema/vev-vt"
9 "github.com/bnema/vev/internal/domain"
10)
11
12func TestToastManagerDurationDismissAndClear(t *testing.T) {
13 now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC)
14
15 t.Run("show returns active immediately", func(t *testing.T) {
16 m := NewToastManager()
17 m.Show(now, Toast{ID: "one", Message: "hello"})
18 active := m.Active(now)
19 if len(active) != 1 || active[0].ID != "one" {
20 t.Fatalf("Active() = %+v, want one active toast", active)
21 }
22 })
23
24 t.Run("zero duration remains until dismissed", func(t *testing.T) {
25 m := NewToastManager()
26 m.Show(now, Toast{ID: "persistent", Duration: 0})
27 if !m.HasActive(now.Add(time.Hour)) {
28 t.Fatalf("HasActive() = false, want persistent toast after one hour")
29 }
30 })
31
32 t.Run("positive duration expires at deadline", func(t *testing.T) {
33 m := NewToastManager()
34 m.Show(now, Toast{ID: "timed", Duration: time.Minute})
35 if !m.HasActive(now.Add(time.Minute - time.Nanosecond)) {
36 t.Fatalf("HasActive() before deadline = false, want true")
37 }
38 if m.HasActive(now.Add(time.Minute)) {
39 t.Fatalf("HasActive() at deadline = true, want false")
40 }
41 if got := m.Active(now.Add(2 * time.Minute)); len(got) != 0 {
42 t.Fatalf("Active() after deadline = %+v, want none", got)
43 }
44 })
45
46 t.Run("dismiss removes only matching toast", func(t *testing.T) {
47 m := NewToastManager()
48 m.Show(now, Toast{ID: "keep"})
49 m.Show(now, Toast{ID: "remove"})
50 m.Dismiss("remove")
51 active := m.Active(now)
52 if len(active) != 1 || active[0].ID != "keep" {
53 t.Fatalf("Active() after dismiss = %+v, want only keep", active)
54 }
55 })
56
57 t.Run("empty IDs use named anchors and replace per anchor", func(t *testing.T) {
58 anchors := []domain.Anchor{
59 domain.AnchorCenter, domain.AnchorTopLeft, domain.AnchorTop, domain.AnchorTopRight,
60 domain.AnchorLeft, domain.AnchorRight, domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight,
61 }
62 for _, anchor := range anchors {
63 m := NewToastManager()
64 m.Show(now, Toast{Message: "first", Anchor: anchor})
65 m.Show(now.Add(time.Second), Toast{Message: "second", Anchor: anchor})
66 active := m.Active(now.Add(time.Second))
67 if len(active) != 1 || active[0].Message != "second" || active[0].ID != "anchor:"+anchor.String() {
68 t.Fatalf("Active() for %s = %+v, want named replacement", anchor, active)
69 }
70 }
71 })
72
73 t.Run("clear removes all toasts", func(t *testing.T) {
74 m := NewToastManager()
75 m.Show(now, Toast{ID: "one"})
76 m.Show(now, Toast{ID: "two"})
77 m.Clear()
78 if got := m.Active(now); len(got) != 0 {
79 t.Fatalf("Active() after Clear() = %+v, want none", got)
80 }
81 })
82}
83
84func TestTruncateText(t *testing.T) {
85 tests := []struct {
86 name string
87 text string
88 maxWidth int
89 want string
90 }{
91 {"no truncation needed", "hello", 10, "hello"},
92 {"truncation with ellipsis", "hello world", 8, "hello w…"},
93 {"maxWidth zero", "hello", 0, ""},
94 // The ellipsis itself is one cell wide, so any non-positive maxWidth is
95 // also "smaller than the ellipsis" and collapses to the same empty result.
96 {"maxWidth smaller than ellipsis", "hello world", -1, ""},
97 {"wide runes", "日本語のテキスト", 5, "日本…"},
98 }
99 for _, tt := range tests {
100 t.Run(tt.name, func(t *testing.T) {
101 if got := TruncateText(tt.text, tt.maxWidth); got != tt.want {
102 t.Fatalf("TruncateText(%q, %d) = %q, want %q", tt.text, tt.maxWidth, got, tt.want)
103 }
104 })
105 }
106}
107
108func TestToastBoundsAnchorsMarginsAndTinyFrames(t *testing.T) {
109 message := "Reconnection attempts…"
110 width := textWidth(message) + 4
111 if width != 26 {
112 t.Fatalf("test message width = %d, want 26", width)
113 }
114
115 tests := []struct {
116 name string
117 base domain.Size
118 toast Toast
119 want domain.Rect
120 }{
121 {"top left", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorTopLeft}, domain.Rect{X: 2, Y: 2, Width: 26, Height: 3}},
122 {"top", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorTop}, domain.Rect{X: 27, Y: 2, Width: 26, Height: 3}},
123 {"top right", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorTopRight}, domain.Rect{X: 52, Y: 2, Width: 26, Height: 3}},
124 {"left", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorLeft}, domain.Rect{X: 2, Y: 10, Width: 26, Height: 3}},
125 {"center", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorCenter}, domain.Rect{X: 27, Y: 10, Width: 26, Height: 3}},
126 {"right", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorRight}, domain.Rect{X: 52, Y: 10, Width: 26, Height: 3}},
127 {"bottom left", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorBottomLeft}, domain.Rect{X: 2, Y: 19, Width: 26, Height: 3}},
128 {"bottom", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorBottom}, domain.Rect{X: 27, Y: 19, Width: 26, Height: 3}},
129 {"bottom right", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message, Anchor: domain.AnchorBottomRight}, domain.Rect{X: 52, Y: 19, Width: 26, Height: 3}},
130 {"omitted anchor centers", domain.Size{Cols: 80, Rows: 24}, Toast{Message: message}, domain.Rect{X: 27, Y: 10, Width: 26, Height: 3}},
131 {"zero frame", domain.Size{}, Toast{Message: "tiny"}, domain.Rect{}},
132 {"one by one clamps", domain.Size{Cols: 1, Rows: 1}, Toast{Message: "tiny"}, domain.Rect{Width: 1, Height: 1}},
133 {"narrow frame clamps width and margin", domain.Size{Cols: 8, Rows: 2}, Toast{Message: "long message", Anchor: domain.AnchorBottomRight}, domain.Rect{Width: 8, Height: 2}},
134 }
135
136 for _, tt := range tests {
137 t.Run(tt.name, func(t *testing.T) {
138 if got := ToastBounds(tt.base, tt.toast); got != tt.want {
139 t.Fatalf("ToastBounds() = %+v, want %+v", got, tt.want)
140 }
141 })
142 }
143}
144
145func TestCompositeToastsDrawsBorderedToastAndTruncates(t *testing.T) {
146 styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Bold: true, Foreground: 4, Background: -1}}
147
148 t.Run("bordered centered toast preserves exterior", func(t *testing.T) {
149 f := renderer.NewFrame(20, 7)
150 exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}
151 FillRect(f, domain.Rect{Width: 20, Height: 7}, exterior)
152
153 CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "center", Message: "Hi", Anchor: domain.AnchorCenter}}}, styles, nil)
154 bounds := ToastBounds(domain.Size{Cols: 20, Rows: 7}, Toast{Message: "Hi", Anchor: domain.AnchorCenter})
155 if bounds != (domain.Rect{X: 7, Y: 2, Width: 6, Height: 3}) {
156 t.Fatalf("bounds = %+v, want centered 6x3 box", bounds)
157 }
158 assertRune(t, f, bounds.X, bounds.Y, '┌')
159 assertRune(t, f, bounds.X+bounds.Width-1, bounds.Y, '┐')
160 assertRune(t, f, bounds.X, bounds.Y+bounds.Height-1, '└')
161 assertRune(t, f, bounds.X+bounds.Width-1, bounds.Y+bounds.Height-1, '┘')
162 assertRune(t, f, bounds.X+1, bounds.Y, '─')
163 assertRune(t, f, bounds.X, bounds.Y+1, '│')
164 assertRune(t, f, bounds.X+2, bounds.Y+1, 'H')
165 assertRune(t, f, bounds.X+3, bounds.Y+1, 'i')
166 assertCell(t, f, 0, 0, exterior)
167 assertCell(t, f, 19, 6, exterior)
168 })
169
170 t.Run("narrow toast truncates with ellipsis", func(t *testing.T) {
171 f := renderer.NewFrame(8, 3)
172 CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "narrow", Message: "abcdef", Anchor: domain.AnchorCenter, MaxWidth: 8}}}, styles, nil)
173 assertRune(t, f, 0, 0, '┌')
174 assertRune(t, f, 7, 0, '┐')
175 assertRune(t, f, 2, 1, 'a')
176 assertRune(t, f, 3, 1, 'b')
177 assertRune(t, f, 4, 1, 'c')
178 assertRune(t, f, 5, 1, '…')
179 })
180}
181
182func TestCompositeToastsLatestToastPerAnchor(t *testing.T) {
183 f := renderer.NewFrame(30, 5)
184 now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC)
185 styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Foreground: 4, Background: -1}}
186
187 CompositeToasts(f, []ActiveToast{
188 {Toast: Toast{ID: "old", Message: "Old", Anchor: domain.AnchorTopLeft}, ShownAt: now},
189 {Toast: Toast{ID: "new", Message: "New", Anchor: domain.AnchorTopLeft}, ShownAt: now.Add(time.Second)},
190 {Toast: Toast{ID: "center", Message: "Mid", Anchor: domain.AnchorCenter}, ShownAt: now},
191 }, styles, nil)
192
193 assertRune(t, f, 4, 3, 'N')
194 assertRune(t, f, 5, 3, 'e')
195 assertRune(t, f, 6, 3, 'w')
196 for y := 0; y < f.Height; y++ {
197 for x := 0; x < f.Width; x++ {
198 if f.At(x, y).Rune == 'O' {
199 t.Fatalf("old toast rendered at (%d,%d), want only latest toast per anchor", x, y)
200 }
201 }
202 }
203}
204
205func TestLatestToastsByAnchorTieAndOrder(t *testing.T) {
206 now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC)
207 anchors := []domain.Anchor{
208 domain.AnchorTopLeft, domain.AnchorTopRight, domain.AnchorBottomLeft,
209 domain.AnchorBottomRight, domain.AnchorCenter, domain.AnchorTop,
210 domain.AnchorLeft, domain.AnchorRight, domain.AnchorBottom,
211 }
212 toasts := make([]ActiveToast, 0, len(anchors)+1)
213 for _, anchor := range anchors {
214 toasts = append(toasts, ActiveToast{Toast: Toast{ID: anchor.String(), Anchor: anchor}, ShownAt: now})
215 }
216 toasts = append(toasts, ActiveToast{Toast: Toast{ID: "later-input", Anchor: domain.AnchorTopLeft}, ShownAt: now})
217
218 visible := latestToastsByAnchor(toasts)
219 if len(visible) != len(anchors) {
220 t.Fatalf("latestToastsByAnchor() count = %d, want %d", len(visible), len(anchors))
221 }
222 for i, anchor := range anchors {
223 if visible[i].Anchor != anchor {
224 t.Fatalf("visible[%d].Anchor = %s, want %s", i, visible[i].Anchor, anchor)
225 }
226 }
227 if visible[0].ID != "later-input" {
228 t.Fatalf("equal-time tie selected %q, want later input", visible[0].ID)
229 }
230}
231
232func TestCompositeToastsDimsBackgroundWithoutClearingContent(t *testing.T) {
233 f := renderer.NewFrame(14, 5)
234 baseStyle := renderer.Style{Foreground: 1, Background: 2}
235 dimStyle := renderer.Style{Foreground: 8, Background: 9, Inverse: true}
236 for y := 0; y < f.Height; y++ {
237 for x := 0; x < f.Width; x++ {
238 f.Set(x, y, renderer.Cell{Rune: rune('a' + y), Style: baseStyle})
239 }
240 }
241 styles := ToastStyles{Text: renderer.Style{Foreground: 3, Background: -1}, Box: renderer.Style{Bold: true, Foreground: 4, Background: -1}}
242
243 CompositeToasts(f, []ActiveToast{{Toast: Toast{ID: "dim", Message: "Hi", Anchor: domain.AnchorCenter, DimBackground: true}}}, styles, func(renderer.Style) renderer.Style {
244 return dimStyle
245 })
246
247 outside := f.At(0, 0)
248 if outside.Rune != 'a' {
249 t.Fatalf("outside rune = %q, want preserved 'a'", outside.Rune)
250 }
251 if !outside.Style.Equal(dimStyle) {
252 t.Fatalf("outside style = %+v, want dimmed %+v", outside.Style, dimStyle)
253 }
254 bounds := ToastBounds(domain.Size{Cols: 14, Rows: 5}, Toast{Message: "Hi", Anchor: domain.AnchorCenter})
255 boxCell := f.At(bounds.X, bounds.Y)
256 if boxCell.Rune != '┌' || !boxCell.Style.Equal(styles.Box) {
257 t.Fatalf("box cell = %+v, want toast box style", boxCell)
258 }
259 textCell := f.At(bounds.X+2, bounds.Y+1)
260 if textCell.Rune != 'H' || !textCell.Style.Equal(styles.Text) {
261 t.Fatalf("text cell = %+v, want toast text style", textCell)
262 }
263}
264
265func TestCompositeToastsHandlesTinyFrames(t *testing.T) {
266 styles := ToastStyles{Text: renderer.DefaultStyle(), Box: renderer.DefaultStyle()}
267 for _, size := range []domain.Size{{}, {Cols: 1, Rows: 1}, {Cols: 2, Rows: 1}, {Cols: 1, Rows: 2}} {
268 t.Run(fmt.Sprintf("%dx%d", size.Cols, size.Rows), func(t *testing.T) {
269 f := renderer.NewFrame(size.Cols, size.Rows)
270 CompositeToasts(f, []ActiveToast{{Toast: Toast{Message: "abcdef", Anchor: domain.AnchorCenter}}}, styles, nil)
271 })
272 }
273}