package app import ( "os" "path/filepath" "strings" "testing" "glint/internal/config" "glint/internal/theme" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) func TestCanvasPaintsFullWidthBackground(t *testing.T) { a := newApp() a.theme = theme.FlexokiDark() a.editor.SetTheme(theme.FlexokiDark()) a.Update(tea.WindowSizeMsg{Width: 100, Height: 12}) a.editor.SetContent([]byte("hi")) out := a.View() for i, ln := range strings.Split(out, "\n") { if w := lipgloss.Width(ln); w != 100 { t.Errorf("line %d visible width = %d, want 100 (full-width background fill)", i, w) } } } func newApp() *App { return New(config.Default()) } func TestLoadReadsFileIntoEditor(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "n.md") os.WriteFile(p, []byte("# hi\nbody"), 0o644) a := newApp() if err := a.Load(p); err != nil { t.Fatal(err) } if a.mode != ModeEditor { t.Errorf("mode = %d, want ModeEditor", a.mode) } if string(a.editor.Bytes()) != "# hi\nbody" { t.Errorf("editor content = %q", string(a.editor.Bytes())) } } func TestCtrlSSavesToDisk(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "n.md") os.WriteFile(p, []byte("old"), 0o644) a := newApp() a.Load(p) a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'!'}}) if !a.editor.Dirty { t.Fatal("expected dirty after edit") } a.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) got, _ := os.ReadFile(p) if string(got) != "!old" { t.Errorf("file on disk = %q, want %q", string(got), "!old") } if a.editor.Dirty { t.Error("save should clear dirty") } } func TestTypingRoutesToEditor(t *testing.T) { a := newApp() a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) if a.editor.Lines[0] != "x" { t.Errorf("editor first line = %q, want x", a.editor.Lines[0]) } } func TestCtrlQCleanQuits(t *testing.T) { a := newApp() _, cmd := a.Update(tea.KeyMsg{Type: tea.KeyCtrlQ}) if cmd == nil { t.Error("clean buffer Ctrl+Q should return a quit command") } } func TestCtrlQDirtyNeedsConfirm(t *testing.T) { a := newApp() a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'z'}}) _, cmd := a.Update(tea.KeyMsg{Type: tea.KeyCtrlQ}) if cmd != nil { t.Error("first dirty Ctrl+Q should not quit") } _, cmd = a.Update(tea.KeyMsg{Type: tea.KeyCtrlQ}) if cmd == nil { t.Error("second Ctrl+Q should quit") } } func TestWindowSizePropagates(t *testing.T) { a := newApp() a.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) if a.editor.Width != 75 { // contentWidth = round(100 * 0.75) = 75 t.Errorf("editor width = %d, want 75", a.editor.Width) } if a.editor.Height != 26 { // minus status bar and top pad (3) t.Errorf("editor height = %d, want 26", a.editor.Height) } } func TestCtrlQDirtyDisarmedByOtherKey(t *testing.T) { a := newApp() // Make it dirty a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'z'}}) // First Ctrl+Q arms (does not quit) _, cmd := a.Update(tea.KeyMsg{Type: tea.KeyCtrlQ}) if cmd != nil { t.Error("first dirty Ctrl+Q should arm, not quit") } // Press another key — this should disarm a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) // Next Ctrl+Q must ARM AGAIN, not quit _, cmd = a.Update(tea.KeyMsg{Type: tea.KeyCtrlQ}) if cmd != nil { t.Error("Ctrl+Q after disarm should re-arm, not quit") } } func TestCtrlSNoPathOpensSaveAsPrompt(t *testing.T) { a := newApp() a.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) if a.mode != ModeSaveAs { t.Errorf("Ctrl+S on unnamed buffer: mode = %d, want ModeSaveAs", a.mode) } } func TestCtrlPTogglesPreview(t *testing.T) { a := newApp() a.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) a.editor.SetContent([]byte("# hello")) a.Update(tea.KeyMsg{Type: tea.KeyCtrlP}) if a.mode != ModePreview { t.Errorf("mode = %d, want ModePreview", a.mode) } a.Update(tea.KeyMsg{Type: tea.KeyCtrlP}) if a.mode != ModeEditor { t.Errorf("mode = %d, want ModeEditor after second toggle", a.mode) } } func TestEscReturnsToEditor(t *testing.T) { a := newApp() a.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) a.editor.SetContent([]byte("x")) a.Update(tea.KeyMsg{Type: tea.KeyCtrlP}) a.Update(tea.KeyMsg{Type: tea.KeyEsc}) if a.mode != ModeEditor { t.Errorf("Esc should return to editor, mode = %d", a.mode) } } func TestCtrlFOpensPicker(t *testing.T) { dir := t.TempDir() os.WriteFile(filepath.Join(dir, "note.md"), []byte("x"), 0o644) cfg := config.Default() t.Setenv("GLINT_VAULT", dir) a := New(cfg) a.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) a.Update(tea.KeyMsg{Type: tea.KeyCtrlF}) if a.mode != ModePicker { t.Errorf("mode = %d, want ModePicker", a.mode) } } func TestCtrlDCreatesAndOpensDaily(t *testing.T) { dir := t.TempDir() cfg := config.Default() t.Setenv("GLINT_VAULT", dir) cfg.DailySubdir = "Daily" cfg.DailyFormat = "2006-01-02" a := New(cfg) a.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) a.Update(tea.KeyMsg{Type: tea.KeyCtrlD}) if a.mode != ModeEditor { t.Errorf("mode = %d, want ModeEditor after daily open", a.mode) } if a.path == "" { t.Error("daily path should be set") } if _, err := os.Stat(a.path); err != nil { t.Errorf("daily file should exist on disk: %v", err) } } func TestStartInPickerWhenNoPath(t *testing.T) { dir := t.TempDir() os.WriteFile(filepath.Join(dir, "n.md"), []byte("x"), 0o644) cfg := config.Default() t.Setenv("GLINT_VAULT", dir) a := New(cfg) if err := a.Start("", false); err != nil { t.Fatal(err) } if a.mode != ModePicker { t.Errorf("no path/daily should start in picker, mode = %d", a.mode) } } func TestStartWithPathLoadsFile(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "n.md") os.WriteFile(p, []byte("hello"), 0o644) a := New(config.Default()) if err := a.Start(p, false); err != nil { t.Fatal(err) } if a.mode != ModeEditor || string(a.editor.Bytes()) != "hello" { t.Errorf("Start(path) should load file into editor") } } func TestStartDailyCreatesFile(t *testing.T) { dir := t.TempDir() cfg := config.Default() t.Setenv("GLINT_VAULT", dir) a := New(cfg) if err := a.Start("", true); err != nil { t.Fatal(err) } if a.mode != ModeEditor || a.path == "" { t.Errorf("Start(daily) should open the daily note") } if _, err := os.Stat(a.path); err != nil { t.Errorf("daily file should exist: %v", err) } } func TestCtrlFDirtyNeedsConfirm(t *testing.T) { dir := t.TempDir() os.WriteFile(filepath.Join(dir, "note.md"), []byte("x"), 0o644) cfg := config.Default() t.Setenv("GLINT_VAULT", dir) a := New(cfg) a.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) // Make dirty a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'z'}}) // First Ctrl+F — should arm, not open picker a.Update(tea.KeyMsg{Type: tea.KeyCtrlF}) if a.mode != ModeEditor { t.Errorf("first Ctrl+F on dirty editor: mode = %d, want ModeEditor", a.mode) } if !strings.Contains(a.status, "discard") { t.Errorf("first Ctrl+F on dirty editor: status = %q, want it to contain 'discard'", a.status) } // Second Ctrl+F — should open picker a.Update(tea.KeyMsg{Type: tea.KeyCtrlF}) if a.mode != ModePicker { t.Errorf("second Ctrl+F: mode = %d, want ModePicker", a.mode) } } func TestCtrlFDirtyDisarmedByOtherKey(t *testing.T) { dir := t.TempDir() os.WriteFile(filepath.Join(dir, "note.md"), []byte("x"), 0o644) cfg := config.Default() t.Setenv("GLINT_VAULT", dir) a := New(cfg) a.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) // Make dirty a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'z'}}) // First Ctrl+F — arms a.Update(tea.KeyMsg{Type: tea.KeyCtrlF}) // Press a different key — disarms pending a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) // Second Ctrl+F — should re-arm (not open) because pending was cleared a.Update(tea.KeyMsg{Type: tea.KeyCtrlF}) if a.mode != ModeEditor { t.Errorf("Ctrl+F after disarm should re-arm, not open picker: mode = %d", a.mode) } } func TestCtrlDDirtyNeedsConfirm(t *testing.T) { dir := t.TempDir() cfg := config.Default() t.Setenv("GLINT_VAULT", dir) cfg.DailySubdir = "Daily" cfg.DailyFormat = "2006-01-02" a := New(cfg) a.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) // Make dirty a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'z'}}) // First Ctrl+D — should arm, not open daily a.Update(tea.KeyMsg{Type: tea.KeyCtrlD}) if a.mode != ModeEditor { t.Errorf("first Ctrl+D on dirty editor: mode = %d, want ModeEditor", a.mode) } if !strings.Contains(a.status, "discard") { t.Errorf("first Ctrl+D on dirty editor: status = %q, want it to contain 'discard'", a.status) } // Second Ctrl+D — should open daily a.Update(tea.KeyMsg{Type: tea.KeyCtrlD}) if a.mode != ModeEditor { t.Errorf("second Ctrl+D: mode = %d, want ModeEditor", a.mode) } if a.path == "" { t.Error("second Ctrl+D: a.path should be set to the daily note path") } if _, err := os.Stat(a.path); err != nil { t.Errorf("second Ctrl+D: daily file should exist on disk: %v", err) } } func TestCtrlTCyclesTheme(t *testing.T) { cfg := config.Default() cfg.Theme = "flexoki-light" a := New(cfg) a.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) if a.theme.Name != "flexoki-light" { t.Fatalf("initial theme = %q, want flexoki-light", a.theme.Name) } a.Update(tea.KeyMsg{Type: tea.KeyCtrlT}) if a.theme.Name != "flexoki-dark" { t.Errorf("after one Ctrl+T = %q, want flexoki-dark", a.theme.Name) } a.Update(tea.KeyMsg{Type: tea.KeyCtrlT}) if a.theme.Name != "charm" { t.Errorf("after two Ctrl+T = %q, want charm", a.theme.Name) } } func TestCtrlNCreatesNoteFromQuery(t *testing.T) { dir := t.TempDir() cfg := config.Default() t.Setenv("GLINT_VAULT", dir) a := New(cfg) a.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) a.Update(tea.KeyMsg{Type: tea.KeyCtrlF}) // open picker (Ctrl+F) if a.mode != ModePicker { t.Fatalf("expected picker mode") } // Type a name into the picker query. a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("ideas")}) a.Update(tea.KeyMsg{Type: tea.KeyCtrlN}) if a.mode != ModeEditor { t.Errorf("Ctrl+N should open the new note in the editor, mode = %d", a.mode) } want := filepath.Join(dir, "ideas.md") if a.path != want { t.Errorf("a.path = %q, want %q", a.path, want) } if _, err := os.Stat(want); err != nil { t.Errorf("new note should exist on disk: %v", err) } } func TestCanvasContentWidthCapsAndCenters(t *testing.T) { a := newApp() a.Update(tea.WindowSizeMsg{Width: 200, Height: 40}) if cw := a.contentWidth(); cw != 120 { t.Errorf("contentWidth = %d, want 120 (capped)", cw) } if lm := a.leftMargin(); lm != (200-120)/2 { t.Errorf("leftMargin = %d, want %d", lm, (200-120)/2) } } func TestCanvasContentWidthFloorsOnNarrow(t *testing.T) { a := newApp() a.Update(tea.WindowSizeMsg{Width: 30, Height: 20}) if cw := a.contentWidth(); cw != 24 { t.Errorf("contentWidth = %d, want 24 (floor)", cw) } } func TestCanvasEditorViewIsIndented(t *testing.T) { a := newApp() a.Update(tea.WindowSizeMsg{Width: 120, Height: 20}) a.editor.SetContent([]byte("hello")) view := a.View() // The editor body sits in a centered column → at least one rendered line // starts with the left-margin spaces. if a.leftMargin() <= 0 { t.Fatal("expected a positive left margin at width 120") } pad := strings.Repeat(" ", a.leftMargin()) found := false for _, ln := range strings.Split(view, "\n") { if strings.HasPrefix(ln, pad) && strings.Contains(ln, "hello") { found = true } } if !found { t.Errorf("editor content not indented by left margin; view:\n%s", view) } } func TestStartNewBlankBuffer(t *testing.T) { cfg := config.Default() a := New(cfg) if err := a.StartNew(""); err != nil { t.Fatal(err) } if a.mode != ModeEditor { t.Errorf("mode = %d, want ModeEditor", a.mode) } if a.path != "" { t.Errorf("path = %q, want empty (unnamed)", a.path) } if got := string(a.editor.Bytes()); got != "" { t.Errorf("editor content = %q, want empty", got) } } func TestStartNewWithNameLandsInInbox(t *testing.T) { dir := t.TempDir() cfg := config.Default() t.Setenv("GLINT_VAULT", dir) cfg.InboxDir = "Inbox" a := New(cfg) if err := a.StartNew("foo"); err != nil { t.Fatal(err) } want := filepath.Join(dir, "Inbox", "foo.md") if a.path != want { t.Errorf("path = %q, want %q", a.path, want) } if _, err := os.Stat(want); err != nil { t.Errorf("inbox note not created: %v", err) } } func TestSaveAsPromptOnPathlessBuffer(t *testing.T) { dir := t.TempDir() cfg := config.Default() t.Setenv("GLINT_VAULT", dir) cfg.InboxDir = "" // vault root a := New(cfg) a.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) a.StartNew("") // blank unnamed buffer a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("hello")}) a.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) // pathless → save-as prompt if a.mode != ModeSaveAs { t.Fatalf("Ctrl+S on pathless buffer: mode = %d, want ModeSaveAs", a.mode) } a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("idea")}) a.Update(tea.KeyMsg{Type: tea.KeyEnter}) // confirm want := filepath.Join(dir, "idea.md") if a.path != want { t.Errorf("path = %q, want %q", a.path, want) } data, err := os.ReadFile(want) if err != nil { t.Fatalf("file not written: %v", err) } if string(data) != "hello" { t.Errorf("saved content = %q, want hello", string(data)) } if a.editor.Dirty { t.Error("buffer should be clean after save") } if a.mode != ModeEditor { t.Errorf("after save mode = %d, want ModeEditor", a.mode) } } func TestEditorCtrlNNewBlankWithDirtyConfirm(t *testing.T) { dir := t.TempDir() src := filepath.Join(dir, "a.md") os.WriteFile(src, []byte("body"), 0o644) cfg := config.Default() t.Setenv("GLINT_VAULT", dir) a := New(cfg) a.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) a.Load(src) a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) // dirty a.Update(tea.KeyMsg{Type: tea.KeyCtrlN}) // first press: arm, do not discard if a.path != src { t.Errorf("first Ctrl+N discarded without confirm; path = %q", a.path) } a.Update(tea.KeyMsg{Type: tea.KeyCtrlN}) // second press: new blank if a.path != "" { t.Errorf("after confirm, path = %q, want empty", a.path) } if got := string(a.editor.Bytes()); got != "" { t.Errorf("editor not blank: %q", got) } } func TestCycleThemeReRendersOpenPreview(t *testing.T) { cfg := config.Default() a := New(cfg) a.theme = theme.FlexokiLight() // start light a.preview.SetStyle("light") a.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) a.editor.SetContent([]byte("# hi")) a.Update(tea.KeyMsg{Type: tea.KeyCtrlP}) // open preview (light) if a.mode != ModePreview { t.Fatalf("expected preview mode") } a.Update(tea.KeyMsg{Type: tea.KeyCtrlT}) // cycle → flexoki-dark if a.theme.Name != "flexoki-dark" { t.Fatalf("theme = %q, want flexoki-dark", a.theme.Name) } if got := a.preview.Style(); got != "dark" { t.Errorf("preview style after cycle = %q, want dark (re-rendered)", got) } } func TestStartVaultOpensVaultPicker(t *testing.T) { vault := t.TempDir() os.WriteFile(filepath.Join(vault, "vaultnote.md"), []byte("x"), 0o644) t.Setenv("GLINT_VAULT", t.TempDir()) // working dir is somewhere else cfg := config.Default() cfg.VaultDir = vault a := New(cfg) a.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) if err := a.StartVault(); err != nil { t.Fatal(err) } if a.mode != ModePicker { t.Fatalf("mode = %d, want ModePicker", a.mode) } if sel := a.picker.Selected(); !strings.HasPrefix(sel, vault) { t.Errorf("vault picker selected %q, want a file under the vault %q", sel, vault) } } func TestMouseWheelScrollsEditorNotCursor(t *testing.T) { a := newApp() a.Update(tea.WindowSizeMsg{Width: 80, Height: 8}) a.editor.SetContent([]byte(strings.Repeat("line\n", 41))) a.editor.Cursor.Row, a.editor.Cursor.Col = 0, 0 a.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown}) if a.editor.Scroll == 0 { t.Error("wheel down should scroll the editor viewport") } if a.editor.Cursor.Row != 0 || a.editor.Cursor.Col != 0 { t.Errorf("wheel should not move the cursor; got %+v", a.editor.Cursor) } } func TestMouseClickMovesCursor(t *testing.T) { a := newApp() a.theme = theme.FlexokiDark() a.editor.SetTheme(theme.FlexokiDark()) a.Update(tea.WindowSizeMsg{Width: 100, Height: 12}) a.editor.SetContent([]byte("first line\nsecond line\nthird line")) a.editor.Cursor.Row, a.editor.Cursor.Col = 0, 0 lm := a.leftMargin() // Click the 2nd editor row, column 3. vi = Scroll + Y - topPad + 1, so the // 2nd row (vi=1, Scroll=0) is at screen Y = topPad. a.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: lm + 3, Y: a.topPad()}) if a.editor.Cursor.Row != 1 { t.Errorf("click row → cursor row %d, want 1", a.editor.Cursor.Row) } if a.editor.Cursor.Col != 3 { t.Errorf("click col → cursor col %d, want 3", a.editor.Cursor.Col) } } func TestStartNewInCreatesInDir(t *testing.T) { dir := t.TempDir() a := newApp() if err := a.StartNewIn(dir, "scratch"); err != nil { t.Fatal(err) } want := filepath.Join(dir, "scratch.md") if a.path != want { t.Errorf("path = %q, want %q", a.path, want) } if _, err := os.Stat(want); err != nil { t.Errorf("note not created: %v", err) } } func TestCtrlNNewInSameDirSaveAs(t *testing.T) { dir := t.TempDir() src := filepath.Join(dir, "sub", "a.md") os.MkdirAll(filepath.Dir(src), 0o755) os.WriteFile(src, []byte("x"), 0o644) a := newApp() a.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) a.Load(src) a.Update(tea.KeyMsg{Type: tea.KeyCtrlN}) // new in the source's directory a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("hi")}) a.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) // unnamed → save-as prompt a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("new")}) a.Update(tea.KeyMsg{Type: tea.KeyEnter}) want := filepath.Join(dir, "sub", "new.md") if a.path != want { t.Errorf("Ctrl+N note saved to %q, want %q (same dir as source)", a.path, want) } } func TestCtrlBNewInInbox(t *testing.T) { dir := t.TempDir() t.Setenv("GLINT_VAULT", dir) cfg := config.Default() cfg.InboxDir = "Inbox" a := New(cfg) a.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) a.Update(tea.KeyMsg{Type: tea.KeyCtrlB}) // new in inbox a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) a.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("note")}) a.Update(tea.KeyMsg{Type: tea.KeyEnter}) want := filepath.Join(dir, "Inbox", "note.md") if a.path != want { t.Errorf("Ctrl+B note saved to %q, want %q (inbox)", a.path, want) } } func TestCtrlZUndoesTypingViaApp(t *testing.T) { a := newApp() a.Update(tea.WindowSizeMsg{Width: 80, Height: 12}) a.mode = ModeEditor a.editor.SetContent([]byte("")) for _, r := range "hello" { a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) } if a.editor.Lines[0] != "hello" { t.Fatalf("setup: %q", a.editor.Lines[0]) } a.Update(tea.KeyMsg{Type: tea.KeyCtrlZ}) if a.editor.Lines[0] != "" { t.Errorf("Ctrl+Z should undo the typing run, got %q", a.editor.Lines[0]) } a.Update(tea.KeyMsg{Type: tea.KeyCtrlY}) if a.editor.Lines[0] != "hello" { t.Errorf("Ctrl+Y should redo, got %q", a.editor.Lines[0]) } } func TestCtrlGOpensFind(t *testing.T) { a := newApp() a.editor.SetContent([]byte("alpha beta alpha")) a.Update(tea.KeyMsg{Type: tea.KeyCtrlG}) if a.mode != ModeFind { t.Fatalf("mode = %d, want ModeFind", a.mode) } } func TestFindQueryHighlightsAndJumps(t *testing.T) { a := newApp() a.editor.SetContent([]byte("alpha\nbeta\nalpha")) a.Update(tea.KeyMsg{Type: tea.KeyCtrlG}) for _, r := range "alpha" { a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) } if a.editor.FindCount() != 2 { t.Fatalf("FindCount = %d, want 2", a.editor.FindCount()) } } func TestFindEnterCyclesNext(t *testing.T) { a := newApp() a.editor.SetContent([]byte("x\nx\nx")) a.Update(tea.KeyMsg{Type: tea.KeyCtrlG}) a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) row, _, _, _ := a.editor.ActiveMatch() if row != 0 { t.Fatalf("first active row = %d, want 0", row) } a.Update(tea.KeyMsg{Type: tea.KeyEnter}) if row, _, _, _ := a.editor.ActiveMatch(); row != 1 { t.Fatalf("after Enter, active row = %d, want 1", row) } } func TestFindEscClosesAndClears(t *testing.T) { a := newApp() a.editor.SetContent([]byte("alpha alpha")) a.Update(tea.KeyMsg{Type: tea.KeyCtrlG}) a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) a.Update(tea.KeyMsg{Type: tea.KeyEsc}) if a.mode != ModeEditor { t.Fatalf("mode = %d, want ModeEditor after Esc", a.mode) } if a.editor.FindCount() != 0 { t.Errorf("Esc should clear find, got %d", a.editor.FindCount()) } } // --- TASK-027: mouse drag-select --- func TestMouseDragSelectsText(t *testing.T) { a := newApp() a.theme = theme.FlexokiDark() a.editor.SetTheme(theme.FlexokiDark()) a.Update(tea.WindowSizeMsg{Width: 100, Height: 12}) a.editor.SetContent([]byte("hello world")) lm := a.leftMargin() y := a.topPad() - 1 // first editor row (vi=0) a.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: lm + 2, Y: y}) a.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionMotion, X: lm + 7, Y: y}) if got := a.editor.SelectedText(); got != "llo w" { t.Fatalf("drag selection = %q, want %q", got, "llo w") } } func TestMousePlainClickLeavesNoSelection(t *testing.T) { a := newApp() a.theme = theme.FlexokiDark() a.editor.SetTheme(theme.FlexokiDark()) a.Update(tea.WindowSizeMsg{Width: 100, Height: 12}) a.editor.SetContent([]byte("hello world")) lm := a.leftMargin() y := a.topPad() - 1 a.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: lm + 4, Y: y}) a.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionRelease, X: lm + 4, Y: y}) if a.editor.HasSelection() { t.Fatal("a plain click left a selection active") } } // --- TASK-023: mouse click on the checkbox glyph toggles it --- func TestMouseClickOnCheckboxToggles(t *testing.T) { a := newApp() a.theme = theme.FlexokiDark() a.editor.SetTheme(theme.FlexokiDark()) a.Update(tea.WindowSizeMsg{Width: 100, Height: 12}) a.editor.SetContent([]byte("- [ ] task")) lm := a.leftMargin() y := a.topPad() - 1 // Box glyph occupies cols 2..4; click col 3 (inside the brackets). a.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: lm + 3, Y: y}) if got := a.editor.Lines[0]; got != "- [x] task" { t.Fatalf("after box click line = %q, want %q", got, "- [x] task") } a.editor.Undo() if got := a.editor.Lines[0]; got != "- [ ] task" { t.Fatalf("box click not undoable: line = %q", got) } } func TestMouseClickPastCheckboxDoesNotToggle(t *testing.T) { a := newApp() a.theme = theme.FlexokiDark() a.editor.SetTheme(theme.FlexokiDark()) a.Update(tea.WindowSizeMsg{Width: 100, Height: 12}) a.editor.SetContent([]byte("- [ ] task")) lm := a.leftMargin() y := a.topPad() - 1 a.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: lm + 8, Y: y}) if got := a.editor.Lines[0]; got != "- [ ] task" { t.Fatalf("click on content toggled the box: line = %q", got) } } func TestStartPreviewOpensFileInReadView(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "doc.md") os.WriteFile(p, []byte("# Title\n\nbody"), 0o644) a := New(config.Default()) if err := a.StartPreview(p); err != nil { t.Fatal(err) } if a.mode != ModePreview { t.Errorf("StartPreview should start in ModePreview, mode = %d", a.mode) } if string(a.editor.Bytes()) != "# Title\n\nbody" { t.Errorf("StartPreview should load the file into the editor too") } } func TestStartPreviewCtrlPTogglesToEditor(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "doc.md") os.WriteFile(p, []byte("# Title"), 0o644) a := New(config.Default()) if err := a.StartPreview(p); err != nil { t.Fatal(err) } a.Update(tea.KeyMsg{Type: tea.KeyCtrlP}) if a.mode != ModeEditor { t.Errorf("Ctrl+P from preview should return to the editor, mode = %d", a.mode) } } func TestStartPreviewNoFileFallsBackToPicker(t *testing.T) { dir := t.TempDir() a := New(config.Default()) t.Setenv("GLINT_VAULT", dir) if err := a.StartPreview(""); err != nil { t.Fatal(err) } if a.mode != ModePicker { t.Errorf("StartPreview with no file should fall back to the picker, mode = %d", a.mode) } } func TestWriteExportProducesHtml(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "doc.md") os.WriteFile(p, []byte("# My Title\n\nbody text"), 0o644) a := New(config.Default()) out, err := a.writeExport(p) if err != nil { t.Fatal(err) } if filepath.Dir(out) == dir { t.Errorf("out = %q landed beside source; export should go to temp", out) } if filepath.Base(out) != "doc.html" { t.Errorf("out base = %q, want doc.html", filepath.Base(out)) } data, err := os.ReadFile(out) if err != nil { t.Fatalf("export file not written: %v", err) } if !strings.Contains(string(data), "My Title") { t.Errorf("exported HTML should contain the title") } } func TestWriteExportMissingFile(t *testing.T) { a := New(config.Default()) if _, err := a.writeExport(filepath.Join(t.TempDir(), "nope.md")); err == nil { t.Error("writeExport should error on a missing file") } }