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('.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('.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('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('.trunk-anchor')!; const last = [...document.querySelectorAll('.node-row:not([hidden]) .demo-node[aria-level="2"]')].at(-1)!; const svg = last.parentElement!.querySelector('.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('.trunk-line')!; const anchor = document.querySelector('.trunk-anchor')!; const reveal = document.querySelector('.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('.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('.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('.trunk-anchor')!; const matrix = anchor.getScreenCTM()!; const start = new DOMPoint(0, 0).matrixTransform(matrix); const tip = new DOMPoint(0, document.querySelector('.trunk-reveal')!.height.baseVal.value).matrixTransform(matrix); const first = document.querySelector('.branch-vector')!; const last = [...document.querySelectorAll('.demo-node[aria-level="2"]')].at(-1)!.parentElement!.querySelector('.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('.branch-prefix')!; const cell = prefix.getBoundingClientRect().width / prefix.dataset.prefix!.length; const frame = document.querySelector('.landing-content')!.getBoundingClientRect(); const rows = [...document.querySelectorAll('.node-row:not(.root-slot):not([hidden])')].map((row) => row.getBoundingClientRect()); const nodes = [...document.querySelectorAll('.node-row:not([hidden]) .demo-node:not(.root-mark)')].map((node) => node.getBoundingClientRect()); const foot = new DOMPoint(12, 60).matrixTransform(document.querySelector('.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(); 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('.trunk-anchor')!; const last = [...document.querySelectorAll('.demo-node[aria-level="2"]')].filter((node) => !node.parentElement!.hidden).at(-1); if (!last) return null; const svg = last.parentElement!.querySelector('.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(); });