| 🛟 Updated. 28d5985 k33g 17h ago | 1 | package acp_test |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "slices" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 📦 Turbo Core f3ade8d k33g 10h ago | 9 | "rickub.com/turbo-editors/turbo-core/acp" |
| 10 | "rickub.com/turbo-editors/turbo-core/syntax" |
| 11 | "rickub.com/turbo-editors/turbo-core/theme" |
| 🛟 Updated. 28d5985 k33g 17h ago | 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. |
| 22 | func 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. |
| 32 | func 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 | |
| 41 | func 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 | |
| 63 | func 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 | |
| 76 | func 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 | |
| 87 | func 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 | |
| 106 | func 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 | |
| 115 | func 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 | |
| 132 | func 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 | |
| 159 | func 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 | |
| 174 | func 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 | |
| 203 | func 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 | |
| 231 | func 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 | |
| 247 | func 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. |
| 266 | func 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 | } |