1package tui
2
3import (
4 "strings"
5 "testing"
6)
7
8const liveTextSample = "Intro paragraph with **bold** and `code`.\n\n" +
9 "## Heading\n\n" +
10 "- item one\n- item two\n - nested\n\n" +
11 "```go\nfunc main() {\n\n\tprintln(\"hi\")\n}\n```\n\n" +
12 "| a | b |\n| --- | ---: |\n| 1 | 2 |\n\n\n" +
13 "> quoted line\n\n" +
14 "1. first\n2. second\n\n" +
15 "A long closing paragraph that should wrap across several rows when the terminal is narrow enough to force it.\n"
16
17// Live rows must equal the finished-message rows for every prefix of a reply,
18// or rows already in terminal scrollback would diverge from the final text.
19func TestLiveTextRowsMatchFinalRendering(t *testing.T) {
20 corpus := map[string]string{
21 "mixed": liveTextSample,
22 "fence-like table": "| a | b |\n| --- | --- |\n```x | y\n\ntext\n```\n\nm\n\nafter",
23 "crlf": "one\r\n\r\ntwo **b**\r\n\r\n- item\r\n",
24 "blank spaces": "para one\n \n\t\t\npara two\n\n\n\npara three",
25 "leading newlines": "\n\nstarts late\n\nand ends\n\n",
26 "indented fence": "list:\n\n ```sh\n echo hi\n\n echo bye\n ```\n\ndone",
27 "trailing table": "| h | i |\n| - | - |\n| 1 | 2 |\n\n\n",
28 }
29 for name, sample := range corpus {
30 for _, width := range []int{10, 24, 80} {
31 v := &View{Theme: Dark}
32 for n := 1; n <= len(sample); n++ {
33 prefix := sample[:n]
34 got := strings.Join(v.liveTextRows(prefix, width), "\n")
35 want := strings.Join(renderAssistantText(prefix, Dark, width), "\n")
36 if got != want {
37 t.Fatalf("%s, width %d, prefix %d %q:\nlive:\n%q\nfinal:\n%q", name, width, n, prefix, got, want)
38 }
39 }
40 }
41 }
42}
43
44// Width and theme changes must not reuse rows rendered for other settings.
45func TestLiveTextRowsResetOnWidthAndTheme(t *testing.T) {
46 const text = "first **block**\n\n```go\nx := 1\n```\n\ntail"
47 v := &View{Theme: Dark}
48 v.liveTextRows(text, 80)
49 if v.liveText.src == "" {
50 t.Fatal("no finished block was cached")
51 }
52 if got, want := strings.Join(v.liveTextRows(text, 12), "\n"), strings.Join(renderAssistantText(text, Dark, 12), "\n"); got != want {
53 t.Fatalf("width change reused stale rows:\n%q\nwant\n%q", got, want)
54 }
55 v.Theme = Light
56 if got, want := strings.Join(v.liveTextRows(text, 12), "\n"), strings.Join(renderAssistantText(text, Light, 12), "\n"); got != want {
57 t.Fatalf("theme change reused stale rows:\n%q\nwant\n%q", got, want)
58 }
59}
60
61// Render snapshots must not share the live-text cache with the owning view.
62func TestCloneForRenderIsolatesLiveTextCache(t *testing.T) {
63 v := &View{Theme: Dark}
64 v.liveTextRows("block one\n\nblock two\n\n", 80)
65 clone := v.CloneForRender()
66 if clone.liveText.src != "" || clone.liveText.rows != nil {
67 t.Fatal("clone shares the live-text cache")
68 }
69}
70
71// A new reply that does not extend the cached text starts a fresh cache.
72func TestLiveTextRowsResetOnNewReply(t *testing.T) {
73 v := &View{Theme: Dark}
74 v.liveTextRows("first reply\n\nmore", 80)
75 got := strings.Join(v.liveTextRows("second", 80), "\n")
76 want := strings.Join(renderAssistantText("second", Dark, 80), "\n")
77 if got != want {
78 t.Fatalf("stale cache leaked into a new reply:\n%s", got)
79 }
80}
81
82func TestMarkdownBoundariesSkipOpenFence(t *testing.T) {
83 lines := strings.Split("text\n\n```\ncode\n\nmore code\n", "\n")
84 var got []int
85 renderMarkdownRaw(lines, Dark, 80, func(line, _ int) { got = append(got, line) })
86 if len(got) != 1 || got[0] != 2 {
87 t.Fatalf("boundaries = %v, want [2] (none inside an open fence)", got)
88 }
89}
90
91func BenchmarkLiveTextStreaming(b *testing.B) {
92 text := strings.Repeat(liveTextSample, 8)
93 for b.Loop() {
94 v := &View{Theme: Dark}
95 for n := 64; n <= len(text); n += 64 {
96 v.liveTextRows(text[:n], 100)
97 }
98 }
99}
100
101func BenchmarkFullRerenderStreaming(b *testing.B) {
102 text := strings.Repeat(liveTextSample, 8)
103 for b.Loop() {
104 for n := 64; n <= len(text); n += 64 {
105 renderAssistantText(text[:n], Dark, 100)
106 }
107 }
108}