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 != 28 { // minus status bar and top pad t.Errorf("editor height = %d, want 28", 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}) for i := 0; i < 40; i++ { a.editor.Lines = append(a.editor.Lines, "line") } 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) } }