Simon Clematide
Add tree resize handle, pedagogical guides, and UI/comparison enhancements
115fba1
Raw History Blame Contribute Delete
17.2 kB
async (page) => {
const base = 'http://127.0.0.1:8765/output/playwright/';
await page.goto(base + 'api.html');
await page.waitForFunction(() => document.getElementById('status').textContent.startsWith('PASS'));
const parser = await page.locator('#status').textContent();
await page.goto(base + 'preview.html');
await page.waitForFunction(() => Array.from(document.querySelectorAll('iframe')).every(f => f.contentWindow.visualizerDiagnostics?.stage === 'ready'), null, {timeout:60000});
const frames = await page.evaluate(() => Array.from(document.querySelectorAll('iframe'), f => {
const d = f.contentWindow.visualizerDiagnostics;
const svg = f.contentDocument.querySelector('#vis svg');
return {id:f.id, entities:d.data.entities.length, text:svg.textContent, width:svg.getBBox().width, height:svg.getBBox().height};
}));
for (const frame of frames) {
if (frame.width <= 0 || frame.height <= 0) throw Error('Empty SVG: ' + frame.id);
}
if (!frames[0].text.includes('nsubj') || !frames[0].text.includes('English')) throw Error('Missing English arc/label');
if (!frames[1].text.includes('Bücher')) throw Error('Missing Unicode text');
if (!frames[2].text.includes('</textarea></script>&')) throw Error('Escaping failed');
for (const id of ['multisentence', 'wrapped']) {
const frame = page.locator('#' + id).contentFrame();
const rows = await frame.locator('#vis svg > g.text > text').evaluateAll(elements => elements.map(row => ({
text:row.textContent,
visible:getComputedStyle(row).display !== 'none' && getComputedStyle(row).visibility !== 'hidden' && row.getBoundingClientRect().height > 0
})));
if (!rows.length || rows.some(row => !row.visible)) throw Error('Missing word row in ' + id + ': ' + JSON.stringify(rows));
const lineCount = await frame.locator('#vis svg > g.text tspan').evaluateAll(words => new Set(words.map(word => Math.round(word.getBoundingClientRect().top))).size);
if (lineCount < 2) throw Error('Fixture did not exercise multiple word lines: ' + id);
const expected = id === 'multisentence' ? ['They', 'Bücher', 'überzeugen'] : ['word1', 'word15', 'word30'];
const visibleText = rows.map(row => row.text).join(' ');
if (expected.some(word => !visibleText.includes(word))) throw Error('Missing words in ' + id);
const exported = await frame.locator('body').evaluate(() => {
const prep = getPreparedSVG();
const svg = new DOMParser().parseFromString(prep.xml, 'image/svg+xml');
return Array.from(svg.querySelectorAll('g.text > text'), row => ({text:row.textContent, display:row.style.display}));
});
if (exported.some(row => row.display === 'none') || expected.some(word => !exported.some(row => row.text.includes(word)))) throw Error('Missing exported words in ' + id);
await page.waitForFunction(id => {
const iframe = document.getElementById(id);
return iframe.clientHeight >= iframe.contentDocument.body.getBoundingClientRect().height;
}, id);
await frame.locator('#vis').screenshot({path:'output/playwright/' + id + '.png'});
}
const overflow = await page.evaluate(() => Array.from(document.querySelectorAll('iframe'), frame => {
const doc = frame.contentDocument, vis = doc.getElementById('vis');
return {horizontal:frame.id !== 'wrapped' && vis.scrollWidth > vis.clientWidth + 1, vertical:doc.body.getBoundingClientRect().height > frame.clientHeight + 1};
}));
if (overflow.some(value => value.horizontal || value.vertical)) throw Error('Unexpected default scrollbars: ' + JSON.stringify(overflow));
const crowded = page.locator('#crowded').contentFrame();
const suppressed = await crowded.locator('[data-label-marker-suppressed]').evaluateAll(paths => paths.map(p => ({
start:getComputedStyle(p).markerStart, end:getComputedStyle(p).markerEnd,
extension:Number(p.dataset.labelLineExtension),
moved:Math.abs(p.getPointAtLength(0).x - Number(p.dataset.originalLabelX)),
tip:p.getPointAtLength(p.getTotalLength()).x + ',' + p.getPointAtLength(p.getTotalLength()).y,
originalTip:p.dataset.originalTip
})));
if (!suppressed.length || suppressed.some(p => p.start !== 'none' || p.end === 'none')) throw Error('Crowded label markers not suppressed correctly');
if (suppressed.some(p => p.extension <= 0 || Math.abs(p.moved - p.extension) > .01 || p.tip !== p.originalTip)) throw Error('Line extension changed the wrong endpoint');
const retained = await crowded.locator('.arcs path').evaluateAll(paths => paths.some(p => getComputedStyle(p).markerStart !== 'none' && getComputedStyle(p).markerEnd !== 'none'));
if (!retained) throw Error('All label triangles were removed');
const tree = page.frames()[1];
if (await tree.locator('.title').textContent() !== 'Parsing Output of English') throw Error('Output heading missing');
await tree.getByText('This syntactic analysis was automatically computed and can contain errors.', {exact:true}).waitFor();
const headerOrder = await tree.evaluate(() => {
const toggle = document.getElementById('toggle-view-controls').getBoundingClientRect();
const download = document.getElementById('download-svg').getBoundingClientRect();
return toggle.right <= download.left && Math.abs(toggle.top - download.top) < 5;
});
if (!headerOrder) throw Error('View button must precede exports in header');
const layersAndPadding = await tree.evaluate(() => {
const arcs = document.querySelector('#vis .arcs');
const span = document.querySelector('#vis .span');
const word = document.querySelector('tspan[data-chunk-id="0"]').getBBox();
const background = document.querySelector('#vis svg > g.highlight > rect');
return {
behind:Boolean(arcs.compareDocumentPosition(span) & Node.DOCUMENT_POSITION_FOLLOWING),
x:word.x - Number(background.getAttribute('x')),
y:word.y - Number(background.getAttribute('y')),
expectedX:Configuration.visual.margin.x,
expectedY:Configuration.visual.margin.y
};
});
if (!layersAndPadding.behind) throw Error('Arcs must be painted behind POS boxes');
if (Math.abs(layersAndPadding.x - layersAndPadding.expectedX) > .1 || Math.abs(layersAndPadding.y - layersAndPadding.expectedY) > .1) throw Error('Word background padding does not match POS padding: ' + JSON.stringify(layersAndPadding));
if (await tree.locator('#zoom-controls').isVisible()) throw Error('Controls should start hidden');
const zoom = () => tree.evaluate(() => window.visualizerDiagnostics.zoom);
if (await zoom() !== 1.4) throw Error('Initial zoom should be 140%');
await tree.getByRole('button', {name:'Show view controls'}).click();
await tree.getByRole('button', {name:'Zoom in', exact:true}).click();
if (await zoom() !== 1.6) throw Error('Zoom in failed');
await tree.getByRole('button', {name:'Zoom out', exact:true}).click();
await tree.getByRole('button', {name:'Zoom out', exact:true}).click();
if (await zoom() !== 1.2) throw Error('Zoom out failed');
await tree.getByRole('button', {name:'Reset zoom', exact:true}).click();
if (await zoom() !== 1.4) throw Error('Reset should restore 140%');
await tree.getByRole('button', {name:'Enter fullscreen', exact:true}).click();
await tree.waitForFunction(() => document.fullscreenElement?.id === 'tree-view');
await tree.getByRole('button', {name:'Exit fullscreen', exact:true}).click();
await tree.waitForFunction(() => !document.fullscreenElement);
await tree.getByRole('button', {name:'Hide view controls'}).click();
if (await tree.locator('#zoom-controls').isVisible()) throw Error('Controls did not close');
const appearance = await page.frames()[1].locator('#vis svg').evaluate(svg => ({
background: getComputedStyle(svg.querySelector('.background0')).fill,
wordFont: getComputedStyle(svg.querySelector('text')).fontFamily,
tagFont: getComputedStyle(svg.querySelector('.span text')).fontFamily,
arcFont: getComputedStyle(svg.querySelector('.arcs text')).fontFamily,
stylesheetURLs: Array.from(document.querySelectorAll('link[rel="stylesheet"]'), l => l.href).filter(url=>url.includes('jsdelivr'))
}));
if (appearance.background !== 'rgb(255, 255, 255)') throw Error('Tree background must be white');
if (!appearance.wordFont.includes('Liberation Sans') || !appearance.tagFont.includes('PT Sans Caption') || !appearance.arcFont.includes('PT Sans Caption')) throw Error('Tree font changed');
if (!appearance.stylesheetURLs.every(url => url.endsWith('.css'))) throw Error('Malformed stylesheet URL');
const styled = await page.frames()[1].locator('.arcs path').evaluateAll(paths => paths.some(p => getComputedStyle(p).stroke === 'rgb(255, 0, 0)'));
if (!styled) throw Error('Custom visual style missing');
await tree.locator('rect[data-span-id="S1-T1"]').hover();
const head = tree.locator('rect[data-span-id="S1-T2"]');
if (await head.evaluate(rect => getComputedStyle(rect).fill) !== 'rgb(214, 179, 74)') throw Error('Hovered token head not highlighted');
const wordBackground = await tree.locator('.hover-head-word').evaluateAll(rects => rects.map(rect => getComputedStyle(rect).fill));
if (!wordBackground.length || wordBackground.some(color => color !== 'rgb(214, 179, 74)')) throw Error('Head word background not yellow');
await tree.locator('tspan[data-chunk-id="0"]').hover();
if (!await head.evaluate(rect => rect.classList.contains('hover-head'))) throw Error('Word hover head not highlighted');
await tree.locator('rect[data-span-id="S1-T2"]').hover();
if (await tree.locator('.hover-head, .hover-head-word').count()) throw Error('Root should not highlight a head');
await tree.locator('rect[data-span-id="S1-T3"]').hover();
if (!await head.evaluate(rect => rect.classList.contains('hover-head'))) throw Error('Other dependent head not highlighted');
await tree.locator('#toggle-view-controls').hover();
if (await tree.locator('.hover-head, .hover-head-word').count()) throw Error('Head highlight not cleared');
await tree.locator('.arcs text').first().hover();
const hover = await tree.evaluate(() => ({
active: Array.from(document.querySelectorAll('.arcs .highlight path')).map(p => getComputedStyle(p).strokeWidth),
labels: Array.from(document.querySelectorAll('.arcs .highlight text')).map(t => getComputedStyle(t).stroke),
inactive: Array.from(document.querySelectorAll('.arcs g[data-from]:not(.highlight) path')).map(p => getComputedStyle(p).stroke)
}));
if (!hover.active.length || hover.active.some(w => w !== '1px')) throw Error('Hover thickened arrows');
if (hover.labels.some(stroke => stroke !== 'none')) throw Error('Hover outlined labels');
if (!hover.inactive.length || hover.inactive.some(color => color !== 'rgb(196, 203, 211)')) throw Error('Unrelated dependencies not dimmed');
const inactiveBorders = await tree.locator('.span:not(.highlight) rect[data-span-id]:not(.hover-head)').evaluateAll(rects => rects.map(rect => getComputedStyle(rect).stroke));
if (!inactiveBorders.length || inactiveBorders.some(color => color !== 'rgb(196, 203, 211)')) throw Error('Inactive POS borders do not match arrows');
const markers = await tree.evaluate(() => Array.from(document.querySelectorAll('.arcs g[data-from]:not(.highlight) path')).flatMap(path => {
return ['marker-start', 'marker-end'].flatMap(property => {
const reference = getComputedStyle(path).getPropertyValue(property);
if (reference === 'none') return [];
const id = reference.slice(reference.indexOf('#') + 1).replace(/["')]/g, '');
const marker = document.getElementById(id);
return Array.from(marker.querySelectorAll('path, polygon, polyline'), shape => getComputedStyle(shape).fill);
});
}));
if (!markers.length || markers.some(color => color !== 'rgb(196, 203, 211)' && color !== 'none')) throw Error('Inactive arrowheads stayed dark: ' + markers);
await page.screenshot({path:'output/playwright/hover-dimmed.png', fullPage:true});
await tree.locator('#toggle-view-controls').hover();
if (await tree.locator('.arcs .highlight').count()) throw Error('Hover state not cleared');
const restored = await tree.locator('.arcs path').evaluateAll(paths => paths.some(p => getComputedStyle(p).stroke === 'rgb(255, 0, 0)'));
if (!restored) throw Error('Original arc color not restored');
await page.screenshot({path:'output/playwright/fixtures.png', fullPage:true});
const exportStyles = await tree.evaluate(() => {
const xml = getPreparedSVG().xml;
const exported = new DOMParser().parseFromString(xml, 'image/svg+xml');
const source = document.querySelector('.span rect[data-span-id]');
const saved = exported.querySelector('.span rect[data-span-id]');
const shadowSource = document.querySelector('.shadow_AnnotatorNotes');
const shadowSaved = exported.querySelector('.shadow_AnnotatorNotes');
const arcSource = document.querySelector('.arcs path');
const arcSaved = exported.querySelector('.arcs path');
return {
border:getComputedStyle(source).stroke === saved.style.stroke,
width:getComputedStyle(source).strokeWidth === saved.style.strokeWidth,
shadow:getComputedStyle(shadowSource).fill === shadowSaved.style.fill,
arc:getComputedStyle(arcSource).stroke === arcSaved.style.stroke,
font:exported.querySelector('.span text').style.fontFamily,
embedded:xml.includes('@font-face'),
hover:exported.querySelectorAll('.hover-head, .hover-head-word').length
};
});
if (!exportStyles.border || !exportStyles.width || !exportStyles.shadow || !exportStyles.arc || !exportStyles.embedded || exportStyles.hover || !exportStyles.font.includes('PT Sans Caption')) throw Error('Export differs from rendered CSS: ' + JSON.stringify(exportStyles));
for (const extension of ['svg', 'png']) {
const pending = page.waitForEvent('download');
await page.frames()[1].getByRole('button', {name:'Download ' + extension.toUpperCase()}).click();
const download = await pending;
await download.saveAs('output/playwright/tree.' + extension);
}
const panel = page.locator('#vis-model-a');
const resizeHandle = tree.getByRole('button', {name:'Resize tree panel', exact:true});
const initialPanel = await panel.boundingBox();
const otherPanel = await page.locator('#vis-model-b').boundingBox();
const grip = await resizeHandle.boundingBox();
await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2);
await page.mouse.down();
await page.mouse.move(grip.x + grip.width / 2 - 100, grip.y + grip.height / 2 + 80, {steps:8});
await page.mouse.up();
const draggedPanel = await panel.boundingBox();
if (Math.abs(draggedPanel.width - (initialPanel.width - 100)) > 3 || Math.abs(draggedPanel.height - (initialPanel.height + 80)) > 3) throw Error('Panel drag resize failed');
await resizeHandle.focus();
await resizeHandle.press('ArrowDown');
await tree.getByRole('button', {name:'Show view controls'}).click();
await tree.getByRole('button', {name:'Zoom in', exact:true}).click();
const retainedPanel = await panel.boundingBox();
if (Math.abs(retainedPanel.height - (draggedPanel.height + 20)) > 3) throw Error('Auto sizing overrode manual height');
const untouchedPanel = await page.locator('#vis-model-b').boundingBox();
if (Math.abs(untouchedPanel.height - otherPanel.height) > 3 || Math.abs(untouchedPanel.width - otherPanel.width) > 3) throw Error('Resizing changed the other tree');
await resizeHandle.dblclick();
const resetPanel = await panel.boundingBox();
if (Math.abs(resetPanel.width - initialPanel.width) > 3) throw Error('Panel fit reset failed');
const failures = [];
for (const pattern of ['**/conllu.js', '**/lib/brat/visualizer.js']) {
await page.route(pattern, route => route.abort());
await page.goto(base + 'preview.html');
await page.waitForFunction(() => Array.from(document.querySelectorAll('iframe')).every(f => f.contentWindow.visualizerDiagnostics?.stage === 'error'), null, {timeout:60000});
const errors = await page.evaluate(() => Array.from(document.querySelectorAll('iframe'), f => ({text:f.contentDocument.getElementById('status').textContent, disabled:Array.from(f.contentDocument.querySelectorAll('button')).every(b=>b.disabled)})));
if (!errors.every(e => e.disabled && e.text.includes('Could not load'))) throw Error('Failure not surfaced');
failures.push({pattern, frames:errors.length});
await page.unroute(pattern);
}
return {parser, frames, appearance, styled, failures};
}