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>
132 lines
4.9 KiB
Go
132 lines
4.9 KiB
Go
package engine
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/cdnutter/playlink/internal/store"
|
||
)
|
||
|
||
func scanWarnings(t *testing.T, ev *env) []store.Warning {
|
||
t.Helper()
|
||
ev.scan()
|
||
p, err := ev.st.Pair(ctx, ev.pair.ID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var out []store.Warning
|
||
for _, w := range p.LastScanWarnings {
|
||
if !strings.Contains(w.Title, "ffprobe") {
|
||
out = append(out, w)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// The user's situation: a singles library with far more artists and songs
|
||
// than the albums library. Slave-only content must not trigger warnings.
|
||
func TestSlaveOnlyContentIsNotALayoutProblem(t *testing.T) {
|
||
ev := setup(t, false)
|
||
for i := 0; i < 40; i++ {
|
||
ev.write(ev.slave, fmt.Sprintf("Singles Artist %02d/Some Single/Song.mp3", i), "s")
|
||
}
|
||
for i := 0; i < 10; i++ {
|
||
rel := fmt.Sprintf("Shared Artist/Album/%02d Track.flac", i)
|
||
ev.write(ev.master, rel, "same")
|
||
ev.write(ev.slave, rel, "same")
|
||
}
|
||
ev.write(ev.master, "Master Only/Album/01 X.flac", "m")
|
||
if w := scanWarnings(t, ev); len(w) != 0 {
|
||
t.Fatalf("slave-only content should not produce warnings, got %+v", w)
|
||
}
|
||
}
|
||
|
||
func TestRenamedFoldersAreReported(t *testing.T) {
|
||
ev := setup(t, false)
|
||
ev.write(ev.master, "AC_DC/Back in Black/01 Hells Bells.flac", "a")
|
||
ev.write(ev.slave, "AC-DC/Back in Black/01 Hells Bells.mp3", "b")
|
||
ev.write(ev.master, "The Beatles/Abbey Road/01 Come Together.flac", "a")
|
||
ev.write(ev.slave, "Beatles, The/Abbey Road/01 Come Together.mp3", "b")
|
||
ev.write(ev.master, "Avenged Sevenfold/City of Evil/03 Bat Country.flac", "a")
|
||
ev.write(ev.slave, "Avenged Sevenfold/City Of Evil/03 Bat Country.mp3", "b")
|
||
ev.write(ev.slave, "Beyoncé/Lemonade/Formation.mp3", "b")
|
||
ev.write(ev.master, "Beyonce/Lemonade/Formation.flac", "a")
|
||
w := scanWarnings(t, ev)
|
||
if len(w) != 1 || w[0].Title != "4 folders are named differently in the two libraries" || w[0].Total != 4 {
|
||
t.Fatalf("want one renamed-folders warning, got %+v", w)
|
||
}
|
||
want := map[store.WarningItem]bool{
|
||
{Slave: "AC-DC", Master: "AC_DC"}: true,
|
||
{Slave: "Beatles, The", Master: "The Beatles"}: true,
|
||
{Slave: "Beyoncé", Master: "Beyonce"}: true,
|
||
{Slave: "Avenged Sevenfold/City Of Evil", Master: "Avenged Sevenfold/City of Evil"}: true,
|
||
}
|
||
for _, it := range w[0].Items {
|
||
if !want[it] {
|
||
t.Errorf("unexpected item %+v", it)
|
||
}
|
||
delete(want, it)
|
||
}
|
||
if len(want) != 0 {
|
||
t.Errorf("missing items %v", want)
|
||
}
|
||
}
|
||
|
||
// A slave library without album folders is a genuine layout difference.
|
||
func TestDifferentLayoutIsReported(t *testing.T) {
|
||
ev := setup(t, false)
|
||
for i := 0; i < 24; i++ {
|
||
ev.write(ev.master, fmt.Sprintf("Band/Album/%02d Song Number %d.flac", i+1, i), "m")
|
||
ev.write(ev.slave, fmt.Sprintf("Band/Song Number %d.mp3", i), "s")
|
||
}
|
||
w := scanWarnings(t, ev)
|
||
if len(w) != 1 || w[0].Title != "24 of 24 songs found in both libraries are in a different folder in the master library" {
|
||
t.Fatalf("want a different-layout warning, got %+v", w)
|
||
}
|
||
if len(w[0].Items) != 1 || w[0].Items[0] != (store.WarningItem{Slave: "Band", Master: "Band/Album", Count: 24}) {
|
||
t.Fatalf("songs should be grouped by folder pair, got %+v", w[0].Items)
|
||
}
|
||
}
|
||
|
||
// Singles matched to their album version are intended, not a layout problem.
|
||
func TestSinglesMatchedToAlbumsAreNotALayoutProblem(t *testing.T) {
|
||
ev := setup(t, false)
|
||
enableApprox(t, ev)
|
||
for i := 0; i < 24; i++ {
|
||
ev.write(ev.master, fmt.Sprintf("Band/Album/%02d Song Number %d.flac", i+1, i), "m")
|
||
ev.write(ev.slave, fmt.Sprintf("Band/Song Number %d - Single/Song Number %d.mp3", i, i), "s")
|
||
}
|
||
if w := scanWarnings(t, ev); len(w) != 0 {
|
||
t.Fatalf("singles matched to album versions should not warn, got %+v", w)
|
||
}
|
||
}
|
||
|
||
func TestFolderKey(t *testing.T) {
|
||
same := [][2]string{{"AC/DC", "AC-DC"}, {"The Beatles", "Beatles, The"}, {"Simon & Garfunkel", "Simon and Garfunkel"},
|
||
{"Beyoncé", "Beyonce"}, {"City Of Evil", "City of Evil"}, {"Sigur Rós", "Sigur Ros"}}
|
||
for _, p := range same {
|
||
if folderKey(p[0]) != folderKey(p[1]) {
|
||
t.Errorf("%q and %q should share a key (%q vs %q)", p[0], p[1], folderKey(p[0]), folderKey(p[1]))
|
||
}
|
||
}
|
||
if folderKey("Theory of a Deadman") == folderKey("ory of a Deadman") {
|
||
t.Error(`"The" must only be stripped as a whole word`)
|
||
}
|
||
if folderKey("Nightmare") == folderKey("Nightmare (Deluxe)") {
|
||
t.Error("different folders must keep different keys")
|
||
}
|
||
}
|
||
|
||
// A renamed folder is reported once, as a renamed folder, not again as songs
|
||
// living in a different folder.
|
||
func TestRenamedFolderNotCountedAsMoved(t *testing.T) {
|
||
var ls layoutStats
|
||
for i := 0; i < 30; i++ {
|
||
ls.add(fmt.Sprintf("ABBA/Voulez-Vous/%02d x.flac", i), fmt.Sprintf("ABBA/Voulez‐vous/%02d x.flac", i), store.KindElsewhere, "")
|
||
}
|
||
if ls.shared != 30 || ls.moved != 0 || ls.warning() != nil {
|
||
t.Fatalf("renamed folder counted as moved: shared=%d moved=%d", ls.shared, ls.moved)
|
||
}
|
||
}
|