Files
minstrel/internal/library/duplicate_sweep_test.go
T
bvandeusenandClaude Opus 5.5 7e6228c13f
release / govulncheck (push) Successful in 20s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m15s
release / android (push) Successful in 5m26s
release / Build signed APK (releases and dev) (push) Successful in 5m45s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
docs: library comments say what the scanner and resolver do, not how they came to be (#6277)
Rewrites the internal/library comment blocks to rule 32. Task, milestone,
decision and rule ids are gone; the incidents behind them are restated as
the constraint they left (why a file is deleted before its row, why the
missing mark is a mark, why duplicates block on duration). Counts and
album names from one library are dropped from production comments; test
comments keep the names their fixtures use. The tagReadVersion log keeps
each version's before and after, without the measurements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:27:34 -04:00

253 lines
9.5 KiB
Go

package library
import (
"bytes"
"context"
"io"
"log/slog"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"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,
ChromaprintLengthSec: defaultChromaprintLengthSec,
}); 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, DefaultFingerprintSettings, 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, DefaultFingerprintSettings, 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, DefaultFingerprintSettings, 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)
}
// 5. Prints taken at another length are never compared. Every print
// here was taken at the default length, so a sweep at 60s has nothing to
// read, rather than scoring 120s prints against each other as if they were
// 60s ones.
atOtherLength := DefaultFingerprintSettings
atOtherLength.ChromaprintLengthSec = 60
res, err = runDuplicateSweep(ctx, pool, logger, atOtherLength, duplicateCandidatePage)
if err != nil {
t.Fatalf("sweep at another length: %v", err)
}
if res.Candidates != 0 || res.Groups != 0 {
t.Fatalf("sweep at another length = %+v, want no candidates and no groups", res)
}
// 6. The sweep applies the threshold it is given. The recording's two copies
// disagree on about 5% of their bits: grouped at the default, not at 1%.
if _, err := pool.Exec(ctx, "DELETE FROM duplicate_groups"); err != nil {
t.Fatalf("clear groups: %v", err)
}
strict := DefaultFingerprintSettings
strict.AcousticMaxBitErrorRate = 0.01
res, err = runDuplicateSweep(ctx, pool, logger, strict, duplicateCandidatePage)
if err != nil {
t.Fatalf("strict sweep: %v", err)
}
if res.Groups != 0 {
t.Fatalf("sweep at a 1%% threshold = %+v, want the copies 5%% apart left ungrouped", res)
}
res, err = runDuplicateSweep(ctx, pool, logger, DefaultFingerprintSettings, duplicateCandidatePage)
if err != nil {
t.Fatalf("default sweep: %v", err)
}
if got := groups(); res.Groups != 1 || got[acousticKey] != (stored{"acoustic", "pending"}) {
t.Fatalf("sweep at the default threshold = %+v, groups %+v; want the recording's copies proposed", res, got)
}
}
func TestSweepIsDue(t *testing.T) {
now := time.Date(2026, 9, 11, 12, 0, 0, 0, time.UTC)
at := func(ago time.Duration) pgtype.Timestamptz {
return pgtype.Timestamptz{Time: now.Add(-ago), Valid: true}
}
never := pgtype.Timestamptz{}
hourly := DefaultFingerprintSettings
daily := DefaultFingerprintSettings
daily.SweepIntervalHours = 24
savedAgo := func(ago time.Duration) FingerprintSettings {
s := DefaultFingerprintSettings
s.UpdatedAt = now.Add(-ago)
return s
}
for _, tc := range []struct {
name string
latestPrint, lastSweep pgtype.Timestamptz
cfg FingerprintSettings
want bool
}{
{"nothing fingerprinted", never, never, hourly, false},
{"never swept", at(time.Minute), never, hourly, true},
{"new fingerprints since the last sweep", at(10 * time.Minute), at(2 * time.Hour), hourly, true},
{"nothing new since the last sweep", at(3 * time.Hour), at(2 * time.Hour), hourly, false},
// The sweep started a moment after the previous tick, so one tick later
// it is a moment under an hour old. Without the slack this is false.
{"one tick after an hourly sweep", at(time.Minute), at(time.Hour - 2*time.Second), hourly, true},
{"new fingerprints inside the interval", at(time.Minute), at(3 * time.Hour), daily, false},
{"new fingerprints past the interval", at(time.Minute), at(25 * time.Hour), daily, true},
{"settings saved since the last sweep", at(3 * time.Hour), at(2 * time.Hour), savedAgo(time.Hour), true},
{"settings saved before the last sweep", at(3 * time.Hour), at(2 * time.Hour), savedAgo(4 * time.Hour), false},
} {
if got := sweepIsDue(tc.latestPrint, tc.lastSweep, tc.cfg, now); got != tc.want {
t.Errorf("%s: sweepIsDue = %v, want %v", tc.name, got, tc.want)
}
}
}