summaryrefslogtreecommitdiff
path: root/internal/app/model_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/app/model_test.go')
-rw-r--r--internal/app/model_test.go482
1 files changed, 482 insertions, 0 deletions
diff --git a/internal/app/model_test.go b/internal/app/model_test.go
new file mode 100644
index 0000000..53e391e
--- /dev/null
+++ b/internal/app/model_test.go
@@ -0,0 +1,482 @@
+package app
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func TestModelHandleVideosLoadedAndSort(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ videos := []video{
+ {Name: "B.mp4", Path: filepath.Join(root, "B.mp4"), Duration: time.Minute, ModTime: time.Now()},
+ {Name: "A.mp4", Path: filepath.Join(root, "A.mp4"), Duration: 2 * time.Minute, ModTime: time.Now().Add(-time.Hour)},
+ }
+ msg := videosLoadedMsg{videos: videos, pending: nil, cache: newDurationCache(filepath.Join(root, "cache.json"))}
+ modelAny, cmd := m.handleVideosLoaded(msg)
+ if cmd != nil {
+ t.Fatalf("expected no duration command")
+ }
+ m = modelAny.(model)
+ if len(m.filtered) != 2 {
+ t.Fatalf("expected 2 videos, got %d", len(m.filtered))
+ }
+ if m.filtered[0].Name != "A.mp4" {
+ t.Fatalf("expected sorted by name ascending")
+ }
+ modelAny, _ = m.handleKeyMsg(keyMsg("l"))
+ m = modelAny.(model)
+ if m.filtered[0].Name != "B.mp4" {
+ t.Fatalf("expected shortest duration first")
+ }
+}
+
+func TestModelHandleDurationUpdateCompletes(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ pendingPath := filepath.Join(root, "pending.mp4")
+ videos := []video{{Name: "pending.mp4", Path: pendingPath}}
+ msg := videosLoadedMsg{videos: videos, pending: []string{pendingPath}, cache: newDurationCache(filepath.Join(root, "cache.json"))}
+ modelAny, cmd := m.handleVideosLoaded(msg)
+ if cmd == nil {
+ t.Fatalf("expected duration command")
+ }
+ m = modelAny.(model)
+ durMsg := durationUpdateMsg{path: pendingPath, duration: time.Minute}
+ modelAny, next := m.handleDurationUpdate(durMsg)
+ m = modelAny.(model)
+ if next != nil {
+ t.Fatalf("expected no further command after completion")
+ }
+ if m.durationDone != 0 || m.pendingDurations != nil {
+ t.Fatalf("expected duration queue cleared")
+ }
+}
+
+func TestModelFiltersWorkflow(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ videos := []video{{Name: "morning flow.mp4", Path: filepath.Join(root, "morning.mp4"), Duration: 10 * time.Minute}}
+ modelAny, _ := m.handleVideosLoaded(videosLoadedMsg{videos: videos, cache: newDurationCache(filepath.Join(root, "cache.json"))})
+ m = modelAny.(model)
+ modelAny, _ = m.handleKeyMsg(keyMsg("/"))
+ m = modelAny.(model)
+ if !m.showFilters {
+ t.Fatalf("expected filters to open")
+ }
+ m.inputs.fields[0].SetValue("morning")
+ inputs, _ := m.updateFilterInputs(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
+ m.inputs = inputs
+ m.inputs.fields[0].SetValue("morning")
+ if modal := m.renderFilterModal(); !strings.Contains(modal, "Filter videos") {
+ t.Fatalf("expected filter modal content")
+ }
+ modelAny, _ = m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyEnter})
+ m = modelAny.(model)
+ if len(m.filtered) != 1 {
+ t.Fatalf("expected filtered result")
+ }
+}
+
+func TestRenderProgressBar(t *testing.T) {
+ bar := renderProgressBar(5, 10, 10)
+ if bar != "[#####-----]" {
+ t.Fatalf("unexpected bar %s", bar)
+ }
+ if renderProgressBar(0, 0, 10) != "" {
+ t.Fatalf("expected empty bar for zero total")
+ }
+ if renderProgressBar(-1, 10, 5) == "" {
+ t.Fatalf("expected bar even when done negative")
+ }
+}
+
+func TestModelViewAndProgress(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = false
+ m.statusMessage = "Ready"
+ m.filtered = []video{}
+ view := m.View()
+ if view == "" {
+ t.Fatalf("expected non-empty view")
+ }
+ m.durationTotal = 10
+ m.durationDone = 5
+ if line := m.renderProgressLine(); !strings.Contains(line, "5/10") {
+ t.Fatalf("unexpected progress line %s", line)
+ }
+}
+
+func TestModelInitAndUpdate(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ cmd := m.Init()
+ if cmd == nil {
+ t.Fatalf("expected init command")
+ }
+ m.loading = false
+ modelAny, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}})
+ m = modelAny.(model)
+ if m.filters.name != "" || !strings.Contains(m.statusMessage, "Filters cleared") {
+ t.Fatalf("expected filters reset via update path")
+ }
+ m.loading = true
+ modelAny, cmd = m.Update(progressUpdateMsg{processed: 1, total: 2, done: false})
+ m = modelAny.(model)
+ if cmd == nil || !strings.Contains(m.statusMessage, "Loading videos") {
+ t.Fatalf("unexpected status %s", m.statusMessage)
+ }
+}
+
+func TestHandlePlayVideoStatuses(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m = m.handlePlayVideo(playVideoMsg{path: "video.mp4", err: errors.New("fail")})
+ if !strings.Contains(m.statusMessage, "Failed") {
+ t.Fatalf("expected failure message")
+ }
+ m = m.handlePlayVideo(playVideoMsg{path: "video.mp4"})
+ if !strings.Contains(m.statusMessage, "Playing") {
+ t.Fatalf("expected playing message")
+ }
+}
+
+func TestDescribeFilters(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.filters = filterState{name: "flow", minEnabled: true, minMinutes: 5, maxEnabled: true, maxMinutes: 20}
+ desc := m.describeFilters()
+ if !strings.Contains(desc, "flow") || !strings.Contains(desc, ">=5") {
+ t.Fatalf("unexpected description %s", desc)
+ }
+}
+
+func TestPlaySelectionCommand(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = false
+ m.filtered = []video{{Name: "clip", Path: "clip.mp4"}}
+ cmdModel, cmd := m.playSelection()
+ if cmd == nil {
+ t.Fatalf("expected command to play video")
+ }
+ if cmdModel.(model).statusMessage == "" {
+ t.Fatalf("expected status message set")
+ }
+}
+
+func TestUpdateTableFallback(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = false
+ m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'z'}})
+}
+
+func TestProgressTickerNil(t *testing.T) {
+ if progressTickerCmd(nil) != nil {
+ t.Fatalf("expected nil command for nil progress")
+ }
+}
+
+func TestHandleFilterKeyTabs(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.showFilters = true
+ m.inputs = buildFilterInputs()
+ m.inputs.fields[0].Focus()
+ modelAny, _ := m.handleFilterKey(tea.KeyMsg{Type: tea.KeyTab})
+ m = modelAny.(model)
+ if !m.inputs.fields[1].Focused() {
+ t.Fatalf("expected focus to move forward")
+ }
+ modelAny, _ = m.handleFilterKey(tea.KeyMsg{Type: tea.KeyShiftTab})
+ m = modelAny.(model)
+ if !m.inputs.fields[0].Focused() {
+ t.Fatalf("expected focus to move back")
+ }
+}
+
+func TestUpdateStatusAfterLoadBranches(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ videos := []video{{Name: "a", Path: "a.mp4"}}
+ msg := videosLoadedMsg{videos: videos, pending: []string{"a.mp4"}, cacheErr: errors.New("cache"), cache: newDurationCache("cache.json")}
+ modelAny, cmd := m.handleVideosLoaded(msg)
+ m = modelAny.(model)
+ if cmd == nil {
+ t.Fatalf("expected pending duration command")
+ }
+ if !strings.Contains(m.statusMessage, "cache warning") {
+ t.Fatalf("expected cache warning status")
+ }
+}
+
+func TestModelUpdateWithVideosLoaded(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ videos := []video{{Name: "a", Path: "a.mp4"}}
+ msg := videosLoadedMsg{videos: videos}
+ modelAny, _ := m.Update(msg)
+ m = modelAny.(model)
+ if len(m.videos) != 1 {
+ t.Fatalf("expected videos loaded")
+ }
+}
+
+func TestModelUpdatePlayVideoMsg(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m = m.handlePlayVideo(playVideoMsg{path: "a.mp4"})
+ modelAny, _ := m.Update(playVideoMsg{path: "a.mp4"})
+ m = modelAny.(model)
+ if !strings.Contains(m.statusMessage, "Playing") {
+ t.Fatalf("expected playing status, got %s", m.statusMessage)
+ }
+}
+
+func TestModelUpdateDurationMsg(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.videos = []video{{Name: "a", Path: "a.mp4"}}
+ m.filtered = m.videos
+ m.pendingDurations = []string{"a.mp4"}
+ m.durationTotal = 1
+ m.cache = newDurationCache("cache.json")
+ update := durationUpdateMsg{path: "a.mp4", duration: time.Second}
+ modelAny, _ := m.Update(update)
+ m = modelAny.(model)
+ if m.durationTotal != 0 {
+ t.Fatalf("expected duration queue cleared")
+ }
+}
+
+func TestUpdateStatusForDurationError(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.videos = []video{{Name: "a", Path: "a.mp4"}, {Name: "b", Path: "b.mp4"}}
+ m.filtered = m.videos
+ m.pendingDurations = []string{"a.mp4", "b.mp4"}
+ m.durationTotal = 2
+ m.durationInFlight = 1
+ m.cache = newDurationCache("cache.json")
+ msg := durationUpdateMsg{path: "a.mp4", err: errors.New("ffprobe")}
+ modelAny, _ := m.Update(msg)
+ m = modelAny.(model)
+ if !strings.Contains(m.statusMessage, "Duration error") {
+ t.Fatalf("expected error status, got %s", m.statusMessage)
+ }
+}
+
+func TestHandleProgressUpdateDone(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = true
+ modelAny, _ := m.handleProgressUpdate(progressUpdateMsg{total: 2, done: true})
+ m = modelAny.(model)
+ if !strings.Contains(m.statusMessage, "Loaded") {
+ t.Fatalf("expected loaded status, got %s", m.statusMessage)
+ }
+}
+
+func TestUpdateStatusAfterLoadCacheWarning(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ msg := videosLoadedMsg{videos: []video{{Name: "a", Path: "a.mp4"}}, cacheErr: errors.New("oops"), cache: newDurationCache("cache.json")}
+ modelAny, _ := m.Update(msg)
+ m = modelAny.(model)
+ if !strings.Contains(m.statusMessage, "cache warning") {
+ t.Fatalf("expected cache warning, got %s", m.statusMessage)
+ }
+}
+
+func TestPassesFiltersBounds(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.filters = filterState{minEnabled: true, minMinutes: 5, maxEnabled: true, maxMinutes: 15}
+ video := video{Name: "clip", Duration: 10 * time.Minute}
+ if !m.passesFilters(video) {
+ t.Fatalf("expected video within bounds")
+ }
+ m.filters.maxMinutes = 5
+ if m.passesFilters(video) {
+ t.Fatalf("expected video to fail with tighter max")
+ }
+ m.filters = filterState{name: "yoga"}
+ if m.passesFilters(video) {
+ t.Fatalf("expected name filter to exclude video")
+ }
+}
+
+func TestProgressTickerCmdTick(t *testing.T) {
+ progress := &loadProgress{}
+ progress.SetTotal(2)
+ cmd := progressTickerCmd(progress)
+ if cmd == nil {
+ t.Fatalf("expected command")
+ }
+ msg := cmd().(progressUpdateMsg)
+ if msg.total != 2 {
+ t.Fatalf("unexpected ticker message %#v", msg)
+ }
+}
+
+func TestProgressUpdateMessages(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ modelAny, cmd := m.handleProgressUpdate(progressUpdateMsg{processed: 1, total: 3, done: false})
+ if cmd == nil {
+ t.Fatalf("expected ticker command")
+ }
+ m = modelAny.(model)
+ if !strings.Contains(m.statusMessage, "Loading videos") {
+ t.Fatalf("unexpected status %s", m.statusMessage)
+ }
+ modelAny, cmd = m.handleProgressUpdate(progressUpdateMsg{total: 0, done: true})
+ m = modelAny.(model)
+ if cmd != nil || m.statusMessage != "No videos found" {
+ t.Fatalf("expected no videos message")
+ }
+}
+
+func TestToggleCrop(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir(), Crop: "5:4"})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = false
+ modelAny, _ := m.handleKeyMsg(keyMsg("c"))
+ m = modelAny.(model)
+ if m.statusMessage != "Crop disabled" {
+ t.Fatalf("expected crop disabled, got %s", m.statusMessage)
+ }
+ modelAny, _ = m.handleKeyMsg(keyMsg("c"))
+ m = modelAny.(model)
+ if !strings.Contains(m.statusMessage, "Crop enabled") {
+ t.Fatalf("expected crop enabled, got %s", m.statusMessage)
+ }
+ if m.activeCrop() == "" {
+ t.Fatalf("expected active crop")
+ }
+}
+
+func TestToggleSort(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.toggleSort(sortByDuration)
+ if m.sortField != sortByDuration || !m.sortAscending {
+ t.Fatalf("expected sort by duration ascending")
+ }
+ m.toggleSort(sortByDuration)
+ if m.sortAscending {
+ t.Fatalf("expected sort order to flip")
+ }
+}
+
+func TestResetFilters(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = false
+ m.filters = filterState{name: "x"}
+ modelAny, _ := m.handleKeyMsg(keyMsg("r"))
+ m = modelAny.(model)
+ if m.filters.name != "" {
+ t.Fatalf("expected filters cleared")
+ }
+}
+
+func TestSyncFilterFocus(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.showFilters = true
+ m.inputs.focus = 1
+ m.syncFilterFocus()
+ if !m.inputs.fields[1].Focused() {
+ t.Fatalf("expected second field focused")
+ }
+}
+
+func TestLoadVideosCmdProducesMessage(t *testing.T) {
+ root := t.TempDir()
+ video := filepath.Join(root, "clip.mp4")
+ if err := os.WriteFile(video, []byte("x"), 0o644); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+ cmd := loadVideosCmd(root, filepath.Join(root, "cache.json"), &loadProgress{})
+ msg := cmd()
+ if _, ok := msg.(videosLoadedMsg); !ok {
+ t.Fatalf("expected videosLoadedMsg")
+ }
+}
+
+func TestProgressTickerCmdProducesMsg(t *testing.T) {
+ progress := &loadProgress{}
+ progress.SetTotal(1)
+ cmd := progressTickerCmd(progress)
+ if cmd == nil {
+ t.Fatalf("expected ticker command")
+ }
+}
+
+func keyMsg(value string) tea.KeyMsg {
+ if len(value) == 1 {
+ return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(value)}
+ }
+ return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(value), Alt: false}
+}