diff --git a/tests/e2e/click-track.spec.mjs b/tests/e2e/click-track.spec.mjs new file mode 100644 index 0000000..41822ba --- /dev/null +++ b/tests/e2e/click-track.spec.mjs @@ -0,0 +1,114 @@ +// The click track, count-in and grid editor in a real browser. +// +// These could not be tested at all until the fixture's beat grid moved to +// stems/beats.json, where the API actually looks. Written to the job root it +// 404'd, the studio said "No beat grid for this track", and every control here +// stayed disabled -- while the fixture looked, from a glance at seed.py, as +// though it covered them. The first test is the one that would have caught it. + +import { test, expect } from "@playwright/test"; +import { openStudio, waitForClickTrack } from "./helpers.mjs"; + +const metro = (page) => ({ + toggle: page.locator("#t-metro"), + panel: page.locator("#t-metro-panel"), + countIn: page.locator("#t-metro-countin"), + accent: page.locator("#t-metro-bar"), + grid: page.locator("#t-metro-edit"), + half: page.locator("#t-metro-half"), + double: page.locator("#t-metro-double"), + note: page.locator("#t-metro-note"), +}); + +test.describe("click track", () => { + test("the fixture's beat grid reaches the studio", async ({ page }) => { + await openStudio(page, { tauri: true }); + await waitForClickTrack(page); + const ui = metro(page); + + // Disabled here means the grid never arrived -- the exact symptom of a + // beats.json the API cannot find. + await expect(ui.toggle).toBeEnabled(); + await expect(ui.panel).not.toHaveClass(/hidden/); + await expect(ui.note).toContainText("120.0 BPM"); + + // Bar marks present, so the detected-meter accent mode is live rather than + // silently degrading to "none found". + await expect(page.locator('#t-metro-bar option[value="-1"]')).toHaveText("Auto (detected)"); + await expect(page.locator('#t-metro-bar option[value="-1"]')).toBeEnabled(); + }); + + test("the click toggles on and off", async ({ page }) => { + await openStudio(page, { tauri: true }); + await waitForClickTrack(page); + const ui = metro(page); + + await ui.toggle.click(); + await expect(ui.toggle).toHaveClass(/active/); + await expect(ui.toggle).toHaveAttribute("aria-pressed", "true"); + + await ui.toggle.click(); + await expect(ui.toggle).not.toHaveClass(/active/); + await expect(ui.toggle).toHaveAttribute("aria-pressed", "false"); + }); + + test("count-in is a toggle and its choice survives a reload", async ({ page }) => { + await openStudio(page, { tauri: true }); + await waitForClickTrack(page); + await metro(page).countIn.click(); + await expect(metro(page).countIn).toHaveClass(/active/); + + await openStudio(page, { tauri: true }); + await waitForClickTrack(page); + // Restored from the store, not merely left in the DOM. + await expect(metro(page).countIn).toHaveClass(/active/); + await expect(metro(page).countIn).toHaveAttribute("aria-pressed", "true"); + }); + + test("the rate control reports the tempo it is actually clicking", async ({ page }) => { + await openStudio(page, { tauri: true }); + await waitForClickTrack(page); + const ui = metro(page); + + await ui.double.click(); + await expect(ui.double).toHaveClass(/active/); + await expect(ui.note).toContainText("240.0 BPM"); + + await ui.half.click(); + await expect(ui.half).toHaveClass(/active/); + await expect(ui.note).toContainText("60.0 BPM"); + }); + + test("the accent choice is reflected in the note", async ({ page }) => { + await openStudio(page, { tauri: true }); + await waitForClickTrack(page); + const ui = metro(page); + + await ui.accent.selectOption("3"); + await expect(ui.note).toContainText("accenting every 3 beats"); + + await ui.accent.selectOption("-1"); + await expect(ui.note).toContainText("accenting 4/4 from the detected downbeat"); + }); + + test("Grid opens and closes the beat-grid editor", async ({ page }) => { + await openStudio(page, { tauri: true }); + await waitForClickTrack(page); + const ui = metro(page); + const toolbar = page.locator("#beatgrid-toolbar"); + + await ui.grid.click(); + await expect(toolbar).not.toHaveClass(/hidden/); + await expect(ui.grid).toHaveClass(/active/); + + // Same button closes it; the editor's own Done must agree. + await ui.grid.click(); + await expect(toolbar).toHaveClass(/hidden/); + await expect(ui.grid).not.toHaveClass(/active/); + + await ui.grid.click(); + await page.locator("#bg-done").click(); + await expect(toolbar).toHaveClass(/hidden/); + await expect(ui.grid).not.toHaveClass(/active/); + }); +}); diff --git a/tests/e2e/helpers.mjs b/tests/e2e/helpers.mjs index d550207..f1d26fd 100644 --- a/tests/e2e/helpers.mjs +++ b/tests/e2e/helpers.mjs @@ -199,6 +199,22 @@ export async function openStudio(page, { tauri = false, updateAvailable = false ); } +/** + * Wait until the click track is live. + * + * openStudio returns once the transport reports a duration, but the metronome + * is built later still -- after the audio engine is up and the beat grid has + * been fetched. Acting before then hits a null metronome, where the rate and + * accent controls silently no-op: the click looks present and does nothing. + */ +export async function waitForClickTrack(page) { + await page.waitForFunction( + () => document.querySelector("#t-metro") && !document.querySelector("#t-metro").disabled, + null, + { timeout: 20000 }, + ); +} + export const exportUi = (page) => ({ button: page.locator("#t-export-btn"), panel: page.locator("#t-export-panel"), diff --git a/tests/e2e/seed.py b/tests/e2e/seed.py index 5bf3481..55c8f33 100644 --- a/tests/e2e/seed.py +++ b/tests/e2e/seed.py @@ -9,6 +9,12 @@ loads with no duration and the studio comes up without playback -- which is the #358 failure mode, and it would make every test here fail for the wrong reason. +Each artifact has to live where the real pipeline puts it -- both peaks.json and +beats.json under stems/ -- or the endpoint that serves it 404s and the feature +it drives is quietly untestable. Both were in the job root once: the studio fell +back to decoding every stem for its waveforms, and the click track never +appeared at all. + Usage: python tests/e2e/seed.py """ @@ -67,15 +73,35 @@ def seed(jobs_dir: Path) -> str: for index, name in enumerate(STEMS): (stems_dir / f"{name}.wav").write_bytes(_wav_bytes(220.0 * (index + 1))) - (job_dir / "peaks.json").write_text( + (stems_dir / "peaks.json").write_text( json.dumps({name: _peaks() for name in STEMS}), encoding="utf-8" ) - (job_dir / "beats.json").write_text( + # stems/beats.json, not /beats.json: that is where the pipeline writes + # it and the only place GET /api/jobs/{id}/beats looks (_beats_paths). Put + # it in the job root and the endpoint 404s, the studio reports "No beat grid + # for this track", and every click-track control stays disabled -- so the + # click track, the count-in and the grid editor were untestable in a browser + # while looking, from the fixture, as though they were covered. + # + # Shape matches what beatgrid.py emits, `bars` included: with no bar marks + # the accent mode falls back to "Auto (none found)" and the detected-meter + # path never runs. + beats = [round(i * 0.5, 3) for i in range(DURATION_SEC * 2)] + (stems_dir / "beats.json").write_text( json.dumps( { + "version": 1, + "source": "drums", + "detector": "e2e-fixture", + # One 4/4 region from the first beat: 120 BPM, a downbeat every + # 2 s. Enough for "Auto (detected)" and a one-bar count-in. + "bars": [{"beat": 0, "beats_per_bar": 4}], "bpm": 120.0, - "beats": [round(i * 0.5, 3) for i in range(DURATION_SEC * 2)], - "downbeats": [round(i * 2.0, 3) for i in range(DURATION_SEC // 2)], + "duration": float(DURATION_SEC), + "confidence": 95, + # The grid editor snaps dragged beats onto these. + "onsets": beats, + "beats": beats, } ), encoding="utf-8",