Files

998 lines
57 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});