feat(harness): agent-drivable dev harness and CI that gates
A coding agent could develop this repo's Go packages and could not develop the application: every path to running YellowJacket ended in a blocking GTK window, so 265 bound methods, 46 events, 33 component directories and 13 stores had exactly one form of verification available — `tsc --noEmit`. The unlock is that `wails dev`'s dev server on :34115 serves the real frontend with the real generated bindings against the same Go backend a desktop window attaches to, so a plain Chromium under Xvfb gets a fully functional app. Four test tiers now exist, cheapest first: - `make ui-test` — 313 Vitest tests in a real browser in ~2 s, no app, no backend, no display. Works because `frontend/wailsjs/` is a pure passthrough to `window.go`/`window.runtime`, so faking just those two globals runs the real bindings and the real store code. - `make test` — services in-process, asserting on the payload the frontend would receive, via a new `events.Emit` wrapper. - `make dev-headless` + `playwright-cli` — the real app, driven interactively, with an event bridge on `window.__yjEvents` and a dev-only control surface at `/__test/`. - `make e2e` — 19 of those flows frozen as Playwright specs. `events.Emit(ctx, …)` replaces all 35 direct `runtime.EventsEmit` call sites: wails' `getEvents` `log.Fatalf`s on any context without its runtime, so those paths could not run under test and a background worker could take the app down. Four packages had each hand-rolled the same guard; nine more guarded on `ctx != nil`, which does not help. `TestNoDirectRuntimeEmits` fails the build on a new one. Fixtures are generated, not committed (`make testdata`), and seeds are built by *running the app* — never by hand-writing config and DB rows, which would be a second description of a valid YJ_HOME. `.gitea/workflows/ci.yml` is the first workflow here that tests anything; the other three only package, so `gitea_ci` reported only packaging jobs and misled anyone asking whether a push was healthy. Both jobs were prototyped to green in a bare ubuntu:24.04 container before the YAML was written, which immediately caught `make lint` linting three configurations that nothing builds: all three passes omitted `webkit2_41`, so wails resolved webkit2gtk-4.0 — which Arch still ships and Ubuntu 24.04 dropped. Operational instructions live in `.pi/skills/yellowjacket-dev/`, measured discoveries in `.planning/NOTES.md`, and architecture in `CLAUDE.md` — split by tense, not by topic, because a topical split gives every new fact two plausible homes. `make skill-check` fails a commit if the skill cites a make target that does not exist.
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"yellowjacket/backend/tagwriter"
|
||||
)
|
||||
|
||||
// Synthesis parameters. Mono 22.05 kHz keeps the whole fixture
|
||||
// library in the single-digit megabytes while staying a format every
|
||||
// decoder in the app handles.
|
||||
const (
|
||||
sampleRate = 22050
|
||||
amplitude = 0.3
|
||||
fadeSeconds = 0.02
|
||||
jpegQuality = 80
|
||||
coverSizePx = 64
|
||||
ffmpegTimeout = 2 * time.Minute
|
||||
)
|
||||
|
||||
var errFFmpegMissing = errors.New(
|
||||
"ffmpeg not found in PATH; install it to generate fixtures",
|
||||
)
|
||||
|
||||
// synthesizeWAV writes a mono 16-bit WAV holding a sine wave at freqHz
|
||||
// for the given duration, with a short fade at each end so lossy
|
||||
// encoders do not introduce a click that shifts the reported length.
|
||||
//
|
||||
// The waveform is a pure function of (duration, freqHz), which is what
|
||||
// makes a fixture reproducible: the same spec always yields the same
|
||||
// PCM, and a decoded sample identifies which track is playing.
|
||||
func synthesizeWAV(path string, dur time.Duration, freqHz float64) error {
|
||||
total := int(float64(sampleRate) * dur.Seconds())
|
||||
fade := int(sampleRate * fadeSeconds)
|
||||
|
||||
pcm := make([]byte, total*2)
|
||||
|
||||
for i := range total {
|
||||
t := float64(i) / sampleRate
|
||||
v := math.Sin(2*math.Pi*freqHz*t) * amplitude
|
||||
|
||||
switch {
|
||||
case i < fade:
|
||||
v *= float64(i) / float64(fade)
|
||||
case i >= total-fade:
|
||||
v *= float64(total-i) / float64(fade)
|
||||
}
|
||||
|
||||
binary.LittleEndian.PutUint16(
|
||||
pcm[i*2:], uint16(int16(v*math.MaxInt16)),
|
||||
)
|
||||
}
|
||||
|
||||
return writeWAVContainer(path, pcm)
|
||||
}
|
||||
|
||||
// writeWAVContainer wraps raw PCM in a canonical 44-byte RIFF header.
|
||||
func writeWAVContainer(path string, pcm []byte) error {
|
||||
const (
|
||||
headerSize = 44
|
||||
fmtChunkSize = 16
|
||||
pcmFormat = 1
|
||||
channels = 1
|
||||
bitsPerSample = 16
|
||||
)
|
||||
|
||||
byteRate := sampleRate * channels * bitsPerSample / 8
|
||||
blockAlign := channels * bitsPerSample / 8
|
||||
|
||||
buf := make([]byte, 0, headerSize+len(pcm))
|
||||
buf = append(buf, "RIFF"...)
|
||||
buf = binary.LittleEndian.AppendUint32(buf, uint32(36+len(pcm)))
|
||||
buf = append(buf, "WAVEfmt "...)
|
||||
buf = binary.LittleEndian.AppendUint32(buf, fmtChunkSize)
|
||||
buf = binary.LittleEndian.AppendUint16(buf, pcmFormat)
|
||||
buf = binary.LittleEndian.AppendUint16(buf, channels)
|
||||
buf = binary.LittleEndian.AppendUint32(buf, sampleRate)
|
||||
buf = binary.LittleEndian.AppendUint32(buf, uint32(byteRate))
|
||||
buf = binary.LittleEndian.AppendUint16(buf, uint16(blockAlign))
|
||||
buf = binary.LittleEndian.AppendUint16(buf, bitsPerSample)
|
||||
buf = append(buf, "data"...)
|
||||
buf = binary.LittleEndian.AppendUint32(buf, uint32(len(pcm)))
|
||||
buf = append(buf, pcm...)
|
||||
|
||||
if err := os.WriteFile(path, buf, filePerm); err != nil {
|
||||
return fmt.Errorf("write wav %s: %w", path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// encodeArgs returns the ffmpeg codec arguments for a target format.
|
||||
//
|
||||
// Metadata is stripped (-map_metadata -1): every tag this library
|
||||
// carries is written afterwards by backend/tagwriter, so the fixtures
|
||||
// and the app's reader cannot drift apart.
|
||||
func encodeArgs(format tagwriter.AudioFormat) ([]string, error) {
|
||||
switch format {
|
||||
case tagwriter.FormatMP3:
|
||||
return []string{"-c:a", "libmp3lame", "-q:a", "5"}, nil
|
||||
case tagwriter.FormatFLAC:
|
||||
return []string{"-c:a", "flac", "-compression_level", "5"}, nil
|
||||
case tagwriter.FormatOGG:
|
||||
return []string{"-c:a", "libvorbis", "-q:a", "2"}, nil
|
||||
case tagwriter.FormatWAV:
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %s", errUnknownFormat, format)
|
||||
}
|
||||
}
|
||||
|
||||
// transcode converts the synthesized WAV at src into dst's format.
|
||||
func transcode(src, dst string, format tagwriter.AudioFormat) error {
|
||||
args, err := encodeArgs(format)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
full := append([]string{
|
||||
"-nostdin", "-hide_banner", "-loglevel", "error", "-y",
|
||||
"-i", src, "-map_metadata", "-1",
|
||||
}, args...)
|
||||
full = append(full, dst)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), ffmpegTimeout)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(ctx, "ffmpeg", full...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ffmpeg %s: %w: %s", dst, err, out)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireFFmpeg fails early with an actionable message rather than
|
||||
// letting the first transcode blow up halfway through generation.
|
||||
func requireFFmpeg() error {
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
return errFFmpegMissing
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// coverJPEG renders a small, deterministic cover image for a key.
|
||||
//
|
||||
// Identical keys produce byte-identical JPEGs, which is exactly what
|
||||
// the library's cover-art deduplication is supposed to collapse into a
|
||||
// single stored blob.
|
||||
func coverJPEG(key string) ([]byte, error) {
|
||||
img := image.NewRGBA(image.Rect(0, 0, coverSizePx, coverSizePx))
|
||||
|
||||
// A per-key hue derived from the key's bytes, plus a diagonal
|
||||
// band, so covers are distinguishable by eye in a screenshot.
|
||||
var seed uint32
|
||||
for _, b := range []byte(key) {
|
||||
seed = seed*31 + uint32(b)
|
||||
}
|
||||
|
||||
base := color.RGBA{
|
||||
R: uint8(seed >> 16),
|
||||
G: uint8(seed >> 8),
|
||||
B: uint8(seed),
|
||||
A: 255,
|
||||
}
|
||||
|
||||
for y := range coverSizePx {
|
||||
for x := range coverSizePx {
|
||||
c := base
|
||||
if (x+y)%16 < 8 {
|
||||
c.R /= 2
|
||||
c.G /= 2
|
||||
c.B /= 2
|
||||
}
|
||||
|
||||
img.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpegQuality}); err != nil {
|
||||
return nil, fmt.Errorf("encode cover %q: %w", key, err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// stampMTime pins a fixture's modification time. The library scanner
|
||||
// keys incremental rescans off audio_files.modified_at, so a fixed
|
||||
// mtime makes "has this changed since the last scan" reproducible.
|
||||
func stampMTime(path string) error {
|
||||
if err := os.Chtimes(path, fixedMTime, fixedMTime); err != nil {
|
||||
return fmt.Errorf("chtimes %s: %w", path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureDir creates a fixture's parent directory.
|
||||
func ensureDir(path string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), dirPerm); err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// Command gentestdata generates the deterministic fixture library used
|
||||
// by tests and by seeded development sandboxes.
|
||||
//
|
||||
// The fixtures are audio the app can actually decode, tagged by
|
||||
// backend/tagwriter — the same writers the application uses — so the
|
||||
// fixtures and the reader under test cannot drift apart. Everything is
|
||||
// derived from the spec in spec.go, so two machines running
|
||||
// `make testdata` get libraries that agree on every logical property
|
||||
// (paths, durations, tags, cover identity). Encoded bytes may differ
|
||||
// between ffmpeg builds; the manifest hash covers the spec, not bytes.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/gentestdata # generate if out of date
|
||||
// go run ./cmd/gentestdata -force # regenerate unconditionally
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"yellowjacket/backend/tagwriter"
|
||||
)
|
||||
|
||||
// Fixed file modes and mtime. The scanner keys incremental rescan off
|
||||
// modified_at, so pinning mtime makes "changed since last scan"
|
||||
// reproducible rather than a function of when generation ran.
|
||||
const (
|
||||
filePerm = 0o644
|
||||
dirPerm = 0o755
|
||||
)
|
||||
|
||||
//nolint:gochecknoglobals // a package-level constant time value.
|
||||
var fixedMTime = time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
outDir string
|
||||
brokenDir string
|
||||
manifestOut string
|
||||
force bool
|
||||
)
|
||||
|
||||
flag.StringVar(
|
||||
&outDir, "out", "test_data/music_library_test",
|
||||
"library root to generate",
|
||||
)
|
||||
flag.StringVar(
|
||||
&brokenDir, "broken", "test_data/music_library_broken",
|
||||
"root for deliberately malformed files",
|
||||
)
|
||||
flag.StringVar(
|
||||
&manifestOut, "manifest", "test_data/music_library_test.manifest.json",
|
||||
"manifest path (kept outside the library root)",
|
||||
)
|
||||
flag.BoolVar(
|
||||
&force, "force", false,
|
||||
"regenerate even when the manifest is already up to date",
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
if err := run(outDir, brokenDir, manifestOut, force); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gentestdata:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(outDir, brokenDir, manifestOut string, force bool) error {
|
||||
want, err := buildManifest(outDir, brokenDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !force && upToDate(manifestOut, want, outDir, brokenDir) {
|
||||
fmt.Printf(
|
||||
"up to date (%d tracks, hash %s)\n",
|
||||
len(want.Tracks), want.Hash[:12],
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := requireFFmpeg(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, dir := range []string{outDir, brokenDir} {
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
return fmt.Errorf("clean %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: slog.LevelError,
|
||||
}))
|
||||
|
||||
for _, f := range libraryFixtures {
|
||||
if err := generateFixture(logger, outDir, f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeAuxFiles(outDir, outDir, libraryExtras); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := writeAuxFiles(outDir, brokenDir, brokenFiles); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := writeManifest(manifestOut, want); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"generated %d tracks + %d broken files in %s (hash %s)\n",
|
||||
len(want.Tracks), len(want.Broken), outDir, want.Hash[:12],
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// upToDate reports whether the recorded manifest matches the spec and
|
||||
// both roots still exist. Cheap enough to run on every make invocation.
|
||||
func upToDate(manifestOut string, want *manifest, roots ...string) bool {
|
||||
if readManifestHash(manifestOut) != want.Hash {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, root := range roots {
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// generateFixture synthesizes, encodes and tags a single fixture.
|
||||
//
|
||||
// Tags are written after encoding, by backend/tagwriter, rather than
|
||||
// handed to ffmpeg: the fixtures must be tagged by the code the app
|
||||
// reads back with, or a tag bug becomes invisible to every test.
|
||||
func generateFixture(
|
||||
logger *slog.Logger,
|
||||
root string,
|
||||
f fixture,
|
||||
) error {
|
||||
dst := filepath.Join(root, filepath.FromSlash(f.Rel))
|
||||
|
||||
if err := ensureDir(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
wav := dst
|
||||
if f.Format != tagwriter.FormatWAV {
|
||||
wav = dst + ".src.wav"
|
||||
}
|
||||
|
||||
if err := synthesizeWAV(wav, f.Duration, f.FreqHz); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if f.Format != tagwriter.FormatWAV {
|
||||
if err := transcode(wav, dst, f.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.Remove(wav); err != nil {
|
||||
return fmt.Errorf("remove scratch wav: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
changes := f.Tags.changes()
|
||||
|
||||
if f.Cover != "" {
|
||||
img, err := coverJPEG(f.Cover)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
changes[tagwriter.FieldCoverArt] = img
|
||||
}
|
||||
|
||||
if len(changes) > 0 {
|
||||
if err := tagwriter.WriteFileTags(logger, dst, changes); err != nil {
|
||||
return fmt.Errorf("tag %s: %w", f.Rel, err)
|
||||
}
|
||||
}
|
||||
|
||||
return stampMTime(dst)
|
||||
}
|
||||
|
||||
// writeAuxFiles writes non-audio and malformed files into dstRoot.
|
||||
//
|
||||
// Truncated fixtures are cut from an already-encoded file under
|
||||
// libraryRoot, so this must run after the audio has been generated.
|
||||
// The malformed set lands outside the library root on purpose: the
|
||||
// clean library's track count has to stay deterministic, so a test
|
||||
// that wants the scanner's error paths registers the broken root as a
|
||||
// second library deliberately.
|
||||
func writeAuxFiles(libraryRoot, dstRoot string, files []auxFile) error {
|
||||
for _, b := range files {
|
||||
dst := filepath.Join(dstRoot, filepath.FromSlash(b.Rel))
|
||||
|
||||
if err := ensureDir(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var content []byte
|
||||
|
||||
switch {
|
||||
case b.Source != "":
|
||||
src := filepath.Join(libraryRoot, filepath.FromSlash(b.Source))
|
||||
|
||||
raw, err := os.ReadFile(src) //nolint:gosec // generated path.
|
||||
if err != nil {
|
||||
return fmt.Errorf("read source %s: %w", src, err)
|
||||
}
|
||||
|
||||
content = raw[:min(b.Bytes, len(raw))]
|
||||
case strings.HasSuffix(b.Rel, ".jpg"):
|
||||
img, err := coverJPEG(b.Rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
content = img
|
||||
default:
|
||||
content = []byte(b.Literal)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(dst, content, filePerm); err != nil {
|
||||
return fmt.Errorf("write %s: %w", dst, err)
|
||||
}
|
||||
|
||||
if err := stampMTime(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// manifestVersion is bumped when the manifest's shape changes in a way
|
||||
// that older readers cannot handle.
|
||||
const manifestVersion = 1
|
||||
|
||||
// manifestTrack records what a fixture is supposed to be, so a test can
|
||||
// assert against the spec rather than against whatever happens to be on
|
||||
// disk.
|
||||
type manifestTrack struct {
|
||||
Path string `json:"path"`
|
||||
Case string `json:"case"`
|
||||
Format string `json:"format"`
|
||||
DurationMS int64 `json:"durationMs"`
|
||||
FreqHz float64 `json:"freqHz"`
|
||||
Cover string `json:"cover,omitempty"`
|
||||
CoverSHA string `json:"coverSha,omitempty"`
|
||||
Tags map[string]any `json:"tags"`
|
||||
}
|
||||
|
||||
// manifest describes a generated fixture library.
|
||||
//
|
||||
// Hash covers the *logical* spec — paths, formats, durations, tags,
|
||||
// cover identity — and deliberately not the encoded bytes: ffmpeg
|
||||
// stamps its own encoder strings, so byte hashes differ between ffmpeg
|
||||
// builds while the fixtures they describe are identical.
|
||||
type manifest struct {
|
||||
Version int `json:"version"`
|
||||
Generator string `json:"generator"`
|
||||
Hash string `json:"hash"`
|
||||
LibraryRoot string `json:"libraryRoot"`
|
||||
BrokenRoot string `json:"brokenRoot"`
|
||||
Cases map[string][]string `json:"cases"`
|
||||
Tracks []manifestTrack `json:"tracks"`
|
||||
Extras []string `json:"extras"`
|
||||
Broken []string `json:"broken"`
|
||||
}
|
||||
|
||||
// buildManifest derives the manifest from the spec alone. It runs
|
||||
// before any file is written, which is what lets generation be skipped
|
||||
// when the on-disk manifest already matches.
|
||||
func buildManifest(libraryRoot, brokenRoot string) (*manifest, error) {
|
||||
m := &manifest{
|
||||
Version: manifestVersion,
|
||||
Generator: "gentestdata",
|
||||
LibraryRoot: filepath.ToSlash(libraryRoot),
|
||||
BrokenRoot: filepath.ToSlash(brokenRoot),
|
||||
Cases: map[string][]string{},
|
||||
Tracks: make([]manifestTrack, 0, len(libraryFixtures)),
|
||||
}
|
||||
|
||||
for _, f := range libraryFixtures {
|
||||
track := manifestTrack{
|
||||
Path: f.Rel,
|
||||
Case: f.Case,
|
||||
Format: string(f.Format),
|
||||
DurationMS: f.Duration.Milliseconds(),
|
||||
FreqHz: f.FreqHz,
|
||||
Cover: f.Cover,
|
||||
Tags: f.Tags.changes(),
|
||||
}
|
||||
|
||||
if f.Cover != "" {
|
||||
img, err := coverJPEG(f.Cover)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(img)
|
||||
track.CoverSHA = hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
m.Tracks = append(m.Tracks, track)
|
||||
m.Cases[f.Case] = append(m.Cases[f.Case], f.Rel)
|
||||
}
|
||||
|
||||
for _, e := range libraryExtras {
|
||||
m.Extras = append(m.Extras, e.Rel)
|
||||
}
|
||||
|
||||
for _, b := range brokenFiles {
|
||||
m.Broken = append(m.Broken, b.Rel)
|
||||
m.Cases[caseBroken] = append(m.Cases[caseBroken], b.Rel)
|
||||
}
|
||||
|
||||
hash, err := hashManifest(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.Hash = hash
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// hashManifest hashes everything except the hash field itself.
|
||||
func hashManifest(m *manifest) (string, error) {
|
||||
clone := *m
|
||||
clone.Hash = ""
|
||||
|
||||
raw, err := json.Marshal(clone)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal manifest for hashing: %w", err)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(raw)
|
||||
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
// writeManifest persists the manifest next to (not inside) the library
|
||||
// root, so the scanner never sees it as a stray file.
|
||||
func writeManifest(path string, m *manifest) error {
|
||||
raw, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal manifest: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), dirPerm); err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, append(raw, '\n'), filePerm); err != nil {
|
||||
return fmt.Errorf("write manifest %s: %w", path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// readManifestHash returns the hash recorded in an existing manifest,
|
||||
// or "" when there is no readable manifest at path.
|
||||
func readManifestHash(path string) string {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var m manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return m.Hash
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"yellowjacket/backend/tagwriter"
|
||||
)
|
||||
|
||||
// Case names group fixtures by the application behaviour they exist to
|
||||
// exercise. Tests select fixtures by case rather than by path, so a
|
||||
// path can be renamed without breaking them.
|
||||
const (
|
||||
caseCoverDedup = "cover-dedup"
|
||||
caseMultiDisc = "multi-disc"
|
||||
caseVariousArtist = "various-artists"
|
||||
caseFLACAlbum = "flac-album"
|
||||
caseOGGAlbum = "ogg-album"
|
||||
caseWAVTracks = "wav-tracks"
|
||||
casePartialTags = "partial-tags"
|
||||
caseUnicode = "unicode"
|
||||
caseDuplicates = "duplicates"
|
||||
caseEdgeLengths = "edge-lengths"
|
||||
caseBroken = "broken"
|
||||
)
|
||||
|
||||
var errUnknownFormat = errors.New("gentestdata: unknown audio format")
|
||||
|
||||
// tags mirrors the subset of tagwriter fields a fixture can set. A
|
||||
// struct rather than a bare map so the spec table stays readable and
|
||||
// the manifest can record exactly what was written.
|
||||
type tags struct {
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
AlbumArtist string
|
||||
Genre string
|
||||
Composer string
|
||||
Year int
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
}
|
||||
|
||||
// changes converts a fixture's tags into a tagwriter diff map,
|
||||
// omitting zero values so "no tag at all" is expressible.
|
||||
func (t tags) changes() tagwriter.TagChanges {
|
||||
c := tagwriter.TagChanges{}
|
||||
|
||||
set := func(field, value string) {
|
||||
if value != "" {
|
||||
c[field] = value
|
||||
}
|
||||
}
|
||||
|
||||
set(tagwriter.FieldTitle, t.Title)
|
||||
set(tagwriter.FieldArtist, t.Artist)
|
||||
set(tagwriter.FieldAlbum, t.Album)
|
||||
set(tagwriter.FieldAlbumArtist, t.AlbumArtist)
|
||||
set(tagwriter.FieldGenre, t.Genre)
|
||||
set(tagwriter.FieldComposer, t.Composer)
|
||||
|
||||
if t.Year != 0 {
|
||||
c[tagwriter.FieldYear] = t.Year
|
||||
}
|
||||
|
||||
if t.TrackNumber != 0 {
|
||||
c[tagwriter.FieldTrackNumber] = t.TrackNumber
|
||||
}
|
||||
|
||||
if t.DiscNumber != 0 {
|
||||
c[tagwriter.FieldDiscNumber] = t.DiscNumber
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// fixture is one generated audio file.
|
||||
type fixture struct {
|
||||
// Rel is the path relative to the library root, with '/'
|
||||
// separators regardless of platform.
|
||||
Rel string
|
||||
// Case is the behaviour group this fixture belongs to.
|
||||
Case string
|
||||
// Format decides both the container and the tag writer used.
|
||||
Format tagwriter.AudioFormat
|
||||
// Duration is the nominal length of the synthesized tone.
|
||||
Duration time.Duration
|
||||
// FreqHz identifies the track audibly and in a decoded sample.
|
||||
FreqHz float64
|
||||
// Cover is a cover-art key; fixtures sharing a key get
|
||||
// byte-identical images, which is what dedup must collapse.
|
||||
Cover string
|
||||
// Tags is what gets written after encoding.
|
||||
Tags tags
|
||||
}
|
||||
|
||||
// Durations kept short deliberately; one long track exists so the
|
||||
// progress bar and seeking have something to work against.
|
||||
const (
|
||||
durShort = 2 * time.Second
|
||||
durNormal = 4 * time.Second
|
||||
durMedium = 6 * time.Second
|
||||
durLong = 90 * time.Second
|
||||
)
|
||||
|
||||
// longTitle is long enough to force truncation in every list view.
|
||||
const longTitle = "An Exhaustively Overlong Track Title That Exists " +
|
||||
"Solely To Find Out Whether The Track List Truncates Or Overflows"
|
||||
|
||||
const longArtist = "The Orchestra Of Very Considerable And " +
|
||||
"Deliberately Unreasonable Length"
|
||||
|
||||
// duplicateTags is shared by the deliberate duplicate pair so the
|
||||
// duplicate-tracks dialog has an unambiguous match to find.
|
||||
var duplicateTags = tags{
|
||||
Title: "Tideline",
|
||||
Artist: "Aurora Fields",
|
||||
Album: "Glass Harbour",
|
||||
AlbumArtist: "Aurora Fields",
|
||||
Genre: "Dream Pop",
|
||||
Year: 2019,
|
||||
TrackNumber: 2,
|
||||
}
|
||||
|
||||
// libraryFixtures is the full contents of the clean fixture library.
|
||||
//
|
||||
// Everything here is scannable audio: a seeded sandbox built from this
|
||||
// root must produce a stable track count, so deliberately broken files
|
||||
// live in a separate root (see brokenFiles).
|
||||
//
|
||||
//nolint:gochecknoglobals // the fixture spec is the point of this cmd.
|
||||
var libraryFixtures = []fixture{
|
||||
// 1. A plain album whose four tracks carry the same embedded
|
||||
// cover: the dedup path should store one blob, not four.
|
||||
{
|
||||
Rel: "Aurora Fields/Glass Harbour/01 Salt Air.mp3",
|
||||
Case: caseCoverDedup, Format: tagwriter.FormatMP3,
|
||||
Duration: durNormal, FreqHz: 220, Cover: "glass-harbour",
|
||||
Tags: tags{
|
||||
Title: "Salt Air", Artist: "Aurora Fields",
|
||||
Album: "Glass Harbour", AlbumArtist: "Aurora Fields",
|
||||
Genre: "Dream Pop", Year: 2019, TrackNumber: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Rel: "Aurora Fields/Glass Harbour/02 Tideline.mp3",
|
||||
Case: caseCoverDedup, Format: tagwriter.FormatMP3,
|
||||
Duration: durMedium, FreqHz: 247, Cover: "glass-harbour",
|
||||
Tags: duplicateTags,
|
||||
},
|
||||
{
|
||||
Rel: "Aurora Fields/Glass Harbour/03 Harbour Lights.mp3",
|
||||
Case: caseCoverDedup, Format: tagwriter.FormatMP3,
|
||||
Duration: durNormal, FreqHz: 262, Cover: "glass-harbour",
|
||||
Tags: tags{
|
||||
Title: "Harbour Lights", Artist: "Aurora Fields",
|
||||
Album: "Glass Harbour", AlbumArtist: "Aurora Fields",
|
||||
Genre: "Dream Pop", Year: 2019, TrackNumber: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
Rel: "Aurora Fields/Glass Harbour/04 Low Water.mp3",
|
||||
Case: caseCoverDedup, Format: tagwriter.FormatMP3,
|
||||
Duration: durShort, FreqHz: 294, Cover: "glass-harbour",
|
||||
Tags: tags{
|
||||
Title: "Low Water", Artist: "Aurora Fields",
|
||||
Album: "Glass Harbour", AlbumArtist: "Aurora Fields",
|
||||
Genre: "Dream Pop", Year: 2019, TrackNumber: 4,
|
||||
},
|
||||
},
|
||||
|
||||
// 2. Multi-disc, with the disc split reflected both in the
|
||||
// directory layout and in the disc number tag. The
|
||||
// semicolon-separated genre also covers metadata.ParseGenres.
|
||||
{
|
||||
Rel: "Aurora Fields/Long Way Round/Disc 1/01 Departure.mp3",
|
||||
Case: caseMultiDisc, Format: tagwriter.FormatMP3,
|
||||
Duration: durNormal, FreqHz: 330, Cover: "long-way-round",
|
||||
Tags: tags{
|
||||
Title: "Departure", Artist: "Aurora Fields",
|
||||
Album: "Long Way Round", AlbumArtist: "Aurora Fields",
|
||||
Genre: "Dream Pop; Ambient", Year: 2021,
|
||||
TrackNumber: 1, DiscNumber: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Rel: "Aurora Fields/Long Way Round/Disc 1/02 Waystation.mp3",
|
||||
Case: caseMultiDisc, Format: tagwriter.FormatMP3,
|
||||
Duration: durShort, FreqHz: 349, Cover: "long-way-round",
|
||||
Tags: tags{
|
||||
Title: "Waystation", Artist: "Aurora Fields",
|
||||
Album: "Long Way Round", AlbumArtist: "Aurora Fields",
|
||||
Genre: "Dream Pop; Ambient", Year: 2021,
|
||||
TrackNumber: 2, DiscNumber: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Rel: "Aurora Fields/Long Way Round/Disc 2/01 Return.mp3",
|
||||
Case: caseMultiDisc, Format: tagwriter.FormatMP3,
|
||||
Duration: durNormal, FreqHz: 392, Cover: "long-way-round",
|
||||
Tags: tags{
|
||||
Title: "Return", Artist: "Aurora Fields",
|
||||
Album: "Long Way Round", AlbumArtist: "Aurora Fields",
|
||||
Genre: "Ambient", Year: 2021,
|
||||
TrackNumber: 1, DiscNumber: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
Rel: "Aurora Fields/Long Way Round/Disc 2/02 Homing.mp3",
|
||||
Case: caseMultiDisc, Format: tagwriter.FormatMP3,
|
||||
Duration: durShort, FreqHz: 440, Cover: "long-way-round",
|
||||
Tags: tags{
|
||||
Title: "Homing", Artist: "Aurora Fields",
|
||||
Album: "Long Way Round", AlbumArtist: "Aurora Fields",
|
||||
Genre: "Ambient", Year: 2021,
|
||||
TrackNumber: 2, DiscNumber: 2,
|
||||
},
|
||||
},
|
||||
|
||||
// 3. Compilation: per-track artists under a Various Artists
|
||||
// album artist, which groups differently from everything else.
|
||||
{
|
||||
Rel: "Various Artists/Night Shift Vol. 1/01 Blue Hour.mp3",
|
||||
Case: caseVariousArtist, Format: tagwriter.FormatMP3,
|
||||
Duration: durShort, FreqHz: 466, Cover: "night-shift",
|
||||
Tags: tags{
|
||||
Title: "Blue Hour", Artist: "Kilowatt",
|
||||
Album: "Night Shift Vol. 1", AlbumArtist: "Various Artists",
|
||||
Genre: "Electronic", Year: 2003, TrackNumber: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Rel: "Various Artists/Night Shift Vol. 1/02 Concrete Sun.mp3",
|
||||
Case: caseVariousArtist, Format: tagwriter.FormatMP3,
|
||||
Duration: durNormal, FreqHz: 494, Cover: "night-shift",
|
||||
Tags: tags{
|
||||
Title: "Concrete Sun", Artist: "Marisol Vega",
|
||||
Album: "Night Shift Vol. 1", AlbumArtist: "Various Artists",
|
||||
Genre: "Electronic", Year: 2003, TrackNumber: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
Rel: "Various Artists/Night Shift Vol. 1/03 Dry Season.mp3",
|
||||
Case: caseVariousArtist, Format: tagwriter.FormatMP3,
|
||||
Duration: durShort, FreqHz: 523, Cover: "night-shift",
|
||||
Tags: tags{
|
||||
Title: "Dry Season", Artist: "The Hollow Coast",
|
||||
Album: "Night Shift Vol. 1", AlbumArtist: "Various Artists",
|
||||
Genre: "Jazz", Year: 2003, TrackNumber: 3,
|
||||
},
|
||||
},
|
||||
|
||||
// 4. FLAC, whose cover art rides in a METADATA_BLOCK_PICTURE and
|
||||
// whose reader/writer share no code with the ID3 path.
|
||||
{
|
||||
Rel: "Pale Circuit/Static Bloom/01 Static Bloom.flac",
|
||||
Case: caseFLACAlbum, Format: tagwriter.FormatFLAC,
|
||||
Duration: durShort, FreqHz: 262, Cover: "static-bloom",
|
||||
Tags: tags{
|
||||
Title: "Static Bloom", Artist: "Pale Circuit",
|
||||
Album: "Static Bloom", AlbumArtist: "Pale Circuit",
|
||||
Genre: "Electronic", Year: 1998, TrackNumber: 1,
|
||||
Composer: "P. Circuit",
|
||||
},
|
||||
},
|
||||
{
|
||||
Rel: "Pale Circuit/Static Bloom/02 Cold Cathode.flac",
|
||||
Case: caseFLACAlbum, Format: tagwriter.FormatFLAC,
|
||||
Duration: durNormal, FreqHz: 277, Cover: "static-bloom",
|
||||
Tags: tags{
|
||||
Title: "Cold Cathode", Artist: "Pale Circuit",
|
||||
Album: "Static Bloom", AlbumArtist: "Pale Circuit",
|
||||
Genre: "Electronic", Year: 1998, TrackNumber: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
Rel: "Pale Circuit/Static Bloom/03 Dust Loop.flac",
|
||||
Case: caseFLACAlbum, Format: tagwriter.FormatFLAC,
|
||||
Duration: durShort, FreqHz: 311, Cover: "static-bloom",
|
||||
Tags: tags{
|
||||
Title: "Dust Loop", Artist: "Pale Circuit",
|
||||
Album: "Static Bloom", AlbumArtist: "Pale Circuit",
|
||||
Genre: "Electronic", Year: 1998, TrackNumber: 3,
|
||||
},
|
||||
},
|
||||
|
||||
// 5. Ogg Vorbis, whose writer rebuilds the page structure by hand
|
||||
// and is the most fragile of the four.
|
||||
{
|
||||
Rel: "Pale Circuit/Ribbon Road/01 Ribbon Road.ogg",
|
||||
Case: caseOGGAlbum, Format: tagwriter.FormatOGG,
|
||||
Duration: durShort, FreqHz: 349, Cover: "ribbon-road",
|
||||
Tags: tags{
|
||||
Title: "Ribbon Road", Artist: "Pale Circuit",
|
||||
Album: "Ribbon Road", AlbumArtist: "Pale Circuit",
|
||||
Genre: "Ambient", Year: 2015, TrackNumber: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Rel: "Pale Circuit/Ribbon Road/02 Verge.ogg",
|
||||
Case: caseOGGAlbum, Format: tagwriter.FormatOGG,
|
||||
Duration: durNormal, FreqHz: 370, Cover: "ribbon-road",
|
||||
Tags: tags{
|
||||
Title: "Verge", Artist: "Pale Circuit",
|
||||
Album: "Ribbon Road", AlbumArtist: "Pale Circuit",
|
||||
Genre: "Ambient", Year: 2015, TrackNumber: 2,
|
||||
},
|
||||
},
|
||||
|
||||
// 6. WAV, where tags live in a RIFF ID3 chunk. One with cover
|
||||
// art, one without, since the chunk layouts differ.
|
||||
{
|
||||
Rel: "Field Recordings/Test Tones/01 Tone A.wav",
|
||||
Case: caseWAVTracks, Format: tagwriter.FormatWAV,
|
||||
Duration: durShort, FreqHz: 400, Cover: "test-tones",
|
||||
Tags: tags{
|
||||
Title: "Tone A", Artist: "Field Recordings",
|
||||
Album: "Test Tones", AlbumArtist: "Field Recordings",
|
||||
Genre: "Field Recording", Year: 2024, TrackNumber: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Rel: "Field Recordings/Test Tones/02 Tone B.wav",
|
||||
Case: caseWAVTracks, Format: tagwriter.FormatWAV,
|
||||
Duration: durShort, FreqHz: 800,
|
||||
Tags: tags{
|
||||
Title: "Tone B", Artist: "Field Recordings",
|
||||
Album: "Test Tones", AlbumArtist: "Field Recordings",
|
||||
Genre: "Field Recording", Year: 2024, TrackNumber: 2,
|
||||
},
|
||||
},
|
||||
|
||||
// 7. Degrees of missing metadata, which is what the "Unknown
|
||||
// Artist" fallbacks and the autotag candidate list are for.
|
||||
{
|
||||
Rel: "unsorted/no-tags-at-all.mp3",
|
||||
Case: casePartialTags, Format: tagwriter.FormatMP3,
|
||||
Duration: durShort, FreqHz: 200,
|
||||
},
|
||||
{
|
||||
Rel: "unsorted/title-only.mp3",
|
||||
Case: casePartialTags, Format: tagwriter.FormatMP3,
|
||||
Duration: durShort, FreqHz: 210,
|
||||
Tags: tags{Title: "Title Only"},
|
||||
},
|
||||
{
|
||||
Rel: "unsorted/no-track-number.mp3",
|
||||
Case: casePartialTags, Format: tagwriter.FormatMP3,
|
||||
Duration: durShort, FreqHz: 230,
|
||||
Tags: tags{
|
||||
Title: "No Track Number", Artist: "Loose Ends",
|
||||
Album: "Odds And Sods",
|
||||
},
|
||||
},
|
||||
|
||||
// 8. Scripts the layout engine handles differently, plus
|
||||
// filenames with characters that break naive URL building.
|
||||
{
|
||||
Rel: "Unicode Tests/多言語アルバム/01 さくら.mp3",
|
||||
Case: caseUnicode, Format: tagwriter.FormatMP3,
|
||||
Duration: durShort, FreqHz: 261, Cover: "unicode",
|
||||
Tags: tags{
|
||||
Title: "さくら", Artist: "サンプル・アーティスト",
|
||||
Album: "多言語アルバム", AlbumArtist: "サンプル・アーティスト",
|
||||
Genre: "J-Pop", Year: 2020, TrackNumber: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Rel: "Unicode Tests/多言語アルバム/02 Привет мир.mp3",
|
||||
Case: caseUnicode, Format: tagwriter.FormatMP3,
|
||||
Duration: durShort, FreqHz: 293, Cover: "unicode",
|
||||
Tags: tags{
|
||||
Title: "Привет мир", Artist: "Тестовый исполнитель",
|
||||
Album: "多言語アルバム", AlbumArtist: "サンプル・アーティスト",
|
||||
Genre: "J-Pop", Year: 2020, TrackNumber: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
Rel: "Unicode Tests/多言語アルバム/03 مرحبا بالعالم.mp3",
|
||||
Case: caseUnicode, Format: tagwriter.FormatMP3,
|
||||
Duration: durShort, FreqHz: 329, Cover: "unicode",
|
||||
Tags: tags{
|
||||
Title: "مرحبا بالعالم", Artist: "فنان تجريبي",
|
||||
Album: "多言語アルバム", AlbumArtist: "サンプル・アーティスト",
|
||||
Genre: "J-Pop", Year: 2020, TrackNumber: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Precomposed é in the filename, decomposed e+U+0301 in the
|
||||
// title: a genuine source of "the same track twice" bugs.
|
||||
Rel: "Unicode Tests/多言語アルバム/04 Café ☕ Über #1's.mp3",
|
||||
Case: caseUnicode, Format: tagwriter.FormatMP3,
|
||||
Duration: durShort, FreqHz: 415, Cover: "unicode",
|
||||
Tags: tags{
|
||||
Title: "Cafe\u0301 ☕ Über #1's", Artist: "サンプル・アーティスト",
|
||||
Album: "多言語アルバム", AlbumArtist: "サンプル・アーティスト",
|
||||
Genre: "J-Pop", Year: 2020, TrackNumber: 4,
|
||||
},
|
||||
},
|
||||
|
||||
// 9. The deliberate duplicate pair: identical tags and length as
|
||||
// "02 Tideline.mp3" above, in another directory and another
|
||||
// format, for the duplicate-tracks dialog to match on.
|
||||
{
|
||||
Rel: "unsorted/dupes/Tideline (copy).mp3",
|
||||
Case: caseDuplicates, Format: tagwriter.FormatMP3,
|
||||
Duration: durMedium, FreqHz: 247, Cover: "glass-harbour",
|
||||
Tags: duplicateTags,
|
||||
},
|
||||
{
|
||||
Rel: "unsorted/dupes/Tideline.flac",
|
||||
Case: caseDuplicates, Format: tagwriter.FormatFLAC,
|
||||
Duration: durMedium, FreqHz: 247, Cover: "glass-harbour",
|
||||
Tags: duplicateTags,
|
||||
},
|
||||
|
||||
// 10. Extremes of text length and track length, plus a track with
|
||||
// no year at all for smart-playlist range rules to exclude.
|
||||
{
|
||||
Rel: "Edge Cases/Extremes/01 Long Title.mp3",
|
||||
Case: caseEdgeLengths, Format: tagwriter.FormatMP3,
|
||||
Duration: durShort, FreqHz: 180,
|
||||
Tags: tags{
|
||||
Title: longTitle, Artist: longArtist,
|
||||
Album: "Extremes", AlbumArtist: longArtist,
|
||||
Genre: "Jazz", Year: 1975, TrackNumber: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Rel: "Edge Cases/Extremes/02 Brief.mp3",
|
||||
Case: caseEdgeLengths, Format: tagwriter.FormatMP3,
|
||||
Duration: durShort, FreqHz: 190,
|
||||
Tags: tags{
|
||||
Title: "Brief", Artist: longArtist,
|
||||
Album: "Extremes", AlbumArtist: longArtist,
|
||||
Genre: "Jazz", Year: 1975, TrackNumber: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
Rel: "Edge Cases/Extremes/03 Long Player.mp3",
|
||||
Case: caseEdgeLengths, Format: tagwriter.FormatMP3,
|
||||
Duration: durLong, FreqHz: 165,
|
||||
Tags: tags{
|
||||
Title: "Long Player", Artist: longArtist,
|
||||
Album: "Extremes", AlbumArtist: longArtist,
|
||||
Genre: "Jazz", Year: 1975, TrackNumber: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
Rel: "Edge Cases/Extremes/04 Undated.mp3",
|
||||
Case: caseEdgeLengths, Format: tagwriter.FormatMP3,
|
||||
Duration: durShort, FreqHz: 175,
|
||||
Tags: tags{
|
||||
Title: "Undated", Artist: longArtist,
|
||||
Album: "Extremes", AlbumArtist: longArtist,
|
||||
Genre: "Jazz", TrackNumber: 4,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// auxFile is a non-audio or malformed file: either debris that
|
||||
// legitimately sits inside a music library, or a deliberately broken
|
||||
// file used to exercise scanner error handling.
|
||||
type auxFile struct {
|
||||
Rel string
|
||||
// Source, when set, names a library fixture whose encoded bytes
|
||||
// get truncated to Bytes; otherwise Literal is written verbatim.
|
||||
Source string
|
||||
Bytes int
|
||||
Literal string
|
||||
}
|
||||
|
||||
// brokenFiles live in their own root so the clean library's track count
|
||||
// stays deterministic. A test that wants the error paths adds this
|
||||
// root as a second library on purpose.
|
||||
//
|
||||
//nolint:gochecknoglobals // the fixture spec is the point of this cmd.
|
||||
var brokenFiles = []auxFile{
|
||||
{Rel: "notes.txt", Literal: "not audio\n"},
|
||||
{Rel: "empty.flac"},
|
||||
{
|
||||
Rel: "truncated.mp3",
|
||||
Source: "Aurora Fields/Glass Harbour/01 Salt Air.mp3",
|
||||
Bytes: 512,
|
||||
},
|
||||
}
|
||||
|
||||
// libraryExtras are the non-audio files a real library is full of.
|
||||
// They belong inside the clean root because ignoring them is itself
|
||||
// behaviour worth testing — folder art in particular, which is a
|
||||
// separate cover source from embedded art.
|
||||
//
|
||||
//nolint:gochecknoglobals // the fixture spec is the point of this cmd.
|
||||
var libraryExtras = []auxFile{
|
||||
{Rel: "Pale Circuit/Ribbon Road/cover.jpg"},
|
||||
{Rel: "Pale Circuit/Ribbon Road/ripping notes.txt", Literal: "EAC log\n"},
|
||||
}
|
||||
Reference in New Issue
Block a user