package engine import ( "io" "log/slog" "os" "path/filepath" "testing" "github.com/cdnutter/playlink/internal/store" ) func renameLibrary(t *testing.T, dryRun bool) *env { ev := setup(t, dryRun) ev.write(ev.master, "AC_DC/Back in Black/01 Hells Bells.flac", "hb") ev.write(ev.slave, "AC-DC/Back In Black/01 Hells Bells.flac", "hb") ev.write(ev.master, "Avenged Sevenfold/City of Evil/03 Bat Country.flac", "bc") ev.write(ev.slave, "Avenged Sevenfold/City Of Evil/03 Bat Country.flac", "bc") ev.scan() return ev } func TestRenameFoldersToMasterSpelling(t *testing.T) { ev := renameLibrary(t, false) plan, err := ev.e.PreviewRenames(ctx, ev.pair.ID) if err != nil { t.Fatal(err) } if len(plan) != 2 || !plan[0].Ready || !plan[1].Ready { t.Fatalf("want 2 ready renames, got %+v", plan) } if _, err := ev.e.StartRenames(ev.pair.ID, nil); err != nil { t.Fatal(err) } j := ev.wait() // The artist rename reveals "Back In Black" inside it: a second pass fixes it. if j.Results["renamed"] != 3 { t.Fatalf("want 3 renames over two passes, got %+v %v", j.Results, j.Skipped) } for _, rel := range []string{"AC_DC/Back in Black/01 Hells Bells.flac", "Avenged Sevenfold/City of Evil/03 Bat Country.flac"} { if ev.read(filepath.Join(ev.slave, rel)) == "" { t.Fatalf("%s missing after rename", rel) } // The job rescans, so the songs now match by path. if m := ev.match(rel); m.Status != store.StatusIdentical || m.Kind != store.KindPath { t.Fatalf("%s should match by path after the rename, got %s/%s", rel, m.Status, m.Kind) } } p, _ := ev.st.Pair(ctx, ev.pair.ID) for _, w := range p.LastScanWarnings { if w.Code == store.WarnRenamedFolders { t.Fatalf("renamed-folders warning should be gone, got %+v", w) } } if ev.read(filepath.Join(ev.master, "AC_DC/Back in Black/01 Hells Bells.flac")) != "hb" { t.Fatal("the master library must not change") } // Undo the artist rename from History. ops, _, _ := ev.st.Ops(ctx, store.OpFilter{PairID: ev.pair.ID}) var artist store.Operation for _, o := range ops { if o.Action == store.ActRename && o.RelSlave == "AC-DC" { artist = o } } if artist.ID == 0 || artist.RelNew != "AC_DC" { t.Fatalf("rename not recorded in history: %+v", ops) } if err := ev.e.Undo(ctx, artist.ID); err != nil { t.Fatal(err) } ev.wait() // undo starts a rescan if _, err := os.Stat(filepath.Join(ev.slave, "AC-DC/Back in Black/01 Hells Bells.flac")); err != nil { t.Fatalf("undo should rename the folder back: %v", err) } } func TestRenameConflictIsSkipped(t *testing.T) { ev := setup(t, false) ev.write(ev.master, "Band/Album Name/01 a.flac", "a") ev.write(ev.slave, "Band/Album name/01 a.flac", "a") ev.write(ev.slave, "Band/Album Name/02 b.flac", "b") // the master spelling already exists too ev.scan() plan, _ := ev.e.PreviewRenames(ctx, ev.pair.ID) if len(plan) != 1 || plan[0].Ready || plan[0].Reason == "" { t.Fatalf("want one conflicting rename, got %+v", plan) } if _, err := ev.e.StartRenames(ev.pair.ID, nil); err != nil { t.Fatal(err) } j := ev.wait() if j.Results["renamed"] != 0 || j.Results["skipped"] != 1 { t.Fatalf("conflict must be skipped, got %+v", j.Results) } if ev.read(filepath.Join(ev.slave, "Band/Album name/01 a.flac")) != "a" || ev.read(filepath.Join(ev.slave, "Band/Album Name/02 b.flac")) != "b" { t.Fatal("nothing may change on a conflict") } } func TestRenameOnlySelectedAndNeverArbitraryPaths(t *testing.T) { ev := renameLibrary(t, false) ev.write(ev.slave, "Unrelated/x.flac", "x") if _, err := ev.e.StartRenames(ev.pair.ID, []string{"Avenged Sevenfold/City Of Evil", "Unrelated"}); err != nil { t.Fatal(err) } j := ev.wait() if j.Results["renamed"] != 1 { t.Fatalf("only the selected, computed rename may run, got %+v", j.Results) } if _, err := os.Stat(filepath.Join(ev.slave, "AC-DC")); err != nil { t.Fatal("unselected folders must not be renamed") } if _, err := os.Stat(filepath.Join(ev.slave, "Unrelated/x.flac")); err != nil { t.Fatal("a folder that is not a computed rename must never be touched") } } func TestRenameDryRun(t *testing.T) { ev := renameLibrary(t, true) if _, err := ev.e.StartRenames(ev.pair.ID, nil); err != nil { t.Fatal(err) } j := ev.wait() if j.Results["planned"] != 2 { t.Fatalf("dry run should plan 2 renames, got %+v", j.Results) } if _, err := os.Stat(filepath.Join(ev.slave, "AC-DC")); err != nil { t.Fatal("dry run must not rename anything") } } func TestRecoverInterruptedCaseRename(t *testing.T) { root := t.TempDir() os.MkdirAll(filepath.Join(root, "Band", ".playlink-tmp-dir-abc123"), 0o755) os.WriteFile(filepath.Join(root, "Band", ".playlink-tmp-dir-abc123", "song.flac"), []byte("data"), 0o644) p := store.Pair{SlaveRoot: root} o := store.Operation{ID: 1, Action: store.ActRename, RelSlave: "Band/City Of Evil", RelNew: "Band/City of Evil"} recoverRename(p, o, slog.New(slog.NewTextHandler(io.Discard, nil))) if b, err := os.ReadFile(filepath.Join(root, "Band", "City of Evil", "song.flac")); err != nil || string(b) != "data" { t.Fatalf("interrupted rename should be completed, got %v", err) } } func TestRenameWarningCountsFixable(t *testing.T) { ev := setup(t, false) ev.write(ev.master, "Band/Album Name/01 a.flac", "a") ev.write(ev.slave, "Band/Album name/01 a.flac", "a") ev.write(ev.slave, "Band/Album Name/02 b.flac", "b") // conflict ev.write(ev.master, "Other/Record/01 c.flac", "c") ev.write(ev.slave, "Other/record/01 c.flac", "c") // renameable ev.scan() p, _ := ev.st.Pair(ctx, ev.pair.ID) for _, w := range p.LastScanWarnings { if w.Code == store.WarnRenamedFolders { if w.Total != 2 || w.Fixable != 1 { t.Fatalf("want 2 renamed folders, 1 fixable; got total=%d fixable=%d", w.Total, w.Fixable) } return } } t.Fatal("renamed-folders warning missing") }