Files
playlink/internal/engine/layout_test.go
T
cdnutterandClaude Opus 5.5 9e848cd765 Show scan warnings as structured cards instead of long sentences
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>
2026-09-23 21:49:22 -07:00

132 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}