Files
playlink/internal/engine/rename_test.go
T
cdnutterandClaude Opus 5.5 42161e4287 Add renaming slave folders to the master's spelling
The "N folders are named differently" warning now offers to rename the
slave folders to match the master, marked as the recommended first step
after a scan (also linked from the dashboard card).

- A preview lists each rename with the differing characters highlighted;
  renames are selectable, and conflicts (the slave already has a folder
  with the master's name, symlinks, the quarantine) are skipped with a
  reason.
- Only renames the server computes itself are applied; a request can
  only narrow them down, never name arbitrary paths. Only slave folders
  are renamed.
- Case-only renames go through a temporary name so they also work on
  case-insensitive filesystems; a rename interrupted by a crash is
  completed on startup (the folder is never removed).
- Renaming everything repeats until folders revealed inside renamed ones
  (an album under a renamed artist) are fixed too, then rescans.
- Each rename is an operation in History that can be undone (which also
  rescans). Dry run records renames as planned.

Also fixes the Review list not refreshing after a job that started and
finished between two status polls, and logs the new path of operations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:20:52 -07:00

164 lines
5.7 KiB
Go

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