Warnings are now stored and served as structured data (title, detail, slave/master items, total) and rendered as cards with a Slave -> Master table: the first five rows, with "Show all" for the rest. - Renamed folders highlight the characters that differ; non-ASCII characters get their code point in a tooltip, so look-alikes such as "-" (U+002D) and "‐" (U+2010) can be told apart. - Songs in a different folder are grouped by folder pair with a song count, most affected first, instead of two long examples. - Folders that only differ by name are no longer also reported as songs in a different folder. - Warnings saved as plain text by earlier scans still display. 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 text := w.Title + " " + w.Detail; strings.Contains(text, "layout") || strings.Contains(text, "folder") || strings.Contains(text, "depth") {
|
|
t.Fatalf("matching layouts must not produce layout warnings, got %+v", 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)
|
|
}
|
|
}
|