yue2-studio / scripts /test-score-editor.mjs
RalphThings's picture
Upload folder using huggingface_hub
6a6bd27 verified
Raw History Blame Contribute Delete
10.8 kB
import { chromium } from 'playwright';
// The port moves between runs; pass it in rather than editing this file.
const BASE = process.env.STUDIO_URL ?? 'http://127.0.0.1:7910';
const b = await chromium.launch({ executablePath: '/opt/pw-browsers/chromium' });
const page = await b.newPage();
const fails = [];
const check = (l, ok, extra) => { console.log((ok?' ok ':' FAIL ')+l+(ok||!extra?'':'\n '+extra)); if(!ok) fails.push(l); };
const errors = [];
page.on('pageerror', (e) => errors.push(String(e)));
await page.goto(`${BASE}/`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#style-input', { timeout: 15000 });
await page.getByRole('button', { name: /Write or paste a score|Open the score editor/ }).click();
await page.waitForTimeout(400);
const grid = page.locator('svg[aria-label="Note grid"]');
check('the grid opens by default', await grid.isVisible());
const notes = () => page.locator('rect[data-note]');
const mine = () => page.locator('rect[data-note][data-voice="0"]');
const before = await notes().count();
check('the skeleton renders as notes', before > 0, `count ${before}`);
check('notation renders beside it', await page.locator('text=Notation').first().isVisible());
await page.waitForTimeout(900);
const engraved = await page.locator('.console-scroll svg').count();
check('abcjs engraved something', engraved > 0, `${engraved} svg`);
// The engraved staff was absolutely positioned and lay across the grid,
// swallowing every click meant for a note. Test a note that is actually
// inside the scroller's visible box, otherwise this only proves the note was
// scrolled out of view.
const topAtNote = await page.evaluate(() => {
const scroll = document.querySelector('svg[aria-label="Note grid"]')?.parentElement;
if (!scroll) return 'no scroller';
const view = scroll.getBoundingClientRect();
const note = [...document.querySelectorAll('rect[data-note]')].find((r) => {
const b = r.getBoundingClientRect();
return b.top >= view.top && b.bottom <= view.bottom && b.left >= view.left && b.right <= view.right;
});
if (!note) return 'no note in view';
const b = note.getBoundingClientRect();
const el = document.elementFromPoint(b.x + b.width / 2, b.y + b.height / 2);
return el ? el.tagName + (el.getAttribute('data-note') ? '#note' : '') : 'null';
});
check('nothing overlays the notes', topAtNote === 'rect#note', `topmost was ${topAtNote}`);
// --- read the ABC back out of the text view --------------------------------
const abcOf = async () => {
await page.getByRole('button', { name: 'text', exact: true }).click();
const v = await page.locator('textarea[aria-label="Score as ABC notation"]').inputValue();
await page.getByRole('button', { name: 'notes', exact: true }).click();
await page.waitForTimeout(200);
return v;
};
const original = await abcOf();
check('text view shows ABC', original.includes('V:Vocal'), original.slice(0, 60));
// --- drag a note up --------------------------------------------------------
const first = mine().first();
const box = await first.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2, box.y - 13 * 2, { steps: 8 });
await page.mouse.up();
await page.waitForTimeout(250);
const afterDrag = await abcOf();
check('dragging up changed the score', afterDrag !== original);
check('drag did not destroy the voices', afterDrag.includes('V:Vocal') && afterDrag.includes('V:Ins'));
check('headers survived the rewrite', afterDrag.includes('T:Your title') && afterDrag.includes('K:G'));
// --- add a note by clicking empty grid -------------------------------------
const scroller = page.locator('div.console-scroll').filter({ has: grid });
const gb = await scroller.boundingBox();
const countBeforeAdd = await notes().count();
await page.mouse.click(gb.x + gb.width - 40, gb.y + gb.height - 30);
await page.waitForTimeout(250);
check('clicking empty grid adds a note', (await notes().count()) === countBeforeAdd + 1, `${countBeforeAdd} -> ${await notes().count()}`);
// --- delete with the keyboard ----------------------------------------------
const countBeforeDelete = await notes().count();
await mine().first().focus();
await page.keyboard.press('Backspace');
await page.waitForTimeout(250);
check('Backspace deletes the focused note', (await notes().count()) === countBeforeDelete - 1, `${countBeforeDelete} -> ${await notes().count()}`);
// --- arrow keys nudge -------------------------------------------------------
await mine().first().focus();
const beforeNudge = await abcOf();
await mine().first().focus();
await page.keyboard.press('ArrowUp');
await page.waitForTimeout(250);
check('arrow keys move a note without a mouse', (await abcOf()) !== beforeNudge);
// --- paste a score and see it render ---------------------------------------
await page.getByRole('button', { name: 'text', exact: true }).click();
const pasted = 'X:1\nT:Pasted\nM:3/4\nL:1/8\nK:D\nV:Lead\nD2 F2 A2 | d6 |';
await page.locator('textarea[aria-label="Score as ABC notation"]').fill(pasted);
await page.getByRole('button', { name: 'notes', exact: true }).click();
await page.waitForTimeout(400);
check('a pasted score renders in the grid', (await notes().count()) === 4, `got ${await notes().count()}`);
check('its voice name is picked up', await page.getByRole('button', { name: 'Lead' }).isVisible());
// --- an unsupported score must refuse, not mangle --------------------------
await page.getByRole('button', { name: 'text', exact: true }).click();
await page.locator('textarea[aria-label="Score as ABC notation"]').fill('X:1\nM:4/4\nL:1/8\nK:C\nV:1\n(3CDE G4 |');
await page.getByRole('button', { name: 'notes', exact: true }).click();
await page.waitForTimeout(300);
check('a tuplet is refused with an explanation', await page.getByText(/cannot draw without losing it/).isVisible());
await page.getByRole('button', { name: 'Edit it as text' }).click();
await page.waitForTimeout(200);
check('the refused score is still intact in the text', (await page.locator('textarea[aria-label="Score as ABC notation"]').inputValue()).includes('(3CDE'));
// --- the score shape that failed in the wild --------------------------------
// Chord symbols, repeats, two voices, and three octaves between the bass and
// the top of the vocal. This used to be refused outright.
const REAL = 'X:1\nT:Take\nM:4/4\nL:1/8\nQ:1/4=84\nK:E\nV:Vocal\n|: "G#m" B2 c2 B2 A2 | "E" e6 z2 :|\nV:Ins\n|: "G#m" E,,4 G,,4 | "E" E,,8 :|';
await page.getByRole('button', { name: 'text', exact: true }).click();
await page.locator('textarea[aria-label="Score as ABC notation"]').fill(REAL);
await page.getByRole('button', { name: 'notes', exact: true }).click();
await page.waitForTimeout(500);
check('a score with chord symbols is no longer refused', !(await page.getByText(/cannot draw without losing it/).isVisible().catch(() => false)));
check('it renders as notes', (await notes().count()) > 0, `got ${await notes().count()}`);
const chords = await page.locator('text[data-chord]').allTextContents();
check('chord symbols are drawn in the lane', chords.includes('G#m') && chords.includes('E'), chords.join(','));
// Every note of the instrument must exist, however far apart the parts are.
const wanted = await page.evaluate(() => {
const rects = [...document.querySelectorAll('rect[data-note]')];
const ys = rects.map((r) => Number(r.getAttribute('y')));
const svg = document.querySelector('svg[aria-label="Note grid"]');
return { count: rects.length, minY: Math.min(...ys), maxY: Math.max(...ys), svgHeight: Number(svg.getAttribute('height')) };
});
check('the keyboard spans every note, none clipped off',
wanted.minY >= 0 && wanted.maxY < wanted.svgHeight, JSON.stringify(wanted));
const scrollState = await page.evaluate(() => {
const box = document.querySelector('svg[aria-label="Note grid"]').parentElement;
return { scrollHeight: box.scrollHeight, clientHeight: box.clientHeight };
});
check('the grid scrolls vertically rather than hiding the rest',
scrollState.scrollHeight > scrollState.clientHeight, JSON.stringify(scrollState));
// --- playback ---------------------------------------------------------------
await page.getByRole('button', { name: 'Play the score' }).click();
await page.waitForTimeout(700);
const head = await page.locator('line[data-playhead]').count();
check('a playhead appears while playing', head === 1, `${head} playheads`);
const firstX = await page.locator('line[data-playhead]').getAttribute('x1');
await page.waitForTimeout(700);
const laterX = await page.locator('line[data-playhead]').getAttribute('x1').catch(() => null);
check('the playhead advances', laterX !== null && Number(laterX) > Number(firstX), `${firstX} -> ${laterX}`);
check('notes light up as they sound', (await page.locator('rect[data-sounding="yes"]').count()) > 0);
await page.getByRole('button', { name: 'Stop' }).click();
await page.waitForTimeout(300);
check('stopping clears the playhead', (await page.locator('line[data-playhead]').count()) === 0);
// --- the notation pane must not clip its last system ------------------------
const staff = await page.evaluate(() => {
const label = [...document.querySelectorAll('button')].find((b) => b.textContent.trim().startsWith('Notation'));
const box = label?.parentElement?.querySelector('div.console-scroll');
if (!box) return null;
const svg = box.querySelector('svg');
if (!svg) return { noSvg: true };
const b = box.getBoundingClientRect(), s = svg.getBoundingClientRect();
return { reachable: box.scrollHeight <= box.clientHeight + 1 || box.scrollHeight > box.clientHeight, boxH: b.height, svgH: s.height, scrollH: box.scrollHeight, clientH: box.clientHeight };
});
check('the notation pane can reach the bottom of the sheet',
staff !== null && !staff.noSvg && staff.scrollH <= staff.clientH + 1 || (staff && staff.scrollH > staff.clientH),
JSON.stringify(staff));
// The notation pane once overlapped the grid's lower strip, which still
// scrolled and still showed notes but silently swallowed every click there.
const overlap = await page.evaluate(() => {
const svg = document.querySelector('svg[aria-label="Note grid"]');
const box = svg.parentElement.getBoundingClientRect();
const x = box.right - 40;
const y = box.bottom - 8;
const top = document.elementFromPoint(x, y);
return { tag: top?.tagName ?? 'none', inGrid: Boolean(top && svg.contains(top)) };
});
check('nothing covers the bottom of the grid', overlap.inGrid, JSON.stringify(overlap));
check('no page errors throughout', errors.length === 0, errors.join('\n'));
await b.close();
console.log(fails.length ? `\n${fails.length} FAILURES` : '\nall assertions passed');
process.exit(fails.length ? 1 : 0);