turbo-editors/turbo-corepublic Fork 0
main
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

render_test.go · 272 lines · 8.6 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 4h ago1package acp_test
2
3import (
4 "os"
5 "slices"
6 "strings"
7 "testing"
8
9 "codeberg.org/turbo-editors/turbo-core/acp"
10 "codeberg.org/turbo-editors/turbo-core/syntax"
11 "codeberg.org/turbo-editors/turbo-core/theme"
12)
13
14// TestMain registers a stand-in Go, because an editor is what teaches this
15// library the language it is *for* — turbo-go registers the real one, and
16// nothing in turbo-core does.
17//
18// The scanner is deliberately trivial: these tests are about whether a fenced
19// block reaches a scanner at all and whether the spans line up with the text
20// as drawn, not about Go. A real one would make the assertions depend on
21// go/scanner's opinion of a fragment.
22func TestMain(m *testing.M) {
23 syntax.Register(syntax.Definition{
24 Language: "go",
25 Extensions: []string{".go"},
26 Highlight: highlightFirstWord,
27 })
28 os.Exit(m.Run())
29}
30
31// highlightFirstWord colours the first word of each line as a keyword.
32func highlightFirstWord(src string) [][]syntax.Span {
33 return syntax.ScanLines(src, func(line []rune, _ struct{}) ([]syntax.Span, struct{}) {
34 s := syntax.NewLineScanner(line)
35 s.SkipSpaces()
36 s.TakeWhile(syntax.ClassKeyword, syntax.IsWordRune)
37 return s.Spans(), struct{}{}
38 })
39}
40
41func TestOnlyAFenceMakesABlockCode(t *testing.T) {
42 // A scanner is never guessed at from the shape of the text: a highlighter
43 // that is wrong is worse than one that is quiet.
44 blocks := acp.SplitBlocks("here it is:\n```go\nx := 1\n```\nand that is all")
45
46 if len(blocks) != 3 {
47 t.Fatalf("split into %d blocks: %v", len(blocks), blocks)
48 }
49 if blocks[0].Code || blocks[0].Text != "here it is:" {
50 t.Errorf("the first block is %v", blocks[0])
51 }
52 if !blocks[1].Code || blocks[1].Text != "x := 1" {
53 t.Errorf("the code block is %v", blocks[1])
54 }
55 if blocks[1].Language != syntax.Language("go") {
56 t.Errorf("the fence named %q, want go", blocks[1].Language)
57 }
58 if blocks[2].Code || blocks[2].Text != "and that is all" {
59 t.Errorf("the last block is %v", blocks[2])
60 }
61}
62
63func TestAFenceNobodyClosedIsStillCode(t *testing.T) {
64 // An agent cut off mid-answer leaves one, and drawing the rest as prose
65 // would change colour halfway down a function for no visible reason.
66 blocks := acp.SplitBlocks("look:\n```go\nfunc main() {\n")
67
68 if len(blocks) != 2 {
69 t.Fatalf("split into %d blocks: %v", len(blocks), blocks)
70 }
71 if !blocks[1].Code {
72 t.Errorf("the unclosed fence came out as prose: %v", blocks[1])
73 }
74}
75
76func TestAFenceNamingALanguageNothingColoursIsLeftPlain(t *testing.T) {
77 blocks := acp.SplitBlocks("```brainfuck\n+++.\n```")
78
79 if len(blocks) != 1 || !blocks[0].Code {
80 t.Fatalf("split into %v", blocks)
81 }
82 if blocks[0].Language != syntax.LanguageNone {
83 t.Errorf("the fence resolved to %q, want none", blocks[0].Language)
84 }
85}
86
87func TestTheTagsModelsWriteAreUnderstood(t *testing.T) {
88 // A model writes ```sh and ```golang by habit, and a block left plain
89 // because of a synonym looks exactly like a scanner that does not work.
90 for tag, want := range map[string]syntax.Language{
91 "sh": syntax.LanguageBash,
92 "shell": syntax.LanguageBash,
93 "yml": syntax.LanguageYAML,
94 "js": syntax.LanguageJavaScript,
95 "md": syntax.LanguageMarkdown,
96 "TOML": syntax.LanguageTOML,
97 "docker": syntax.LanguageDockerfile,
98 } {
99 blocks := acp.SplitBlocks("```" + tag + "\nx\n```")
100 if got := blocks[0].Language; got != want {
101 t.Errorf("```%s resolved to %q, want %q", tag, got, want)
102 }
103 }
104}
105
106func TestWrappingKeepsTheAuthorsOwnBreaks(t *testing.T) {
107 // A reply laid out in paragraphs should stay in paragraphs.
108 got := acp.Wrap("one\n\ntwo", 40)
109 want := []string{"one", "", "two"}
110 if !slices.Equal(got, want) {
111 t.Errorf("Wrap() = %v, want %v", got, want)
112 }
113}
114
115func TestWrappingBreaksAtSpacesAndThenAnywhere(t *testing.T) {
116 if got := acp.Wrap("aaa bbb ccc", 7); !slices.Equal(got, []string{"aaa bbb", "ccc"}) {
117 t.Errorf("Wrap() = %v", got)
118 }
119
120 // A word longer than the line must still end: a space-only rule would loop
121 // for ever on a long path or a base64 blob.
122 long := strings.Repeat("x", 25)
123 got := acp.Wrap(long, 10)
124 if len(got) != 3 {
125 t.Fatalf("Wrap() = %v, want three pieces", got)
126 }
127 if strings.Join(got, "") != long {
128 t.Errorf("Wrap() lost or added characters: %v", got)
129 }
130}
131
132func TestSpeakersAndThoughtsTakeTheirOwnStyles(t *testing.T) {
133 entries := []acp.Entry{
134 {Kind: acp.EntryUser, Speaker: "You", Text: "hello"},
135 {Kind: acp.EntryThought, Text: "hmm"},
136 {Kind: acp.EntryNotice, Text: "the agent stopped"},
137 }
138
139 lines := acp.Lines(entries, "Bob", 40)
140 styles := map[string]string{}
141 for _, line := range lines {
142 styles[strings.TrimSpace(line.Text)] = line.Style
143 }
144
145 if got := styles["‣ You"]; got != theme.KeySyntaxKeyword {
146 t.Errorf("a speaker's label is drawn in %q", got)
147 }
148 if got := styles["hmm"]; got != theme.KeySyntaxComment {
149 t.Errorf("a thought is drawn in %q", got)
150 }
151 if got := styles["the agent stopped"]; got != theme.KeyDiagnosticError {
152 t.Errorf("a notice is drawn in %q", got)
153 }
154 if got := styles["hello"]; got != "" {
155 t.Errorf("ordinary prose is drawn in %q, want the window's own text", got)
156 }
157}
158
159func TestAFailedToolCallIsDrawnAsAProblem(t *testing.T) {
160 ok := acp.Lines([]acp.Entry{{Kind: acp.EntryTool, Speaker: "Shell", Status: acp.StatusCompleted}}, "Bob", 60)
161 bad := acp.Lines([]acp.Entry{{Kind: acp.EntryTool, Speaker: "Shell", Status: acp.StatusFailed}}, "Bob", 60)
162
163 if ok[0].Style != theme.KeySyntaxType {
164 t.Errorf("a completed tool call is drawn in %q", ok[0].Style)
165 }
166 if bad[0].Style != theme.KeyDiagnosticError {
167 t.Errorf("a failed tool call is drawn in %q, want the error colour", bad[0].Style)
168 }
169 if !strings.Contains(ok[0].Text, "done") || !strings.Contains(bad[0].Text, "failed") {
170 t.Errorf("the statuses read %q and %q; a tick and a cross alone are one cell apart", ok[0].Text, bad[0].Text)
171 }
172}
173
174func TestCodeInAFenceIsColouredSpanBySpan(t *testing.T) {
175 entries := []acp.Entry{{Kind: acp.EntryAgent, Speaker: "Bob", Text: "```go\nif true\n```"}}
176 lines := acp.Lines(entries, "Bob", 60)
177
178 var code *acp.Line
179 for i, line := range lines {
180 if strings.Contains(line.Text, "if true") {
181 code = &lines[i]
182 }
183 }
184 if code == nil {
185 t.Fatalf("the code never appeared: %v", lines)
186 }
187 if len(code.Spans) == 0 {
188 t.Fatalf("the code line carries no spans: %q", code.Text)
189 }
190
191 // The spans must line up with the text as drawn, which is indented.
192 for _, span := range code.Spans {
193 if span.Start < 0 || span.End > len([]rune(code.Text)) {
194 t.Errorf("a span runs from %d to %d, outside %q", span.Start, span.End, code.Text)
195 }
196 }
197 first := code.Spans[0]
198 if got := string([]rune(code.Text)[first.Start:first.End]); got != "if" {
199 t.Errorf("the first span covers %q, want the keyword — the indent was not accounted for", got)
200 }
201}
202
203func TestProseIsWrappedAndCodeIsNot(t *testing.T) {
204 // A wrapped line of code would need its spans remapped onto the pieces,
205 // and half a line of Go under the line above reads worse than one that is
206 // simply too long. Editors clip code; they do not reflow it.
207 long := strings.Repeat("word ", 20)
208 prose := acp.Lines([]acp.Entry{{Kind: acp.EntryAgent, Text: long}}, "Bob", 30)
209 wrapped := 0
210 for _, line := range prose {
211 if strings.TrimSpace(line.Text) != "" && !strings.HasPrefix(line.Text, "‣") {
212 wrapped++
213 }
214 }
215 if wrapped < 3 {
216 t.Errorf("long prose came out as %d lines at width 30", wrapped)
217 }
218
219 code := acp.Lines([]acp.Entry{{Kind: acp.EntryAgent, Text: "```go\n" + strings.Repeat("x", 100) + "\n```"}}, "Bob", 30)
220 found := false
221 for _, line := range code {
222 if len([]rune(line.Text)) > 30 {
223 found = true
224 }
225 }
226 if !found {
227 t.Error("a long line of code was wrapped; it should run off the edge instead")
228 }
229}
230
231func TestAPlanIsDrawnWithABoxPerStep(t *testing.T) {
232 entries := []acp.Entry{{Kind: acp.EntryPlan, Plan: []acp.PlanEntry{
233 {Content: "look", Status: acp.StatusCompleted},
234 {Content: "report", Status: acp.StatusInProgress},
235 {Content: "stop", Status: acp.StatusPending},
236 }}}
237
238 lines := acp.Lines(entries, "Bob", 60)
239 joined := strings.Join(textsOf(lines), "\n")
240 for _, want := range []string{"[x] look", "[~] report", "[ ] stop"} {
241 if !strings.Contains(joined, want) {
242 t.Errorf("the plan never shows %q:\n%s", want, joined)
243 }
244 }
245}
246
247func TestEntriesAreSeparatedByABlankLine(t *testing.T) {
248 entries := []acp.Entry{
249 {Kind: acp.EntryUser, Text: "one"},
250 {Kind: acp.EntryUser, Text: "two"},
251 }
252
253 lines := acp.Lines(entries, "Bob", 40)
254 blanks := 0
255 for _, line := range lines {
256 if line.Text == "" {
257 blanks++
258 }
259 }
260 if blanks != 1 {
261 t.Errorf("two entries are separated by %d blank lines, want 1", blanks)
262 }
263}
264
265// textsOf returns the text of each line.
266func textsOf(lines []acp.Line) []string {
267 out := make([]string, len(lines))
268 for i, line := range lines {
269 out[i] = line.Text
270 }
271 return out
272}