package library import ( "bytes" "context" "io" "log/slog" "path/filepath" "sort" "strings" "testing" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" ) func TestCoveredByDismissal(t *testing.T) { dismissed := []map[string]struct{}{{"a": {}, "b": {}, "c": {}}} for _, tc := range []struct { name string members []string want bool }{ {"the same set", []string{"a", "b", "c"}, true}, {"a subset of it", []string{"a", "b"}, true}, // A new copy joining is new evidence: ask again. {"a superset of it", []string{"a", "b", "c", "d"}, false}, {"overlapping only in part", []string{"a", "d"}, false}, {"unrelated", []string{"x", "y"}, false}, } { if got := coveredByDismissal(tc.members, dismissed); got != tc.want { t.Errorf("%s: coveredByDismissal = %v, want %v", tc.name, got, tc.want) } } } // TestDuplicateSweep_Integration pins what the sweep proposes, what it leaves out, // and how re-sweeping treats a dismissal and a proposal that no longer holds. func TestDuplicateSweep_Integration(t *testing.T) { pool := newPool(t) ctx := context.Background() q := dbq.New(pool) dir := t.TempDir() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) // seedTrack's own track has no fingerprint row: it must be absent from the // report, not grouped with every other track lacking one. _, album, artist := seedTrack(t, pool, filepath.Join(dir, "unfingerprinted.mp3")) hash := func(b byte) []byte { return bytes.Repeat([]byte{b}, 32) } add := func(name string, durationMs int32, sum []byte, print []int32) string { t.Helper() tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{ Title: name, AlbumID: album.ID, ArtistID: artist.ID, DurationMs: durationMs, FilePath: filepath.Join(dir, name+".mp3"), FileSize: 100, FileFormat: "mp3", }) if err != nil { t.Fatalf("track %s: %v", name, err) } if err := q.UpsertTrackFingerprint(ctx, dbq.UpsertTrackFingerprintParams{ TrackID: tr.ID, AudioStreamSha256: sum, Chromaprint: print, FingerprintVersion: fingerprintVersion, }); err != nil { t.Fatalf("fingerprint %s: %v", name, err) } return syncpkg.FormatUUID(tr.ID) } key := func(ids ...string) string { sorted := append([]string(nil), ids...) sort.Strings(sorted) return strings.Join(sorted, ",") } recording := randomPrint(200, printLen) onAlbum := add("recording-album", 240000, hash(1), recording) onCompilation := add("recording-compilation", 241000, hash(2), withBitNoise(recording, 0.05, 201)) www1 := add("www-01", 215000, hash(9), randomPrint(210, printLen)) www2 := add("www-02", 215000, hash(9), randomPrint(210, printLen)) // Near-identical duration to the recording, different audio. add("different-song", 240500, hash(3), randomPrint(220, printLen)) // Identical to the album copy, but its file is gone: nothing to compare. missing := add("missing-copy", 240000, hash(4), recording) if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = now() WHERE file_path LIKE '%missing-copy.mp3'"); err != nil { t.Fatalf("mark missing: %v", err) } type stored struct { tier, status string } groups := func() map[string]stored { t.Helper() rows, err := pool.Query(ctx, `SELECT member_key, tier, status FROM duplicate_groups`) if err != nil { t.Fatalf("read groups: %v", err) } defer rows.Close() out := map[string]stored{} for rows.Next() { var k string var s stored if err := rows.Scan(&k, &s.tier, &s.status); err != nil { t.Fatalf("scan group: %v", err) } out[k] = s } return out } // 1. A page size of one forces the keyset cursor across every candidate. res, err := runDuplicateSweep(ctx, pool, logger, 1) if err != nil { t.Fatalf("first sweep: %v", err) } // Five tracks carry a chromaprint and a present file. if res.Candidates != 5 || res.Groups != 2 || res.Proposed != 2 { t.Fatalf("first sweep = %+v, want 5 candidates, 2 groups, 2 proposed", res) } acousticKey, exactKey := key(onAlbum, onCompilation), key(www1, www2) got := groups() want := map[string]stored{ acousticKey: {"acoustic", "pending"}, exactKey: {"exact", "pending"}, } if len(got) != len(want) || got[acousticKey] != want[acousticKey] || got[exactKey] != want[exactKey] { t.Fatalf("groups = %+v, want %+v", got, want) } for k := range got { if strings.Contains(k, missing) { t.Fatalf("a missing track was proposed: %s", k) } } // 2. A dismissed group is not proposed again, and the pending one is // refreshed in place rather than duplicated. if _, err := pool.Exec(ctx, "UPDATE duplicate_groups SET status = 'dismissed' WHERE member_key = $1", acousticKey); err != nil { t.Fatalf("dismiss: %v", err) } res, err = runDuplicateSweep(ctx, pool, logger, duplicateCandidatePage) if err != nil { t.Fatalf("second sweep: %v", err) } if res.Proposed != 1 || res.Suppressed != 1 { t.Fatalf("second sweep = %+v, want 1 proposed, 1 suppressed", res) } got = groups() if len(got) != 2 || got[acousticKey].status != "dismissed" || got[exactKey].status != "pending" { t.Fatalf("after dismissal groups = %+v, want the dismissal kept and one pending group", got) } // 3. A proposal that no longer holds is retired; the dismissal survives it. if _, err := pool.Exec(ctx, "DELETE FROM track_fingerprints f USING tracks t WHERE f.track_id = t.id AND t.file_path LIKE '%www-02.mp3'"); err != nil { t.Fatalf("drop fingerprint: %v", err) } res, err = runDuplicateSweep(ctx, pool, logger, duplicateCandidatePage) if err != nil { t.Fatalf("third sweep: %v", err) } if res.Retired != 1 { t.Fatalf("third sweep = %+v, want 1 retired", res) } got = groups() if len(got) != 1 || got[acousticKey].status != "dismissed" { t.Fatalf("after retiring groups = %+v, want only the dismissal", got) } // 4. The sweep record reflects the last run. last, err := q.GetLatestDuplicateSweep(ctx) if err != nil { t.Fatalf("latest sweep: %v", err) } if !last.FinishedAt.Valid || last.ErrorMessage != nil { t.Fatalf("latest sweep = %+v, want finished without error", last) } }