Spaces:
Runtime error
Runtime error
Download scripts/test-score-editor.mjs from RalphThings/yue2-studio: direct link, hf CLI and curl.
- Browser
- Download file 10.8 kB
-
https://huggingface.co/spaces/RalphThings/yue2-studio/resolve/main/scripts/test-score-editor.mjs
- Command line
-
hf download hf://spaces/RalphThings/yue2-studio/scripts/test-score-editor.mjs
-
curl -L -o test-score-editor.mjs https://huggingface.co/spaces/RalphThings/yue2-studio/resolve/main/scripts/test-score-editor.mjs
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); | |