File size: 10,794 Bytes
5c0b162
6a6bd27
 
 
5c0b162
 
 
 
 
 
 
 
6a6bd27
5c0b162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6a6bd27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5c0b162
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
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);