998 lines
57 KiB
TypeScript
998 lines
57 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
||
import { documentPaths, treePaths, treeNodeCount, bookURL, routeURL, chapterSections, treeParents } from './documents';
|
||
|
||
test.use({ javaScriptEnabled: true });
|
||
|
||
const stage = '[data-landing-stage]';
|
||
const initialPath = '/1-concept.md';
|
||
const selectedContent = '.selected-content.prose[data-selected-content]';
|
||
const nodeAt = (page: import('@playwright/test').Page, path: string) => page.locator(`.demo-node[data-path="${path}"]`);
|
||
const visiblePrefixes = '.node-row:not([hidden]) .branch-prefix';
|
||
|
||
async function expectNodeGlitch(page: import('@playwright/test').Page) {
|
||
expect(await page.locator('.demo-node:not(.root-mark)').evaluateAll((nodes) => nodes.some((node) =>
|
||
node.querySelector('.node-label')!.textContent !== ((node as HTMLElement).dataset.path === '/book' ? 'book' : (node as HTMLElement).dataset.path!.split('/').at(-1))
|
||
))).toBe(true);
|
||
}
|
||
|
||
// Ink sampling only: expose the last branch without scroll-container clipping.
|
||
// Interaction tests retain the real scrollable viewport.
|
||
async function exposeTree(page: import('@playwright/test').Page) {
|
||
await page.setViewportSize({ width: page.viewportSize()!.width, height: 2000 });
|
||
await page.locator('.tree-viewport').evaluate((el) => {
|
||
(el as HTMLElement).style.height = 'auto';
|
||
el.scrollTop = el.scrollLeft = 0;
|
||
});
|
||
await page.evaluate(() => scrollTo(0, 0));
|
||
}
|
||
|
||
async function checkGlyphRefs(page: import('@playwright/test').Page) {
|
||
const rendered = await page.locator(visiblePrefixes).evaluateAll((prefixes, parents) => {
|
||
const paths = [...document.querySelectorAll<HTMLElement>('.node-row:not([hidden]) .demo-node')].map((node) => node.dataset.path!);
|
||
const parent = (path: string) => parents[path];
|
||
const last = (path: string) => paths.filter((other) => other !== '/' && parent(other) === parent(path)).at(-1) === path;
|
||
return prefixes.map((prefix) => {
|
||
const path = prefix.parentElement!.querySelector<HTMLElement>('.demo-node')!.dataset.path!;
|
||
let expected = last(path) ? '└─ ' : '├─ ';
|
||
let ancestor = parent(path);
|
||
while (ancestor !== '/') {
|
||
expected = (last(ancestor) ? ' ' : '│ ') + expected;
|
||
ancestor = parent(ancestor);
|
||
}
|
||
const refs = [...prefix.querySelectorAll<SVGUseElement>('use[data-cell]')].map((use) => ({
|
||
cell: Number(use.dataset.cell), href: use.getAttribute('href'),
|
||
resolves: Boolean(document.querySelector(use.getAttribute('href')!)),
|
||
}));
|
||
return { actual: prefix.getAttribute('data-prefix'), expected, refs };
|
||
});
|
||
}, treeParents);
|
||
expect(rendered.length).toBeGreaterThan(0);
|
||
for (const { actual, expected, refs } of rendered) {
|
||
expect(actual).toBe(expected);
|
||
expect(refs.map(({ cell }) => cell)).toEqual([...expected].map((_, index) => index));
|
||
for (const { cell, href, resolves } of refs) {
|
||
const char = cell === 0 && !['├', '└'].includes(expected[0]) ? ' ' : expected[cell];
|
||
expect(href).toBe('#tree-glyph-' + char.codePointAt(0));
|
||
expect(resolves).toBe(true);
|
||
}
|
||
}
|
||
await expect(page.locator('.branch-prefix path')).toHaveCount(0);
|
||
await expect(page.locator('.tree-glyphs defs path')).toHaveCount(5);
|
||
const endpoint = await page.evaluate(() => {
|
||
const anchor = document.querySelector<SVGGElement>('.trunk-anchor')!;
|
||
const last = [...document.querySelectorAll<HTMLElement>('.node-row:not([hidden]) .demo-node[aria-level="2"]')].at(-1)!;
|
||
const svg = last.parentElement!.querySelector<SVGSVGElement>('.branch-vector')!;
|
||
const join = new DOMPoint(Number(svg.dataset.cellWidth) / 2, Number(anchor.dataset.branchY) + Number(anchor.dataset.branchThickness) / 2).matrixTransform(svg.getScreenCTM()!);
|
||
return { x: join.x, y: join.y };
|
||
});
|
||
const trunk = await trunkGeometry(page);
|
||
expect(Math.abs(trunk.x - endpoint.x)).toBeLessThan(.1);
|
||
expect(Math.abs(trunk.bottom - endpoint.y)).toBeLessThan(.1);
|
||
}
|
||
// Filled glyph bounds describe the ink; the anchor describes its origin.
|
||
// Path length/start points describe an outline perimeter, not the trunk axis.
|
||
async function trunkGeometry(page: import('@playwright/test').Page) {
|
||
return page.evaluate(() => {
|
||
const path = document.querySelector<SVGPathElement>('.trunk-line')!;
|
||
const anchor = document.querySelector<SVGGElement>('.trunk-anchor')!;
|
||
const reveal = document.querySelector<SVGRectElement>('.trunk-reveal')!;
|
||
const bounds = path.getBBox();
|
||
const matrix = path.getScreenCTM()!;
|
||
const anchorMatrix = anchor.getScreenCTM()!;
|
||
const start = new DOMPoint(0, 0).matrixTransform(anchorMatrix);
|
||
const tip = new DOMPoint(0, reveal.height.baseVal.value).matrixTransform(anchorMatrix);
|
||
const slash = document.querySelector('.root-shape')!.getBoundingClientRect();
|
||
const foot = new DOMPoint(12, 60).matrixTransform(document.querySelector<SVGPathElement>('.root-shape')!.getScreenCTM()!);
|
||
return {
|
||
width: bounds.width * Math.hypot(matrix.a, matrix.b),
|
||
length: reveal.height.baseVal.value,
|
||
opacity: Number(getComputedStyle(path).opacity),
|
||
stemWidth: Number(anchor.dataset.stemWidth),
|
||
slashHeight: slash.height,
|
||
rowHeight: document.querySelector('.node-row:not(.root-slot)')!.getBoundingClientRect().height,
|
||
anchorX: (start.x - slash.left) / slash.width,
|
||
gapRatio: (start.y - slash.bottom) / slash.height,
|
||
dx: tip.x - start.x, dy: tip.y - start.y,
|
||
axis: start.x - foot.x,
|
||
gap: start.y - document.querySelector('.root-mark')!.getBoundingClientRect().bottom,
|
||
top: start.y, bottom: tip.y, x: start.x,
|
||
};
|
||
});
|
||
}
|
||
async function armed(page: import('@playwright/test').Page) {
|
||
await page.goto('./');
|
||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed');
|
||
}
|
||
async function complete(page: import('@playwright/test').Page) {
|
||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete', { timeout: 10000 });
|
||
await expect(page.locator('html')).not.toHaveAttribute('data-landing');
|
||
expect((await page.locator('.root-shape').boundingBox())!.height).toBeCloseTo(12.25, 4);
|
||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||
await expect(page.locator('.site-header').getByRole('link', { name: 'Book', exact: true })).toBeVisible();
|
||
}
|
||
|
||
test('only the resting slash shrinks; intro ink and tree dimensions stay unchanged', async ({ page }, testInfo) => {
|
||
await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') });
|
||
await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z'));
|
||
await armed(page);
|
||
const slash = page.locator('.root-shape');
|
||
const initial = (await slash.boundingBox())!;
|
||
expect(initial.height).toBeCloseTo(151.2, 3); // Original 21px ink × 7.2 intro scale.
|
||
expect(initial.width).toBeCloseTo(91.8, 3);
|
||
await page.screenshot({ path: testInfo.outputPath('slash-initial-unchanged.png') });
|
||
await page.keyboard.press('Enter');
|
||
let previousTime = 0, previousHeight = initial.height;
|
||
for (const time of [500, 1400, 2200, 2800, 4000]) {
|
||
await page.clock.runFor(time - previousTime);
|
||
const ink = await trunkGeometry(page);
|
||
expect(ink.slashHeight).toBeLessThan(previousHeight);
|
||
// Shared scale is still applied to the original 24px rows and 1.33px stem.
|
||
expect(ink.width / ink.rowHeight).toBeCloseTo(1.33 / 24, 5);
|
||
expect(Math.abs(ink.axis)).toBeLessThan(.01);
|
||
previousHeight = ink.slashHeight;
|
||
previousTime = time;
|
||
}
|
||
await complete(page);
|
||
const final = (await slash.boundingBox())!;
|
||
expect(final.width).toBeCloseTo(7.4375, 4);
|
||
const dimensions = () => page.evaluate(() => ({
|
||
rows: [...document.querySelectorAll('.node-row, .demo-node, .branch-prefix')].map((el) => el.getBoundingClientRect().toJSON()),
|
||
trunk: document.querySelector('.trunk-line')!.getBoundingClientRect().toJSON(),
|
||
textSize: getComputedStyle(document.querySelector('.demo-node:not(.root-mark)')!).fontSize,
|
||
}));
|
||
const tree = await dimensions();
|
||
expect(tree.textSize).toBe('14px');
|
||
expect(tree.trunk.width).toBeCloseTo(1.33, 4);
|
||
await page.locator('.slash-art').evaluate((el) => (el as SVGGElement).style.setProperty('--slash-scale', '1'));
|
||
expect((await slash.boundingBox())!.height).toBeCloseTo(21, 4);
|
||
expect(await dimensions()).toEqual(tree); // Changing only slash ink cannot resize/reflow the tree.
|
||
await page.locator('.slash-art').evaluate((el) => (el as SVGGElement).style.removeProperty('--slash-scale'));
|
||
await page.locator('.demo-tree').screenshot({ path: testInfo.outputPath('slash-final-smaller.png') });
|
||
});
|
||
|
||
for (const trigger of ['click', 'wheel', 'key']) {
|
||
test(`one ${trigger} plays through to the interactive world without more input`, async ({ page }) => {
|
||
const errors: string[] = [];
|
||
page.on('pageerror', (error) => errors.push(error.message));
|
||
await armed(page);
|
||
if (trigger === 'click') await nodeAt(page, '/').click();
|
||
if (trigger === 'wheel') await page.mouse.wheel(0, 240);
|
||
if (trigger === 'key') await page.keyboard.press('a');
|
||
await expect(page.locator('html')).toHaveAttribute('data-landing', 'playing');
|
||
await complete(page);
|
||
await nodeAt(page, initialPath).click();
|
||
await expect(page.locator(selectedContent)).toBeVisible();
|
||
await expect(page.locator(selectedContent + ' h1')).toHaveText('1. コンセプト');
|
||
expect(errors).toEqual([]);
|
||
});
|
||
}
|
||
|
||
test('hover streams are masked at the actual slash edges throughout the loop', async ({ page }, testInfo) => {
|
||
await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') });
|
||
await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z'));
|
||
await armed(page);
|
||
const root = page.locator('.root-mark');
|
||
const box = (await root.boundingBox())!;
|
||
const shape = (await root.locator('.root-shape').boundingBox())!;
|
||
const clip = { x: Math.floor(box.x) - 8, y: Math.floor(box.y) - 8, width: Math.ceil(box.width) + 16, height: Math.ceil(box.height) + 16 };
|
||
const before = (await page.screenshot({ clip })).toString('base64');
|
||
await root.hover();
|
||
await expect(page.locator('.tap-mask')).toHaveAttribute('mask', 'url(#tap-edge-mask)');
|
||
const mask = await page.locator('#tap-edge-mask rect').evaluate((rect) => {
|
||
const svg = rect.closest('svg')!;
|
||
const matrix = svg.getScreenCTM()!;
|
||
return { top: new DOMPoint(0, 4).matrixTransform(matrix).y, bottom: new DOMPoint(0, 60).matrixTransform(matrix).y };
|
||
});
|
||
expect(mask.top).toBeCloseTo(shape.y, 1);
|
||
expect(mask.bottom).toBeCloseTo(shape.y + shape.height, 1);
|
||
let previousTime = 0;
|
||
const transforms: string[][] = [];
|
||
for (const time of [16, 700, 1399, 1416, 2100, 2799]) {
|
||
// Anime.js advances on requestAnimationFrame, not Element.getAnimations().
|
||
await page.clock.runFor(time - previousTime);
|
||
transforms.push(await page.locator('.tap-stream text').evaluateAll((texts) => texts.map((text) => getComputedStyle(text).transform)));
|
||
previousTime = time;
|
||
const after = (await page.screenshot({ clip, path: testInfo.outputPath(`hover-mask-${time}.png`) })).toString('base64');
|
||
const ink = await page.evaluate(async ({ before, after }) => {
|
||
const pixels = async (base64: string) => {
|
||
const image = new Image();
|
||
image.src = 'data:image/png;base64,' + base64;
|
||
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);
|
||
return { data: ctx.getImageData(0, 0, image.width, image.height).data, width: image.width };
|
||
};
|
||
const a = await pixels(before), b = await pixels(after);
|
||
let minY = Infinity, maxY = -Infinity, count = 0;
|
||
for (let i = 0; i < a.data.length; i += 4) {
|
||
if (Math.abs(a.data[i] - b.data[i]) + Math.abs(a.data[i + 1] - b.data[i + 1]) + Math.abs(a.data[i + 2] - b.data[i + 2]) > 30) {
|
||
const y = Math.floor(i / 4 / a.width);
|
||
minY = Math.min(minY, y); maxY = Math.max(maxY, y); count++;
|
||
}
|
||
}
|
||
return { minY, maxY, count };
|
||
}, { before, after });
|
||
expect(ink.count).toBeGreaterThan(50);
|
||
expect(ink.minY + clip.y).toBeGreaterThanOrEqual(Math.floor(shape.y));
|
||
expect(ink.maxY + clip.y).toBeLessThanOrEqual(Math.ceil(shape.y + shape.height));
|
||
}
|
||
for (let stream = 0; stream < 2; stream++) {
|
||
expect(transforms[1][stream]).not.toBe(transforms[0][stream]);
|
||
expect(transforms[2][stream]).not.toBe(transforms[1][stream]);
|
||
expect(transforms[4][stream]).not.toBe(transforms[3][stream]);
|
||
}
|
||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed');
|
||
await page.mouse.move(0, 0);
|
||
await expect(page.locator('.tap-stream-a')).toHaveCSS('opacity', '0');
|
||
await expect(page.locator('.tap-stream-b')).toHaveCSS('opacity', '0');
|
||
});
|
||
|
||
test.describe('persistent vertical trunk', () => {
|
||
test.use({ deviceScaleFactor: 4 });
|
||
|
||
test('painted top stays below the slash and at the SVG origin through completion', async ({ page }, testInfo) => {
|
||
await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') });
|
||
await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z'));
|
||
await armed(page);
|
||
await page.keyboard.press('Enter');
|
||
// Isolate the line ink: the slash could otherwise occlude a glyph extending
|
||
// upward behind it. Visibility does not change geometry or the timeline.
|
||
await page.locator('.root-shape').evaluate((shape) => { (shape as SVGElement).style.visibility = 'hidden'; });
|
||
let previous = 0;
|
||
for (const time of [2440, 2600, 2800, 4000]) {
|
||
await page.clock.runFor(time - previous);
|
||
const points = await page.locator('.root-vector').evaluate((svg) => {
|
||
const matrix = (svg as SVGSVGElement).getScreenCTM()!;
|
||
const origin = new DOMPoint(12, 64).matrixTransform(matrix);
|
||
const foot = new DOMPoint(12, 60).matrixTransform(svg.querySelector<SVGPathElement>('.root-shape')!.getScreenCTM()!);
|
||
return { x: origin.x, y: origin.y, footY: foot.y };
|
||
});
|
||
const clip = { x: Math.floor(points.x) - 2, y: Math.floor(points.y) - 8, width: 5, height: 20 };
|
||
const png = (await page.screenshot({ clip, path: testInfo.outputPath(`origin-${time}.png`) })).toString('base64');
|
||
const top = 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, image.width, image.height).data;
|
||
for (let i = 0; i < data.length; i += 4) {
|
||
if (data[i] + data[i + 1] + data[i + 2] < 690) return Math.floor(i / 4 / image.width) / devicePixelRatio;
|
||
}
|
||
return null;
|
||
}, png);
|
||
expect(top).not.toBeNull();
|
||
const paintedTop = clip.y + top!;
|
||
expect.soft(paintedTop, `line top below slash at ${time}ms`).toBeGreaterThan(points.footY);
|
||
expect.soft(Math.abs(paintedTop - points.y), `line top matches origin at ${time}ms`).toBeLessThan(.3);
|
||
previous = time;
|
||
}
|
||
await complete(page);
|
||
await page.locator('.root-shape').evaluate((shape) => { (shape as SVGElement).style.removeProperty('visibility'); });
|
||
await page.locator('.demo-tree').screenshot({ path: testInfo.outputPath('completed-origin.png') });
|
||
});
|
||
});
|
||
|
||
test('completed tree aligns the slash foot with rendered box-drawing ink', async ({ page }, testInfo) => {
|
||
await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') });
|
||
await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z'));
|
||
await armed(page);
|
||
await page.keyboard.press('Enter');
|
||
await page.clock.runFor(4000);
|
||
await complete(page);
|
||
const foot = await page.locator('.root-shape').evaluate((svg) => {
|
||
const point = new DOMPoint(12, 60).matrixTransform((svg as SVGGraphicsElement).getScreenCTM()!);
|
||
return { x: point.x, y: point.y };
|
||
});
|
||
// Sample the painted vertical stroke, below the slash and above the first
|
||
// horizontal branch. Element boxes alone missed the original 4.5px error.
|
||
const clip = { x: Math.floor(foot.x) - 6, y: Math.ceil(foot.y) + 4, width: 13, height: 5 };
|
||
const png = (await page.screenshot({ clip })).toString('base64');
|
||
const ink = 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, image.width, image.height).data;
|
||
let weight = 0, moment = 0;
|
||
for (let i = 0; i < data.length; i += 4) {
|
||
const ink = Math.max(0, 240 - (data[i] + data[i + 1] + data[i + 2]) / 3);
|
||
weight += ink;
|
||
moment += (i / 4 % image.width + .5) * ink;
|
||
}
|
||
return { weight, x: moment / weight };
|
||
}, png);
|
||
expect(ink.weight).toBeGreaterThan(100);
|
||
expect(Math.abs(clip.x + ink.x - foot.x)).toBeLessThan(.75);
|
||
await page.locator('.demo-tree').screenshot({ path: testInfo.outputPath('completed-tree.png') });
|
||
});
|
||
|
||
test('trunk thickness follows the unchanged tree scale while the slash shrinks independently', async ({ page }, testInfo) => {
|
||
await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') });
|
||
await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z'));
|
||
await armed(page);
|
||
await page.keyboard.press('Enter');
|
||
let previousTime = 0;
|
||
let previousWidth = Infinity;
|
||
for (const time of [1360, 1520, 1760, 2000, 2240, 2480, 2700]) {
|
||
await page.clock.runFor(time - previousTime);
|
||
const ink = await trunkGeometry(page);
|
||
expect(ink.opacity).toBeGreaterThan(0);
|
||
// At rest: tree ink is weighted 25% above the font's native 1.064px stem.
|
||
expect(ink.stemWidth).toBeCloseTo(1.33, 5);
|
||
expect(ink.width / ink.rowHeight).toBeCloseTo(ink.stemWidth / 24, 5);
|
||
// The foot is at x=12 within ink bounds x=7…41, not their centre.
|
||
expect(ink.anchorX).toBeCloseTo(5 / 34, 5);
|
||
expect(ink.gapRatio).toBeCloseTo(1 / 14, 5);
|
||
expect(ink.width).toBeLessThan(previousWidth);
|
||
if ([1520, 2000, 2480].includes(time)) await page.screenshot({ path: testInfo.outputPath(`proportions-${time}.png`) });
|
||
previousTime = time;
|
||
previousWidth = ink.width;
|
||
}
|
||
await page.clock.runFor(1400);
|
||
await complete(page);
|
||
});
|
||
|
||
test('continuous motion overlaps growth, travel and reveal without intermediate stops', async ({ page }, testInfo) => {
|
||
await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') });
|
||
await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z'));
|
||
await armed(page);
|
||
const root = page.locator('.root-mark');
|
||
await expect(root.locator('svg .root-shape')).toHaveCount(1);
|
||
await expect(root.locator('.root-shape')).toHaveAttribute('d', 'M31 4H41L17 60H7Z'); // The slash is geometry, not a font glyph.
|
||
await expect(page.locator('.document-pane[data-reveal]')).toHaveAttribute('inert', '');
|
||
const initial = await root.boundingBox();
|
||
expect(initial).not.toBeNull();
|
||
expect(Math.abs(initial!.x + initial!.width / 2 - page.viewportSize()!.width / 2)).toBeLessThan(2);
|
||
expect(Math.abs(initial!.y + initial!.height / 2 - page.viewportSize()!.height / 2)).toBeLessThan(2);
|
||
await page.screenshot({ path: testInfo.outputPath('01-root.png') });
|
||
await root.hover();
|
||
await expect(page.locator(stage)).toHaveAttribute('data-hover', 'true');
|
||
await page.clock.runFor(8000);
|
||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed');
|
||
await expect(page.locator('.tap-stream-a')).toHaveCSS('opacity', '1');
|
||
expect(await root.boundingBox()).toEqual(initial);
|
||
await page.screenshot({ path: testInfo.outputPath('02-hover.png') });
|
||
await page.mouse.move(0, 0);
|
||
await expect(page.locator(stage)).not.toHaveAttribute('data-hover');
|
||
await expect(page.locator('.tap-stream-a')).toHaveCSS('opacity', '0');
|
||
await root.click();
|
||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'flow');
|
||
await expect(page.locator(stage)).not.toHaveAttribute('data-hover');
|
||
await page.clock.runFor(420);
|
||
const arcLength = await page.locator('#welcome-orbit').evaluate((path) => (path as SVGPathElement).getTotalLength());
|
||
const circlePath = await page.locator('#welcome-orbit').getAttribute('d');
|
||
await page.screenshot({ path: testInfo.outputPath('03-orbit.png') });
|
||
await page.mouse.wheel(0, 180);
|
||
await page.keyboard.press('ArrowDown');
|
||
await page.mouse.click(20, 20);
|
||
// No parked pose between orbit, release and growth: every sample keeps moving.
|
||
let previous = (await root.boundingBox())!;
|
||
for (let i = 0; i < 10; i++) {
|
||
await page.clock.runFor(100);
|
||
const current = (await root.boundingBox())!;
|
||
expect(Math.hypot(current.x - previous.x, current.y - previous.y)).toBeGreaterThan(.1);
|
||
expect(current.height).toBeLessThan(previous.height);
|
||
previous = current;
|
||
}
|
||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'flow');
|
||
expect(await page.locator('#welcome-orbit').getAttribute('d')).toMatch(new RegExp('^' + circlePath + ' H'));
|
||
const tangent = await page.locator('#welcome-orbit').evaluate((element, length) => {
|
||
const path = element as SVGPathElement;
|
||
const first = path.getPointAtLength(length + 40);
|
||
const second = path.getPointAtLength(length + 80);
|
||
return { dx: second.x - first.x, dy: second.y - first.y, y: first.y };
|
||
}, arcLength);
|
||
expect(tangent.dx).toBeCloseTo(40, 1);
|
||
expect(tangent.dy).toBeCloseTo(0, 1);
|
||
expect(tangent.y).toBeCloseTo(-180, 1);
|
||
expect(Number(await page.locator('.orbit textPath').getAttribute('startOffset'))).toBeGreaterThan(arcLength / 2 + 100);
|
||
const early = await trunkGeometry(page);
|
||
const earlyLength = early.length;
|
||
const earlyRoot = (await root.boundingBox())!;
|
||
expect(early.dy).toBeGreaterThan(10);
|
||
await page.screenshot({ path: testInfo.outputPath('04-release-growth.png') });
|
||
await page.clock.runFor(260);
|
||
const extended = await trunkGeometry(page);
|
||
const extendedLength = extended.length;
|
||
const extendedRoot = (await root.boundingBox())!;
|
||
expect(extendedLength).toBeGreaterThan(earlyLength * 1.4);
|
||
expect(extended.dy).toBeGreaterThan(page.viewportSize()!.height * .15);
|
||
// The trunk grows WHILE the root travels and shrinks, never after a hold.
|
||
expect(extendedRoot.height).toBeLessThan(earlyRoot.height - 5);
|
||
expect(extendedRoot.x + extendedRoot.width / 2).toBeLessThan(earlyRoot.x + earlyRoot.width / 2 - 2);
|
||
await expect(page.locator('.demo-node:not(.root-mark)').first()).toHaveCSS('opacity', '0');
|
||
expect(Math.abs(extended.dx)).toBeLessThan(.1);
|
||
expect(Math.abs(extended.axis)).toBeLessThan(.1);
|
||
expect(extended.dy).toBeGreaterThan(0);
|
||
expect(extended.gap).toBeCloseTo(0, 1);
|
||
expect(extended.bottom).toBeLessThan(page.viewportSize()!.height);
|
||
await page.screenshot({ path: testInfo.outputPath('04-travelling-trunk.png') });
|
||
await page.clock.runFor(920); // 2600ms: moving shared scene, persistent trunk AND article onset.
|
||
const overlap = await page.evaluate(() => {
|
||
const anchor = document.querySelector<SVGGElement>('.trunk-anchor')!;
|
||
const matrix = anchor.getScreenCTM()!;
|
||
const start = new DOMPoint(0, 0).matrixTransform(matrix);
|
||
const tip = new DOMPoint(0, document.querySelector<SVGRectElement>('.trunk-reveal')!.height.baseVal.value).matrixTransform(matrix);
|
||
const first = document.querySelector<SVGSVGElement>('.branch-vector')!;
|
||
const last = [...document.querySelectorAll('.demo-node[aria-level="2"]')].at(-1)!.parentElement!.querySelector<SVGSVGElement>('.branch-vector')!;
|
||
const join = new DOMPoint(Number(last.dataset.cellWidth) / 2, Number(anchor.dataset.branchY) + Number(anchor.dataset.branchThickness) / 2).matrixTransform(last.getScreenCTM()!);
|
||
const firstTop = new DOMPoint(Number(first.dataset.cellWidth) / 2, 0).matrixTransform(first.getScreenCTM()!);
|
||
return {
|
||
start: Math.hypot(start.x - firstTop.x, start.y - firstTop.y),
|
||
end: Math.hypot(tip.x - join.x, tip.y - join.y),
|
||
spineOpacity: Number(getComputedStyle(first.querySelector('.branch-spine')!).opacity),
|
||
trunkOpacity: Number(getComputedStyle(document.querySelector('.trunk-line')!).opacity),
|
||
labelOpacity: Number(getComputedStyle(document.querySelector('.demo-node:not(.root-mark)')!).opacity),
|
||
articleOpacity: Number(getComputedStyle(document.querySelector('.document-pane[data-reveal]')!).opacity),
|
||
sceneTransform: getComputedStyle(document.querySelector('.tree-scene')!).transform,
|
||
rowTransforms: [...document.querySelectorAll('.node-row, .demo-node')].map((row) => getComputedStyle(row).transform),
|
||
};
|
||
});
|
||
expect(overlap.start).toBeLessThan(1);
|
||
expect(overlap.end).toBeLessThan(1);
|
||
expect(overlap.spineOpacity).toBeGreaterThan(0);
|
||
expect(overlap.trunkOpacity).toBe(1); // No opacity handoff or replacement text spine.
|
||
expect(overlap.labelOpacity).toBeGreaterThan(0);
|
||
expect(overlap.articleOpacity).toBeGreaterThan(0);
|
||
expect(overlap.sceneTransform).not.toBe('none');
|
||
expect(overlap.rowTransforms.every((transform) => transform === 'none')).toBe(true);
|
||
const growingTreeRoot = (await root.boundingBox())!;
|
||
expect(growingTreeRoot.height).toBeGreaterThan(24.1);
|
||
await page.screenshot({ path: testInfo.outputPath('05-moving-tree.png') });
|
||
await page.clock.runFor(200);
|
||
expect((await root.boundingBox())!.height).toBeLessThan(growingTreeRoot.height);
|
||
await expectNodeGlitch(page);
|
||
await expect(page.locator(selectedContent + ' [data-glitching]')).toHaveCount(0);
|
||
await page.screenshot({ path: testInfo.outputPath('06-overlapping-glitch.png') });
|
||
await page.clock.runFor(400);
|
||
const chromeOpacity = await page.locator('.site-header').evaluate((el) => Number(getComputedStyle(el).opacity));
|
||
expect(chromeOpacity).toBeGreaterThan(0);
|
||
expect(chromeOpacity).toBeLessThan(1);
|
||
await expect(page.locator('.site-header')).toHaveAttribute('inert', '');
|
||
await page.clock.runFor(800);
|
||
await complete(page);
|
||
await expect(root).toHaveCount(1);
|
||
await expect(page.locator('.document-pane[data-reveal]')).not.toHaveAttribute('inert');
|
||
await expect(page.locator('[data-glitching]')).toHaveCount(0);
|
||
await expect(page.locator(selectedContent).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
|
||
await expect(page.locator('.branch-lines')).toHaveCount(0);
|
||
await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount);
|
||
await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount);
|
||
await checkGlyphRefs(page);
|
||
await expect(page.locator('.root-wire')).toHaveCount(0);
|
||
await expect(page.locator(visiblePrefixes + ' svg')).toHaveCount(treeNodeCount - 1);
|
||
await expect(page.locator(visiblePrefixes + ' .branch-spine use')).toHaveCount(treeNodeCount - 1);
|
||
await expect(page.locator('.tree-scene')).toHaveCSS('transform', 'none');
|
||
const grid = await page.evaluate(() => {
|
||
const root = document.querySelector('.root-mark')!.getBoundingClientRect();
|
||
const prefix = document.querySelector<HTMLElement>('.branch-prefix')!;
|
||
const cell = prefix.getBoundingClientRect().width / prefix.dataset.prefix!.length;
|
||
const frame = document.querySelector('.landing-content')!.getBoundingClientRect();
|
||
const rows = [...document.querySelectorAll<HTMLElement>('.node-row:not(.root-slot):not([hidden])')].map((row) => row.getBoundingClientRect());
|
||
const nodes = [...document.querySelectorAll<HTMLElement>('.node-row:not([hidden]) .demo-node:not(.root-mark)')].map((node) => node.getBoundingClientRect());
|
||
const foot = new DOMPoint(12, 60).matrixTransform(document.querySelector<SVGPathElement>('.root-shape')!.getScreenCTM()!);
|
||
return {
|
||
rootColumnError: Math.abs(foot.x - prefix.getBoundingClientRect().left - cell / 2),
|
||
rootGap: Math.abs(root.bottom - prefix.getBoundingClientRect().top),
|
||
frameCells: frame.width / cell,
|
||
rowHeights: rows.map((row) => row.height),
|
||
nodeColumns: nodes.map((node) => (node.left - frame.left) / cell),
|
||
font: getComputedStyle(prefix).fontFamily,
|
||
};
|
||
});
|
||
expect(grid.rootColumnError).toBeLessThan(1);
|
||
expect(grid.rootGap).toBeLessThan(1);
|
||
expect(Math.abs(grid.frameCells / 4 - Math.round(grid.frameCells / 4))).toBeLessThan(.02);
|
||
expect(grid.rowHeights.every((height) => height === 24)).toBe(true);
|
||
expect(grid.nodeColumns.every((col) => Math.abs(col - Math.round(col)) < .02)).toBe(true);
|
||
expect(grid.font).toContain('Mono');
|
||
await page.screenshot({ path: testInfo.outputPath('06-world.png'), fullPage: true, animations: 'disabled' });
|
||
await page.keyboard.press('z');
|
||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete');
|
||
});
|
||
|
||
for (const time of [1500, 2600]) {
|
||
test(`skipping at ${time}ms removes the moving tree transforms`, async ({ page }) => {
|
||
await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') });
|
||
await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z'));
|
||
await armed(page);
|
||
await page.keyboard.press('a');
|
||
await page.clock.runFor(time);
|
||
await expect(page.locator('.tree-scene')).not.toHaveCSS('transform', 'none');
|
||
await page.keyboard.press('Escape');
|
||
await complete(page);
|
||
await expect(page.locator('.tree-scene')).toHaveCSS('transform', 'none');
|
||
for (const row of await page.locator('.node-row').all()) await expect(row).toHaveCSS('transform', 'none');
|
||
await expect(page.locator('.root-mark')).toHaveCSS('transform', 'none');
|
||
await expect(page.locator('.site-header')).toHaveCSS('opacity', '1');
|
||
await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '├─ ');
|
||
await expect(page.locator('.intro-art')).toBeHidden();
|
||
await page.clock.runFor(4000);
|
||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete');
|
||
});
|
||
}
|
||
|
||
test('skipping an active node glitch restores exact filenames without altering document HTML', async ({ page }) => {
|
||
await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') });
|
||
await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z'));
|
||
await armed(page);
|
||
const html = await page.locator(selectedContent).innerHTML();
|
||
await page.keyboard.press('a');
|
||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'flow');
|
||
await expect(page.locator('.tap-stream-a')).toHaveCSS('opacity', '0');
|
||
await page.clock.runFor(3000);
|
||
await expectNodeGlitch(page);
|
||
await expect(page.locator(selectedContent + ' [data-glitching]')).toHaveCount(0);
|
||
expect(await page.locator(selectedContent).innerHTML()).toBe(html);
|
||
await page.keyboard.press('Escape');
|
||
await complete(page);
|
||
await expect(page.locator('[data-glitching]')).toHaveCount(0);
|
||
for (const path of treePaths.slice(1)) await expect(nodeAt(page, path).locator('.node-label')).toHaveText(path === '/book' ? 'book' : path.split('/').at(-1)!);
|
||
await page.clock.runFor(2000);
|
||
expect(await page.locator(selectedContent).innerHTML()).toBe(html);
|
||
await page.locator('.root-mark').hover();
|
||
await expect(page.locator(stage)).not.toHaveAttribute('data-hover');
|
||
});
|
||
|
||
test('landing contains only the real filename tree and selected canonical content', async ({ page }, testInfo) => {
|
||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||
await page.goto('./');
|
||
await complete(page);
|
||
await expect(page.locator([
|
||
'.intro-invitation', '.tree-toolbar', '.tree-search', '[data-tree-count]', '.tree-help',
|
||
'.tree-caption', '.root-caption', '.demo-badge', '.node-description', '.ascii-panel',
|
||
'.box-frame', '.panel-title', '[data-object-signature]', '[data-interface-signature]',
|
||
'[data-result]', '[data-sources]', '.demo-call', '.demo-status', '.static-guides',
|
||
'.guide-body', '.no-script-note', '.brand-caption', '.site-footer',
|
||
'main > :not(.landing):not(script):not(style)',
|
||
].join(','))).toHaveCount(0);
|
||
await expect(page.getByRole('status')).toHaveCount(0);
|
||
await expect(page.getByRole('searchbox')).toHaveCount(0);
|
||
await expect(page.getByRole('button', { name: '本文を読む' })).toHaveCount(0);
|
||
await expect(page.getByRole('link', { name: 'Home', exact: true })).toHaveCount(0);
|
||
await expect(page.locator('.site-header')).toContainText('WIP');
|
||
await expect(page.locator('.site-header').getByRole('link', { name: 'Source', exact: true })).toBeVisible();
|
||
await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount);
|
||
await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount);
|
||
expect(await page.locator('.demo-node').evaluateAll((nodes) => nodes.map((node) => (node as HTMLElement).dataset.path))).toEqual(treePaths);
|
||
for (const path of documentPaths) {
|
||
const node = nodeAt(page, path);
|
||
await expect(node).toHaveAttribute('role', 'treeitem');
|
||
await expect(node).toHaveAttribute('aria-level', treeParents[path] === '/book' ? '3' : '4');
|
||
await expect(node.locator('.node-label')).toHaveText(path.split('/').at(-1)!);
|
||
expect(await node.evaluate((node) => node.tagName)).toBe('A');
|
||
expect(await node.evaluate((node) => (node as HTMLAnchorElement).href)).toBe(bookURL(path, testInfo.project.use.baseURL!));
|
||
if (chapterSections[path]) await expect(node).toHaveAttribute('aria-expanded', 'true');
|
||
else await expect(node).not.toHaveAttribute('aria-expanded');
|
||
}
|
||
for (const path of ['/', '/book']) {
|
||
const folder = nodeAt(page, path);
|
||
expect(await folder.evaluate((node) => node.tagName)).toBe('A');
|
||
await expect(folder).toHaveAttribute('href', new URL(path === '/' ? './' : 'book/', testInfo.project.use.baseURL!).pathname);
|
||
await expect(folder).toHaveAttribute('aria-expanded', 'true');
|
||
await expect(folder).toHaveAttribute('aria-level', path === '/' ? '1' : '2');
|
||
if (path === '/book') {
|
||
await expect(folder.locator('.node-label')).toHaveText('book');
|
||
const toggle = page.locator('.node-toggle[data-toggle-path="/book"]');
|
||
await expect(toggle).toHaveText('−');
|
||
expect(await toggle.evaluate((button) => button.tagName)).toBe('BUTTON');
|
||
expect(await toggle.evaluate((button) => button.parentElement === document.querySelector('.demo-node[data-path="/book"]')!.parentElement)).toBe(true);
|
||
await expect(folder.locator('button')).toHaveCount(0);
|
||
}
|
||
}
|
||
await expect(page.locator('.demo-node[data-path^="/draft"], .landing a[href*="/draft/"]')).toHaveCount(0);
|
||
expect(await page.locator('.demo-node:not(.root-mark)').evaluateAll((nodes) => nodes.every((node) =>
|
||
node.textContent === node.querySelector('.node-label')!.textContent
|
||
&& [...node.children].every((child) => child.matches('.node-label'))
|
||
))).toBe(true);
|
||
const payload = page.locator('script#landing-documents');
|
||
await expect(payload).toHaveCount(1);
|
||
await expect(payload).toHaveAttribute('type', 'application/json');
|
||
const serialized = (await payload.textContent())!;
|
||
expect(serialized).not.toContain('<'); // Rendered HTML cannot terminate its JSON script element.
|
||
const stored: { path: string; html?: string; title?: string; href?: string }[] = JSON.parse(serialized);
|
||
expect(stored.map((document) => document.path)).toEqual(treePaths);
|
||
for (const path of documentPaths) {
|
||
const document = stored.find((document) => document.path === path)!;
|
||
expect(document.href).toBe(new URL(bookURL(path, testInfo.project.use.baseURL!)).pathname);
|
||
expect(document.title).toBeTruthy();
|
||
expect(document).not.toHaveProperty('html');
|
||
}
|
||
for (const path of ['/', '/book']) {
|
||
const folder = stored.find((document) => document.path === path)!;
|
||
expect(folder).not.toHaveProperty('html');
|
||
expect(folder.href).toBe(new URL(path === '/' ? './' : 'book/', testInfo.project.use.baseURL!).pathname);
|
||
}
|
||
await expect(page.locator(stage)).toHaveAttribute('data-current-path', '/');
|
||
await expect(nodeAt(page, '/')).toHaveAttribute('aria-selected', 'true');
|
||
await expect(nodeAt(page, '/')).toHaveAttribute('aria-current', 'page');
|
||
await expect(nodeAt(page, initialPath)).toHaveAttribute('aria-selected', 'false');
|
||
await expect(page.locator(selectedContent)).toBeVisible();
|
||
await expect(page.locator(selectedContent).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
|
||
await expect(page.locator(selectedContent + ' .root-page[data-root-page]')).toHaveCount(1);
|
||
await expect(page.locator(selectedContent)).not.toContainText('1. コンセプト');
|
||
await expect(page.locator('[data-protocol-panel]')).toBeHidden();
|
||
expect(await page.locator('.landing :is(h1, h2, h3, h4, h5, h6):visible').evaluateAll((headings) => headings.every((heading) => heading.closest('[data-selected-content]')))).toBe(true);
|
||
await checkGlyphRefs(page);
|
||
});
|
||
|
||
test('numbered chapters nest visually, fold independently, and retain section URLs', async ({ page }, testInfo) => {
|
||
const section = '/1.1-interaction-abstraction.md';
|
||
await page.goto(bookURL(section, testInfo.project.use.baseURL!));
|
||
await complete(page);
|
||
const chapter = nodeAt(page, initialPath);
|
||
const child = nodeAt(page, section);
|
||
await expect(chapter).toHaveAttribute('aria-level', '3');
|
||
await expect(child).toHaveAttribute('aria-level', '4');
|
||
const prefix = child.locator('..').locator('.branch-prefix');
|
||
await expect(prefix).toHaveAttribute('data-prefix', '│ │ ├─ ');
|
||
await expect(nodeAt(page, '/1.4-concepts-and-scope.md').locator('..').locator('.branch-prefix')).toHaveAttribute('data-prefix', '│ │ └─ ');
|
||
const before = await page.locator(selectedContent).innerHTML();
|
||
await nodeAt(page, '/1-concept.md').locator('..').hover();
|
||
await page.locator('.node-toggle[data-toggle-path="/1-concept.md"]').click();
|
||
for (const path of chapterSections[initialPath]) await expect(nodeAt(page, path)).toBeHidden();
|
||
await expect(nodeAt(page, '/2-core-model.md')).toBeVisible();
|
||
await expect(page).toHaveURL(bookURL(section, testInfo.project.use.baseURL!));
|
||
expect(await page.locator(selectedContent).innerHTML()).toBe(before);
|
||
await expect(child).toHaveAttribute('aria-current', 'page');
|
||
await checkGlyphRefs(page);
|
||
await page.keyboard.press('ArrowRight');
|
||
await expect(child).toBeVisible();
|
||
await page.keyboard.press('ArrowRight');
|
||
await expect(child).toBeFocused();
|
||
await page.keyboard.press('ArrowLeft');
|
||
await expect(chapter).toBeFocused();
|
||
await page.keyboard.press('ArrowLeft');
|
||
await expect(child).toBeHidden();
|
||
// Other navigation preserves the collapsed chapter; returning to a child reveals it.
|
||
await nodeAt(page, '/2-core-model.md').click();
|
||
await expect(nodeAt(page, '/2-core-model.md')).toHaveAttribute('aria-current', 'page');
|
||
await expect(chapter).toHaveAttribute('aria-expanded', 'false');
|
||
await page.goBack();
|
||
await expect(child).toHaveAttribute('aria-current', 'page');
|
||
await expect(chapter).toHaveAttribute('aria-expanded', 'true');
|
||
await expect(child).toBeVisible();
|
||
await expect(child).toHaveAttribute('href', new URL(bookURL(section, testInfo.project.use.baseURL!)).pathname);
|
||
await checkGlyphRefs(page);
|
||
});
|
||
|
||
test('each file navigates to exactly its Book HTML and title, including links', async ({ page }, testInfo) => {
|
||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||
await page.goto('./');
|
||
await complete(page);
|
||
const book = await page.context().newPage();
|
||
const documents = new Map<string, { html: string; title: string }>();
|
||
for (const path of documentPaths) {
|
||
await book.goto(bookURL(path, testInfo.project.use.baseURL!));
|
||
documents.set(path, { html: await book.locator('article.prose').innerHTML(), title: await book.title() });
|
||
}
|
||
await book.close();
|
||
expect(await page.locator(selectedContent).innerHTML()).not.toBe(documents.get(initialPath)!.html);
|
||
await nodeAt(page, initialPath).click();
|
||
await expect(page).toHaveURL(bookURL(initialPath, testInfo.project.use.baseURL!));
|
||
await expect.poll(() => page.locator(selectedContent).innerHTML()).toBe(documents.get(initialPath)!.html);
|
||
for (const path of documentPaths) {
|
||
await nodeAt(page, path).click();
|
||
await expect(page).toHaveURL(bookURL(path, testInfo.project.use.baseURL!));
|
||
await expect(page.locator(stage)).toHaveAttribute('data-current-path', path);
|
||
await expect(page).toHaveTitle(documents.get(path)!.title);
|
||
await expect.poll(() => page.locator(selectedContent).innerHTML()).toBe(documents.get(path)!.html);
|
||
await expect(page.locator('article.prose')).toHaveCount(1);
|
||
await expect(nodeAt(page, path)).toHaveAttribute('aria-selected', 'true');
|
||
await expect(nodeAt(page, path)).toHaveAttribute('aria-current', 'page');
|
||
await expect(page.locator('.demo-node[aria-current="page"]')).toHaveCount(1);
|
||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete');
|
||
await expect(page.locator('html')).not.toHaveAttribute('data-landing');
|
||
}
|
||
});
|
||
|
||
test('keyboard arrows only move focus or fold; Enter and Space activate route links', async ({ page }, testInfo) => {
|
||
await armed(page);
|
||
await page.keyboard.press('Escape');
|
||
await complete(page);
|
||
const originalURL = page.url();
|
||
const originalHTML = await page.locator(selectedContent).innerHTML();
|
||
const unchanged = async () => {
|
||
expect(page.url()).toBe(originalURL);
|
||
expect(await page.locator(selectedContent).innerHTML()).toBe(originalHTML);
|
||
await expect(nodeAt(page, '/')).toHaveAttribute('aria-selected', 'true');
|
||
};
|
||
await expect(nodeAt(page, '/')).toBeFocused();
|
||
await page.keyboard.press('ArrowDown');
|
||
await expect(nodeAt(page, '/book')).toBeFocused();
|
||
await unchanged();
|
||
await page.keyboard.press('ArrowDown');
|
||
await expect(nodeAt(page, initialPath)).toBeFocused();
|
||
await unchanged();
|
||
await page.keyboard.press('ArrowDown');
|
||
await expect(nodeAt(page, '/1.1-interaction-abstraction.md')).toBeFocused();
|
||
await expect(nodeAt(page, '/1.1-interaction-abstraction.md')).toHaveAttribute('aria-selected', 'false');
|
||
await unchanged();
|
||
const book = nodeAt(page, '/book');
|
||
await book.focus();
|
||
await page.keyboard.press('ArrowLeft');
|
||
await expect(book).toHaveAttribute('aria-expanded', 'false');
|
||
for (const path of documentPaths) await expect(nodeAt(page, path)).toBeHidden();
|
||
await expect(page.getByRole('treeitem')).toHaveCount(5);
|
||
await unchanged();
|
||
await checkGlyphRefs(page);
|
||
await page.keyboard.press('ArrowRight');
|
||
await expect(book).toHaveAttribute('aria-expanded', 'true');
|
||
await page.keyboard.press('ArrowRight');
|
||
await expect(nodeAt(page, initialPath)).toBeFocused();
|
||
await unchanged();
|
||
await page.keyboard.press('ArrowLeft'); // Fold the chapter before moving to its parent.
|
||
await expect(nodeAt(page, initialPath)).toHaveAttribute('aria-expanded', 'false');
|
||
await page.keyboard.press('ArrowLeft');
|
||
await expect(book).toBeFocused();
|
||
await unchanged();
|
||
await nodeAt(page, initialPath).focus();
|
||
await page.keyboard.press('ArrowRight'); // Restore all descendants for root collapse/reopen.
|
||
await page.keyboard.press('Home');
|
||
const root = nodeAt(page, '/');
|
||
await expect(root).toBeFocused();
|
||
await page.keyboard.press('ArrowLeft');
|
||
await expect(page.getByRole('treeitem')).toHaveCount(1);
|
||
expect((await trunkGeometry(page)).length).toBe(0);
|
||
await unchanged();
|
||
await page.keyboard.press('ArrowRight');
|
||
await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount);
|
||
await checkGlyphRefs(page);
|
||
await page.keyboard.press('End');
|
||
const finalPath = '/playground';
|
||
const finalLeaf = nodeAt(page, finalPath);
|
||
await expect(finalLeaf).toBeFocused();
|
||
await expect(finalLeaf).toHaveAttribute('aria-selected', 'false');
|
||
const scroll = await page.locator('.tree-viewport').evaluate((viewport) => {
|
||
const node = document.activeElement!.getBoundingClientRect(), box = viewport.getBoundingClientRect();
|
||
return { top: viewport.scrollTop, needsScroll: viewport.scrollHeight > viewport.clientHeight, inside: node.top >= box.top && node.bottom <= box.bottom && node.left >= box.left && node.right <= box.right };
|
||
});
|
||
if (scroll.needsScroll) expect(scroll.top).toBeGreaterThan(0);
|
||
expect(scroll.inside).toBe(true);
|
||
await page.keyboard.press('ArrowUp');
|
||
const previousPath = '/packages';
|
||
await expect(nodeAt(page, previousPath)).toBeFocused();
|
||
await page.keyboard.press('ArrowDown');
|
||
await expect(finalLeaf).toBeFocused();
|
||
await unchanged();
|
||
await expect(page.locator('.demo-node[tabindex="0"]')).toHaveCount(1);
|
||
await page.keyboard.press('Enter');
|
||
await expect(page).toHaveURL(routeURL(finalPath, testInfo.project.use.baseURL!));
|
||
await expect(finalLeaf).toHaveAttribute('aria-current', 'page');
|
||
await expect(page.locator(selectedContent)).toBeVisible();
|
||
await nodeAt(page, previousPath).focus();
|
||
await page.keyboard.press('Space');
|
||
await expect(page).toHaveURL(routeURL(previousPath, testInfo.project.use.baseURL!));
|
||
await expect(nodeAt(page, previousPath)).toHaveAttribute('aria-current', 'page');
|
||
await expect(page.locator(selectedContent)).toBeVisible();
|
||
});
|
||
|
||
test.describe('glyph trunk regressions', () => {
|
||
test.use({ deviceScaleFactor: 4 });
|
||
|
||
test('one SVG glyph representation persists through growth and completion', async ({ page }) => {
|
||
await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') });
|
||
await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z'));
|
||
await armed(page);
|
||
const original = await page.locator('.trunk-line').elementHandle();
|
||
const outline = await page.locator('.trunk-line').getAttribute('d');
|
||
await page.keyboard.press('Enter');
|
||
let previous = 0;
|
||
for (const time of [1400, 2440, 2600, 2800, 4000]) {
|
||
await page.clock.runFor(time - previous);
|
||
await expect(page.locator('.trunk-line')).toHaveCount(1);
|
||
expect(await original!.evaluate((path) => path === document.querySelector('.trunk-line'))).toBe(true);
|
||
await expect(page.locator('.trunk-line')).toHaveAttribute('d', outline!);
|
||
await expect(page.locator('.trunk-line')).toHaveCSS('stroke', 'none');
|
||
await expect(page.locator('.root-vector .trunk-anchor .trunk-line')).toHaveCount(1);
|
||
await expect(page.locator('.trunk-line').locator('..')).toHaveAttribute('mask', 'url(#trunk-growth-mask)');
|
||
await expect(page.locator('.root-vector path')).toHaveCount(2); // Slash and trunk only.
|
||
await expect(page.locator('.branch-prefix text, .branch-prefix span, .root-wire, .branch-lines')).toHaveCount(0);
|
||
expect(await page.locator('.branch-prefix').evaluateAll((prefixes) => prefixes.every((prefix) => !prefix.textContent!.trim()))).toBe(true);
|
||
// First-column outlines are clipped to horizontal joins: no second spine.
|
||
const clips = await page.locator('.branch-vector clipPath rect').evaluateAll((rects) => rects.map((rect) => {
|
||
const svg = rect.closest('svg') as SVGSVGElement;
|
||
return { x: Number(rect.getAttribute('x')), width: Number(rect.getAttribute('width')), cell: Number(svg.dataset.cellWidth) };
|
||
}));
|
||
expect(clips.every(({ x, width, cell }) => Math.abs(x - cell / 2) < 1e-6 && Math.abs(width - cell / 2) < 1e-6)).toBe(true);
|
||
previous = time;
|
||
}
|
||
await complete(page);
|
||
});
|
||
|
||
test('collapse and re-expand keep the painted trunk aligned without excess spine', async ({ page }, testInfo) => {
|
||
await armed(page);
|
||
await page.keyboard.press('Escape');
|
||
await complete(page);
|
||
await exposeTree(page);
|
||
const checkEnd = async (name: string) => {
|
||
// Keyboard selection can scroll long filenames horizontally. Reset
|
||
// the sampling column before measuring, never after computing screen points.
|
||
await page.locator('.tree-viewport').evaluate((el) => { el.scrollTop = el.scrollLeft = 0; });
|
||
await page.evaluate(() => scrollTo(0, 0));
|
||
const trunk = await trunkGeometry(page);
|
||
const join = await page.evaluate(() => {
|
||
const anchor = document.querySelector<SVGGElement>('.trunk-anchor')!;
|
||
const last = [...document.querySelectorAll<HTMLElement>('.demo-node[aria-level="2"]')].filter((node) => !node.parentElement!.hidden).at(-1);
|
||
if (!last) return null;
|
||
const svg = last.parentElement!.querySelector<SVGSVGElement>('.branch-vector')!;
|
||
const point = new DOMPoint(Number(svg.dataset.cellWidth) / 2, Number(anchor.dataset.branchY) + Number(anchor.dataset.branchThickness) / 2).matrixTransform(svg.getScreenCTM()!);
|
||
return { x: point.x, y: point.y };
|
||
});
|
||
if (join) {
|
||
expect(Math.abs(trunk.x - join.x)).toBeLessThan(.1);
|
||
expect(Math.abs(trunk.bottom - join.y)).toBeLessThan(.1);
|
||
} else {
|
||
expect(trunk.length).toBe(0);
|
||
}
|
||
// Include the final painted pixel and the blank column underneath. This
|
||
// detects a path extending beyond its reveal mask or a spare text spine.
|
||
const clip = { x: Math.floor(trunk.x) - 2, y: Math.floor(trunk.bottom) - (join ? 2 : 0), width: 5, height: 28 };
|
||
// The root's keyboard focus ring crosses this column when collapsed;
|
||
// isolate tree ink without changing focus, geometry or glyph visibility.
|
||
const png = (await page.screenshot({ clip, path: testInfo.outputPath(`collapse-${name}.png`), style: '.root-mark:focus-visible { outline: none !important; }' })).toString('base64');
|
||
const pixels = 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, image.width, image.height).data;
|
||
let count = 0, bottom = -Infinity;
|
||
for (let i = 0; i < data.length; i += 4) {
|
||
if (data[i] + data[i + 1] + data[i + 2] < 690) {
|
||
count++; bottom = Math.max(bottom, (Math.floor(i / 4 / image.width) + 1) / devicePixelRatio);
|
||
}
|
||
}
|
||
return { count, bottom };
|
||
}, png);
|
||
if (join) {
|
||
expect(pixels.count).toBeGreaterThan(0);
|
||
expect(Math.abs(clip.y + pixels.bottom - trunk.bottom)).toBeLessThan(.3);
|
||
} else {
|
||
expect(pixels.count).toBe(0);
|
||
}
|
||
return trunk.length;
|
||
};
|
||
const expanded = await checkEnd('expanded');
|
||
const book = nodeAt(page, '/book');
|
||
await book.focus();
|
||
await page.keyboard.press('ArrowLeft');
|
||
await expect(book).toHaveAttribute('aria-expanded', 'false');
|
||
// Collapsing Book moves the following catalog leaves up and shortens the root spine.
|
||
const collapsed = await checkEnd('book');
|
||
expect(collapsed).toBeLessThan(expanded);
|
||
await page.keyboard.press('Home');
|
||
await page.keyboard.press('ArrowLeft');
|
||
await expect(page.getByRole('treeitem')).toHaveCount(1);
|
||
await checkEnd('root');
|
||
await page.keyboard.press('ArrowRight');
|
||
expect(await checkEnd('root-reopened')).toBe(collapsed);
|
||
await book.focus();
|
||
await page.keyboard.press('ArrowRight');
|
||
expect(await checkEnd('all-reopened')).toBe(expanded);
|
||
});
|
||
});
|
||
|
||
test.describe('JavaScript-free glyph tree', () => {
|
||
test.use({ javaScriptEnabled: false });
|
||
|
||
test('independent home article and every file link remain usable without JavaScript', async ({ page }, testInfo) => {
|
||
await page.goto('./');
|
||
await expect(page.locator(selectedContent)).toBeVisible();
|
||
await expect(page.locator(selectedContent).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
|
||
await expect(page.locator(stage)).toHaveAttribute('data-current-path', '/');
|
||
await expect(nodeAt(page, '/')).toHaveAttribute('aria-current', 'page');
|
||
await expect(nodeAt(page, '/')).toHaveAttribute('aria-selected', 'true');
|
||
await expect(page.locator(selectedContent)).not.toContainText('1. コンセプト');
|
||
const home = page.locator(selectedContent + ' .root-page[data-root-page]');
|
||
await expect(home).toContainText('Worldspace Interface Protocol');
|
||
const nav = home.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true });
|
||
await expect(nav.getByRole('link', { name: /^Book(?:\s|$)/ })).toHaveAttribute('href', new URL('book/', testInfo.project.use.baseURL!).pathname);
|
||
await expect(nav.getByRole('link', { name: /^Sources(?:\s|$)/ })).toHaveAttribute('href', new URL('sources/', testInfo.project.use.baseURL!).pathname);
|
||
await expect(nav.getByRole('link', { name: /^Packages(?:\s|$)/ })).toHaveAttribute('href', new URL('packages/', testInfo.project.use.baseURL!).pathname);
|
||
const initialHTML = await page.locator(selectedContent).innerHTML();
|
||
await expect(page.locator('.static-guides, .guide-body, .ascii-panel')).toHaveCount(0);
|
||
await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount);
|
||
for (const path of documentPaths) {
|
||
await page.goto('./');
|
||
const node = nodeAt(page, path);
|
||
expect(await node.evaluate((node) => (node as HTMLAnchorElement).href)).toBe(bookURL(path, testInfo.project.use.baseURL!));
|
||
await node.click();
|
||
await expect(page).toHaveURL(bookURL(path, testInfo.project.use.baseURL!));
|
||
await expect(page.locator('article.prose h1')).toBeVisible();
|
||
await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount);
|
||
await expect(nodeAt(page, path)).toHaveAttribute('aria-current', 'page');
|
||
await expect(page.locator(stage)).toHaveAttribute('data-current-path', path);
|
||
if (path === initialPath) {
|
||
await expect(page.locator('article.prose h1')).toHaveText('1. コンセプト');
|
||
expect(await page.locator('article.prose').innerHTML()).not.toBe(initialHTML);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('SSR paints one filled trunk and SVG branch outlines without animation code', async ({ page }, testInfo) => {
|
||
await page.goto('./');
|
||
await expect(page.locator('html')).not.toHaveAttribute('data-landing');
|
||
await expect(page.locator(selectedContent)).toBeVisible();
|
||
expect((await page.locator('.root-shape').boundingBox())!.height).toBeCloseTo(12.25, 4);
|
||
await expect(page.locator('.trunk-line')).toHaveCount(1);
|
||
await expect(page.locator('.trunk-line')).toHaveCSS('opacity', '1');
|
||
await expect(page.locator('.trunk-line')).toHaveCSS('stroke', 'none');
|
||
await expect(page.locator(visiblePrefixes + ' svg')).toHaveCount(treeNodeCount - 1);
|
||
expect(await page.locator('.branch-prefix').evaluateAll((prefixes) => prefixes.every((prefix) => !prefix.textContent!.trim()))).toBe(true);
|
||
const trunk = await trunkGeometry(page);
|
||
expect(trunk.width).toBeCloseTo(1.33, 5);
|
||
expect(trunk.length).toBeGreaterThan(0);
|
||
expect(Math.abs(trunk.axis)).toBeLessThan(.1);
|
||
expect(trunk.gapRatio).toBeCloseTo(1 / 14, 5);
|
||
await page.locator('.demo-tree').screenshot({ path: testInfo.outputPath('no-js-glyph-tree.png') });
|
||
await page.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true }).getByRole('link', { name: /^Book(?:\s|$)/ }).click();
|
||
await expect(page).toHaveURL(/\/book\/$/);
|
||
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-current', 'page');
|
||
await expect(page.locator('.landing a[href$="/book/1-concept/"]').last()).toBeVisible();
|
||
});
|
||
});
|
||
|
||
test('reduced motion opens the real tree and article immediately', async ({ page }) => {
|
||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||
await page.goto('./');
|
||
await complete(page);
|
||
await expect(page.locator('.intro-skip')).toBeHidden();
|
||
await expect(page.locator(selectedContent)).toHaveCSS('opacity', '1');
|
||
await nodeAt(page, '/2-core-model.md').click();
|
||
await expect(page.locator(selectedContent + ' h1')).toHaveText('2. コアモデル');
|
||
});
|
||
|
||
test('changing motion preference and resizing cannot strand the intro', async ({ page }) => {
|
||
await armed(page);
|
||
await page.setViewportSize({ width: 360, height: 740 });
|
||
await page.keyboard.press('Space');
|
||
await expect(page.locator('html')).toHaveAttribute('data-landing', 'playing');
|
||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||
await complete(page);
|
||
// The dedicated home is deliberately short; retain the mobile reading-scroll
|
||
// regression on the long canonical chapter rather than requiring home filler.
|
||
await nodeAt(page, initialPath).click();
|
||
await expect(page.locator(selectedContent + ' h1')).toHaveText('1. コンセプト');
|
||
// Keep the wheel outside the tree's overscroll-contained scroll viewport.
|
||
await page.mouse.move(5, page.viewportSize()!.height / 2);
|
||
await page.mouse.wheel(0, 650);
|
||
await expect.poll(() => page.evaluate(() => scrollY)).toBeGreaterThan(0);
|
||
});
|
||
|
||
test('blocked animation module fails open to readable content', async ({ page }) => {
|
||
await page.route('**/*.js', (route) => route.abort());
|
||
await page.goto('./');
|
||
await expect(page.locator('html')).not.toHaveAttribute('data-landing', { timeout: 6000 });
|
||
await expect(page.locator(selectedContent).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
|
||
await expect(page.locator(selectedContent)).toBeVisible();
|
||
await nodeAt(page, '/2-core-model.md').click();
|
||
await expect(page).toHaveURL(/\/book\/2-core-model\/$/);
|
||
await expect(page.locator('article.prose h1')).toBeVisible();
|
||
await page.goto('./');
|
||
await expect(page.locator(selectedContent)).toBeVisible();
|
||
await page.locator('.site-header').getByRole('link', { name: 'Book', exact: true }).click();
|
||
await expect(page).toHaveURL(/\/book\/$/);
|
||
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-current', 'page');
|
||
await expect(page.locator('.landing a[href$="/book/1-concept/"]').last()).toBeVisible();
|
||
});
|
||
|
||
test('touch swipe starts the uninterrupted timeline', async ({ page }, testInfo) => {
|
||
test.skip(testInfo.project.name !== 'mobile', 'Touch viewport regression');
|
||
await armed(page);
|
||
const cdp = await page.context().newCDPSession(page);
|
||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: 195, y: 550 }] });
|
||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: 195, y: 400 }] });
|
||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||
await expect(page.locator('html')).toHaveAttribute('data-landing', 'playing');
|
||
await complete(page);
|
||
await cdp.detach();
|
||
});
|