test: updated tests and pulled theme into EditorSettings.
All checks were successful
Run Test Suite / test (push) Successful in 37s

This commit is contained in:
Hayden Hargreaves 2026-04-08 17:19:32 -07:00
parent 2cfa17705b
commit 64c448c639
7 changed files with 529 additions and 98 deletions

View File

@ -23,7 +23,6 @@ type MockModel struct {
CommandHistoryList []string CommandHistoryList []string
CommandHistoryCur int CommandHistoryCur int
LastFindVal core.LastFindCommand LastFindVal core.LastFindCommand
CurrentThemeName string
ThemesMap map[string]theme.EditorTheme ThemesMap map[string]theme.EditorTheme
LastChangeKeysList []string LastChangeKeysList []string
} }
@ -41,14 +40,13 @@ func NewMockModel() *MockModel {
Build() Build()
return &MockModel{ return &MockModel{
WindowsList: []*core.Window{&win}, WindowsList: []*core.Window{&win},
ActiveWindowVal: &win, ActiveWindowVal: &win,
BuffersList: []*core.Buffer{&buf}, BuffersList: []*core.Buffer{&buf},
SettingsVal: core.NewDefaultSettings(), SettingsVal: core.NewDefaultSettings(),
ModeVal: core.NormalMode, ModeVal: core.NormalMode,
RegistersMap: core.DefaultRegisters(), RegistersMap: core.DefaultRegisters(),
CurrentThemeName: "default", ThemesMap: map[string]theme.EditorTheme{"default": {}},
ThemesMap: map[string]theme.EditorTheme{"default": {}},
} }
} }
@ -61,28 +59,26 @@ func NewMockModelWithBuffer(buf *core.Buffer) *MockModel {
Build() Build()
return &MockModel{ return &MockModel{
WindowsList: []*core.Window{&win}, WindowsList: []*core.Window{&win},
ActiveWindowVal: &win, ActiveWindowVal: &win,
BuffersList: []*core.Buffer{buf}, BuffersList: []*core.Buffer{buf},
SettingsVal: core.NewDefaultSettings(), SettingsVal: core.NewDefaultSettings(),
ModeVal: core.NormalMode, ModeVal: core.NormalMode,
RegistersMap: core.DefaultRegisters(), RegistersMap: core.DefaultRegisters(),
CurrentThemeName: "default", ThemesMap: map[string]theme.EditorTheme{"default": {}},
ThemesMap: map[string]theme.EditorTheme{"default": {}},
} }
} }
// NewMockModelWithWindow creates a mock with a custom window. // NewMockModelWithWindow creates a mock with a custom window.
func NewMockModelWithWindow(win *core.Window) *MockModel { func NewMockModelWithWindow(win *core.Window) *MockModel {
return &MockModel{ return &MockModel{
WindowsList: []*core.Window{win}, WindowsList: []*core.Window{win},
ActiveWindowVal: win, ActiveWindowVal: win,
BuffersList: []*core.Buffer{win.Buffer}, BuffersList: []*core.Buffer{win.Buffer},
SettingsVal: core.NewDefaultSettings(), SettingsVal: core.NewDefaultSettings(),
ModeVal: core.NormalMode, ModeVal: core.NormalMode,
RegistersMap: core.DefaultRegisters(), RegistersMap: core.DefaultRegisters(),
CurrentThemeName: "default", ThemesMap: map[string]theme.EditorTheme{"default": {}},
ThemesMap: map[string]theme.EditorTheme{"default": {}},
} }
} }
@ -126,17 +122,17 @@ func (m *MockModel) Settings() core.EditorSettings { return m.SettingsVal }
func (m *MockModel) SetSettings(s core.EditorSettings) { m.SettingsVal = s } func (m *MockModel) SetSettings(s core.EditorSettings) { m.SettingsVal = s }
func (m *MockModel) Theme() (string, theme.EditorTheme) { func (m *MockModel) Theme() (string, theme.EditorTheme) {
if m.ThemesMap != nil { if m.ThemesMap != nil {
if t, ok := m.ThemesMap[m.CurrentThemeName]; ok { if t, ok := m.ThemesMap[m.SettingsVal.CurrentTheme]; ok {
return m.CurrentThemeName, t return m.SettingsVal.CurrentTheme, t
} }
if t, ok := m.ThemesMap["default"]; ok { if t, ok := m.ThemesMap["default"]; ok {
return "default", t return "default", t
} }
} }
return m.CurrentThemeName, theme.EditorTheme{} return m.SettingsVal.CurrentTheme, theme.EditorTheme{}
} }
func (m *MockModel) SetTheme(name string) { m.CurrentThemeName = name } func (m *MockModel) SetTheme(name string) { m.SettingsVal.CurrentTheme = name }
func (m *MockModel) Themes() map[string]theme.EditorTheme { func (m *MockModel) Themes() map[string]theme.EditorTheme {
if m.ThemesMap == nil { if m.ThemesMap == nil {
m.ThemesMap = map[string]theme.EditorTheme{} m.ThemesMap = map[string]theme.EditorTheme{}

View File

@ -2,8 +2,8 @@ package core
// EditorSettings: Configuration options for editor display and behavior. // EditorSettings: Configuration options for editor display and behavior.
type EditorSettings struct { type EditorSettings struct {
TabStop int TabStop int
// TODO: Colors CurrentTheme string
} }
// NewDefaultSettings: Creates a Settings struct with sensible defaults for // NewDefaultSettings: Creates a Settings struct with sensible defaults for
@ -11,5 +11,7 @@ type EditorSettings struct {
func NewDefaultSettings() EditorSettings { func NewDefaultSettings() EditorSettings {
return EditorSettings{ return EditorSettings{
TabStop: 2, TabStop: 2,
// TODO: This should be "default" but until we have a startup config, this is fine
CurrentTheme: "kanagawa",
} }
} }

View File

@ -51,9 +51,9 @@ type Model struct {
registers map[rune]core.Register // name -> register registers map[rune]core.Register // name -> register
// Visual styles // Visual styles
currentTheme string // Name of current theme // currentTheme string // Name of current theme
themes map[string]theme.EditorTheme themes map[string]theme.EditorTheme
syntax syntax.Engine syntax syntax.Engine
// Dot operator state // Dot operator state
lastChangeKeys []string lastChangeKeys []string
@ -346,15 +346,15 @@ func (m *Model) SetSettings(s core.EditorSettings) {
// Themes // Themes
// ================================================== // ==================================================
func (m *Model) Theme() (string, theme.EditorTheme) { func (m *Model) Theme() (string, theme.EditorTheme) {
t, ok := m.themes[m.currentTheme] t, ok := m.themes[m.settings.CurrentTheme]
if ok { if ok {
return m.currentTheme, t return m.settings.CurrentTheme, t
} }
return "default", m.themes["default"] return "default", m.themes["default"]
} }
func (m *Model) SetTheme(name string) { func (m *Model) SetTheme(name string) {
m.currentTheme = name m.settings.CurrentTheme = name
if m.syntax == nil { if m.syntax == nil {
return return

View File

@ -44,7 +44,6 @@ func NewModelBuilder() *ModelBuilder {
settings: core.NewDefaultSettings(), settings: core.NewDefaultSettings(),
registers: core.DefaultRegisters(), registers: core.DefaultRegisters(),
syntax: syntax.NewTreeSitterEngine(editorTheme), syntax: syntax.NewTreeSitterEngine(editorTheme),
currentTheme: "default",
themes: embededThemes, themes: embededThemes,
}, },
} }

View File

@ -2,44 +2,109 @@ package syntax
import "testing" import "testing"
func TestLanguageRegistryResolveByFiletype(t *testing.T) { func TestLanguageRegistryResolve(t *testing.T) {
r := newLanguageRegistry() r := newLanguageRegistry()
res, ok, err := r.resolve("go", "") tests := []struct {
name string
args struct {
filetype string
filename string
}
expected string
wantErr bool
}{
{
name: "resolve by filetype",
args: struct {
filetype string
filename string
}{filetype: "go"},
expected: "go",
wantErr: false,
},
{
name: "resolve by extension",
args: struct {
filetype string
filename string
}{filename: "main.js"},
expected: "javascript",
wantErr: false,
},
{
name: "filetype has precedence over extension",
args: struct {
filetype string
filename string
}{filetype: "python", filename: "main.go"},
expected: "python",
wantErr: false,
},
{
name: "normalizes case and whitespace",
args: struct {
filetype string
filename string
}{filetype: " Go "},
expected: "go",
wantErr: false,
},
{
name: "unknown language does not resolve",
args: struct {
filetype string
filename string
}{filetype: "txt", filename: "notes.txt"},
expected: "",
wantErr: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
res, ok, err := r.resolve(tc.args.filetype, tc.args.filename)
if (err != nil) != tc.wantErr {
t.Fatalf("resolve error = %v, wantErr=%v", err, tc.wantErr)
}
if tc.expected == "" {
if ok || res != nil {
t.Fatalf("expected unresolved language, got ok=%v res=%+v", ok, res)
}
return
}
if !ok || res == nil {
t.Fatalf("expected language %q to resolve", tc.expected)
}
if res.id != tc.expected {
t.Fatalf("resolved id mismatch: got %q want %q", res.id, tc.expected)
}
})
}
}
func TestLanguageRegistryResolveReusesCompiledAssets(t *testing.T) {
r := newLanguageRegistry()
first, ok, err := r.resolve("go", "")
if err != nil { if err != nil {
t.Fatalf("resolve error: %v", err) t.Fatalf("resolve error: %v", err)
} }
if !ok || res == nil { if !ok || first == nil {
t.Fatalf("expected go to resolve") t.Fatalf("expected first resolution to succeed")
} }
if res.id != "go" {
t.Fatalf("expected go id, got %q", res.id)
}
}
func TestLanguageRegistryResolveByExtension(t *testing.T) { second, ok, err := r.resolve("golang", "")
r := newLanguageRegistry()
res, ok, err := r.resolve("", "main.js")
if err != nil { if err != nil {
t.Fatalf("resolve error: %v", err) t.Fatalf("resolve error: %v", err)
} }
if !ok || res == nil { if !ok || second == nil {
t.Fatalf("expected javascript to resolve") t.Fatalf("expected second resolution to succeed")
} }
if res.id != "javascript" {
t.Fatalf("expected javascript id, got %q", res.id) if first != second {
} t.Fatalf("expected compiled assets to be reused for same language id")
}
func TestLanguageRegistryUnknown(t *testing.T) {
r := newLanguageRegistry()
res, ok, err := r.resolve("txt", "notes.txt")
if err != nil {
t.Fatalf("expected no error for unknown language, got: %v", err)
}
if ok || res != nil {
t.Fatalf("expected unknown language to not resolve")
} }
} }

View File

@ -6,6 +6,7 @@ import (
"testing" "testing"
"git.gophernest.net/azpect/TextEditor/internal/core" "git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/theme"
"git.gophernest.net/azpect/TextEditor/internal/theme/themes" "git.gophernest.net/azpect/TextEditor/internal/theme/themes"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
@ -231,6 +232,141 @@ func TestTreeSitterEngineLastLineEditDoesNotPanicAndRebuilds(t *testing.T) {
} }
} }
func TestTreeSitterEngineThemeChangeRebuildsWithNewCaptureStylesAfterInvalidation(t *testing.T) {
b := core.NewBufferBuilder().
WithFilename("sample.go").
WithFiletype("go").
WithLines([]string{"package main", "func main() {", " return", "}"}).
Build()
buf := &b
themeA := themes.NewDefaultTheme()
themeB := makeThemeWithCaptureOverrides(lipgloss.Color("#ffffff"), lipgloss.Color("#ff00ff"), lipgloss.Color("#00ffaa"))
engine := NewTreeSitterEngine(themeA)
engine.PrepareBuffer(buf, themeA)
line := buf.Line(0)
keywordIdx := strings.Index(line, "package")
if keywordIdx < 0 {
t.Fatalf("test setup failed: expected package keyword")
}
before := engine.LineStyleMap(buf, 0, themeA)
beforeStyle := before[keywordIdx]
if styleEquivalent(beforeStyle, themeA.Line) {
t.Fatalf("expected keyword style to differ from base style before theme switch")
}
engine.InvalidateBuffer(buf)
engine.PrepareBuffer(buf, themeB)
after := engine.LineStyleMap(buf, 0, themeB)
afterStyle := after[keywordIdx]
if styleEquivalent(afterStyle, themeB.Line) {
t.Fatalf("expected keyword style to differ from base style after theme switch")
}
if styleEquivalent(beforeStyle, afterStyle) {
t.Fatalf("expected keyword style to change after theme switch and invalidation")
}
}
func TestTreeSitterEngineThemeChangeRebuildsFallbackLineStylesAfterInvalidation(t *testing.T) {
b := core.NewBufferBuilder().
WithFilename("notes.txt").
WithFiletype("txt").
WithLines([]string{"plain text"}).
Build()
buf := &b
themeA := themes.NewDefaultTheme()
themeB := makeThemeWithCaptureOverrides(lipgloss.Color("#101010"), lipgloss.Color("#ff00ff"), lipgloss.Color("#00ffaa"))
engine := NewTreeSitterEngine(themeA)
mapA := engine.LineStyleMap(buf, 0, themeA)
if len(mapA) == 0 {
t.Fatalf("expected non-empty style map for text line")
}
if !styleEquivalent(mapA[0], themeA.Line) {
t.Fatalf("expected fallback style to use first theme line style")
}
engine.InvalidateBuffer(buf)
mapB := engine.LineStyleMap(buf, 0, themeB)
if !styleEquivalent(mapB[0], themeB.Line) {
t.Fatalf("expected fallback style to use second theme line style after invalidation")
}
if styleEquivalent(mapA[0], mapB[0]) {
t.Fatalf("expected fallback style to change after theme switch and invalidation")
}
}
func TestTreeSitterEngineUnsupportedApplyEditFallsBackToDefaultStyles(t *testing.T) {
b := core.NewBufferBuilder().
WithFilename("notes.txt").
WithFiletype("txt").
WithLines([]string{"just text"}).
Build()
buf := &b
editorTheme := themes.NewDefaultTheme()
engine := NewTreeSitterEngine(editorTheme)
engine.PrepareBuffer(buf, editorTheme)
baseline := engine.LineStyleMap(buf, 0, editorTheme)
if len(baseline) == 0 {
t.Fatalf("expected baseline style map")
}
var edit *core.BufferEdit
buf.OnChange = func(change core.BufferChange) {
edit = change.Edit
}
buf.SetLine(0, "still plain text")
if edit == nil {
t.Fatalf("expected edit metadata from SetLine")
}
engine.ApplyEdit(buf, edit)
engine.PrepareBuffer(buf, editorTheme)
after := engine.LineStyleMap(buf, 0, editorTheme)
if len(after) != len([]rune(buf.Line(0))) {
t.Fatalf("style map length mismatch after unsupported apply edit")
}
for i := range after {
if !styleEquivalent(after[i], editorTheme.Line) {
t.Fatalf("expected fallback line style at rune %d after unsupported apply edit", i)
}
}
}
func makeThemeWithCaptureOverrides(lineFg, keywordFg, stringFg lipgloss.Color) theme.EditorTheme {
t := themes.NewDefaultTheme()
t.Line = t.Line.Foreground(lineFg)
t.Syntax.Group = cloneStyleMap(t.Syntax.Group)
t.Syntax.Exact = cloneStyleMap(t.Syntax.Exact)
t.Syntax.Group["keyword"] = lipgloss.NewStyle().Foreground(keywordFg)
t.Syntax.Group["string"] = lipgloss.NewStyle().Foreground(stringFg)
for key := range t.Syntax.Exact {
if strings.HasPrefix(key, "keyword") {
t.Syntax.Exact[key] = lipgloss.NewStyle().Foreground(keywordFg)
}
if strings.HasPrefix(key, "string") {
t.Syntax.Exact[key] = lipgloss.NewStyle().Foreground(stringFg)
}
}
return t
}
func cloneStyleMap(in map[string]lipgloss.Style) map[string]lipgloss.Style {
out := make(map[string]lipgloss.Style, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func styleEquivalent(a, b lipgloss.Style) bool { func styleEquivalent(a, b lipgloss.Style) bool {
return styleSignature(a) == styleSignature(b) return styleSignature(a) == styleSignature(b)
} }

View File

@ -3,48 +3,281 @@ package syntax
import "testing" import "testing"
func TestMergeRanges(t *testing.T) { func TestMergeRanges(t *testing.T) {
in := []lineRange{{start: 5, end: 8}, {start: 1, end: 2}, {start: 2, end: 4}, {start: 10, end: 10}} tests := []struct {
out := mergeRanges(in) name string
input []lineRange
expected []lineRange
wantErr bool
}{
{
name: "overlapping and unsorted ranges are merged",
input: []lineRange{{start: 5, end: 8}, {start: 1, end: 2}, {start: 2, end: 4}, {start: 10, end: 10}},
expected: []lineRange{{start: 1, end: 8}, {start: 10, end: 10}},
wantErr: false,
},
{
name: "adjacent ranges are merged",
input: []lineRange{{start: 0, end: 1}, {start: 2, end: 3}},
expected: []lineRange{{start: 0, end: 3}},
wantErr: false,
},
{
name: "empty input returns nil",
input: nil,
expected: nil,
wantErr: false,
},
}
if len(out) != 2 { for _, tc := range tests {
t.Fatalf("expected 2 merged ranges, got %d", len(out)) t.Run(tc.name, func(t *testing.T) {
} got := mergeRanges(tc.input)
if out[0].start != 1 || out[0].end != 8 {
t.Fatalf("unexpected first merged range: %+v", out[0]) if tc.wantErr {
} t.Fatalf("unexpected wantErr=true for mergeRanges")
if out[1].start != 10 || out[1].end != 10 { }
t.Fatalf("unexpected second merged range: %+v", out[1])
if len(got) != len(tc.expected) {
t.Fatalf("unexpected merged range count: got %d want %d", len(got), len(tc.expected))
}
for i := range got {
if got[i] != tc.expected[i] {
t.Fatalf("range %d mismatch: got %+v want %+v", i, got[i], tc.expected[i])
}
}
})
} }
} }
func TestNormalizedDirtyRanges(t *testing.T) { func TestNormalizedDirtyRanges(t *testing.T) {
ranges := []lineRange{{start: -2, end: 1}, {start: 3, end: 99}} tests := []struct {
out := normalizedDirtyRanges(ranges, 5) name string
args struct {
ranges []lineRange
lineCount int
}
expected []lineRange
wantErr bool
}{
{
name: "clamps negative and overflowing ranges",
args: struct {
ranges []lineRange
lineCount int
}{
ranges: []lineRange{{start: -2, end: 1}, {start: 3, end: 99}},
lineCount: 5,
},
expected: []lineRange{{start: 0, end: 1}, {start: 3, end: 4}},
wantErr: false,
},
{
name: "drops invalid clamped ranges",
args: struct {
ranges []lineRange
lineCount int
}{
ranges: []lineRange{{start: 8, end: 9}},
lineCount: 5,
},
expected: nil,
wantErr: false,
},
{
name: "merges adjacent ranges after clamping",
args: struct {
ranges []lineRange
lineCount int
}{
ranges: []lineRange{{start: -3, end: 0}, {start: 1, end: 2}},
lineCount: 4,
},
expected: []lineRange{{start: 0, end: 2}},
wantErr: false,
},
}
if len(out) != 2 { for _, tc := range tests {
t.Fatalf("expected 2 normalized ranges, got %d", len(out)) t.Run(tc.name, func(t *testing.T) {
} got := normalizedDirtyRanges(tc.args.ranges, tc.args.lineCount)
if out[0].start != 0 || out[0].end != 1 { if tc.wantErr {
t.Fatalf("unexpected first normalized range: %+v", out[0]) t.Fatalf("unexpected wantErr=true for normalizedDirtyRanges")
} }
if out[1].start != 3 || out[1].end != 4 {
t.Fatalf("unexpected second normalized range: %+v", out[1]) if len(got) != len(tc.expected) {
t.Fatalf("unexpected normalized range count: got %d want %d", len(got), len(tc.expected))
}
for i := range got {
if got[i] != tc.expected[i] {
t.Fatalf("range %d mismatch: got %+v want %+v", i, got[i], tc.expected[i])
}
}
})
} }
} }
func TestByteColToRuneIndexUTF8(t *testing.T) { func TestByteColToRuneIndexUTF8(t *testing.T) {
line := []byte("aéb") tests := []struct {
name string
args struct {
line []byte
col int
}
expected int
wantErr bool
}{
{
name: "zero column maps to first rune",
args: struct {
line []byte
col int
}{line: []byte("aéb"), col: 0},
expected: 0,
wantErr: false,
},
{
name: "middle byte offset on multibyte rune",
args: struct {
line []byte
col int
}{line: []byte("aéb"), col: 1},
expected: 1,
wantErr: false,
},
{
name: "end of multibyte rune maps after rune",
args: struct {
line []byte
col int
}{line: []byte("aéb"), col: 3},
expected: 2,
wantErr: false,
},
{
name: "column at line end maps to rune length",
args: struct {
line []byte
col int
}{line: []byte("aéb"), col: len([]byte("aéb"))},
expected: 3,
wantErr: false,
},
}
if got := byteColToRuneIndex(line, 0); got != 0 { for _, tc := range tests {
t.Fatalf("expected 0, got %d", got) t.Run(tc.name, func(t *testing.T) {
} got := byteColToRuneIndex(tc.args.line, tc.args.col)
if got := byteColToRuneIndex(line, 1); got != 1 { if tc.wantErr {
t.Fatalf("expected 1, got %d", got) t.Fatalf("unexpected wantErr=true for byteColToRuneIndex")
} }
if got := byteColToRuneIndex(line, 3); got != 2 { if got != tc.expected {
t.Fatalf("expected 2, got %d", got) t.Fatalf("unexpected rune index: got %d want %d", got, tc.expected)
} }
if got := byteColToRuneIndex(line, len(line)); got != 3 { })
t.Fatalf("expected 3, got %d", got) }
}
func TestAddDirtyRangeNormalizesAndMerges(t *testing.T) {
tests := []struct {
name string
args struct {
initial []lineRange
start int
end int
}
expected []lineRange
wantErr bool
}{
{
name: "swaps start and end when reversed",
args: struct {
initial []lineRange
start int
end int
}{initial: nil, start: 7, end: 3},
expected: []lineRange{{start: 3, end: 7}},
wantErr: false,
},
{
name: "clamps negative values",
args: struct {
initial []lineRange
start int
end int
}{initial: nil, start: -5, end: -1},
expected: []lineRange{{start: 0, end: 0}},
wantErr: false,
},
{
name: "merges with existing adjacent range",
args: struct {
initial []lineRange
start int
end int
}{initial: []lineRange{{start: 1, end: 2}}, start: 3, end: 4},
expected: []lineRange{{start: 1, end: 4}},
wantErr: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
bc := &bufferCache{dirty: append([]lineRange{}, tc.args.initial...)}
addDirtyRange(bc, tc.args.start, tc.args.end)
if tc.wantErr {
t.Fatalf("unexpected wantErr=true for addDirtyRange")
}
if len(bc.dirty) != len(tc.expected) {
t.Fatalf("unexpected dirty range count: got %d want %d", len(bc.dirty), len(tc.expected))
}
for i := range bc.dirty {
if bc.dirty[i] != tc.expected[i] {
t.Fatalf("dirty range %d mismatch: got %+v want %+v", i, bc.dirty[i], tc.expected[i])
}
}
})
}
}
func TestRowInRanges(t *testing.T) {
tests := []struct {
name string
input struct {
row int
ranges []lineRange
}
expected bool
wantErr bool
}{
{
name: "row inside range",
input: struct {
row int
ranges []lineRange
}{row: 3, ranges: []lineRange{{start: 1, end: 4}}},
expected: true,
wantErr: false,
},
{
name: "row outside all ranges",
input: struct {
row int
ranges []lineRange
}{row: 8, ranges: []lineRange{{start: 1, end: 4}, {start: 10, end: 12}}},
expected: false,
wantErr: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := rowInRanges(tc.input.row, tc.input.ranges)
if tc.wantErr {
t.Fatalf("unexpected wantErr=true for rowInRanges")
}
if got != tc.expected {
t.Fatalf("unexpected result: got %v want %v", got, tc.expected)
}
})
} }
} }