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>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-23 21:49:22 -07:00
1 parent 1d31999793
commit 9e848cd765
13 files changed
+305 -92

No files matched your search

+39 -12
View File
@@ -4,18 +4,20 @@ import (
"fmt"
"strings"
"testing"
"github.com/cdnutter/playlink/internal/store"
)
func scanWarnings(t *testing.T, ev *env) []string {
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 []string
var out []store.Warning
for _, w := range p.LastScanWarnings {
if !strings.Contains(w, "ffprobe") {
if !strings.Contains(w.Title, "ffprobe") {
out = append(out, w)
}
}
@@ -36,7 +38,7 @@ func TestSlaveOnlyContentIsNotALayoutProblem(t *testing.T) {
}
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 %q", w)
t.Fatalf("slave-only content should not produce warnings, got %+v", w)
}
}
@@ -51,13 +53,23 @@ func TestRenamedFoldersAreReported(t *testing.T) {
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 {
t.Fatalf("want one renamed-folders warning, got %q", w)
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)
}
for _, want := range []string{`"AC-DC" (slave) vs "AC_DC" (master)`, `"Avenged Sevenfold/City Of Evil" (slave) vs "Avenged Sevenfold/City of Evil" (master)`, "4 folders", "1 more"} {
if !strings.Contains(w[0], want) {
t.Errorf("warning %q should contain %q", w[0], want)
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)
}
}
@@ -69,8 +81,11 @@ func TestDifferentLayoutIsReported(t *testing.T) {
ev.write(ev.slave, fmt.Sprintf("Band/Song Number %d.mp3", i), "s")
}
w := scanWarnings(t, ev)
if len(w) != 1 || !strings.Contains(w[0], "Of 24 songs found in both libraries, 24 are in a different folder") {
t.Fatalf("want a different-layout warning, got %q", w)
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)
}
}
@@ -83,7 +98,7 @@ func TestSinglesMatchedToAlbumsAreNotALayoutProblem(t *testing.T) {
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 %q", w)
t.Fatalf("singles matched to album versions should not warn, got %+v", w)
}
}
@@ -102,3 +117,15 @@ func TestFolderKey(t *testing.T) {
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)
}
}