package tui import ( "context" "fmt" "os" "path/filepath" "strings" "testing" tea "charm.land/bubbletea/v2" "intake/internal/audio" "intake/internal/config" "intake/internal/plan" "intake/internal/scan" "intake/internal/sheet" "intake/internal/tags" ) func testModule(w, h int) Module { m := New(Options{Config: config.Defaults(), StagingDir: "/staging"}) m.st = StylesFor(makeTheme(false)) m.width, m.height = w, h return m } func albums() []scan.Album { return []scan.Album{ {Name: "aphex-twin", Dir: "/staging/aphex-twin", Status: scan.StatusReady, FileCount: 12}, {Name: "boards-of-canada", Dir: "/staging/boards", Status: scan.StatusReady, FileCount: 18}, {Name: "empty-dir", Dir: "/staging/empty", Status: scan.StatusEmpty, Notes: []string{"no audio files found"}}, } } func key(s string) tea.KeyPressMsg { switch s { case "enter": return tea.KeyPressMsg{Code: tea.KeyEnter} case "esc": return tea.KeyPressMsg{Code: tea.KeyEscape} case "up": return tea.KeyPressMsg{Code: tea.KeyUp} case "down": return tea.KeyPressMsg{Code: tea.KeyDown} case "ctrl+c": return tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl} case "/": return tea.KeyPressMsg{Code: '/', Text: "/"} default: return tea.KeyPressMsg{Code: []rune(s)[0], Text: s} } } func readyPlan() plan.Plan { return plan.Build(scan.Album{ Name: "boards", Status: scan.StatusReady, Tracks: []audio.Track{{Path: "/s/01 x.flac", Ext: ".flac", Tags: []tags.Note{ {Field: tags.FieldTitle, Value: "Roygbiv"}, {Field: tags.FieldAlbum, Value: "Music Has the Right to Children"}, {Field: tags.FieldAlbumArtist, Value: "Boards of Canada"}, {Field: tags.FieldArtist, Value: "Boards of Canada"}, {Field: tags.FieldTrackNumber, Value: "1"}, {Field: tags.FieldDiscNumber, Value: "1"}, {Field: tags.FieldDate, Value: "1998"}, }}}, }, "/archive", "/library", plan.DirKey{}, "always") } func TestBrowseListsSortedAlbums(t *testing.T) { m := testModule(100, 30) next, _ := m.Update(albumsMsg{albums: albums()}) got := next.(Module) if len(got.albums) != 3 { t.Fatalf("got %d albums, want 3", len(got.albums)) } if got.albums[0].Name != "aphex-twin" { t.Errorf("albums should be sorted, got %q first", got.albums[0].Name) } if !strings.Contains(got.render(), "aphex-twin") { t.Error("the list should render album names") } } func TestBrowseFilter(t *testing.T) { m := testModule(100, 30) next, _ := m.Update(albumsMsg{albums: albums()}) m = next.(Module) next, _ = m.Update(key("/")) m = next.(Module) if !m.filtering { t.Fatal("/ should start filtering") } for _, r := range "board" { next, _ = m.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) m = next.(Module) } if vis := m.visibleAlbums(); len(vis) != 1 || vis[0].Name != "boards-of-canada" { t.Errorf("filter should match one album, got %v", vis) } next, _ = m.Update(key("esc")) m = next.(Module) if m.filtering || m.filter != "" { t.Error("esc should clear the filter") } } func TestEnterOpensReviewInsteadOfFiling(t *testing.T) { ran := false m := testModule(100, 30) m.opts.Run = func(ctx context.Context, _ scan.Album, _ func(string)) (Outcome, error) { ran = true return Outcome{}, nil } next, _ := m.Update(albumsMsg{albums: albums()}) m = next.(Module) next, cmd := m.Update(key("enter")) m = next.(Module) if cmd == nil { t.Fatal("enter should request the plan for the selected album") } if m.screen != ScreenBrowse { t.Errorf("enter should not change screen until the plan arrives, got %v", m.screen) } if ran { t.Error("enter must never file an album directly") } } func TestPlanArrivalOpensMetadataStep(t *testing.T) { m := testModule(100, 30) next, _ := m.Update(planMsg{album: scan.Album{Name: "boards"}, plan: readyPlan()}) m = next.(Module) if m.screen != ScreenReview { t.Fatalf("screen = %v, want review", m.screen) } if m.step != StepMetadata { t.Errorf("step = %v, want metadata", m.step) } } func TestReviewStepsAdvance(t *testing.T) { m := testModule(100, 30) next, _ := m.Update(planMsg{album: scan.Album{Name: "boards"}, plan: readyPlan()}) m = next.(Module) next, _ = m.Update(key("enter")) m = next.(Module) if m.step != StepTracks { t.Fatalf("step = %v, want tracks", m.step) } next, _ = m.Update(key("enter")) m = next.(Module) if m.step != StepCover { t.Fatalf("step = %v, want cover", m.step) } next, _ = m.Update(key("esc")) m = next.(Module) if m.step != StepTracks { t.Errorf("esc should go back a step, got %v", m.step) } } func TestBlockedPlanCannotLeaveMetadata(t *testing.T) { m := testModule(100, 30) blocked := plan.Build(scan.Album{ Name: "untagged", Status: scan.StatusReady, Tracks: []audio.Track{{Path: "/s/01.flac", Ext: ".flac", Tags: []tags.Note{ {Field: tags.FieldTitle, Value: "Song"}, }}}, }, "/archive", "/library", plan.DirKey{}, "always") if !blocked.Blocked() { t.Fatal("fixture should be blocked") } next, _ := m.Update(planMsg{album: scan.Album{Name: "untagged"}, plan: blocked}) m = next.(Module) next, _ = m.Update(key("enter")) m = next.(Module) if m.step != StepMetadata { t.Error("a blocked plan must not advance") } if m.status == "" { t.Error("the refusal should be explained") } } func TestReviewShowsTheImportantFacts(t *testing.T) { m := testModule(120, 40) next, _ := m.Update(planMsg{album: scan.Album{Name: "boards"}, plan: readyPlan()}) m = next.(Module) view := m.render() for _, want := range []string{"Metadata", "Boards of Canada", "Music Has the Right to Children", "1998"} { if !strings.Contains(view, want) { t.Errorf("metadata step should show %q, got:\n%s", want, view) } } next, _ = m.Update(key("enter")) m = next.(Module) view = m.render() if !strings.Contains(view, "roygbiv") { t.Errorf("track step should show the target filename, got:\n%s", view) } if !strings.Contains(view, "1-01") { t.Error("track step should show the disc-track prefix") } } func TestReviewScrollsWhenContentOverflows(t *testing.T) { m := testModule(80, 12) p := readyPlan() for i := 0; i < 40; i++ { p.Tracks = append(p.Tracks, p.Tracks[0]) } next, _ := m.Update(planMsg{album: scan.Album{Name: "boards"}, plan: p}) m = next.(Module) top := m.render() if !strings.Contains(top, "more") { t.Errorf("overflowing content should advertise more, got:\n%s", top) } if m.scroll != 0 { t.Fatalf("scroll should start at 0, got %d", m.scroll) } next, _ = m.Update(key("down")) m = next.(Module) if m.scroll != 1 { t.Fatalf("down should scroll, got %d", m.scroll) } scrolled := m.render() if scrolled == top { t.Error("scrolling should change what is drawn") } next, _ = m.Update(key("up")) m = next.(Module) if m.scroll != 0 { t.Errorf("up should scroll back, got %d", m.scroll) } } func TestLongContentScreensScroll(t *testing.T) { for _, screen := range []Screen{ScreenReview, ScreenDone, ScreenWorking} { m := testModule(80, 12) next, _ := m.Update(planMsg{album: scan.Album{Name: "boards"}, plan: readyPlan()}) m = next.(Module) m.screen = screen m.log = []string{"line one", "line two"} next, _ = m.Update(key("down")) got := next.(Module) if got.scroll != 1 { t.Errorf("screen %v did not scroll (scroll=%d)", screen, got.scroll) } } } func TestCoverPickerWindowsLongCandidateLists(t *testing.T) { m := testModule(80, 14) next, _ := m.Update(planMsg{album: scan.Album{Name: "boards"}, plan: readyPlan()}) m = next.(Module) next, _ = m.Update(key("enter")) m = next.(Module) next, _ = m.Update(key("enter")) m = next.(Module) var many []CoverOption for i := 0; i < 12; i++ { many = append(many, CoverOption{Source: fmt.Sprintf("source-%02d", i), Width: 600, Height: 600}) } next, _ = m.Update(coversMsg{covers: many}) m = next.(Module) for i := 0; i < len(many)-1; i++ { next, _ = m.Update(key("down")) m = next.(Module) } if m.coverCursor != len(many)-1 { t.Fatalf("cursor = %d, want %d", m.coverCursor, len(many)-1) } out := m.render() if !strings.Contains(out, "source-11") { t.Errorf("the window should follow the cursor to the last candidate, got:\n%s", out) } if strings.Contains(out, "source-00") { t.Errorf("the first candidate should have scrolled out of view, got:\n%s", out) } for _, line := range strings.Split(out, "\n") { if displayWidth(line) > 80 { t.Errorf("line exceeds the terminal: %q", line) } } } func TestCoverPickerListsAndSelects(t *testing.T) { covers := []CoverOption{ {Source: "embedded", Width: 300, Height: 300, Bytes: 1024, Label: "embedded"}, {Source: "coverartarchive", Width: 1200, Height: 1200, Bytes: 400000, Label: "CAA"}, } m := testModule(120, 40) next, _ := m.Update(planMsg{album: scan.Album{Name: "boards"}, plan: readyPlan()}) m = next.(Module) next, _ = m.Update(key("enter")) m = next.(Module) next, _ = m.Update(key("enter")) m = next.(Module) next, _ = m.Update(coversMsg{covers: covers}) m = next.(Module) if m.screen != ScreenCoverPicker { t.Fatalf("screen = %v, want cover picker", m.screen) } view := m.render() if !strings.Contains(view, "embedded") || !strings.Contains(view, "coverartarchive") { t.Errorf("both candidates should be listed, got:\n%s", view) } if !strings.Contains(view, "300x300") || !strings.Contains(view, "1200x1200") { t.Error("dimensions must be shown, since a preview may not be available") } next, _ = m.Update(key("down")) m = next.(Module) if m.coverCursor != 1 { t.Fatalf("cursor = %d, want 1", m.coverCursor) } applied := false m.opts.ApplyCover = func(_ *plan.Plan, c CoverOption) error { applied = true if c.Source != "coverartarchive" { t.Errorf("wrong cover applied: %s", c.Source) } return nil } next, _ = m.Update(key("enter")) m = next.(Module) if !applied { t.Error("enter should apply the highlighted cover") } if !m.hasCover { t.Error("the chosen cover should be remembered") } if m.screen != ScreenReview || m.step != StepConfirm { t.Errorf("picking a cover should land on confirm, got screen=%v step=%v", m.screen, m.step) } } func TestLayoutNeverExceedsTheTerminal(t *testing.T) { sizes := []struct{ w, h int }{{40, 10}, {60, 18}, {80, 24}, {100, 30}, {200, 60}, {20, 6}, {12, 4}} for _, sz := range sizes { m := testModule(sz.w, sz.h) next, _ := m.Update(albumsMsg{albums: albums()}) m = next.(Module) out := m.render() for _, line := range strings.Split(out, "\n") { if w := displayWidth(line); w > sz.w { t.Errorf("%dx%d: line is %d cells wide: %q", sz.w, sz.h, w, line) } } } } func TestTinyTerminalDegradesToOneLine(t *testing.T) { m := testModule(80, 5) if m.mode() != modeTiny { t.Fatalf("a 5-row terminal should be tiny mode, got %v", m.mode()) } out := m.render() if strings.Count(out, "\n") != 0 { t.Errorf("tiny mode should be one line, got:\n%s", out) } if !strings.Contains(out, "staging") { t.Errorf("tiny mode should still say what is happening, got %q", out) } } func TestModesSwitchOnSize(t *testing.T) { cases := []struct { w, h int want mode }{ {200, 60, modeFull}, {100, 30, modeFull}, {80, 24, modeCompact}, {70, 20, modeCompact}, {50, 16, modeMini}, {30, 10, modeMini}, {60, 6, modeTiny}, } for _, c := range cases { if got := testModule(c.w, c.h).mode(); got != c.want { t.Errorf("%dx%d: mode = %v, want %v", c.w, c.h, got, c.want) } } } func TestBothThemesRenderEveryScreen(t *testing.T) { for _, dark := range []bool{true, false} { for _, screen := range []Screen{ScreenBrowse, ScreenReview, ScreenCoverPicker, ScreenDone, ScreenWorking} { m := testModule(100, 30) m.st = StylesFor(makeTheme(dark)) next, _ := m.Update(albumsMsg{albums: albums()}) m = next.(Module) next, _ = m.Update(planMsg{album: scan.Album{Name: "boards"}, plan: readyPlan()}) m = next.(Module) m.screen = screen if out := m.render(); out == "" { t.Errorf("dark=%v screen=%v rendered nothing", dark, screen) } } } } func TestQuitKeys(t *testing.T) { for _, k := range []string{"q", "ctrl+c"} { m := testModule(100, 30) _, cmd := m.Update(key(k)) if cmd == nil { t.Errorf("%q should quit", k) } } } func TestErrorIsShownNotCrashed(t *testing.T) { m := testModule(100, 30) next, _ := m.Update(albumsMsg{err: errBoom{}}) got := next.(Module) if got.err == nil { t.Fatal("error should be recorded") } if !strings.Contains(got.render(), "boom") { t.Errorf("error should be visible, got:\n%s", got.render()) } } func TestDoneScreenExplainsPlaceholderRecovery(t *testing.T) { m := testModule(100, 30) next, _ := m.Update(planMsg{album: scan.Album{Name: "boards"}, plan: readyPlan()}) m = next.(Module) next, _ = m.Update(doneMsg{outcome: Outcome{ ArchiveDir: "/archive/a/b", LibraryDir: "/library/a/b", Files: 10, Bytes: 1024 * 1024, Placeholder: true, Library: true, }}) m = next.(Module) view := m.render() if !strings.Contains(view, "placeholder") { t.Error("a placeholder cover should be called out") } if !strings.Contains(view, "re-cover") { t.Error("the summary should say how to replace placeholder art") } if !strings.Contains(view, "1.0 MB") { t.Errorf("the summary should show the size, got:\n%s", view) } } type errBoom struct{} func (errBoom) Error() string { return "boom" } // Everything about an album stays editable until it is actually being filed. // Deciding on a cover is exactly when a wrong album artist gets noticed, so the // editor has to be reachable from the picker too, without backing out first. func TestTheSheetEditorIsReachableFromEveryReviewScreen(t *testing.T) { if mode := testModule(120, 40); mode.mode() == modeTiny { t.Skip("the module needs a normal-sized terminal") } for _, screen := range []Screen{ScreenReview, ScreenCoverPicker} { for _, step := range []Step{StepMetadata, StepTracks, StepCover, StepConfirm} { m := testModule(120, 40) m.opts.Config.StateDir = t.TempDir() m.pl = readyPlan() m.sheetPath, m.sheetBefore = "", nil m.current = scan.Album{Name: "boards", Dir: m.pl.Album.Dir, Status: scan.StatusReady} m.screen, m.step = screen, step m.covers = []CoverOption{{Path: "/tmp/cover.jpg", Source: "folder"}} got, cmd := m.handleKey(key("e")) m = got.(Module) if m.sheetPath == "" { t.Errorf("screen %v step %v: 'e' did not open the album sheet", screen, step) } if cmd == nil { t.Errorf("screen %v step %v: 'e' produced no command", screen, step) } if m.status != "" && strings.Contains(m.status, "nothing") { t.Errorf("screen %v step %v: %s", screen, step, m.status) } } } } // The sheet is written with the cover that is already chosen, so saving without // touching the cover field does not silently drop it. func TestTheSheetShowsTheChosenCover(t *testing.T) { m := testModule(120, 40) m.pl = readyPlan() m.cover = CoverOption{Path: "/tmp/front.jpg", Source: "folder"} m.hasCover = true if got := m.coverDescription(); got != "/tmp/front.jpg" { t.Errorf("cover field = %q", got) } m.cover = CoverOption{Placeholder: true} if got := m.coverDescription(); got != "placeholder" { t.Errorf("cover field = %q", got) } m.cover, m.hasCover = CoverOption{}, false if got := m.coverDescription(); got != "" { t.Errorf("cover field = %q", got) } } // A cover typed into the sheet wins over the picker, and 'none' clears whatever // was chosen. func TestACoverTypedIntoTheSheetIsApplied(t *testing.T) { dir := t.TempDir() img := filepath.Join(dir, "front.jpg") if err := os.WriteFile(img, []byte("not really a jpeg"), 0o644); err != nil { t.Fatal(err) } m := testModule(120, 40) if err := m.applySheetCover(img); err != nil { t.Fatal(err) } if !m.hasCover || m.cover.Path != img { t.Errorf("the cover was not applied: %+v", m.cover) } if err := m.applySheetCover("none"); err != nil { t.Fatal(err) } if m.hasCover { t.Error("'none' did not clear the cover") } if err := m.applySheetCover("/does/not/exist.jpg"); err == nil { t.Error("a cover that cannot be read should be reported") } } func journalModule(t *testing.T, entries []JournalEntry, forget, unfile *[]string) Module { t.Helper() m := testModule(120, 40) m.journal = entries m.opts.Journal = func(context.Context) ([]JournalEntry, error) { return entries, nil } m.opts.ForgetSource = func(source string) error { *forget = append(*forget, source) return nil } m.opts.UnfileSource = func(source string, progress func(string)) error { *unfile = append(*unfile, source) if progress != nil { progress("moved") } return nil } return m } // The journal is reachable from the album list, because a record whose archive // has gone and an album with no record both look fine everywhere else. func TestTheJournalOpensFromTheAlbumList(t *testing.T) { var forget, unfile []string m := journalModule(t, []JournalEntry{{Source: "/s/a", Album: "A"}}, &forget, &unfile) got, cmd := m.handleKey(key("J")) m = got.(Module) if cmd == nil { t.Fatal("'J' should ask for the journal") } // The command produces the message; feed it back the way the runtime would. got, _ = m.Update(cmd()) m = got.(Module) if m.screen != ScreenJournal { t.Fatalf("screen = %v, want the journal", m.screen) } if len(m.journal) != 1 { t.Fatalf("journal holds %d entries", len(m.journal)) } } // Choosing "forget" from the menu drops the record and touches nothing else, so // it happens on one keypress. func TestForgettingARecordFromTheJournal(t *testing.T) { var forget, unfile []string m := journalModule(t, []JournalEntry{{Source: "/s/a", Album: "A", Tracks: 3}}, &forget, &unfile) m.screen = ScreenJournal got, cmd := m.handleKey(key("enter")) m = got.(Module) if m.journalMenu < 0 { t.Fatal("enter should open the actions") } got, cmd = m.handleKey(key("enter")) m = got.(Module) if cmd == nil { t.Fatal("choosing forget should do something") } got, _ = m.Update(cmd()) m = got.(Module) if len(forget) != 1 || forget[0] != "/s/a" { t.Errorf("forget was not called: %v", forget) } if !strings.Contains(m.status, "untouched") { t.Errorf("status = %q", m.status) } if len(unfile) != 0 { t.Errorf("the other action ran too: %v", unfile) } } // Moving an album out of the archive is the one action that changes files, so it // takes two keypresses and the second only after the first said what would happen. func TestUnfilingNeedsAConfirmation(t *testing.T) { var forget, unfile []string m := journalModule(t, []JournalEntry{{Source: "/s/a", Album: "A", Tracks: 1}}, &forget, &unfile) m.screen = ScreenJournal got, _ := m.handleKey(key("enter")) // open the menu m = got.(Module) got, _ = m.handleKey(key("down")) // move to unfile m = got.(Module) if m.journalAction != 1 { t.Fatalf("action = %d, want unfile", m.journalAction) } got, cmd := m.handleKey(key("enter")) m = got.(Module) if cmd != nil { t.Fatal("the first enter should only say what is about to happen") } if len(unfile) != 0 { t.Fatalf("unfile ran before it was confirmed: %v", unfile) } if !strings.Contains(m.status, "enter again") { t.Errorf("status = %q", m.status) } got, cmd = m.handleKey(key("enter")) m = got.(Module) if cmd == nil { t.Fatal("the second enter should do it") } got, _ = m.Update(cmd()) m = got.(Module) if len(unfile) != 1 { t.Errorf("unfile was not called: %v", unfile) } if m.screen != ScreenJournal { t.Errorf("the journal screen was left: %v", m.screen) } } // Escape gets out of the action menu without doing anything. func TestEscapingTheJournalMenu(t *testing.T) { var forget, unfile []string m := journalModule(t, []JournalEntry{{Source: "/s/a", Album: "A"}}, &forget, &unfile) m.screen = ScreenJournal got, _ := m.handleKey(key("enter")) m = got.(Module) got, cmd := m.handleKey(key("esc")) m = got.(Module) if m.journalMenu >= 0 { t.Error("esc did not close the actions") } if cmd != nil || len(forget) != 0 || len(unfile) != 0 { t.Error("esc did something") } } // An album in the archive that no record names has nothing to forget and nothing // to un-file; the screen says so rather than offering an action that cannot work. func TestAnUnrecordedArchiveEntryOffersNoAction(t *testing.T) { var forget, unfile []string m := journalModule(t, []JournalEntry{{ArchiveDir: "/archive/a/b", Unrecorded: true}}, &forget, &unfile) m.screen = ScreenJournal got, cmd := m.handleKey(key("enter")) m = got.(Module) if cmd != nil || m.journalMenu >= 0 { t.Error("an unrecorded entry should not open an action menu") } if !strings.Contains(m.status, "no record") { t.Errorf("status = %q", m.status) } } func TestJournalFlagsSayWhatIsWrong(t *testing.T) { m := testModule(120, 40) got := m.journalFlags(JournalEntry{Tracks: 3, Missing: true, Placeholder: true}) for _, want := range []string{"archive missing", "placeholder cover", "no library copy", "3 tracks"} { if !strings.Contains(got, want) { t.Errorf("flags %q are missing %q", got, want) } } if got := m.journalFlags(JournalEntry{Tracks: 1, Library: true}); !strings.Contains(got, "complete") { t.Errorf("a complete record should say so: %q", got) } } func batchModule(t *testing.T, rep BatchReport, parked map[string]string) Module { t.Helper() m := testModule(120, 40) m.albums = albums() m.ready = true m.attention = parked m.opts.Batch = func(context.Context, func(string)) (BatchReport, error) { return rep, nil } m.opts.Attention = func(context.Context) (map[string]string, error) { return parked, nil } return m } // The whole point of an unattended run is that it does not stop, so the key that // starts one has to be on the album list and the working screen has to be able to // show it. func TestBatchStartsFromTheAlbumList(t *testing.T) { m := batchModule(t, BatchReport{}, nil) got, cmd := m.handleKey(key("b")) m = got.(Module) if cmd == nil { t.Fatal("'b' should start a batch") } if m.screen != ScreenWorking { t.Fatalf("screen = %v, want the working screen", m.screen) } if !strings.Contains(m.status, "unattended") { t.Errorf("status = %q", m.status) } } func TestBatchRefusesWhenNothingIsReady(t *testing.T) { m := batchModule(t, BatchReport{}, nil) m.albums = []scan.Album{{Name: "empty", Status: scan.StatusEmpty}} got, cmd := m.handleKey(key("b")) m = got.(Module) if cmd != nil { t.Error("a batch with nothing ready should not start") } if !strings.Contains(m.status, "nothing ready") { t.Errorf("status = %q", m.status) } } // The parked albums are the ones that still need a person, so they are named in // the summary rather than only counted. func TestBatchSummaryNamesWhatNeedsYou(t *testing.T) { m := batchModule(t, BatchReport{ Filed: []string{"one", "two"}, Parked: []string{"three: album artist is unknown"}, Failed: []string{"four: decode failed"}, }, nil) rep := BatchReport{ Filed: []string{"one", "two"}, Parked: []string{"three: album artist is unknown"}, Failed: []string{"four: decode failed"}, } m.batch = &rep m.screen = ScreenDone out := m.View().Content for _, want := range []string{"filed 2 album", "need you", "three", "four"} { if !strings.Contains(out, want) { t.Errorf("the summary does not mention %q:\n%s", want, out) } } } // 'n' walks to the next album an unattended run parked, which is how somebody // works through the backlog a batch leaves behind. func TestJumpingToTheNextAlbumThatNeedsYou(t *testing.T) { m := batchModule(t, BatchReport{}, map[string]string{"/staging/boards": "no artist"}) m.cursor = 0 got, _ := m.jumpToAttention() m = got.(Module) if m.albums[m.cursor].Dir != "/staging/boards" { t.Errorf("cursor is on %q", m.albums[m.cursor].Name) } if m.status != "no artist" { t.Errorf("status = %q, want the reason", m.status) } m.attention = map[string]string{} got, _ = m.jumpToAttention() m = got.(Module) if !strings.Contains(m.status, "no album is waiting") { t.Errorf("status = %q", m.status) } } // The album list says which ones are parked, because otherwise an album that // needs a decision looks exactly like one that is ready. func TestTheAlbumListMarksWhatNeedsYou(t *testing.T) { m := batchModule(t, BatchReport{}, map[string]string{"/staging/boards": "no artist"}) if got := m.albumSummary(scan.Album{Dir: "/staging/boards", Status: scan.StatusReady}); !strings.Contains(got, "needs you") { t.Errorf("summary = %q", got) } if got := m.albumSummary(scan.Album{Dir: "/staging/aphex-twin", Status: scan.StatusReady}); strings.Contains(got, "needs you") { t.Errorf("a ready album was marked: %q", got) } } // Being told before the review starts is the point: otherwise the cover of an // album already in the library gets reviewed in full. func TestAlreadyHavingTheAlbumIsSaidBeforeTheReview(t *testing.T) { m := testModule(120, 40) m.pl = readyPlan() m.opts.Existing = func(context.Context, plan.Plan) (Existing, bool) { return Existing{ArchiveDir: "/archive/an-artist/an-album", AlbumArtist: "An Artist", Album: "An Album"}, true } m.ready = true m.current = scan.Album{Name: "boards", Dir: m.pl.Album.Dir, Status: scan.StatusReady} m.screen, m.step = ScreenReview, StepMetadata gotModel, _ := m.Update(existingMsg{existing: Existing{ArchiveDir: "/archive/an-artist/an-album"}, found: true}) m = gotModel.(Module) if m.existing == nil { t.Fatal("the existing album was not remembered") } if m.collision != "both" { t.Errorf("collision = %q; keeping both is the only choice that cannot lose an album", m.collision) } out := m.View().Content for _, want := range []string{"already have", "keep both", "/archive/an-artist/an-album"} { if !strings.Contains(out, want) { t.Errorf("the metadata screen does not say %q:\n%s", want, out) } } } // The three choices are one keypress each, and skipping leaves the album alone. func TestTheAlreadyHaveChoices(t *testing.T) { m := testModule(120, 40) m.pl = readyPlan() m.current = scan.Album{Name: "boards", Dir: m.pl.Album.Dir, Status: scan.StatusReady} m.screen, m.step = ScreenReview, StepMetadata e := Existing{ArchiveDir: "/archive/an-artist/an-album", AlbumArtist: "A", Album: "B"} m.existing, m.collision = &e, "both" got, _ := m.handleKey(key("r")) m = got.(Module) if m.collision != "replace" { t.Errorf("collision after 'r' = %q", m.collision) } got, _ = m.handleKey(key("k")) m = got.(Module) if m.collision != "both" { t.Errorf("collision after 'k' = %q", m.collision) } got, cmd := m.handleKey(key("s")) m = got.(Module) if cmd == nil || m.screen != ScreenBrowse { t.Error("'s' should leave the album alone and go back to the list") } if m.existing != nil { t.Error("the existing album should be forgotten when the album is skipped") } } func helpHas(keys []helpKey, key string) bool { for _, k := range keys { if k.key == key { return true } } return false } // The overlay opens and closes on every screen that has keys, and always names // the keys that work everywhere alongside the screen's own. func TestHelpOpensAndClosesOnEveryScreen(t *testing.T) { screens := []Screen{ScreenBrowse, ScreenJournal, ScreenReview, ScreenCoverPicker, ScreenWorking, ScreenDone} for _, screen := range screens { m := testModule(120, 40) m.screen = screen got, _ := m.Update(key("?")) m = got.(Module) if !m.helpOpen { t.Fatalf("screen %v: '?' did not open the help", screen) } out := m.render() for _, want := range []string{"ctrl+c", "quit"} { if !strings.Contains(out, want) { t.Errorf("screen %v: the help does not mention %q:\n%s", screen, want, out) } } got, _ = m.Update(key("?")) m = got.(Module) if m.helpOpen { t.Errorf("screen %v: '?' did not close the help", screen) } got, _ = m.Update(key("?")) m = got.(Module) got, _ = m.Update(key("esc")) m = got.(Module) if m.helpOpen { t.Errorf("screen %v: esc did not close the help", screen) } } } // While the overlay is open it takes every key but the ones that close it, so a // stray press cannot change anything underneath. func TestHelpTakesTheKeysWhileItIsOpen(t *testing.T) { m := testModule(120, 40) got, _ := m.Update(albumsMsg{albums: albums()}) m = got.(Module) m.cursor = 1 got, _ = m.Update(key("?")) m = got.(Module) before := m for _, k := range []string{"/", "r", "b", "J", "n", "enter", "down", "q"} { got, cmd := m.Update(key(k)) gotM := got.(Module) if cmd != nil { t.Errorf("%q produced a command while the help was open", k) } if !gotM.helpOpen { t.Errorf("%q closed the help instead of being ignored", k) } if gotM.screen != before.screen || gotM.cursor != before.cursor || gotM.filtering { t.Errorf("%q changed the screen underneath the help", k) } m = gotM } if _, cmd := m.Update(key("ctrl+c")); cmd == nil { t.Error("ctrl+c should still quit with the help open") } } // The overlay is not a screen: closing it leaves the review's scroll and the // list's cursor exactly where they were. func TestHelpLeavesTheScreenWhereItWas(t *testing.T) { m := testModule(80, 12) p := readyPlan() for i := 0; i < 40; i++ { p.Tracks = append(p.Tracks, p.Tracks[0]) } got, _ := m.Update(planMsg{album: scan.Album{Name: "boards"}, plan: p}) m = got.(Module) got, _ = m.Update(key("down")) m = got.(Module) got, _ = m.Update(key("down")) m = got.(Module) scroll := m.scroll got, _ = m.Update(key("?")) m = got.(Module) got, _ = m.Update(key("esc")) m = got.(Module) if m.screen != ScreenReview || m.step != StepMetadata || m.scroll != scroll { t.Errorf("closing the help moved the review: screen=%v step=%v scroll=%d, want %d", m.screen, m.step, m.scroll, scroll) } list := testModule(120, 40) got, _ = list.Update(albumsMsg{albums: albums()}) list = got.(Module) got, _ = list.Update(key("down")) list = got.(Module) cursor := list.cursor got, _ = list.Update(key("?")) list = got.(Module) got, _ = list.Update(key("esc")) list = got.(Module) if list.cursor != cursor || list.screen != ScreenBrowse { t.Errorf("closing the help moved the list: cursor=%d, want %d", list.cursor, cursor) } } // The list is derived from what each screen actually does, including the states // inside a screen. func TestHelpListsWhatEachScreenDoes(t *testing.T) { m := testModule(120, 40) for _, want := range []string{"↑↓ / k j", "g / home", "G / end", "/", "enter", "b", "n", "J", "r", "q"} { if !helpHas(m.helpKeys(), want) { t.Errorf("browse help is missing %q: %v", want, m.helpKeys()) } } journal := testModule(120, 40) journal.screen = ScreenJournal for _, want := range []string{"enter", "r", "esc", "q"} { if !helpHas(journal.helpKeys(), want) { t.Errorf("journal help is missing %q: %v", want, journal.helpKeys()) } } menu := testModule(120, 40) menu.screen, menu.journalMenu = ScreenJournal, 0 for _, want := range []string{"↑↓ / k j", "enter", "esc / q"} { if !helpHas(menu.helpKeys(), want) { t.Errorf("journal menu help is missing %q: %v", want, menu.helpKeys()) } } cover := testModule(120, 40) cover.screen = ScreenCoverPicker for _, want := range []string{"enter", "e", "esc", "q"} { if !helpHas(cover.helpKeys(), want) { t.Errorf("cover help is missing %q: %v", want, cover.helpKeys()) } } working := testModule(120, 40) working.screen = ScreenWorking for _, want := range []string{"↑ / k", "↓ / j", "pgup / pgdown", "g / home", "esc / q"} { if !helpHas(working.helpKeys(), want) { t.Errorf("working help is missing %q: %v", want, working.helpKeys()) } } done := testModule(120, 40) done.screen = ScreenDone for _, want := range []string{"↑↓ / k j", "pgup / pgdown", "enter / esc", "q"} { if !helpHas(done.helpKeys(), want) { t.Errorf("done help is missing %q: %v", want, done.helpKeys()) } } } // The review screen's collision choices only exist while an album that is // already in the library is on screen, so the help changes with them. func TestHelpReflectsTheAlreadyHaveChoices(t *testing.T) { m := testModule(120, 40) m.pl = readyPlan() m.current = scan.Album{Name: "boards", Dir: m.pl.Album.Dir, Status: scan.StatusReady} m.screen, m.step = ScreenReview, StepMetadata e := Existing{ArchiveDir: "/archive/an-artist/an-album"} m.existing = &e keys := m.helpKeys() for _, want := range []string{"k", "r", "s"} { if !helpHas(keys, want) { t.Errorf("the collision choices should be in the help, missing %q: %v", want, keys) } } if helpHas(keys, "↑↓ / k j") { t.Error("k is taken by the collision choices, so the combined scroll keys should not be offered") } m.existing = nil keys = m.helpKeys() for _, no := range []string{"k", "r", "s"} { if helpHas(keys, no) { t.Errorf("nothing collides, so %q should not be in the help: %v", no, keys) } } } // The rendered overlay, not just the list, reflects the collision choices. func TestHelpOverlayShowsTheCollisionChoices(t *testing.T) { m := testModule(120, 40) m.pl = readyPlan() m.current = scan.Album{Name: "boards", Dir: m.pl.Album.Dir, Status: scan.StatusReady} m.screen, m.step = ScreenReview, StepMetadata e := Existing{ArchiveDir: "/archive/an-artist/an-album"} m.existing = &e got, _ := m.Update(key("?")) m = got.(Module) out := m.render() for _, want := range []string{"keep both", "replace it", "skip this album"} { if !strings.Contains(out, want) { t.Errorf("the overlay does not show %q:\n%s", want, out) } } } // The overlay has to fit the terminal it is drawn on, including the small ones. func TestHelpFitsTheTerminal(t *testing.T) { sizes := []struct{ w, h int }{{8, 8}, {10, 10}, {12, 4}, {20, 6}, {30, 10}, {50, 16}, {60, 18}, {80, 24}, {100, 30}, {200, 60}} screens := []Screen{ScreenBrowse, ScreenJournal, ScreenReview, ScreenCoverPicker, ScreenWorking, ScreenDone} for _, sz := range sizes { for _, screen := range screens { m := testModule(sz.w, sz.h) m.screen = screen got, _ := m.Update(key("?")) m = got.(Module) out := m.render() for _, line := range strings.Split(out, "\n") { if w := displayWidth(line); w > sz.w { t.Errorf("%dx%d %v: line is %d cells wide: %q", sz.w, sz.h, screen, w, line) } } if lines := strings.Count(out, "\n") + 1; lines > sz.h { t.Errorf("%dx%d %v: %d lines, want at most %d", sz.w, sz.h, screen, lines, sz.h) } } } } // Every footer advertises the key that opens the overlay, including the footers // that belong to a state inside a screen. func TestHelpIsInEveryFooter(t *testing.T) { screens := []Screen{ScreenBrowse, ScreenJournal, ScreenReview, ScreenCoverPicker, ScreenWorking, ScreenDone} for _, screen := range screens { m := testModule(200, 60) m.screen = screen if footer := m.renderFooter(); !strings.Contains(footer, "?") || !strings.Contains(footer, "help") { t.Errorf("screen %v: the footer does not advertise the help: %q", screen, footer) } } filtering := testModule(200, 60) filtering.filtering = true if footer := filtering.renderFooter(); !strings.Contains(footer, "help") { t.Errorf("the filtering footer does not advertise the help: %q", footer) } menu := testModule(200, 60) menu.screen, menu.journalMenu = ScreenJournal, 0 if footer := menu.renderFooter(); !strings.Contains(footer, "help") { t.Errorf("the journal menu footer does not advertise the help: %q", footer) } } // A terminal too short for the whole list still shows the way out rather than // spilling past the bottom of the screen. func TestShortHelpKeepsTheWayOut(t *testing.T) { m := testModule(60, 8) m.screen = ScreenBrowse got, _ := m.Update(key("?")) m = got.(Module) out := m.render() if !strings.Contains(out, "close help") || !strings.Contains(out, "quit") { t.Errorf("the way out should survive a short terminal:\n%s", out) } } // Tiny terminals get one line, and it still says how to get back. func TestTinyHelpIsOneLine(t *testing.T) { m := testModule(80, 5) got, _ := m.Update(key("?")) m = got.(Module) if !m.helpOpen { t.Fatal("'?' should open the help on a tiny terminal") } out := m.render() if strings.Count(out, "\n") != 0 { t.Errorf("the tiny help should be one line, got:\n%s", out) } if !strings.Contains(out, "help") { t.Errorf("the tiny help should say what it is: %q", out) } } // Restoring the saved edits is what makes a review resumable, and the album says // so rather than silently applying corrections from an earlier session. func TestSavedEditsAreRestoredOnOpeningAnAlbum(t *testing.T) { m := testModule(120, 40) m.opts.Config.StateDir = t.TempDir() m.pl = readyPlan() m.current = scan.Album{Name: "boards", Dir: m.pl.Album.Dir, Status: scan.StatusReady} edited := m.pl.Sheet() edited.AlbumArtist = "The Right Artist" if _, err := m.sheetStore().Save(m.current.Dir, sheet.Render(edited)); err != nil { t.Fatal(err) } gotModel, _ := m.Update(planMsg{album: m.current, plan: readyPlan()}) m = gotModel.(Module) if m.pl.AlbumArt != "The Right Artist" { t.Fatalf("the saved edits were not applied: %q", m.pl.AlbumArt) } if !m.restored { t.Error("the album does not know its edits were restored") } if !strings.Contains(m.status, "restored") { t.Errorf("status = %q", m.status) } // And they can be thrown away. got, _ := m.handleKey(key("D")) m = got.(Module) if m.restored { t.Error("'D' did not discard the saved edits") } if _, ok, _ := m.sheetStore().Load(m.current.Dir); ok { t.Error("the saved sheet is still on disk") } } // Once the album is filed the edits have done their job: leaving them would apply // last time's corrections to a fresh folder of the same name. func TestSavedEditsAreDroppedAfterFiling(t *testing.T) { m := testModule(120, 40) m.opts.Config.StateDir = t.TempDir() m.pl = readyPlan() m.current = scan.Album{Name: "boards", Dir: m.pl.Album.Dir, Status: scan.StatusReady} if _, err := m.sheetStore().Save(m.current.Dir, sheet.Render(m.pl.Sheet())); err != nil { t.Fatal(err) } gotModel, _ := m.Update(doneMsg{outcome: Outcome{Files: 1}}) m = gotModel.(Module) if _, ok, _ := m.sheetStore().Load(m.current.Dir); ok { t.Error("the saved sheet outlived the filing") } } // The overlay is the one place that says what the keys are, so a key added after // it has to appear there or the overlay is quietly wrong. func TestHelpKnowsAboutDiscardingSavedEdits(t *testing.T) { m := testModule(120, 40) m.pl = readyPlan() m.screen, m.step = ScreenReview, StepMetadata for _, k := range m.helpKeys() { if k.key == "D" { t.Fatal("'D' is offered before there is anything to discard") } } m.restored = true found := false for _, k := range m.helpKeys() { if k.key == "D" { found = true } } if !found { t.Error("the help overlay does not mention 'D' when saved edits were restored") } }