package editor import ( "testing" "glint/internal/theme" "github.com/charmbracelet/lipgloss" ) // spanText concatenates the raw text of a line's spans. It must equal the // original raw line exactly (markup-visible invariant). func spanText(spans []Span) string { s := "" for _, sp := range spans { s += sp.Text } return s } func TestRenderSpansPreservesText(t *testing.T) { spans := []Span{ {Text: "ab", Style: lipgloss.NewStyle()}, {Text: "cd", Style: lipgloss.NewStyle()}, } // rendering may add ANSI, but stripped content is checked elsewhere; // here we only assert it does not panic and is non-empty. if renderSpans(spans) == "" { t.Error("renderSpans returned empty for non-empty spans") } } func TestScanPlainTextGetsExplicitForeground(t *testing.T) { th := theme.FlexokiDark() out := ScanLines([]string{"hello world"}, th) if len(out) != 1 || len(out[0]) == 0 { t.Fatalf("expected spans for one line, got %v", out) } for _, sp := range out[0] { // A style with no foreground returns "" from GetForeground(). if sp.Style.GetForeground() == lipgloss.Color("") { t.Errorf("plain span %q has no explicit foreground", sp.Text) } } } func TestScanPreservesRawTextAcrossConstructs(t *testing.T) { th := theme.FlexokiDark() lines := []string{ "# Heading", "plain **bold** and *italic* and `code`", "- a list item with a [link](http://x) and [[wikilink]]", "> a quote", "```", "raw **not bold** here", "```", "---", } out := ScanLines(lines, th) for i, raw := range lines { if got := spanText(out[i]); got != raw { t.Errorf("line %d: span text %q != raw %q", i, got, raw) } } } func TestScanFencedCodeSuppressesInline(t *testing.T) { th := theme.FlexokiDark() lines := []string{"```", "**x**", "```"} out := ScanLines(lines, th) // The fence body line should be a single code-colored span, not split // into bold spans. if len(out[1]) != 1 { t.Errorf("fenced line split into %d spans, want 1", len(out[1])) } if out[1][0].Style.GetForeground() != th.Code { t.Errorf("fenced body not code-colored") } } func TestScanLeadingFrontmatter(t *testing.T) { th := theme.FlexokiDark() lines := []string{"---", "title: x", "---", "body"} out := ScanLines(lines, th) if out[0][0].Style.GetForeground() != th.Muted { t.Errorf("opening --- not muted") } if out[1][0].Style.GetForeground() != th.Accent { t.Errorf("frontmatter key not accent-colored") } if out[3][0].Style.GetForeground() != th.Text { t.Errorf("post-frontmatter line should be plain text") } } func TestScanEmptyLineYieldsNoSpans(t *testing.T) { out := ScanLines([]string{""}, theme.FlexokiDark()) if len(out[0]) != 0 { t.Errorf("empty line should yield no spans, got %d", len(out[0])) } } func TestScanHeadingKeepsHashes(t *testing.T) { th := theme.FlexokiDark() out := ScanLines([]string{"### Title"}, th) if spanText(out[0]) != "### Title" { t.Errorf("heading hashes dropped: %q", spanText(out[0])) } // The hashes are muted; the heading text is heading-colored and bold. if out[0][0].Style.GetForeground() != th.Muted { t.Errorf("heading marker not muted, got %v", out[0][0].Style.GetForeground()) } last := out[0][len(out[0])-1] if last.Style.GetForeground() != th.Heading || !last.Style.GetBold() { t.Errorf("heading text not bold heading color") } } func TestScanEmphasisUsesEmphasisToneDimMarkers(t *testing.T) { th := theme.FlexokiDark() out := ScanLines([]string{"a **bold** b"}, th) if spanText(out[0]) != "a **bold** b" { t.Fatalf("text altered: %q", spanText(out[0])) } var sawMutedMarker, sawBoldContent bool for _, sp := range out[0] { if sp.Text == "**" && sp.Style.GetForeground() == th.Muted { sawMutedMarker = true } if sp.Text == "bold" && sp.Style.GetForeground() == th.Emphasis && sp.Style.GetBold() { sawBoldContent = true } } if !sawMutedMarker { t.Error("** markers not muted") } if !sawBoldContent { t.Error("bold content not Emphasis+bold") } } func TestScanLinkTextColoredUrlAndBracketsMuted(t *testing.T) { th := theme.FlexokiDark() out := ScanLines([]string{"see [text](http://x)"}, th) if spanText(out[0]) != "see [text](http://x)" { t.Fatalf("text altered: %q", spanText(out[0])) } var linkText, dimmed bool for _, sp := range out[0] { if sp.Text == "text" && sp.Style.GetForeground() == th.Link { linkText = true } if (sp.Text == "http://x" || sp.Text == "[" || sp.Text == "](" || sp.Text == ")") && sp.Style.GetForeground() == th.Muted { dimmed = true } } if !linkText { t.Error("link text not Link-colored") } if !dimmed { t.Error("url/brackets not muted") } } func TestScanReferenceLink(t *testing.T) { th := theme.FlexokiDark() out := ScanLines([]string{"a [Glamour][glamour] b"}, th) if spanText(out[0]) != "a [Glamour][glamour] b" { t.Fatalf("text altered: %q", spanText(out[0])) } found := false for _, sp := range out[0] { if sp.Text == "Glamour" && sp.Style.GetForeground() == th.Link { found = true } } if !found { t.Error("reference-link text not Link-colored") } } func TestScanReferenceDefinition(t *testing.T) { th := theme.FlexokiDark() out := ScanLines([]string{"[glamour]: https://github.com/x"}, th) if spanText(out[0]) != "[glamour]: https://github.com/x" { t.Fatalf("text altered: %q", spanText(out[0])) } last := out[0][len(out[0])-1] if last.Text != "https://github.com/x" || last.Style.GetForeground() != th.Link { t.Errorf("ref-def url not Link-colored: %+v", last) } if out[0][0].Style.GetForeground() != th.Muted { t.Error("ref-def [ref]: not muted") } } func TestScanHighlightGetsBackground(t *testing.T) { th := theme.FlexokiDark() out := ScanLines([]string{"a ==mark== b"}, th) if spanText(out[0]) != "a ==mark== b" { t.Fatalf("text altered: %q", spanText(out[0])) } found := false for _, sp := range out[0] { if sp.Text == "mark" && sp.Style.GetBackground() == th.Highlight { found = true } } if !found { t.Error("highlight content has no Highlight background") } } func TestScanCommentVisible(t *testing.T) { th := theme.FlexokiDark() out := ScanLines([]string{""}, th) if out[0][0].Style.GetForeground() != th.Comment { t.Errorf("comment not Comment-colored, got %v", out[0][0].Style.GetForeground()) } } func TestScanBlockquoteItalicTextDimMarker(t *testing.T) { th := theme.FlexokiDark() out := ScanLines([]string{"> quoted"}, th) if spanText(out[0]) != "> quoted" { t.Fatalf("text altered: %q", spanText(out[0])) } if out[0][0].Style.GetForeground() != th.Muted { t.Error("blockquote marker not muted") } last := out[0][len(out[0])-1] if last.Style.GetForeground() != th.Text || !last.Style.GetItalic() { t.Error("blockquote text not italic base color") } } func TestScanListMarkerThenInline(t *testing.T) { th := theme.FlexokiDark() out := ScanLines([]string{"- **x**"}, th) if out[0][0].Style.GetForeground() != th.ListMarker { t.Errorf("first span should be the list marker, got fg %v", out[0][0].Style.GetForeground()) } if spanText(out[0]) != "- **x**" { t.Errorf("list line text altered: %q", spanText(out[0])) } } func TestScanFrontmatterHighlightsKeysAndValues(t *testing.T) { th := theme.FlexokiDark() lines := []string{"---", "title: Hello", "tags:", " - one", " - two", "# a comment", "---", "body"} out := ScanLines(lines, th) // Char-for-char preservation across every frontmatter line. for i, raw := range lines { if got := spanText(out[i]); got != raw { t.Errorf("line %d: span text %q != raw %q", i, got, raw) } } // Opening and closing --- are muted. if out[0][0].Style.GetForeground() != th.Muted { t.Errorf("opening --- not muted") } if out[6][0].Style.GetForeground() != th.Muted { t.Errorf("closing --- not muted") } // "title:" key portion is Accent; the value is Text. if out[1][0].Style.GetForeground() != th.Accent { t.Errorf("key not Accent-colored, got %v", out[1][0].Style.GetForeground()) } if last := out[1][len(out[1])-1]; last.Style.GetForeground() != th.Text { t.Errorf("value not Text-colored, got %v", last.Style.GetForeground()) } // List item marker "-" is ListMarker-colored. foundMarker := false for _, sp := range out[3] { if sp.Style.GetForeground() == th.ListMarker { foundMarker = true } } if !foundMarker { t.Errorf("list item dash not ListMarker-colored") } // Comment line is muted. if out[5][0].Style.GetForeground() != th.Muted { t.Errorf("comment not muted") } // After the closing ---, normal text resumes. if out[7][0].Style.GetForeground() != th.Text { t.Errorf("post-frontmatter line should be plain text") } } func TestConflictHighlight(t *testing.T) { th := theme.FlexokiDark() lines := []string{ "before", "<<<<<<< HEAD", "our change", "=======", "their change", ">>>>>>> feature", "after", } all := ScanLines(lines, th) applyConflictHighlight(all, lines, th) // Markup-visible invariant: span text still reproduces each raw line. for i, line := range lines { if spanText(all[i]) != line { t.Errorf("line %d: spanText %q != raw %q", i, spanText(all[i]), line) } } // The three marker lines are one bold span in the marker color. for _, i := range []int{1, 3, 5} { if len(all[i]) != 1 { t.Fatalf("marker line %d: want 1 span, got %d", i, len(all[i])) } sp := all[i][0] if sp.Style.GetForeground() != th.ConflictMarker || !sp.Style.GetBold() { t.Errorf("marker line %d not bold ConflictMarker (fg=%v bold=%v)", i, sp.Style.GetForeground(), sp.Style.GetBold()) } } // "ours" content tinted with ConflictOurs. if all[2][0].Style.GetBackground() != th.ConflictOurs { t.Errorf("ours block bg = %v, want ConflictOurs %v", all[2][0].Style.GetBackground(), th.ConflictOurs) } // "theirs" content tinted with ConflictTheirs. if all[4][0].Style.GetBackground() != th.ConflictTheirs { t.Errorf("theirs block bg = %v, want ConflictTheirs %v", all[4][0].Style.GetBackground(), th.ConflictTheirs) } // Lines outside the conflict are untouched (no conflict tint). if bg := all[0][0].Style.GetBackground(); bg == th.ConflictOurs || bg == th.ConflictTheirs { t.Errorf("pre-conflict line should not be tinted, got %v", bg) } if bg := all[6][0].Style.GetBackground(); bg == th.ConflictOurs || bg == th.ConflictTheirs { t.Errorf("post-conflict line should not be tinted, got %v", bg) } } func TestConflictDiff3BaseBlock(t *testing.T) { th := theme.FlexokiDark() lines := []string{ "<<<<<<< HEAD", "ours", "||||||| base", "original", "=======", "theirs", ">>>>>>> branch", } all := ScanLines(lines, th) applyConflictHighlight(all, lines, th) // ||||||| is a marker line. if len(all[2]) != 1 || all[2][0].Style.GetForeground() != th.ConflictMarker { t.Errorf("||||||| base line not marked as a conflict marker") } // The base block shares the "ours" tint. if all[3][0].Style.GetBackground() != th.ConflictOurs { t.Errorf("base block bg = %v, want ConflictOurs", all[3][0].Style.GetBackground()) } } func TestConflictSeparatorNotFalselyDetected(t *testing.T) { th := theme.FlexokiDark() // A lone ======= (setext-ish underline) outside a conflict must NOT be // treated as a separator or gain a conflict tint. lines := []string{"Title", "=======", "body"} all := ScanLines(lines, th) applyConflictHighlight(all, lines, th) for i := range lines { if len(all[i]) == 0 { continue } if bg := all[i][0].Style.GetBackground(); bg == th.ConflictOurs || bg == th.ConflictTheirs { t.Errorf("line %d falsely tinted as conflict content", i) } } }