1package daemon
2
3import (
4 "strings"
5 "testing"
6 "time"
7
8 "github.com/stretchr/testify/require"
9
10 vt "github.com/bnema/vev-vt"
11 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"
13)
14
15// replayOutputFrames decodes and writes each MsgOutput frame's terminal bytes
16// into screen, growing its shadow the same way a real client would.
17func replayOutputFrames(t *testing.T, screen *vt.Screen, frames []ports.Frame) {
18 t.Helper()
19 for _, f := range frames {
20 require.Equal(t, ports.MsgOutput, f.Type)
21 out, err := ports.UnmarshalOutput(f.Payload)
22 require.NoError(t, err)
23 screen.Write(out.Data)
24 }
25}
26
27// drainAllFrames collects every frame already queued on sends without blocking.
28func drainAllFrames(sends chan ports.Frame) []ports.Frame {
29 var out []ports.Frame
30 for {
31 select {
32 case f := <-sends:
33 out = append(out, f)
34 default:
35 return out
36 }
37 }
38}
39
40// runeColumn returns the rune-indexed column at which text first occurs in
41// row, or -1. Rows are exactly one rune per terminal column, so this must not
42// use strings.Index: box-drawing borders are multi-byte and would inflate a
43// byte offset past the real column.
44func runeColumn(row, text string) int {
45 runes, sub := []rune(row), []rune(text)
46 for i := 0; i+len(sub) <= len(runes); i++ {
47 match := true
48 for j, r := range sub {
49 if runes[i+j] != r {
50 match = false
51 break
52 }
53 }
54 if match {
55 return i
56 }
57 }
58 return -1
59}
60
61// containsTopRight reports whether text appears anywhere at or past minCol in
62// the frame's top half.
63func containsTopRight(rows []string, text string, minCol int) bool {
64 for y, row := range rows {
65 if y > len(rows)/2 {
66 break
67 }
68 if runeColumn(row, text) >= minCol {
69 return true
70 }
71 }
72 return false
73}
74
75// TestPaintComposesNoticeToastTopRightAndExpiresOnTTL drives an actual
76// notify -> showToast -> paint -> emit round trip and decodes the resulting
77// wire bytes through a terminal emulator, the same way a real client would,
78// so this proves the composed frame (not just internal toast state) carries
79// the message top-right, and that it disappears once the fake clock crosses
80// the toast's TTL.
81func TestPaintComposesNoticeToastTopRightAndExpiresOnTTL(t *testing.T) {
82 clk := newNoticeClock()
83 d, sess, ac, sends := newNoticeFixture(t, clk)
84 screen := vt.NewScreen(ac.size.Cols, ac.size.Rows)
85
86 d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "couldn't open pane", nil)
87 awaitToastCount(t, ac, 1)
88 replayOutputFrames(t, screen, drainAllFrames(sends))
89
90 rows := frameRows(screen.Frame)
91 if !containsTopRight(rows, "couldn't open pane", ac.size.Cols/2) {
92 t.Fatalf("composed frame missing toast message top-right:\n%s", strings.Join(rows, "\n"))
93 }
94
95 clk.advance(noticeTTL(domain.NoticeError))
96 awaitToastCount(t, ac, 0)
97 select {
98 case f := <-sends:
99 replayOutputFrames(t, screen, append([]ports.Frame{f}, drainAllFrames(sends)...))
100 case <-time.After(2 * time.Second):
101 t.Fatal("expiry did not invalidate the render")
102 }
103
104 rows = frameRows(screen.Frame)
105 if containsTopRight(rows, "couldn't open pane", ac.size.Cols/2) {
106 t.Fatalf("toast message still present after TTL expired:\n%s", strings.Join(rows, "\n"))
107 }
108}