172 lines
11 KiB
TypeScript
172 lines
11 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
import { rootChildCount, treeParents } from './documents';
|
|
|
|
test.use({ javaScriptEnabled: true });
|
|
|
|
test('self-hosted Lyth Mono and EB Garamond load with the configured base', async ({ page }, testInfo) => {
|
|
const external: string[] = [];
|
|
const fontResponses: { url: string; status: number }[] = [];
|
|
page.on('response', (response) => {
|
|
if (response.request().resourceType() === 'font') fontResponses.push({ url: response.url(), status: response.status() });
|
|
});
|
|
page.on('request', (request) => {
|
|
if (request.resourceType() === 'font' && !request.url().startsWith(testInfo.project.use.baseURL!)) external.push(request.url());
|
|
});
|
|
// Cold dev startup may reload the document while modules initialize. Let
|
|
// initial asset traffic settle before probing FontFaceSet in that document.
|
|
await page.goto('./', { waitUntil: 'networkidle' });
|
|
const fonts = await page.evaluate(async () => {
|
|
await document.fonts.ready;
|
|
const mono = await document.fonts.load('14px "Lyth Mono"', '0W│├└─');
|
|
const bold = await document.fonts.load('700 14px "Lyth Mono"', 'WIP');
|
|
const serif = await document.fonts.load('16px "EB Garamond"', 'Worldspace');
|
|
const italic = await document.fonts.load('italic 16px "EB Garamond"', 'Worldspace');
|
|
const serifBold = await document.fonts.load('700 16px "EB Garamond"', 'Worldspace');
|
|
const boldItalic = await document.fonts.load('italic 700 16px "EB Garamond"', 'Worldspace');
|
|
return {
|
|
mono: mono.map((font) => font.status), bold: bold.map((font) => font.status),
|
|
serif: serif.map((font) => font.status), italic: italic.map((font) => font.status),
|
|
serifBold: serifBold.map((font) => font.status), boldItalic: boldItalic.map((font) => font.status),
|
|
stack: getComputedStyle(document.body).fontFamily,
|
|
prefix: document.querySelector('.branch-prefix')!.textContent!.trim(),
|
|
};
|
|
});
|
|
expect(fonts.mono).toEqual(['loaded']);
|
|
expect(fonts.bold).toEqual(['loaded']);
|
|
expect(fonts.serif).toEqual(['loaded']);
|
|
expect(fonts.italic).toEqual(['loaded']);
|
|
expect(fonts.serifBold).toEqual(['loaded']);
|
|
expect(fonts.boldItalic).toEqual(['loaded']);
|
|
const filenames = ['LythMono-Regular', 'LythMono-Bold', 'EBGaramond-Regular', 'EBGaramond-Bold', 'EBGaramond-Italic', 'EBGaramond-BoldItalic'];
|
|
expect([...new Set(fontResponses.map(({ url }) => url))].sort()).toEqual(
|
|
filenames.map((name) => new URL(`fonts/${name}.woff2`, testInfo.project.use.baseURL!).href).sort(),
|
|
);
|
|
// The dev server may revalidate the preloaded regular face after a reload.
|
|
expect(fontResponses.filter(({ status }) => status !== 200 && status !== 304)).toEqual([]);
|
|
expect(fonts.stack).toContain('Lyth Mono');
|
|
expect(fonts.prefix).toBe(''); // SSR uses the same glyph paths, not HTML wires.
|
|
expect(external).toEqual([]);
|
|
await page.keyboard.press('Escape');
|
|
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-phase', 'complete');
|
|
await expect(page.locator('[data-selected-content]').getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
|
|
expect(await page.locator('[data-selected-content]').evaluate((element) => getComputedStyle(element).fontFamily)).toContain('EB Garamond');
|
|
await page.locator('[data-selected-content]').screenshot({ path: testInfo.outputPath('eb-garamond-selected-content.png') });
|
|
const tree = await page.locator('.tree-pane').elementHandle();
|
|
await page.locator('.demo-node[data-path="/1-concept.md"]').click();
|
|
await expect(page).toHaveURL(/\/book\/1-concept\/$/);
|
|
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-phase', 'complete');
|
|
expect(await tree!.evaluate((element) => element === document.querySelector('.tree-pane'))).toBe(true);
|
|
await expect(page.locator('.tree-pane .root-vector .trunk-line')).toHaveCount(1);
|
|
await expect(page.locator('.tree-pane .trunk-reveal')).toHaveCount(1);
|
|
// Measure the actual root trunk, not branch-spine glyphs (nested prefixes have their own spines).
|
|
expect(await page.locator('.tree-pane .root-vector .trunk-reveal').evaluate((rect) => (rect as SVGRectElement).height.baseVal.value)).toBeGreaterThan(0);
|
|
expect(await page.locator('article.prose').evaluate((element) => getComputedStyle(element).fontFamily)).toContain('EB Garamond');
|
|
await page.screenshot({ path: testInfo.outputPath('eb-garamond-book.png') });
|
|
// A fresh nested route must resolve fonts relative to their stylesheet, not
|
|
// relative to the page URL, without relying on ClientRouter's persisted DOM.
|
|
await page.reload();
|
|
const reloaded = await page.evaluate(async () => {
|
|
await document.fonts.ready;
|
|
return [
|
|
(await document.fonts.load('14px "Lyth Mono"', 'WIP')).map((font) => font.status),
|
|
(await document.fonts.load('16px "EB Garamond"', 'Worldspace')).map((font) => font.status),
|
|
];
|
|
});
|
|
expect(reloaded).toEqual([['loaded'], ['loaded']]);
|
|
expect(external).toEqual([]);
|
|
});
|
|
|
|
test.describe('static font and tree rendering', () => {
|
|
test.use({ javaScriptEnabled: false, deviceScaleFactor: 4 });
|
|
test('single horizontal branches paint continuously from their stems', async ({ page }, testInfo) => {
|
|
await page.goto('./');
|
|
await page.evaluate(() => document.fonts.ready);
|
|
// Sample root and book-child joins without clipping the final branch in the scrollable tree.
|
|
await page.setViewportSize({ width: page.viewportSize()!.width, height: 2000 });
|
|
await page.locator('.tree-viewport').evaluate((el) => { (el as HTMLElement).style.height = 'auto'; });
|
|
const joins = await page.locator('.demo-node[aria-level="2"], .demo-node[aria-level="3"]').evaluateAll((nodes) => nodes.map((node) => {
|
|
const svg = node.parentElement!.querySelector<SVGSVGElement>('.branch-vector')!;
|
|
const level = Number(node.getAttribute('aria-level'));
|
|
const p = new DOMPoint((3 * (level - 2) + .5) * Number(svg.dataset.cellWidth), 12).matrixTransform(svg.getScreenCTM()!);
|
|
const end = new DOMPoint((3 * (level - 2) + 2) * Number(svg.dataset.cellWidth), 12).matrixTransform(svg.getScreenCTM()!);
|
|
const gapEnd = new DOMPoint((3 * (level - 2) + 3) * Number(svg.dataset.cellWidth), 12).matrixTransform(svg.getScreenCTM()!);
|
|
return { x: p.x, y: p.y, end: end.x, gapEnd: gapEnd.x, level };
|
|
}));
|
|
expect(rootChildCount).toBe(4);
|
|
expect(joins.filter(({ level }) => level === 2)).toHaveLength(rootChildCount);
|
|
expect(joins.filter(({ level }) => level === 3)).toHaveLength(Object.values(treeParents).filter((parent) => parent === '/book').length);
|
|
for (const [index, join] of joins.entries()) {
|
|
const clip = { x: Math.floor(join.x), y: Math.floor(join.y) - 1, width: Math.ceil(join.gapEnd) - Math.floor(join.x), height: 3 };
|
|
const png = (await page.screenshot({ clip, path: testInfo.outputPath(`glyph-join-${index}.png`) })).toString('base64');
|
|
const columns = await page.evaluate(async (png) => {
|
|
const image = new Image(); image.src = 'data:image/png;base64,' + png;
|
|
await image.decode();
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = image.width; canvas.height = image.height;
|
|
const ctx = canvas.getContext('2d')!; ctx.drawImage(image, 0, 0);
|
|
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
return Array.from({ length: canvas.width }, (_, x) => {
|
|
let darkest = 765;
|
|
for (let y = 0; y < canvas.height; y++) {
|
|
const i = (y * canvas.width + x) * 4;
|
|
darkest = Math.min(darkest, data[i] + data[i + 1] + data[i + 2]);
|
|
}
|
|
return darkest;
|
|
});
|
|
}, png);
|
|
// The hook and one horizontal glyph are followed by a blank cell.
|
|
const start = Math.ceil((join.x - clip.x + .25) * 4);
|
|
const end = Math.floor((join.end - clip.x - .25) * 4);
|
|
expect(end).toBeGreaterThan(start);
|
|
expect(columns.slice(start, end).every((ink) => ink < 710), JSON.stringify(columns)).toBe(true);
|
|
const gapStart = Math.ceil((join.end - clip.x + 1) * 4);
|
|
const gapEnd = Math.floor((join.gapEnd - clip.x - .25) * 4);
|
|
expect(gapEnd).toBeGreaterThan(gapStart);
|
|
expect(columns.slice(gapStart, gapEnd).every((ink) => ink > 710), JSON.stringify(columns)).toBe(true);
|
|
}
|
|
});
|
|
test('node names start exactly one space after the branch', async ({ page }) => {
|
|
await page.goto('./');
|
|
await page.evaluate(() => document.fonts.ready);
|
|
const spacing = await page.locator('.demo-node:not(.root-mark)').evaluateAll((nodes) => nodes.map((node) => {
|
|
const svg = node.parentElement!.querySelector<SVGSVGElement>('.branch-vector')!;
|
|
const width = svg.viewBox.baseVal.width;
|
|
const cell = Number(svg.dataset.cellWidth);
|
|
const matrix = svg.getScreenCTM()!;
|
|
const branchEnd = new DOMPoint(width - cell, 12).matrixTransform(matrix).x;
|
|
const prefixEnd = new DOMPoint(width, 12).matrixTransform(matrix).x;
|
|
const label = node.querySelector('.node-label')!.getBoundingClientRect();
|
|
return { padding: getComputedStyle(node).paddingLeft, gap: label.left - branchEnd, space: prefixEnd - branchEnd };
|
|
}));
|
|
expect(spacing.length).toBeGreaterThan(0);
|
|
for (const { padding, gap, space } of spacing) {
|
|
expect(padding).toBe('0px');
|
|
expect(Math.abs(gap - space)).toBeLessThan(.1);
|
|
}
|
|
});
|
|
test('the same SVG trunk and Lyth Mono joins are visible without JavaScript', async ({ page }, testInfo) => {
|
|
await page.goto('./');
|
|
await expect(page.locator('html')).not.toHaveAttribute('data-landing');
|
|
await expect(page.locator('.trunk-line')).toBeVisible();
|
|
await expect(page.locator('.trunk-line')).toHaveCSS('stroke', 'none');
|
|
await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '├─ ');
|
|
await expect(page.locator('.branch-prefix').first().locator('path')).toHaveCount(0);
|
|
await expect(page.locator('.branch-prefix').first().locator('use')).toHaveCount(3);
|
|
await expect(page.locator('.branch-prefix').first().locator('.branch-spine use')).toHaveAttribute('href', '#tree-glyph-9500');
|
|
await expect(page.locator('.tree-glyphs defs path')).toHaveCount(5);
|
|
await expect(page.locator('.site-header').getByRole('link', { name: 'Book', exact: true })).toBeVisible();
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
|
await page.screenshot({ path: testInfo.outputPath('lyth-mono-static.png') });
|
|
});
|
|
});
|
|
|
|
test('failed font downloads do not strand the animation or interactive demo', async ({ page }) => {
|
|
await page.route('**/*.woff2', (route) => route.abort());
|
|
await page.goto('./');
|
|
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-phase', 'armed');
|
|
await page.keyboard.press('Escape');
|
|
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-phase', 'complete');
|
|
await page.locator('.demo-node[data-path="/2-core-model.md"]').click();
|
|
await expect(page.locator('[data-selected-content] h1')).toHaveText('2. コアモデル');
|
|
});
|