Compare commits

..
Author SHA1 Message Date
logan dfb338fc37 docs(skill): the WAV fixtures scan tagged, and have since #104
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m45s
CI / e2e (pull_request) Successful in 10m12s
`fixtures.md` told an agent the WAV fixtures scan in untitled, that
there is no "Field Recordings" artist in the Artists view, and that
this is a known open bug "pinned by TestWAVTagsAreNotReadableYet" — a
test #104 deleted, because it existed to assert the reader did not work
and failed the moment it did.

That last clause is why this is worth a diff rather than being left to
rot: the paragraph is an instruction, and it instructs the next reader
that a spec asserting the *working* behaviour is the mistake. It is the
same #104 staleness #217 removed from `queue-selection.spec.ts`, one
file over, still telling agents to put it back.

Measured against a running app rather than corrected from the issue
text — and the seed had to be rebuilt first, since the one on disk
predated #104 and would have replayed a pre-#104 scan and confirmed the
stale paragraph. On a fresh `make sandbox-seed NAME=default`, both WAVs
carry a title, an artist credit and an album: "Field Recordings" is an
ordinary artist with 2 tracks and "Test Tones" has a cover row. The
only two tracks with no album at all are `unsorted/no-tags-at-all.mp3`
and `unsorted/title-only.mp3`.

The replacement also says that prose written before #104 disagrees,
because it does, and saying nothing is how the next reader reintroduces
the claim from a source this change deliberately does not touch.

Deliberately carries no `Closes` footer. #225 covers two halves, and
the second — the same staleness in two *dated* `.planning/NOTES.md`
entries — is left alone: whether measured history gets a correcting
clause is a judgement about what that file is for, which the issue
raises on purpose and this change must not settle by auto-closing it.
2026-08-30 03:36:54 -04:00
4 changed files with 10 additions and 328 deletions
@@ -42,11 +42,16 @@ strings and identical specs produce different bytes on different builds.
playback and then clicks pause races the track ending and fails
against a correct UI. Use `LONG_TRACK` (90 s, `edge-lengths`) exported
from `e2e/support/fixtures.ts`.
- **WAV tracks scan in untitled.** `backend/tagwriter` writes WAV tags
into a RIFF `id3 ` chunk and `dhowden/tag` has no RIFF parser, so
there is no "Field Recordings" artist in the Artists view. This is a
known open bug pinned by `TestWAVTagsAreNotReadableYet`; do not
"fix" a spec by asserting the broken behaviour elsewhere.
- **WAV tracks scan like every other format.** #104 added
`backend/riff`, so the scan reads the `id3 ` chunk `backend/tagwriter`
writes and both WAVs come in fully tagged: "Field Recordings" is an
ordinary artist in the Artists view, with a "Test Tones" album and a
cover. They are therefore not an example of an untitled or albumless
track — the only two tracks with no album are
`unsorted/no-tags-at-all.mp3` and `unsorted/title-only.mp3`. Prose
written before #104 says the opposite and names
`TestWAVTagsAreNotReadableYet`, a test that change deleted; that is
dated history rather than a description of the app.
## Seeds
-22
View File
@@ -662,28 +662,6 @@ rather than renaming them.
one of the shell's rows, which is what the skip link is absolutely
positioned to avoid.
- `config` — TOML-based settings. Settings page uses HTMX + templ for server-rendered HTML fragments.
**A setter that can reject its argument puts the old value back**, and
that is a correctness rule rather than hygiene (#231). `Save()`
validates the *whole* config, so a value left behind by a failed write
does not merely fail its own call: it fails every later save, of every
unrelated setting — theme, launch page, shortcuts, libraries — for the
rest of the session. Nothing reaches disk, so a restart clears it,
which is exactly what makes the fault invisible and unreportable. One
rejected track-list column list was enough to stop the app saving
anything at all.
Two shapes are safe and a third is the trap. A setter that assigns and
*then* validates snapshots the field first and restores it on the
error path — seven do. `SetLibraryDirectory` is the better shape where
the value can be built on its own: it validates a candidate *before*
assigning, so there is nothing to undo. And a setter whose argument no
validation inspects needs neither — the bools, the favourites playlist
id and the shortcut bindings, plus `SetViewVisible`, which refuses an
unknown, non-hideable or launch-page view up front so
`GeneralConfig.Validate` never sees one it would fail on. Which set a
new setter joins is decided by whether its own `Validate` can reject
it, not by preference.
- `playlist` / `smartplaylist` — Playlist CRUD and rule-based smart playlists.
- `mediacontrols` — OS media controls behind one `Handler`: MPRIS over
D-Bus on desktop Linux, a MediaSession on Android, a no-op stub
-39
View File
@@ -303,24 +303,6 @@ func (c *Config) GetLibraryDirectory() string {
return string(c.Library.DirectoryPath)
}
// A rejected setter puts the old value back, and that is not tidiness
// (#231). Save validates the *whole* config, so a value left behind by
// a failed write does not merely fail its own call: it fails every
// later save, of every unrelated setting, silently and for the rest of
// the session. Nothing reaches disk, so a restart clears it -- which
// is exactly what makes the fault hard to see and impossible to report.
//
// The setters below that assign and then validate therefore snapshot
// the field first and restore it on the error path. SetLibraryDirectory
// is the other safe shape and the better one where the value can be
// built on its own: it validates a candidate *before* assigning
// anything, so there is nothing to undo.
//
// Not every setter needs either. A bool, an int64 and the shortcut
// bindings pass through no validation that can reject them, and
// SetViewVisible refuses an unknown, non-hideable or launch-page view
// up front, so GeneralConfig.Validate never sees one it would fail on.
// SetLibraryDirectory validates and saves a new library directory,
// then emits the LibraryConfigChanged event so listeners (e.g. the
// Library scanner) can react.
@@ -378,14 +360,11 @@ func (c *Config) SetScanConcurrency(mode string) error {
c.Library.ApplyDefaults()
}
previous := c.Library.ScanConcurrency
c.Library.ScanConcurrency = library.ScanConcurrency(
mode,
)
if err := c.Library.Validate(); err != nil {
c.Library.ScanConcurrency = previous
return fmt.Errorf(
"invalid scan concurrency mode: %w", err,
)
@@ -476,12 +455,9 @@ func (c *Config) SetThemeAccentColor(
c.Theme.ApplyDefaults()
}
previous := c.Theme.AccentColor
c.Theme.AccentColor = color
if err := c.Theme.Validate(); err != nil {
c.Theme.AccentColor = previous
return fmt.Errorf(
"invalid theme accent color: %w", err,
)
@@ -512,12 +488,9 @@ func (c *Config) SetThemeBackgroundShade(
c.Theme.ApplyDefaults()
}
previous := c.Theme.BackgroundShade
c.Theme.BackgroundShade = theme.BackgroundShade(shade)
if err := c.Theme.Validate(); err != nil {
c.Theme.BackgroundShade = previous
return fmt.Errorf(
"invalid theme background shade: %w", err,
)
@@ -571,12 +544,9 @@ func (c *Config) SetDefaultPage(page string) error {
c.General.ApplyDefaults()
}
previous := c.General.DefaultPage
c.General.DefaultPage = View(page)
if err := c.General.Validate(); err != nil {
c.General.DefaultPage = previous
return fmt.Errorf(
"invalid default page: %w", err,
)
@@ -621,12 +591,9 @@ func (c *Config) SetQueueFallback(mode string) error {
c.General.ApplyDefaults()
}
previous := c.General.QueueFallback
c.General.QueueFallback = QueueFallback(mode)
if err := c.General.Validate(); err != nil {
c.General.QueueFallback = previous
return fmt.Errorf(
"invalid queue fallback: %w", err,
)
@@ -834,12 +801,9 @@ func (c *Config) SetTrackListColumns(
c.TrackList = &tracklist.Config{}
}
previous := c.TrackList.Columns
c.TrackList.Columns = columns
if err := c.TrackList.Validate(); err != nil {
c.TrackList.Columns = previous
return fmt.Errorf(
"invalid track-list columns: %w", err,
)
@@ -937,12 +901,9 @@ func (c *Config) SetFavoritesIconStyle(
c.Favorites.ApplyDefaults()
}
previous := c.Favorites.IconStyle
c.Favorites.IconStyle = favorites.IconStyle(style)
if err := c.Favorites.Validate(); err != nil {
c.Favorites.IconStyle = previous
return fmt.Errorf(
"invalid favorites icon style: %w", err,
)
-262
View File
@@ -1,262 +0,0 @@
package config
import (
"log/slog"
"path/filepath"
"testing"
"yellowjacket/backend/library"
"yellowjacket/backend/tracklist"
)
// newSavableConfig builds a loaded, valid config in a temp directory,
// so Save() writes rather than refusing with errSaveBeforeLoad.
//
// The library directory is real and set, because Config.Validate only
// validates the Library section when DirectoryPath is non-empty -- an
// empty one would hide a poisoned ScanConcurrency from the whole-config
// save that is the symptom under test.
func newSavableConfig(t *testing.T) *Config {
t.Helper()
c := &Config{
logger: slog.Default(),
filePath: filepath.Join(t.TempDir(), "config.toml"),
Library: &library.Config{
DirectoryPath: library.Directory(t.TempDir()),
},
}
c.applyDefaults()
if err := c.Load(); err != nil {
t.Fatalf("Load() error: %v", err)
}
if err := c.Save(); err != nil {
t.Fatalf("Save() on a fresh config error: %v", err)
}
return c
}
// TestSetterRejectionDoesNotPoisonTheConfig is the whole of #231.
//
// Every setter here assigns to the in-memory config and then validates.
// When the validation rejects the argument, the rejected value has to go
// back -- not because the caller sees it (it gets an error either way),
// but because Config.Save() validates the *whole* config. A value left
// behind by a failed setter therefore fails every later save, of every
// unrelated setting, silently and for the rest of the session.
//
// So each case asserts three things in order: the setter reports the
// error, the getter still reports the old value, and an unrelated save
// still works. The third is the one the user feels.
func TestSetterRejectionDoesNotPoisonTheConfig(t *testing.T) {
t.Parallel()
cases := []struct {
name string
// reject calls the setter with an argument its own Validate
// refuses.
reject func(*Config) error
// read reports the value the setter writes, so the rollback is
// asserted on the config rather than only on the save.
read func(*Config) string
}{
{
name: "scan concurrency",
reject: func(c *Config) error {
return c.SetScanConcurrency("telepathy")
},
read: (*Config).GetScanConcurrency,
},
{
name: "theme accent colour",
reject: func(c *Config) error {
return c.SetThemeAccentColor("not-a-hex")
},
read: (*Config).GetThemeAccentColor,
},
{
name: "theme background shade",
reject: func(c *Config) error {
return c.SetThemeBackgroundShade("chartreuse")
},
read: (*Config).GetThemeBackgroundShade,
},
{
name: "default page",
reject: func(c *Config) error {
return c.SetDefaultPage("nowhere")
},
read: (*Config).GetDefaultPage,
},
{
name: "queue fallback",
reject: func(c *Config) error {
return c.SetQueueFallback("improvise")
},
read: (*Config).GetQueueFallback,
},
{
name: "favorites icon style",
reject: func(c *Config) error {
return c.SetFavoritesIconStyle("asterisk")
},
read: (*Config).GetFavoritesIconStyle,
},
{
name: "track-list columns",
reject: func(c *Config) error {
// titleArtist is a drawing definition, not a
// configurable column (#197), so it is exactly what
// the frontend used to be able to send.
return c.SetTrackListColumns([]tracklist.Column{
{ID: "titleArtist"},
})
},
read: func(c *Config) string {
return columnIDs(c.GetTrackListColumns())
},
},
{
name: "track-list columns, duplicated",
reject: func(c *Config) error {
// The route #197 closed was one invalid id; a
// duplicate is the one still reachable from a client
// that assembles the list itself.
return c.SetTrackListColumns([]tracklist.Column{
{ID: tracklist.ColTrackName},
{ID: tracklist.ColTrackName},
})
},
read: func(c *Config) string {
return columnIDs(c.GetTrackListColumns())
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
c := newSavableConfig(t)
before := tc.read(c)
if err := tc.reject(c); err == nil {
t.Fatal("setter accepted an invalid value, want an error")
}
if after := tc.read(c); after != before {
t.Errorf(
"value after a rejected write = %q, want the previous %q",
after, before,
)
}
// The symptom: an unrelated setting can no longer be saved.
if err := c.SetPopupVolume(true); err != nil {
t.Errorf("an unrelated setter failed after a rejected write: %v", err)
}
if err := c.Save(); err != nil {
t.Errorf("Save() failed after a rejected write: %v", err)
}
})
}
}
// TestRejectedSetterLeavesNothingOnDisk pairs with the sweep above: the
// rollback must not be undone by what the file already holds, so a
// config reloaded from disk after a rejected write agrees with memory.
func TestRejectedSetterLeavesNothingOnDisk(t *testing.T) {
t.Parallel()
c := newSavableConfig(t)
if err := c.SetThemeAccentColor("#123456"); err != nil {
t.Fatalf("SetThemeAccentColor() error: %v", err)
}
if err := c.SetThemeAccentColor("not-a-hex"); err == nil {
t.Fatal("SetThemeAccentColor accepted a non-colour, want an error")
}
reloaded := &Config{logger: slog.Default(), filePath: c.filePath}
reloaded.applyDefaults()
if err := reloaded.Load(); err != nil {
t.Fatalf("Load() error: %v", err)
}
if got := reloaded.GetThemeAccentColor(); got != "#123456" {
t.Errorf("accent colour on disk = %q, want %q", got, "#123456")
}
if c.GetThemeAccentColor() != reloaded.GetThemeAccentColor() {
t.Errorf(
"in-memory accent %q disagrees with disk %q after a rejected write",
c.GetThemeAccentColor(), reloaded.GetThemeAccentColor(),
)
}
}
// TestSetLibraryDirectoryValidatesBeforeAssigning pins the precedent the
// seven rolled-back setters follow: this one has always built and
// validated a candidate before assigning, so a bad path never reaches
// the config at all.
func TestSetLibraryDirectoryValidatesBeforeAssigning(t *testing.T) {
t.Parallel()
c := newSavableConfig(t)
before := c.GetLibraryDirectory()
if err := c.SetLibraryDirectory(filepath.Join(t.TempDir(), "no-such-dir")); err == nil {
t.Fatal("SetLibraryDirectory accepted a missing directory, want an error")
}
if after := c.GetLibraryDirectory(); after != before {
t.Errorf("library directory = %q, want the previous %q", after, before)
}
if err := c.Save(); err != nil {
t.Errorf("Save() failed after a rejected library directory: %v", err)
}
}
// TestSetViewVisibleRefusesBeforeAssigning covers the other setter left
// out of the rollback pass: it guards its own argument up front, so
// GeneralConfig.Validate never sees a view it would reject.
func TestSetViewVisibleRefusesBeforeAssigning(t *testing.T) {
t.Parallel()
c := newSavableConfig(t)
if err := c.SetViewVisible("no-such-view", false); err == nil {
t.Fatal("SetViewVisible accepted an unknown view, want an error")
}
if err := c.SetViewVisible(c.GetDefaultPage(), false); err == nil {
t.Fatal("SetViewVisible hid the launch page, want an error")
}
if err := c.Save(); err != nil {
t.Errorf("Save() failed after a refused view visibility change: %v", err)
}
}
// columnIDs renders a column list for comparison in the table above.
func columnIDs(cols []tracklist.Column) string {
ids := make([]byte, 0, len(cols)*8)
for i, col := range cols {
if i > 0 {
ids = append(ids, ',')
}
ids = append(ids, col.ID...)
}
return string(ids)
}