okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

Aly Raffaufrefactor(cli): require explicit daemon client dependencies2ce1ecd

main
6.2 KiB141 linesraw
1// A live tool burst must settle once and invalidate its cache on reset and eviction.
2package tui
3
4import (
5	"fmt"
6	"strings"
7	"testing"
8
9	"albedo/cli/internal/daemon"
10	"github.com/charmbracelet/x/ansi"
11)
12
13func traced(activities []daemon.ToolActivity, changes ...daemon.FileChange) HistoryEntry {
14	return HistoryEntry{Kind: EntryTool, ToolName: "python", ToolResult: `{"status":"ok"}`,
15		ToolTrace: &daemon.ToolTrace{Activities: activities, Changes: changes}}
16}
17
18func burstFixture() []HistoryEntry {
19	ws := "/w/albedo/"
20	return []HistoryEntry{
21		{Kind: EntryThinking, Text: "hm", ElapsedMs: 34_000},
22		traced([]daemon.ToolActivity{{Kind: "read", Target: ws + "cli/internal/tui/chat.go"}, {Kind: "read", Target: ws + "cli/internal/tui/transcript.go"}}),
23		traced([]daemon.ToolActivity{{Kind: "run", Target: "cd cli && go test ./..."}}),
24		traced([]daemon.ToolActivity{{Kind: "run", Target: "go test ./internal/tui"}}),
25		traced([]daemon.ToolActivity{{Kind: "read", Target: ws + "priv/python/albedo_trace.py"}},
26			daemon.FileChange{Path: ws + "priv/python/albedo_trace.py", Kind: "diff", Added: 5, Removed: 2}),
27		{Kind: EntryTool, ToolName: "python", ToolResult: `{"status":"error","error":"boom"}`, ToolArgs: map[string]any{"code": "1/0"}},
28	}
29}
30
31func TestBurstSettlesOnceProseEndsIt(t *testing.T) {
32	m := newTestChatModel(t, &daemon.Session{ID: "s", Workspace: "/w/albedo"})
33	m.SetSize(120, 40)
34	m.appendSettledEntry(HistoryEntry{Kind: EntryUser, Text: "go"})
35	settled := len(m.settledLines)
36	for _, entry := range burstFixture()[:4] {
37		m.appendSettledEntry(entry)
38	}
39	if len(m.settledLines) != settled {
40		t.Fatal("an open burst wrote settled rows")
41	}
42	m.refreshViewportContent()
43	if view := ansi.Strip(m.Viewport.View()); !strings.Contains(view, "ran go test ×2") {
44		t.Fatalf("the open burst is not drawn: %q", view)
45	}
46	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventMessage, Text: "done"})
47	joined := ansi.Strip(strings.Join(m.settledLines[settled:], "\n"))
48	if strings.Count(joined, "ran go test") != 1 || !strings.Contains(joined, "done") ||
49		strings.Index(joined, "ran go test") > strings.Index(joined, "done") {
50		t.Fatalf("burst did not settle once, before the prose: %q", joined)
51	}
52	m.rebuildSettledLines()
53	if again := ansi.Strip(strings.Join(m.settledLines, "\n")); strings.Count(again, "ran go test") != 1 {
54		t.Fatalf("a rebuild drew the burst %d times", strings.Count(again, "ran go test"))
55	}
56}
57
58// The cached live frame renders exactly what collecting the same entries
59// renders, and is rebuilt only when one of the inputs in its key changes:
60// each step below mutates exactly one, and the render counter must move once.
61func TestCachedBurstMatchesCollectedBurst(t *testing.T) {
62	m := newTestChatModel(t, &daemon.Session{ID: "s", Workspace: "/w/albedo"})
63	m.SetSize(120, 30)
64	for _, entry := range burstFixture() {
65		m.appendSettledEntry(entry)
66	}
67	m.refreshViewportContent()
68	entries := m.History.Entries()
69	burst := trailingBurst(entries, m.Flags)
70	if len(burst) != len(burstFixture()) {
71		t.Fatalf("trailing burst is %d entries, want %d", len(burst), len(burstFixture()))
72	}
73	want := strings.Join(m.Renderer.BurstBlock(entries[:len(entries)-len(burst)], burst, m.Flags), "\n")
74	if got := strings.Join(m.burstRows, "\n"); got != want {
75		t.Fatalf("cached frame diverged:\n%s\nwant:\n%s", got, want)
76	}
77
78	renders := m.burstRenders
79	step := func(name string, mutate func(), want int) {
80		mutate()
81		m.refreshViewportContent()
82		if got := m.burstRenders - renders; got != want {
83			t.Fatalf("%s: frame rebuilt %d times, want %d", name, got, want)
84		}
85		renders = m.burstRenders
86	}
87	step("idle refresh", func() {}, 0)
88	step("a settled entry", func() { m.appendSettledEntry(burstFixture()[0]) }, 1)
89	step("width", func() { m.SetSize(100, 30) }, 1)
90	// the frame depends on the flags (diffs render the changes), so each flip
91	// redraws once; flipping back lands on the cached frame again
92	step("flags there and back", func() { m.Flags.Tools = true; m.refreshViewportContent(); m.Flags.Tools = false }, 2)
93	step("workspace", func() { m.Renderer.Workspace = "/w/other" }, 1)
94	step("reset then the same entries", func() {
95		m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventReset})
96		for _, entry := range burstFixture() {
97			m.appendSettledEntry(entry)
98		}
99	}, 1)
100	// same entry count after a reset, different content: only the epoch tells
101	// the cache these are not the entries it rendered
102	other := burstFixture()
103	other[4].ToolTrace.Changes[0].Path = "/w/albedo/cli/internal/tui/other.go"
104	step("reset then same-sized different entries", func() {
105		m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventReset})
106		for _, entry := range other {
107			m.appendSettledEntry(entry)
108		}
109	}, 1)
110	m.SetSize(240, 30) // wide enough that the ran list is not counted away
111	m.refreshViewportContent()
112	entries = m.History.Entries()
113	burst = trailingBurst(entries, m.Flags)
114	want = strings.Join(m.Renderer.BurstBlock(entries[:len(entries)-len(burst)], burst, m.Flags), "\n")
115	if got := strings.Join(m.burstRows, "\n"); got != want || !strings.Contains(got, "tui/other.go") {
116		t.Fatalf("same-sized history after a reset kept the old frame:\n%s\nwant:\n%s", got, want)
117	}
118	// the same equivalence under the width and workspace the steps left behind
119	entries = m.History.Entries()
120	burst = trailingBurst(entries, m.Flags)
121	want = strings.Join(m.Renderer.BurstBlock(entries[:len(entries)-len(burst)], burst, m.Flags), "\n")
122	if strings.Join(m.burstRows, "\n") != want {
123		t.Fatalf("after a reset the frame went stale:\n%s\nwant:\n%s", strings.Join(m.burstRows, "\n"), want)
124	}
125}
126
127// Eviction swaps the head of history without changing its length, so the
128// cached frame must key on evictions too or the live burst freezes.
129func TestEvictionKeepsTheLiveBurstCurrent(t *testing.T) {
130	m := newTestChatModel(t, &daemon.Session{ID: "s", Workspace: "/w"})
131	m.SetSize(120, 30)
132	m.History = NewBoundedHistory(4, 1<<20)
133	for i := range 8 {
134		m.appendSettledEntry(traced([]daemon.ToolActivity{{Kind: "read", Target: fmt.Sprintf("/w/f%d.go", i)}}))
135		m.refreshViewportContent()
136	}
137	rows := strings.Join(m.burstRows, "\n")
138	if !strings.Contains(rows, "f7}") || strings.Contains(rows, "f0") || strings.Contains(rows, "f3") {
139		t.Fatalf("live burst froze across eviction: %q", rows)
140	}
141}