package acp_test import ( "os" "slices" "strings" "testing" "rickub.com/turbo-editors/turbo-core/acp" "rickub.com/turbo-editors/turbo-core/syntax" "rickub.com/turbo-editors/turbo-core/theme" ) // TestMain registers a stand-in Go, because an editor is what teaches this // library the language it is *for* — turbo-go registers the real one, and // nothing in turbo-core does. // // The scanner is deliberately trivial: these tests are about whether a fenced // block reaches a scanner at all and whether the spans line up with the text // as drawn, not about Go. A real one would make the assertions depend on // go/scanner's opinion of a fragment. func TestMain(m *testing.M) { syntax.Register(syntax.Definition{ Language: "go", Extensions: []string{".go"}, Highlight: highlightFirstWord, }) os.Exit(m.Run()) } // highlightFirstWord colours the first word of each line as a keyword. func highlightFirstWord(src string) [][]syntax.Span { return syntax.ScanLines(src, func(line []rune, _ struct{}) ([]syntax.Span, struct{}) { s := syntax.NewLineScanner(line) s.SkipSpaces() s.TakeWhile(syntax.ClassKeyword, syntax.IsWordRune) return s.Spans(), struct{}{} }) } func TestOnlyAFenceMakesABlockCode(t *testing.T) { // A scanner is never guessed at from the shape of the text: a highlighter // that is wrong is worse than one that is quiet. blocks := acp.SplitBlocks("here it is:\n```go\nx := 1\n```\nand that is all") if len(blocks) != 3 { t.Fatalf("split into %d blocks: %v", len(blocks), blocks) } if blocks[0].Code || blocks[0].Text != "here it is:" { t.Errorf("the first block is %v", blocks[0]) } if !blocks[1].Code || blocks[1].Text != "x := 1" { t.Errorf("the code block is %v", blocks[1]) } if blocks[1].Language != syntax.Language("go") { t.Errorf("the fence named %q, want go", blocks[1].Language) } if blocks[2].Code || blocks[2].Text != "and that is all" { t.Errorf("the last block is %v", blocks[2]) } } func TestAFenceNobodyClosedIsStillCode(t *testing.T) { // An agent cut off mid-answer leaves one, and drawing the rest as prose // would change colour halfway down a function for no visible reason. blocks := acp.SplitBlocks("look:\n```go\nfunc main() {\n") if len(blocks) != 2 { t.Fatalf("split into %d blocks: %v", len(blocks), blocks) } if !blocks[1].Code { t.Errorf("the unclosed fence came out as prose: %v", blocks[1]) } } func TestAFenceNamingALanguageNothingColoursIsLeftPlain(t *testing.T) { blocks := acp.SplitBlocks("```brainfuck\n+++.\n```") if len(blocks) != 1 || !blocks[0].Code { t.Fatalf("split into %v", blocks) } if blocks[0].Language != syntax.LanguageNone { t.Errorf("the fence resolved to %q, want none", blocks[0].Language) } } func TestTheTagsModelsWriteAreUnderstood(t *testing.T) { // A model writes ```sh and ```golang by habit, and a block left plain // because of a synonym looks exactly like a scanner that does not work. for tag, want := range map[string]syntax.Language{ "sh": syntax.LanguageBash, "shell": syntax.LanguageBash, "yml": syntax.LanguageYAML, "js": syntax.LanguageJavaScript, "md": syntax.LanguageMarkdown, "TOML": syntax.LanguageTOML, "docker": syntax.LanguageDockerfile, } { blocks := acp.SplitBlocks("```" + tag + "\nx\n```") if got := blocks[0].Language; got != want { t.Errorf("```%s resolved to %q, want %q", tag, got, want) } } } func TestWrappingKeepsTheAuthorsOwnBreaks(t *testing.T) { // A reply laid out in paragraphs should stay in paragraphs. got := acp.Wrap("one\n\ntwo", 40) want := []string{"one", "", "two"} if !slices.Equal(got, want) { t.Errorf("Wrap() = %v, want %v", got, want) } } func TestWrappingBreaksAtSpacesAndThenAnywhere(t *testing.T) { if got := acp.Wrap("aaa bbb ccc", 7); !slices.Equal(got, []string{"aaa bbb", "ccc"}) { t.Errorf("Wrap() = %v", got) } // A word longer than the line must still end: a space-only rule would loop // for ever on a long path or a base64 blob. long := strings.Repeat("x", 25) got := acp.Wrap(long, 10) if len(got) != 3 { t.Fatalf("Wrap() = %v, want three pieces", got) } if strings.Join(got, "") != long { t.Errorf("Wrap() lost or added characters: %v", got) } } func TestSpeakersAndThoughtsTakeTheirOwnStyles(t *testing.T) { entries := []acp.Entry{ {Kind: acp.EntryUser, Speaker: "You", Text: "hello"}, {Kind: acp.EntryThought, Text: "hmm"}, {Kind: acp.EntryNotice, Text: "the agent stopped"}, } lines := acp.Lines(entries, "Bob", 40) styles := map[string]string{} for _, line := range lines { styles[strings.TrimSpace(line.Text)] = line.Style } if got := styles["‣ You"]; got != theme.KeySyntaxKeyword { t.Errorf("a speaker's label is drawn in %q", got) } if got := styles["hmm"]; got != theme.KeySyntaxComment { t.Errorf("a thought is drawn in %q", got) } if got := styles["the agent stopped"]; got != theme.KeyDiagnosticError { t.Errorf("a notice is drawn in %q", got) } if got := styles["hello"]; got != "" { t.Errorf("ordinary prose is drawn in %q, want the window's own text", got) } } func TestAFailedToolCallIsDrawnAsAProblem(t *testing.T) { ok := acp.Lines([]acp.Entry{{Kind: acp.EntryTool, Speaker: "Shell", Status: acp.StatusCompleted}}, "Bob", 60) bad := acp.Lines([]acp.Entry{{Kind: acp.EntryTool, Speaker: "Shell", Status: acp.StatusFailed}}, "Bob", 60) if ok[0].Style != theme.KeySyntaxType { t.Errorf("a completed tool call is drawn in %q", ok[0].Style) } if bad[0].Style != theme.KeyDiagnosticError { t.Errorf("a failed tool call is drawn in %q, want the error colour", bad[0].Style) } if !strings.Contains(ok[0].Text, "done") || !strings.Contains(bad[0].Text, "failed") { t.Errorf("the statuses read %q and %q; a tick and a cross alone are one cell apart", ok[0].Text, bad[0].Text) } } func TestCodeInAFenceIsColouredSpanBySpan(t *testing.T) { entries := []acp.Entry{{Kind: acp.EntryAgent, Speaker: "Bob", Text: "```go\nif true\n```"}} lines := acp.Lines(entries, "Bob", 60) var code *acp.Line for i, line := range lines { if strings.Contains(line.Text, "if true") { code = &lines[i] } } if code == nil { t.Fatalf("the code never appeared: %v", lines) } if len(code.Spans) == 0 { t.Fatalf("the code line carries no spans: %q", code.Text) } // The spans must line up with the text as drawn, which is indented. for _, span := range code.Spans { if span.Start < 0 || span.End > len([]rune(code.Text)) { t.Errorf("a span runs from %d to %d, outside %q", span.Start, span.End, code.Text) } } first := code.Spans[0] if got := string([]rune(code.Text)[first.Start:first.End]); got != "if" { t.Errorf("the first span covers %q, want the keyword — the indent was not accounted for", got) } } func TestProseIsWrappedAndCodeIsNot(t *testing.T) { // A wrapped line of code would need its spans remapped onto the pieces, // and half a line of Go under the line above reads worse than one that is // simply too long. Editors clip code; they do not reflow it. long := strings.Repeat("word ", 20) prose := acp.Lines([]acp.Entry{{Kind: acp.EntryAgent, Text: long}}, "Bob", 30) wrapped := 0 for _, line := range prose { if strings.TrimSpace(line.Text) != "" && !strings.HasPrefix(line.Text, "‣") { wrapped++ } } if wrapped < 3 { t.Errorf("long prose came out as %d lines at width 30", wrapped) } code := acp.Lines([]acp.Entry{{Kind: acp.EntryAgent, Text: "```go\n" + strings.Repeat("x", 100) + "\n```"}}, "Bob", 30) found := false for _, line := range code { if len([]rune(line.Text)) > 30 { found = true } } if !found { t.Error("a long line of code was wrapped; it should run off the edge instead") } } func TestAPlanIsDrawnWithABoxPerStep(t *testing.T) { entries := []acp.Entry{{Kind: acp.EntryPlan, Plan: []acp.PlanEntry{ {Content: "look", Status: acp.StatusCompleted}, {Content: "report", Status: acp.StatusInProgress}, {Content: "stop", Status: acp.StatusPending}, }}} lines := acp.Lines(entries, "Bob", 60) joined := strings.Join(textsOf(lines), "\n") for _, want := range []string{"[x] look", "[~] report", "[ ] stop"} { if !strings.Contains(joined, want) { t.Errorf("the plan never shows %q:\n%s", want, joined) } } } func TestEntriesAreSeparatedByABlankLine(t *testing.T) { entries := []acp.Entry{ {Kind: acp.EntryUser, Text: "one"}, {Kind: acp.EntryUser, Text: "two"}, } lines := acp.Lines(entries, "Bob", 40) blanks := 0 for _, line := range lines { if line.Text == "" { blanks++ } } if blanks != 1 { t.Errorf("two entries are separated by %d blank lines, want 1", blanks) } } // textsOf returns the text of each line. func textsOf(lines []acp.Line) []string { out := make([]string, len(lines)) for i, line := range lines { out[i] = line.Text } return out }