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.
268 lines
6.0 KiB
Go
268 lines
6.0 KiB
Go
package metadata
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"yellowjacket/internal/testfixtures"
|
|
)
|
|
|
|
// testMP3Files returns every .mp3 in the generated fixture library
|
|
// (`make testdata`). Scoped to that manifest so ad-hoc scramble /
|
|
// autotag fixtures elsewhere under `test_data/` don't get pulled into
|
|
// the assertion and fail on non-curated codecs. Skips when the
|
|
// fixtures haven't been generated.
|
|
func testMP3Files(t *testing.T) []string {
|
|
t.Helper()
|
|
|
|
m := testfixtures.Load(t)
|
|
|
|
var files []string
|
|
|
|
for _, track := range m.Tracks {
|
|
if track.Format == "mp3" {
|
|
files = append(files, m.Abs(track.Path))
|
|
}
|
|
}
|
|
|
|
if len(files) == 0 {
|
|
t.Skip("no .mp3 fixtures in the manifest")
|
|
}
|
|
|
|
return files
|
|
}
|
|
|
|
// TestGetMP3Duration_MatchesBeepDecode verifies that the fast
|
|
// header-only parser produces a duration within 1 second of the
|
|
// full decode via beep, for every test MP3 file.
|
|
func TestGetMP3Duration_MatchesBeepDecode(t *testing.T) {
|
|
for _, path := range testMP3Files(t) {
|
|
t.Run(filepath.Base(path), func(t *testing.T) {
|
|
// Reference value: full beep decode.
|
|
refMS, err := GetTrackLengthMillis(path)
|
|
if err != nil {
|
|
t.Fatalf(
|
|
"beep decode failed: %v", err,
|
|
)
|
|
}
|
|
|
|
// Fast path.
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
|
|
defer func() { _ = f.Close() }()
|
|
|
|
fastMS, _, err := getMP3Duration(f)
|
|
if err != nil {
|
|
t.Fatalf(
|
|
"getMP3Duration failed: %v", err,
|
|
)
|
|
}
|
|
|
|
diffMS := refMS - fastMS
|
|
if diffMS < 0 {
|
|
diffMS = -diffMS
|
|
}
|
|
|
|
// Allow up to 1 second of difference to account
|
|
// for rounding and the slight inaccuracy of the
|
|
// CBR fallback for VBR-without-Xing files.
|
|
const toleranceMS = 1000
|
|
|
|
t.Logf(
|
|
"beep=%dms fast=%dms diff=%dms",
|
|
refMS, fastMS, diffMS,
|
|
)
|
|
|
|
if diffMS > toleranceMS {
|
|
t.Errorf(
|
|
"duration mismatch: beep=%dms fast=%dms "+
|
|
"(diff %dms exceeds %dms tolerance)",
|
|
refMS, fastMS, diffMS, toleranceMS,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGetMP3Duration_BasicParsing exercises the parser on a single
|
|
// file and verifies a positive duration is returned.
|
|
func TestGetMP3Duration_BasicParsing(t *testing.T) {
|
|
files := testMP3Files(t)
|
|
|
|
f, err := os.Open(files[0])
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
|
|
defer func() { _ = f.Close() }()
|
|
|
|
ms, _, err := getMP3Duration(f)
|
|
if err != nil {
|
|
t.Fatalf("getMP3Duration: %v", err)
|
|
}
|
|
|
|
if ms <= 0 {
|
|
t.Errorf("expected positive duration, got %d", ms)
|
|
}
|
|
}
|
|
|
|
// TestGetMP3Duration_WithMultipleID3v2 creates a temporary MP3 file
|
|
// with two consecutive ID3v2 tags prepended and verifies that
|
|
// getMP3Duration correctly skips both and finds the audio.
|
|
func TestGetMP3Duration_WithMultipleID3v2(t *testing.T) {
|
|
files := testMP3Files(t)
|
|
src := files[0]
|
|
|
|
srcData, err := os.ReadFile(src)
|
|
if err != nil {
|
|
t.Fatalf("reading source: %v", err)
|
|
}
|
|
|
|
// Get reference duration from the original file.
|
|
origF, err := os.Open(src)
|
|
if err != nil {
|
|
t.Fatalf("open original: %v", err)
|
|
}
|
|
|
|
defer func() { _ = origF.Close() }()
|
|
|
|
origMS, _, err := getMP3Duration(origF)
|
|
if err != nil {
|
|
t.Fatalf("getMP3Duration on original: %v", err)
|
|
}
|
|
|
|
// Build a file with two ID3v2 tags: 1 KB + 2 KB of padding.
|
|
//nolint:mnd // synthetic tag construction.
|
|
tag1Size := 1024
|
|
tag2Size := 2048
|
|
|
|
tag1 := buildID3v2Header(tag1Size)
|
|
tag2 := buildID3v2Header(tag2Size)
|
|
|
|
out := make(
|
|
[]byte,
|
|
0,
|
|
len(tag1)+tag1Size+len(tag2)+tag2Size+len(srcData),
|
|
)
|
|
out = append(out, tag1...)
|
|
out = append(out, make([]byte, tag1Size)...)
|
|
out = append(out, tag2...)
|
|
out = append(out, make([]byte, tag2Size)...)
|
|
out = append(out, srcData...)
|
|
|
|
tmpDir := t.TempDir()
|
|
tmpPath := filepath.Join(tmpDir, "multi_id3v2.mp3")
|
|
|
|
if err := os.WriteFile(
|
|
tmpPath, out, 0o644,
|
|
); err != nil {
|
|
t.Fatalf("writing temp file: %v", err)
|
|
}
|
|
|
|
tmpF, err := os.Open(tmpPath)
|
|
if err != nil {
|
|
t.Fatalf("open temp: %v", err)
|
|
}
|
|
|
|
defer func() { _ = tmpF.Close() }()
|
|
|
|
wrappedMS, _, err := getMP3Duration(tmpF)
|
|
if err != nil {
|
|
t.Fatalf(
|
|
"getMP3Duration on multi-ID3v2 file: %v", err,
|
|
)
|
|
}
|
|
|
|
diffMS := origMS - wrappedMS
|
|
if diffMS < 0 {
|
|
diffMS = -diffMS
|
|
}
|
|
|
|
// The CBR calculation uses file size, so the prepended tags
|
|
// will cause a slight overestimate. Allow generous tolerance.
|
|
const toleranceMS = 5000
|
|
|
|
t.Logf(
|
|
"original=%dms wrapped=%dms diff=%dms",
|
|
origMS, wrappedMS, diffMS,
|
|
)
|
|
|
|
if diffMS > toleranceMS {
|
|
t.Errorf(
|
|
"duration mismatch: original=%dms "+
|
|
"wrapped=%dms (diff %dms "+
|
|
"exceeds %dms tolerance)",
|
|
origMS, wrappedMS, diffMS, toleranceMS,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestSkipAdditionalID3v2 verifies that skipAdditionalID3v2 handles
|
|
// files with no additional tags, one additional tag, and multiple
|
|
// additional tags.
|
|
func TestSkipAdditionalID3v2(t *testing.T) {
|
|
// Build a file: [ID3v2(100)] [ID3v2(200)] [ID3v2(50)] [data]
|
|
//nolint:mnd // synthetic tag sizes for test.
|
|
sizes := []int{100, 200, 50}
|
|
|
|
var buf []byte
|
|
|
|
for _, sz := range sizes {
|
|
buf = append(buf, buildID3v2Header(sz)...)
|
|
buf = append(buf, make([]byte, sz)...)
|
|
}
|
|
|
|
buf = append(buf, []byte("audio data here")...)
|
|
|
|
tmpDir := t.TempDir()
|
|
tmpPath := filepath.Join(tmpDir, "multi_id3.bin")
|
|
|
|
if err := os.WriteFile(
|
|
tmpPath, buf, 0o644,
|
|
); err != nil {
|
|
t.Fatalf("writing temp file: %v", err)
|
|
}
|
|
|
|
f, err := os.Open(tmpPath)
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
|
|
defer func() { _ = f.Close() }()
|
|
|
|
// skipID3v2 handles the first tag.
|
|
firstEnd, err := skipID3v2(f)
|
|
if err != nil {
|
|
t.Fatalf("skipID3v2: %v", err)
|
|
}
|
|
|
|
//nolint:mnd // expected offset after first tag.
|
|
expectedFirst := int64(10 + 100)
|
|
if firstEnd != expectedFirst {
|
|
t.Fatalf(
|
|
"first tag end: got %d, want %d",
|
|
firstEnd, expectedFirst,
|
|
)
|
|
}
|
|
|
|
// skipAdditionalID3v2 handles the remaining tags.
|
|
finalOffset, err := skipAdditionalID3v2(f, firstEnd)
|
|
if err != nil {
|
|
t.Fatalf("skipAdditionalID3v2: %v", err)
|
|
}
|
|
|
|
// Expected: 10+100 + 10+200 + 10+50 = 380
|
|
//nolint:mnd // expected offset after all tags.
|
|
expectedAll := int64(10 + 100 + 10 + 200 + 10 + 50)
|
|
if finalOffset != expectedAll {
|
|
t.Errorf(
|
|
"final offset: got %d, want %d",
|
|
finalOffset, expectedAll,
|
|
)
|
|
}
|
|
}
|