package app import ( "strings" "testing" "glint/internal/grammar" "glint/internal/theme" ) // TestApplyGrammarDiagsRendersUnderline checks the app buckets a diagnostic batch // by line and the editor renders it as a green undercurl. FlexokiDark's Grammar // green is #879A39 -> the underline-color SGR carries its RGB (135,154,57). func TestApplyGrammarDiagsRendersUnderline(t *testing.T) { a := newApp() a.setSize(100, 24) a.editor.SetTheme(theme.FlexokiDark()) a.editor.SetContent([]byte("This is a a test")) a.editor.SetGrammar(true) a.applyGrammarDiags([]grammar.Diag{{Line: 0, StartCol: 8, EndCol: 11, Message: "repeat"}}) const greenSGR = "58:2::135:154:57" // undercurl color for #879A39 if !strings.Contains(a.editor.View(), greenSGR) { t.Error("expected a green grammar undercurl (SGR " + greenSGR + ") in the rendered view") } } // TestGrammarEndToEnd drives the whole pipeline against the real harper: Init // starts the subprocess and opens the buffer, the listener command blocks for the // first diagnostic batch, and applying it renders a green undercurl. Skipped // without harper on PATH or under -short. func TestGrammarEndToEnd(t *testing.T) { if testing.Short() { t.Skip("skipping live harper test under -short") } if !grammar.Available() { t.Skip("harper-ls not on PATH") } a := newApp() a.setSize(100, 24) a.editor.SetTheme(theme.FlexokiDark()) a.editor.SetContent([]byte("This is a a test.\n")) listen := a.Init() // starts harper, opens the buffer, returns the listener cmd defer a.Close() if a.grammar == nil { t.Fatal("Init did not start a harper client") } msg := listen() // blocks until harper publishes diagnostics diag, ok := msg.(grammarDiagMsg) if !ok { t.Fatalf("listener returned %T, want grammarDiagMsg", msg) } a.applyGrammarDiags(diag.diags) const greenSGR = "58:2::135:154:57" // #879A39 undercurl if !strings.Contains(a.editor.View(), greenSGR) { t.Error("no green grammar undercurl after live harper round-trip") } } // TestGrammarIgnoreClearsUnderline: ignoring a lint drops its underline immediately // and keeps it dropped when harper re-publishes the same batch. func TestGrammarIgnoreClearsUnderline(t *testing.T) { a := newApp() a.setSize(100, 24) a.editor.SetTheme(theme.FlexokiDark()) a.editor.SetContent([]byte("This is a a test")) a.editor.SetGrammar(true) const greenSGR = "58:2::135:154:57" batch := []grammar.Diag{{Line: 0, StartCol: 8, EndCol: 11, Code: "RepeatedWords", Message: "repeat"}} a.applyGrammarDiags(batch) if !strings.Contains(a.editor.View(), greenSGR) { t.Fatal("expected a grammar underline before ignore") } a.ignoreGrammar("RepeatedWords", "a a") if strings.Contains(a.editor.View(), greenSGR) { t.Error("underline should vanish immediately after ignore") } // A fresh identical batch stays suppressed. a.applyGrammarDiags(batch) if strings.Contains(a.editor.View(), greenSGR) { t.Error("ignored lint should stay suppressed across re-batches") } } // TestApplyGrammarFix applies Harper replacement edits to the buffer. func TestApplyGrammarFix(t *testing.T) { a := newApp() a.setSize(100, 24) a.editor.SetContent([]byte("This is a a test")) a.applyGrammarFix([]grammar.TextEdit{{StartLine: 0, StartCol: 8, EndLine: 0, EndCol: 11, NewText: "a"}}) if got := string(a.editor.Bytes()); got != "This is a test" { t.Errorf("got %q, want %q", got, "This is a test") } } // TestApplyGrammarFixMultiEdit applies two edits on one line; last-first ordering // keeps the earlier edit's range valid after the later one shifts the text. func TestApplyGrammarFixMultiEdit(t *testing.T) { a := newApp() a.setSize(100, 24) a.editor.SetContent([]byte("aa bb")) a.applyGrammarFix([]grammar.TextEdit{ {StartLine: 0, StartCol: 0, EndLine: 0, EndCol: 2, NewText: "AA"}, {StartLine: 0, StartCol: 3, EndLine: 0, EndCol: 5, NewText: "BBB"}, }) if got := string(a.editor.Bytes()); got != "AA BBB" { t.Errorf("got %q, want %q", got, "AA BBB") } } // TestGrammarCodeAt maps a span back to its rule code from the retained batch. func TestGrammarCodeAt(t *testing.T) { a := newApp() a.lastGrammarDiags = []grammar.Diag{{Line: 0, StartCol: 8, EndCol: 11, Code: "RepeatedWords"}} if got := a.grammarCodeAt(0, 8, 11); got != "RepeatedWords" { t.Errorf("grammarCodeAt = %q, want RepeatedWords", got) } if got := a.grammarCodeAt(0, 0, 3); got != "" { t.Errorf("unmatched span code = %q, want empty", got) } } // TestGrammarPopupApplyFix drives the popup apply path directly (no client needed): // a grammarFix option, when applied, edits the buffer and returns to editor mode. func TestGrammarPopupApplyFix(t *testing.T) { a := newApp() a.setSize(100, 24) a.editor.SetContent([]byte("This is a a test")) a.mode = ModeSpell a.spell = spellPopup{ word: "a a", code: "RepeatedWords", row: 0, start: 8, end: 11, options: []spellOption{ {label: `Replace with: "a"`, kind: grammarFix, edits: []grammar.TextEdit{{StartLine: 0, StartCol: 8, EndLine: 0, EndCol: 11, NewText: "a"}}}, {label: "Ignore", kind: grammarIgnore}, }, } a.applySpell(0) if got := string(a.editor.Bytes()); got != "This is a test" { t.Errorf("got %q, want %q", got, "This is a test") } if a.mode != ModeEditor { t.Error("popup should close after applying a fix") } } // TestGrammarPopupLive opens the popup on a real harper-flagged span and applies the // first fix. Gated on harper + not -short. func TestGrammarPopupLive(t *testing.T) { if testing.Short() { t.Skip("skipping live harper test under -short") } if !grammar.Available() { t.Skip("harper-ls not on PATH") } a := newApp() a.setSize(100, 24) a.editor.SetContent([]byte("This is a a test.\n")) listen := a.Init() defer a.Close() msg := listen() diag, ok := msg.(grammarDiagMsg) if !ok { t.Fatalf("listener returned %T", msg) } a.applyGrammarDiags(diag.diags) d := diag.diags[0] if !a.openGrammarPopupAt(d.Line, d.StartCol) { t.Fatal("openGrammarPopupAt did not open on a flagged span") } if a.mode != ModeSpell { t.Fatal("expected ModeSpell after opening the grammar popup") } fixes := 0 for _, o := range a.spell.options { if o.kind == grammarFix { fixes++ } } if fixes == 0 { t.Fatal("grammar popup listed no replacement fixes from harper") } before := string(a.editor.Bytes()) a.applySpell(0) // apply the first fix if string(a.editor.Bytes()) == before { t.Error("applying a harper fix did not change the buffer") } } // TestGrammarNilClientNoOps confirms the debounce/flush path is inert without a // running harper client, so plain app tests never touch a subprocess. func TestGrammarNilClientNoOps(t *testing.T) { a := newApp() if a.grammar != nil { t.Fatal("newApp should not start a harper client") } if cmd := a.grammarChanged(); cmd != nil { t.Error("grammarChanged should return nil without a client") } a.grammarFlush(a.grammarGen) // must not panic a.grammarOpen() // must not panic }