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/protocol"
13 "github.com/bnema/vev/internal/protocol/wire"
14)
15
16// replayOutputFrames decodes and writes each Output envelope’s terminal bytes
17// into screen, growing its shadow the same way a real client would.
18func replayOutputFrames(t *testing.T, screen *vt.Screen, frames []wire.Envelope) {
19 t.Helper()
20 for _, f := range frames {
21 out, ok := decodeServerMessage(t, f).(protocol.Output)
22 require.True(t, ok)
23 screen.Write(out.Data)
24 }
25}
26
27// drainAllFrames collects every frame already queued on sends without blocking.
28func drainAllFrames(sends chan wire.Envelope) []wire.Envelope {
29 var out []wire.Envelope
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.geometry.Size.Cols, ac.geometry.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(captureTestFrame(screen))
91 if !containsTopRight(rows, "couldn't open pane", ac.geometry.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([]wire.Envelope{f}, drainAllFrames(sends)...))
100 case <-time.After(2 * time.Second):
101 t.Fatal("expiry did not invalidate the render")
102 }
103
104 rows = frameRows(captureTestFrame(screen))
105 if containsTopRight(rows, "couldn't open pane", ac.geometry.Size.Cols/2) {
106 t.Fatalf("toast message still present after TTL expired:\n%s", strings.Join(rows, "\n"))
107 }
108}