Matching: - Prefer the studio album over singles, EPs and compilations when a song exists on several releases, including when the master also has the single at the slave's exact folder. - Ignore release labels (Single Version, Remastered, Explicit, Deluxe) inside brackets or a trailing " - ..." part; "Clean" stays a version. - Treat live/acoustic/remix/... album folders as different recordings; the strict top-folder fallback now respects this too. - Add a last-resort library-wide search that only accepts candidates whose artist tag agrees with the slave file. Linking: - Cross-folder matches now mirror the master's path in the slave (e.g. a single moves into the album folder) and prune the emptied folder; a "keep in current folder" option preserves the old behaviour. Undo recreates and prunes folders accordingly. - Operations record the match kind and reason, so undo (and promotes that rename the master file) restore the correct pairing instead of leaving the file unmatched until the next scan. UI: rewrite the approximate matching description with Avenged Sevenfold examples and clear safeguards, and show where linking will place a file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
531 lines
15 KiB
Go
531 lines
15 KiB
Go
package engine
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/cdnutter/playlink/internal/fsops"
|
|
"github.com/cdnutter/playlink/internal/probe"
|
|
"github.com/cdnutter/playlink/internal/store"
|
|
)
|
|
|
|
type env struct {
|
|
t *testing.T
|
|
root string
|
|
master string
|
|
slave string
|
|
st *store.Store
|
|
e *Engine
|
|
pair store.Pair
|
|
}
|
|
|
|
func setup(t *testing.T, dryRun bool) *env {
|
|
t.Helper()
|
|
root, err := filepath.EvalSymlinks(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ev := &env{t: t, root: root, master: filepath.Join(root, "Albums"), slave: filepath.Join(root, "Singles")}
|
|
for _, d := range []string{ev.master, ev.slave, filepath.Join(root, "db")} {
|
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
st, err := store.Open(filepath.Join(root, "db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { st.DB.Close() })
|
|
v := "0"
|
|
if dryRun {
|
|
v = "1"
|
|
}
|
|
if err := st.SetSetting(context.Background(), "dry_run", v); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ev.st = st
|
|
ev.e = New(st, probe.Prober{}, []string{root}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
ev.pair = store.Pair{Name: "test", MasterRoot: ev.master, SlaveRoot: ev.slave, RetentionDays: 30}
|
|
if _, err := ev.e.NormalizePair(&ev.pair); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.SavePair(context.Background(), &ev.pair); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return ev
|
|
}
|
|
|
|
func (ev *env) write(base, rel, content string) string {
|
|
ev.t.Helper()
|
|
p := filepath.Join(base, rel)
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
ev.t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
|
ev.t.Fatal(err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func (ev *env) read(p string) string {
|
|
ev.t.Helper()
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
ev.t.Fatal(err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func (ev *env) scan() {
|
|
ev.t.Helper()
|
|
if _, err := ev.e.StartScan(ev.pair.ID, "test"); err != nil {
|
|
ev.t.Fatal(err)
|
|
}
|
|
ev.wait()
|
|
}
|
|
|
|
func (ev *env) wait() *Job {
|
|
ev.t.Helper()
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if j := ev.e.Job(ev.pair.ID); j != nil && j.State != "running" {
|
|
if j.State != "done" {
|
|
ev.t.Fatalf("job %s: %s", j.State, j.Error)
|
|
}
|
|
return j
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
ev.t.Fatal("job did not finish")
|
|
return nil
|
|
}
|
|
|
|
func (ev *env) match(rel string) store.Match {
|
|
ev.t.Helper()
|
|
m, err := ev.st.MatchBySlave(context.Background(), ev.pair.ID, rel)
|
|
if err != nil {
|
|
ev.t.Fatalf("match %s: %v", rel, err)
|
|
}
|
|
return m
|
|
}
|
|
|
|
func sameInode(t *testing.T, a, b string) bool {
|
|
t.Helper()
|
|
ia, err := fsops.Lstat(a)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ib, err := fsops.Lstat(b)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return ia.SameInode(ib)
|
|
}
|
|
|
|
var ctx = context.Background()
|
|
|
|
func TestIdenticalLinkAndUndo(t *testing.T) {
|
|
ev := setup(t, false)
|
|
rel := "bbno$/Bag or Die/edamame.flac"
|
|
h := ev.write(ev.master, rel, "same-bytes")
|
|
s := ev.write(ev.slave, rel, "same-bytes")
|
|
ev.scan()
|
|
|
|
m := ev.match(rel)
|
|
if m.Status != store.StatusIdentical || m.Kind != store.KindPath {
|
|
t.Fatalf("want identical/path, got %s/%s", m.Status, m.Kind)
|
|
}
|
|
op, err := ev.e.Link(ctx, m.ID, LinkOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if op.QuarantinePath != "" {
|
|
t.Fatal("identical link must not quarantine")
|
|
}
|
|
if !sameInode(t, h, s) {
|
|
t.Fatal("slave is not a hardlink of master")
|
|
}
|
|
if ev.match(rel).Status != store.StatusLinked {
|
|
t.Fatal("match not marked linked")
|
|
}
|
|
|
|
if err := ev.e.Undo(ctx, op.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if sameInode(t, h, s) || ev.read(s) != "same-bytes" {
|
|
t.Fatal("undo should leave an independent identical copy")
|
|
}
|
|
}
|
|
|
|
func TestDifferentLinkQuarantinesAndUndoRestores(t *testing.T) {
|
|
ev := setup(t, false)
|
|
rel := "Artist/Album/song.flac"
|
|
h := ev.write(ev.master, rel, "master-version-longer")
|
|
s := ev.write(ev.slave, rel, "slave-version")
|
|
ev.scan()
|
|
|
|
m := ev.match(rel)
|
|
if m.Status != store.StatusDifferent {
|
|
t.Fatalf("want different, got %s", m.Status)
|
|
}
|
|
op, err := ev.e.Link(ctx, m.ID, LinkOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !sameInode(t, h, s) {
|
|
t.Fatal("not linked")
|
|
}
|
|
if op.QuarantinePath == "" || ev.read(op.QuarantinePath) != "slave-version" {
|
|
t.Fatal("original slave file not quarantined")
|
|
}
|
|
if err := ev.e.Undo(ctx, op.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ev.read(s) != "slave-version" || ev.read(h) != "master-version-longer" {
|
|
t.Fatal("undo did not restore the original")
|
|
}
|
|
if _, err := os.Stat(op.QuarantinePath); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatal("quarantine entry should be consumed by the restore")
|
|
}
|
|
if err := ev.e.Undo(ctx, op.ID); err == nil {
|
|
t.Fatal("double undo must fail")
|
|
}
|
|
}
|
|
|
|
func TestSimilarExtensionLinkRenamesAndUndo(t *testing.T) {
|
|
ev := setup(t, false)
|
|
h := ev.write(ev.master, "A/B/01 - Song.flac", "flac-data")
|
|
s := ev.write(ev.slave, "A/B/01 - Song.mp3", "mp3")
|
|
ev.scan()
|
|
|
|
m := ev.match("A/B/01 - Song.mp3")
|
|
if m.Status != store.StatusSimilar || m.Kind != store.KindExtension || m.RelMaster != "A/B/01 - Song.flac" {
|
|
t.Fatalf("unexpected match %+v", m)
|
|
}
|
|
op, err := ev.e.Link(ctx, m.ID, LinkOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
newSlave := filepath.Join(ev.slave, "A/B/01 - Song.flac")
|
|
if !sameInode(t, h, newSlave) {
|
|
t.Fatal("slave should now hold the master's .flac as a hardlink")
|
|
}
|
|
if _, err := os.Stat(s); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatal("old .mp3 should be moved to quarantine")
|
|
}
|
|
if err := ev.e.Undo(ctx, op.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ev.read(s) != "mp3" {
|
|
t.Fatal(".mp3 not restored")
|
|
}
|
|
if m := ev.match("A/B/01 - Song.mp3"); m.Kind != store.KindExtension || m.Status != store.StatusSimilar {
|
|
t.Fatalf("undo should restore the match for review, got %s/%s", m.Status, m.Kind)
|
|
}
|
|
if _, err := os.Stat(newSlave); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatal("linked .flac should be removed on undo")
|
|
}
|
|
if ev.read(h) != "flac-data" {
|
|
t.Fatal("master must be untouched")
|
|
}
|
|
}
|
|
|
|
func TestFuzzyTitleMatch(t *testing.T) {
|
|
ev := setup(t, false)
|
|
ev.write(ev.master, "A/B/03. My Song!.flac", "x")
|
|
ev.write(ev.slave, "A/B/My Song.mp3", "y")
|
|
ev.write(ev.slave, "A/B/My Song (Live).mp3", "z")
|
|
ev.write(ev.master, "A/Other Album/Lonely.flac", "x")
|
|
ev.write(ev.slave, "A/Single/Lonely.flac", "x")
|
|
ev.scan()
|
|
if m := ev.match("A/B/My Song.mp3"); m.Kind != store.KindFuzzy {
|
|
t.Fatalf("want fuzzy, got %s", m.Kind)
|
|
}
|
|
if m := ev.match("A/B/My Song (Live).mp3"); m.Status != store.StatusUnmatched {
|
|
t.Fatalf("live version must not match studio version, got %s", m.Status)
|
|
}
|
|
if m := ev.match("A/Single/Lonely.flac"); m.Kind != store.KindElsewhere || m.Status != store.StatusSimilar {
|
|
t.Fatalf("want elsewhere/similar, got %s/%s", m.Kind, m.Status)
|
|
}
|
|
}
|
|
|
|
func TestDowngradeGuardAndPromote(t *testing.T) {
|
|
ev := setup(t, false)
|
|
rel := "A/B/song.flac"
|
|
h := ev.write(ev.master, rel, "lossy-master")
|
|
s := ev.write(ev.slave, rel, "lossless-slave!")
|
|
ev.scan()
|
|
m := ev.match(rel)
|
|
|
|
// Simulate the probe verdict that the slave is better.
|
|
v, _ := json.Marshal(probe.Verdict{Better: "slave"})
|
|
m.Verdict = v
|
|
if err := ev.st.UpsertMatch(ctx, &m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := ev.e.Link(ctx, m.ID, LinkOptions{}); !errors.Is(err, ErrDowngrade) {
|
|
t.Fatalf("want ErrDowngrade, got %v", err)
|
|
}
|
|
if _, err := ev.e.Promote(ctx, m.ID, false); !errors.Is(err, ErrConfirm) {
|
|
t.Fatalf("promote without confirm must fail, got %v", err)
|
|
}
|
|
op, err := ev.e.Promote(ctx, m.ID, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !sameInode(t, h, s) || ev.read(h) != "lossless-slave!" {
|
|
t.Fatal("master should now be the slave's file")
|
|
}
|
|
if ev.read(op.QuarantinePath) != "lossy-master" {
|
|
t.Fatal("master original not quarantined")
|
|
}
|
|
if err := ev.e.Undo(ctx, op.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ev.read(h) != "lossy-master" || ev.read(s) != "lossless-slave!" || sameInode(t, h, s) {
|
|
t.Fatal("promote undo failed")
|
|
}
|
|
}
|
|
|
|
func TestChangedSinceScanIsRefused(t *testing.T) {
|
|
ev := setup(t, false)
|
|
rel := "A/B/c.flac"
|
|
ev.write(ev.master, rel, "aaa")
|
|
s := ev.write(ev.slave, rel, "bbb")
|
|
ev.scan()
|
|
m := ev.match(rel)
|
|
time.Sleep(10 * time.Millisecond)
|
|
if err := os.WriteFile(s, []byte("user edited this"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := ev.e.Link(ctx, m.ID, LinkOptions{}); !errors.Is(err, fsops.ErrChanged) {
|
|
t.Fatalf("want ErrChanged, got %v", err)
|
|
}
|
|
if ev.read(s) != "user edited this" {
|
|
t.Fatal("file must be untouched")
|
|
}
|
|
}
|
|
|
|
func TestDryRunChangesNothing(t *testing.T) {
|
|
ev := setup(t, true)
|
|
rel := "A/B/d.flac"
|
|
h := ev.write(ev.master, rel, "same")
|
|
s := ev.write(ev.slave, rel, "same")
|
|
ev.scan()
|
|
op, err := ev.e.Link(ctx, ev.match(rel).ID, LinkOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !op.DryRun || op.Status != store.OpPlanned {
|
|
t.Fatalf("expected planned dry-run op, got %+v", op)
|
|
}
|
|
if sameInode(t, h, s) {
|
|
t.Fatal("dry run must not link")
|
|
}
|
|
}
|
|
|
|
func TestAutoLinkRespectsSettleTime(t *testing.T) {
|
|
ev := setup(t, false)
|
|
ev.pair.AutoLinkIdentical = true
|
|
ev.pair.SettleMinutes = 60
|
|
if err := ev.st.SavePair(ctx, &ev.pair); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := ev.write(ev.master, "A/B/e.flac", "same")
|
|
s := ev.write(ev.slave, "A/B/e.flac", "same")
|
|
ev.scan()
|
|
if sameInode(t, h, s) {
|
|
t.Fatal("freshly written files must not be auto-linked")
|
|
}
|
|
ev.pair.SettleMinutes = 0
|
|
if err := ev.st.SavePair(ctx, &ev.pair); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ev.scan()
|
|
if !sameInode(t, h, s) {
|
|
t.Fatal("settled identical file should be auto-linked")
|
|
}
|
|
}
|
|
|
|
func TestBuildAndRemove(t *testing.T) {
|
|
ev := setup(t, false)
|
|
h1 := ev.write(ev.master, "bbno$/Album/one.flac", "1")
|
|
ev.write(ev.master, "bbno$/Album/two.flac", "2")
|
|
ev.write(ev.master, "bbno$/Album/cover.jpg", "img")
|
|
ev.write(ev.slave, "bbno$/Album/two.flac", "user's own different file")
|
|
|
|
if _, err := ev.e.StartBuild(ev.pair.ID, []string{"bbno$"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
j := ev.wait()
|
|
if j.Results["linked"] != 1 || j.Results["skipped"] != 1 {
|
|
t.Fatalf("unexpected results %+v", j.Results)
|
|
}
|
|
s1 := filepath.Join(ev.slave, "bbno$/Album/one.flac")
|
|
if !sameInode(t, h1, s1) {
|
|
t.Fatal("build did not hardlink")
|
|
}
|
|
if ev.read(filepath.Join(ev.slave, "bbno$/Album/two.flac")) != "user's own different file" {
|
|
t.Fatal("build must never overwrite an existing slave file")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(ev.slave, "bbno$/Album/cover.jpg")); err == nil {
|
|
t.Fatal("non-audio files must not be linked")
|
|
}
|
|
|
|
if _, err := ev.e.StartRemove(ev.pair.ID, []string{"bbno$"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
j = ev.wait()
|
|
if j.Results["removed"] != 1 || j.Results["skipped"] != 1 {
|
|
t.Fatalf("unexpected remove results %+v", j.Results)
|
|
}
|
|
if _, err := os.Stat(s1); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatal("linked file should be removed")
|
|
}
|
|
if ev.read(h1) != "1" {
|
|
t.Fatal("master must be untouched")
|
|
}
|
|
if ev.read(filepath.Join(ev.slave, "bbno$/Album/two.flac")) != "user's own different file" {
|
|
t.Fatal("remove must never delete a non-linked file")
|
|
}
|
|
}
|
|
|
|
func TestSymlinkEscapeIgnored(t *testing.T) {
|
|
ev := setup(t, false)
|
|
outside := t.TempDir()
|
|
os.WriteFile(filepath.Join(outside, "secret.flac"), []byte("x"), 0o644)
|
|
os.MkdirAll(filepath.Join(ev.slave, "Evil"), 0o755)
|
|
if err := os.Symlink(outside, filepath.Join(ev.slave, "Evil", "Album")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ev.write(ev.master, "Evil/Album/secret.flac", "x")
|
|
ev.scan()
|
|
if _, err := ev.st.MatchBySlave(ctx, ev.pair.ID, "Evil/Album/secret.flac"); err == nil {
|
|
t.Fatal("files behind a symlinked directory must not be scanned")
|
|
}
|
|
root, _ := fsops.NewRoot(ev.slave)
|
|
if _, err := root.Resolve("Evil/Album/secret.flac"); !errors.Is(err, fsops.ErrOutsideRoot) {
|
|
t.Fatalf("resolve through an escaping symlink must fail, got %v", err)
|
|
}
|
|
if _, err := root.Resolve("../Albums/x.flac"); !errors.Is(err, fsops.ErrOutsideRoot) {
|
|
t.Fatalf("dot-dot must fail, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPurgeAndOverlapValidation(t *testing.T) {
|
|
ev := setup(t, false)
|
|
rel := "A/B/f.flac"
|
|
ev.write(ev.master, rel, "one")
|
|
ev.write(ev.slave, rel, "two!")
|
|
ev.scan()
|
|
op, err := ev.e.Link(ctx, ev.match(rel).ID, LinkOptions{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ev.e.Purge(ctx, op.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(op.QuarantinePath); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatal("purge did not delete quarantine")
|
|
}
|
|
if err := ev.e.Undo(ctx, op.ID); err == nil {
|
|
t.Fatal("undo after purge must fail")
|
|
}
|
|
|
|
bad := store.Pair{Name: "bad", MasterRoot: ev.master, SlaveRoot: filepath.Join(ev.master, "A")}
|
|
if _, err := ev.e.NormalizePair(&bad); err == nil {
|
|
t.Fatal("nested libraries must be rejected")
|
|
}
|
|
outside := store.Pair{Name: "bad", MasterRoot: ev.master, SlaveRoot: t.TempDir()}
|
|
if _, err := ev.e.NormalizePair(&outside); err == nil {
|
|
t.Fatal("paths outside allowed roots must be rejected")
|
|
}
|
|
}
|
|
|
|
func TestSameContent(t *testing.T) {
|
|
dir := t.TempDir()
|
|
big := bytes.Repeat([]byte("abcdefgh"), 300_000) // spans several chunks
|
|
a, b, c := filepath.Join(dir, "a"), filepath.Join(dir, "b"), filepath.Join(dir, "c")
|
|
os.WriteFile(a, big, 0o644)
|
|
os.WriteFile(b, big, 0o644)
|
|
alt := append([]byte(nil), big...)
|
|
alt[len(alt)-1] = 'X'
|
|
os.WriteFile(c, alt, 0o644)
|
|
if same, err := fsops.SameContent(a, b); err != nil || !same {
|
|
t.Fatalf("a,b should match: %v %v", same, err)
|
|
}
|
|
if same, _ := fsops.SameContent(a, c); same {
|
|
t.Fatal("a,c differ in the last byte")
|
|
}
|
|
}
|
|
|
|
func TestNormTitle(t *testing.T) {
|
|
cases := map[string]string{
|
|
"01 - Song.flac": "song",
|
|
"1-03 Song.mp3": "song",
|
|
"03. Song!.flac": "song",
|
|
"22.flac": "22",
|
|
"Song (Live).flac": "songlive",
|
|
"07 Song (Remix).flac": "songremix",
|
|
}
|
|
for in, want := range cases {
|
|
if got := normTitle(in); got != want {
|
|
t.Errorf("normTitle(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAnyLayoutMatchesWithoutWarnings(t *testing.T) {
|
|
ev := setup(t, false)
|
|
// Flat files, deeper Genre/Artist/Album/Disc nesting: all fine as long as both sides agree.
|
|
for _, rel := range []string{"loose.flac", "Rock/Band/Album/Disc 1/01 - Song.flac", "Band/Song.flac"} {
|
|
ev.write(ev.master, rel, "same:"+rel)
|
|
ev.write(ev.slave, rel, "same:"+rel)
|
|
}
|
|
ev.scan()
|
|
for _, rel := range []string{"loose.flac", "Rock/Band/Album/Disc 1/01 - Song.flac", "Band/Song.flac"} {
|
|
if m := ev.match(rel); m.Status != store.StatusIdentical || m.Kind != store.KindPath {
|
|
t.Fatalf("%s: want identical/path, got %s/%s", rel, m.Status, m.Kind)
|
|
}
|
|
}
|
|
p, err := ev.st.Pair(ctx, ev.pair.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, w := range p.LastScanWarnings {
|
|
if strings.Contains(w, "layout") || strings.Contains(w, "folder") || strings.Contains(w, "depth") {
|
|
t.Fatalf("matching layouts must not produce layout warnings, got %q", w)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPromoteRenamedMasterStaysLinked(t *testing.T) {
|
|
ev := setup(t, false)
|
|
ev.write(ev.master, "A/B/Song.mp3", "lossy")
|
|
ev.write(ev.slave, "A/B/Song.flac", "lossless")
|
|
ev.scan()
|
|
m := ev.match("A/B/Song.flac")
|
|
op, err := ev.e.Promote(ctx, m.ID, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := ev.match("A/B/Song.flac"); got.Status != store.StatusLinked || got.RelMaster != "A/B/Song.flac" {
|
|
t.Fatalf("after promote the pair should be linked to the renamed master, got %s -> %q", got.Status, got.RelMaster)
|
|
}
|
|
if err := ev.e.Undo(ctx, op.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := ev.match("A/B/Song.flac"); got.Status != store.StatusSimilar || got.RelMaster != "A/B/Song.mp3" {
|
|
t.Fatalf("undo should restore the original match, got %s -> %q", got.Status, got.RelMaster)
|
|
}
|
|
}
|