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")
}
}