import { test, expect } from "@playwright/test"; import { caps, NO_SONG_DATA } from "./capabilities"; // Undo, and the window a card is left on. // // A capture is already WRITTEN -- it goes through the same verdict machinery as // any other -- so taking one back cannot mean dropping a marker and pretending. // A re-trimmed window ACCEPTS a new key and REJECTS the old one, so putting it // back means restoring membership in several piles at once; the journal records // that and undo replays it rather than trying to invert the logic. test("undo restores the exact pile membership a verdict changed", async ({ request }) => { // A VERDICT NEEDS A CLIP. /sort serves candidates, and make-fixture copies // one only when its wav is present — with no song bulk data there is nothing // to judge, so there is nothing to take back. (The empty-journal refusal // below deliberately keeps running: it is about the journal, not a clip.) test.skip(!caps.song, NO_SONG_DATA); const before = await (await request.get("/api/clips?mode=sort&limit=200")).json(); expect(before.clips.length).toBeGreaterThan(0); const key: string = before.clips[0].key; const detail = await (await request.get(`/api/clip/${encodeURIComponent(key)}`)).json(); // A MOVED window, which is the case that makes undo hard: it creates a new // key and rejects the old one, so two keys change piles at once. const moved = { from: +(detail.selStart + 0.02).toFixed(4), to: +(detail.selEnd + 0.02).toFixed(4), }; const res = await request.post("/api/verdict", { data: { key, mode: "sort", verdict: 1, code: 0, window: moved, defaultWindow: { from: detail.defaultStart, to: detail.defaultEnd }, }, }); expect(res.ok()).toBe(true); const out = await res.json(); const mid = await (await request.get("/api/clips?mode=sort&limit=200")).json(); expect(mid.counts.accepted).toBe(before.counts.accepted + 1); const undo = await request.post("/api/verdict", { data: { undo: true } }); expect(undo.ok()).toBe(true); const after = await (await request.get("/api/clips?mode=sort&limit=200")).json(); expect(after.counts.accepted).toBe(before.counts.accepted); expect(after.counts.rejected).toBe(before.counts.rejected); expect(after.counts.unclean).toBe(before.counts.unclean); expect(after.counts.impure).toBe(before.counts.impure); // The clip is offered again, and the key the re-trim invented is gone. expect(after.clips.map((c: { key: string }) => c.key)).toContain(key); if (out.finalKey !== key) { expect(after.clips.map((c: { key: string }) => c.key)).not.toContain(out.finalKey); } }); test("undo with an empty journal is refused rather than guessed at", async ({ request }) => { // Drain whatever this run has written. for (let i = 0; i < 40; i += 1) { const r = await request.post("/api/verdict", { data: { undo: true } }); if (!r.ok()) { expect(r.status()).toBe(400); expect((await r.json()).error).toMatch(/nothing to undo/i); return; } } throw new Error("journal never drained"); }); test("backspace takes back a capture before it leaves the card", async ({ page, request }) => { // A VERDICT NEEDS A CLIP. /sort serves candidates, and make-fixture copies // one only when its wav is present — with no song bulk data there is nothing // to judge, so there is nothing to take back. (The empty-journal refusal // below deliberately keeps running: it is about the journal, not a clip.) test.skip(!caps.song, NO_SONG_DATA); await page.goto("/sort"); await expect(page.getByTestId("waveform")).toBeVisible(); const before = await (await request.get("/api/clips?mode=sort&limit=50")).json(); const firstKey = before.clips[0].key; // Tick "another sound in this window", then accept: the card must STAY. await page.getByRole("checkbox").check(); await page.locator('[data-verdict="1"]').click(); await expect(page.getByText(/taken from this window/)).toBeVisible(); await expect .poll(async () => (await (await request.get("/api/clips?mode=sort&limit=50")).json()).counts.accepted) .toBe(before.counts.accepted + 1); // Backspace undoes the capture instead of stepping back a card. await page.keyboard.press("Backspace"); await expect(page.getByText(/taken from this window/)).toHaveCount(0); await expect .poll(async () => (await (await request.get("/api/clips?mode=sort&limit=50")).json()).counts.accepted) .toBe(before.counts.accepted); // And the clip is genuinely back in the queue, not merely uncounted. const after = await (await request.get("/api/clips?mode=sort&limit=50")).json(); expect(after.clips.map((c: { key: string }) => c.key)).toContain(firstKey); }); test("a card remembers the window it was left on, and R returns it to the server's", async ({ page, }) => { // A VERDICT NEEDS A CLIP. /sort serves candidates, and make-fixture copies // one only when its wav is present — with no song bulk data there is nothing // to judge, so there is nothing to take back. (The empty-journal refusal // below deliberately keeps running: it is about the journal, not a clip.) test.skip(!caps.song, NO_SONG_DATA); await page.goto("/sort"); await expect(page.getByTestId("waveform")).toBeVisible(); const selText = page.getByText(/selection\s/); await expect(selText).toBeVisible(); // Wait for the REAL window. Before the clip's detail arrives the card shows // its initial placeholder ("0.00-1.00s"), and capturing that as the baseline // compares the loaded selection against a value no clip ever has. await expect.poll(async () => (await selText.textContent())!).not.toMatch(/0\.00–1\.00s/); const original = (await selText.textContent())!; // Widen the selection by dragging the end handle well to the right. const canvas = page.getByTestId("waveform"); const box = (await canvas.boundingBox())!; await page.mouse.move(box.x + box.width * 0.72, box.y + box.height / 2); await page.mouse.down(); await page.mouse.move(box.x + box.width * 0.85, box.y + box.height / 2, { steps: 8 }); await page.mouse.up(); await expect.poll(async () => (await selText.textContent())!).not.toBe(original); const edited = (await selText.textContent())!; // Forward, then back: the edit must survive the round trip. await page.keyboard.press("Shift+ArrowRight"); await expect(page.getByTestId("waveform")).toBeVisible(); await page.keyboard.press("Backspace"); await expect.poll(async () => (await selText.textContent())!).toBe(edited); // R is the way back to the server's untouched window. await page.keyboard.press("r"); await expect.poll(async () => (await selText.textContent())!).toBe(original); });