Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions tests/e2e/click-track.spec.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
});
16 changes: 16 additions & 0 deletions tests/e2e/helpers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
34 changes: 30 additions & 4 deletions tests/e2e/seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <jobs-dir>
"""

Expand Down Expand Up @@ -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 <job>/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",
Expand Down
Loading