fix: reduce landing to existing documents and their tree
Reference site / build (push) Canceled after 0s

This commit is contained in:
2026-10-13 19:06:31 +09:00
parent 822272cad3
commit 6e9e675c70
16 changed files with 479 additions and 1348 deletions
+22
View File
@@ -0,0 +1,22 @@
// Existing Astro book collection filenames, not a second authored guide dataset.
export const documentPaths = [
'/1-concept.md',
'/2-core-model.md',
'/2.1-object-tree-schema.md',
'/2.2-operation-and-interface.md',
'/3-protocol.md',
'/4-client.md',
'/4.1-ai-tools.md',
'/4.2-text-view.md',
'/5-transports.md',
'/5.1-wip-over-http.md',
'/draft/dynamic-worldspace-projection.md',
'/draft/filesystem-discovery.md',
'/draft/rust-api-model.md',
'/draft/typescript-api-model.md',
'/draft/unresolved-issues.md',
];
export const treePaths = ['/', ...documentPaths.filter((path) => !path.startsWith('/draft/')), '/draft', ...documentPaths.filter((path) => path.startsWith('/draft/'))];
export const treeNodeCount = treePaths.length;
export const rootChildCount = treePaths.filter((path) => path !== '/' && path.lastIndexOf('/') === 0).length;
export const bookURL = (path: string, base: string) => new URL(`book/${path.slice(1, -3).split('/').map(encodeURIComponent).join('/')}/`, base).href;
+8 -8
View File
@@ -1,4 +1,5 @@
import { expect, test } from '@playwright/test';
import { rootChildCount } from './documents';
test.use({ javaScriptEnabled: true });
@@ -30,10 +31,9 @@ test('self-hosted Lyth Mono and EB Garamond load with the configured base', asyn
expect(external).toEqual([]);
await page.keyboard.press('Escape');
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-phase', 'complete');
await page.getByRole('button', { name: '本文を読む' }).click();
await expect(page.locator('[data-result]')).toContainText('Unicode正規化');
expect(await page.locator('[data-result]').evaluate((element) => getComputedStyle(element).fontFamily)).toContain('EB Garamond');
await page.locator('.call-panel').screenshot({ path: testInfo.outputPath('eb-garamond-guide.png') });
await expect(page.locator('[data-selected-content] h1')).toHaveText('1. コンセプト');
expect(await page.locator('[data-selected-content]').evaluate((element) => getComputedStyle(element).fontFamily)).toContain('EB Garamond');
await page.locator('[data-selected-content]').screenshot({ path: testInfo.outputPath('eb-garamond-selected-content.png') });
await page.goto('./book/1-concept/');
expect(await page.locator('.prose').evaluate((element) => getComputedStyle(element).fontFamily)).toContain('EB Garamond');
await page.screenshot({ path: testInfo.outputPath('eb-garamond-book.png') });
@@ -44,7 +44,7 @@ test.describe('static font and tree rendering', () => {
test('glyph joins paint continuously across SVG cell boundaries', async ({ page }, testInfo) => {
await page.goto('./');
await page.evaluate(() => document.fonts.ready);
// Sample all four joins without clipping the final branch in the scrollable tree.
// Sample every root-child join without clipping the final branch in the scrollable tree.
await page.setViewportSize({ width: page.viewportSize()!.width, height: 2000 });
await page.locator('.tree-viewport').evaluate((el) => { (el as HTMLElement).style.height = 'auto'; });
const joins = await page.locator('.demo-node[aria-level="2"]').evaluateAll((nodes) => nodes.map((node) => {
@@ -52,7 +52,7 @@ test.describe('static font and tree rendering', () => {
const p = new DOMPoint(Number(svg.dataset.cellWidth) / 2, 12).matrixTransform(svg.getScreenCTM()!);
return { x: p.x, y: p.y };
}));
expect(joins).toHaveLength(4);
expect(joins).toHaveLength(rootChildCount);
for (const [index, join] of joins.entries()) {
const clip = { x: Math.floor(join.x), y: Math.floor(join.y) - 1, width: 14, height: 3 };
const png = (await page.screenshot({ clip, path: testInfo.outputPath(`glyph-join-${index}.png`) })).toString('base64');
@@ -99,6 +99,6 @@ test('failed font downloads do not strand the animation or interactive demo', as
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-phase', 'armed');
await page.keyboard.press('Escape');
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-phase', 'complete');
await page.getByRole('button', { name: '本文を読む' }).click();
await expect(page.locator('[data-result]')).toContainText('Unicode正規化');
await page.locator('.demo-node[data-path="/2-core-model.md"]').click();
await expect(page.locator('[data-selected-content] h1')).toHaveText('2. コアモデル');
});
+199 -260
View File
@@ -1,12 +1,20 @@
import { expect, test } from '@playwright/test';
import { documentPaths, treePaths, treeNodeCount, bookURL } from './documents';
test.use({ javaScriptEnabled: true });
const stage = '[data-landing-stage]';
const initialPath = '/design/object-tree/paths/names';
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!.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) {
@@ -147,19 +155,14 @@ for (const trigger of ['click', 'wheel', 'key']) {
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
await armed(page);
if (trigger === 'click') await page.getByRole('treeitem', { name: 'Worldspaceを開く' }).click();
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('[data-object-signature]')).toBeHidden();
await page.getByText('Object signature', { exact: true }).click();
await expect(page.locator('[data-object-signature]')).toContainText('object at "/design/object-tree/paths/names"');
await expect(page.locator('[data-result]')).not.toContainText('Unicode正規化');
await page.getByRole('button', { name: '本文を読む' }).click();
await expect(page.locator('[data-result]')).toContainText('Unicode正規化');
await expect(page.getByRole('status')).toContainText('read_body が完了');
await expect(page.locator(selectedContent)).toBeVisible();
await expect(page.locator(selectedContent + ' h1')).toHaveText('1. コンセプト');
expect(errors).toEqual([]);
});
}
@@ -342,7 +345,7 @@ test('continuous motion overlaps growth, travel and reveal without intermediate
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('.call-panel')).toHaveAttribute('inert', '');
await expect(page.locator(selectedContent)).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);
@@ -410,7 +413,7 @@ test('continuous motion overlaps growth, travel and reveal without intermediate
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 panel onset.
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()!;
@@ -426,7 +429,7 @@ test('continuous motion overlaps growth, travel and reveal without intermediate
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),
panelOpacity: Number(getComputedStyle(document.querySelector('.text-panel')!).opacity),
articleOpacity: Number(getComputedStyle(document.querySelector('[data-selected-content]')!).opacity),
sceneTransform: getComputedStyle(document.querySelector('.tree-scene')!).transform,
rowTransforms: [...document.querySelectorAll('.node-row, .demo-node')].map((row) => getComputedStyle(row).transform),
};
@@ -436,7 +439,7 @@ test('continuous motion overlaps growth, travel and reveal without intermediate
expect(overlap.spineOpacity).toBeGreaterThan(0);
expect(overlap.trunkOpacity).toBe(1); // No opacity handoff or replacement text spine.
expect(overlap.labelOpacity).toBeGreaterThan(0);
expect(overlap.panelOpacity).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())!;
@@ -444,8 +447,8 @@ test('continuous motion overlaps growth, travel and reveal without intermediate
await page.screenshot({ path: testInfo.outputPath('05-moving-tree.png') });
await page.clock.runFor(200);
expect((await root.boundingBox())!.height).toBeLessThan(growingTreeRoot.height);
await expect(page.locator('.call-panel')).toHaveAttribute('data-glitching');
expect(await page.locator('.call-panel .box-fill').first().textContent()).not.toMatch(/^─+$/);
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));
@@ -455,25 +458,16 @@ test('continuous motion overlaps growth, travel and reveal without intermediate
await page.clock.runFor(800);
await complete(page);
await expect(root).toHaveCount(1);
await expect(page.locator('.call-panel')).not.toHaveAttribute('inert');
await expect(page.locator(selectedContent)).not.toHaveAttribute('inert');
await expect(page.locator('[data-glitching]')).toHaveCount(0);
await expect(page.locator('[data-object-signature]')).toContainText('object at "/design/object-tree/paths/names"');
for (const panel of await page.locator('.ascii-panel').all()) {
await expect(panel).toHaveCSS('outline-style', 'none');
await expect(panel).toHaveCSS('border-top-width', '0px');
await expect(panel.locator('.box-corner')).toHaveText(['┌', '┐', '└', '┘']);
for (const fill of await panel.locator('.box-fill').all()) await expect(fill).toHaveText(/^─+$/);
for (const side of await panel.locator('.box-column').all()) await expect(side).toHaveText(/^[│\s]+$/);
}
await expect(page.locator(selectedContent + ' h1')).toHaveText('1. コンセプト');
await expect(page.locator('.branch-lines')).toHaveCount(0);
await expect(page.locator('.demo-node')).toHaveCount(42);
await expect(page.getByRole('treeitem')).toHaveCount(24);
await expect(page.locator('[data-tree-count]')).toHaveText('24 / 42 objects');
await expect(page.locator('.demo-badge')).toHaveText('4 AREAS / 26 GUIDES / 42 OBJECTS');
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(23);
await expect(page.locator(visiblePrefixes + ' .branch-spine use')).toHaveCount(23);
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();
@@ -489,7 +483,6 @@ test('continuous motion overlaps growth, travel and reveal without intermediate
frameCells: frame.width / cell,
rowHeights: rows.map((row) => row.height),
nodeColumns: nodes.map((node) => (node.left - frame.left) / cell),
panels: [...document.querySelectorAll('.ascii-panel')].map((panel) => panel.getBoundingClientRect().toJSON()),
font: getComputedStyle(prefix).fontFamily,
};
});
@@ -498,9 +491,6 @@ test('continuous motion overlaps growth, travel and reveal without intermediate
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);
// Prose determines panel height; the panels still must not overlap.
expect(grid.panels[1].top - grid.panels[0].bottom).toBeCloseTo(24, 4);
expect(grid.panels.every((panel) => panel.height > 0 && panel.width > 0)).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');
@@ -528,44 +518,155 @@ for (const time of [1500, 2600]) {
});
}
test('skipping an active glitch restores exact text and frames', async ({ page }) => {
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 expect(page.locator('.call-panel')).toHaveAttribute('data-glitching');
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);
await expect(page.locator('#call-result-title')).toHaveText('03 / Read');
await expect(page.locator('.call-panel .box-corner')).toHaveText(['┌', '┐', '└', '┘']);
await expect(page.locator('.call-panel .box-fill').first()).toHaveText(/^─+$/);
for (const path of treePaths.slice(1)) await expect(nodeAt(page, path).locator('.node-label')).toHaveText(path.split('/').at(-1)!);
await page.clock.runFor(2000);
await expect(page.locator('[data-object-signature]')).toContainText('object at "/design/object-tree/paths/names"');
expect(await page.locator(selectedContent).innerHTML()).toBe(html);
await page.locator('.root-mark').hover();
await expect(page.locator(stage)).not.toHaveAttribute('data-hover');
});
test('tree selection, collapse and reading are keyboard accessible in the scrollable viewport', async ({ page }) => {
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.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', path.startsWith('/draft/') ? '3' : '2');
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!));
await expect(node).not.toHaveAttribute('aria-expanded');
}
for (const path of ['/', '/draft']) {
const folder = nodeAt(page, path);
expect(await folder.evaluate((node) => node.tagName)).toBe('BUTTON');
await expect(folder).toHaveAttribute('aria-expanded', 'true');
await expect(folder).toHaveAttribute('aria-level', path === '/' ? '1' : '2');
}
expect(await page.locator('.demo-node:not(.root-mark)').evaluateAll((nodes) => nodes.every((node) =>
node.textContent === node.querySelector('.node-label')!.textContent + (node.querySelector('.node-indicator')?.textContent ?? '')
&& [...node.children].every((child) => child.matches('.node-label, .node-indicator'))
&& [...node.querySelectorAll('.node-indicator')].every((indicator) => /^[+−-]$/.test(indicator.textContent!))
))).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.html).toContain('<h1');
}
for (const path of ['/', '/draft']) {
const folder = stored.find((document) => document.path === path)!;
expect(folder.html).toBeUndefined();
expect(folder.title).toBeUndefined();
expect(folder.href).toBeUndefined();
}
await expect(nodeAt(page, initialPath)).toHaveAttribute('aria-selected', 'true');
await expect(page.locator(selectedContent)).toBeVisible();
await expect(page.locator(selectedContent + ' h1')).toHaveText('1. コンセプト');
expect(await page.locator('.landing h1, .landing h2, .landing h3, .landing h4, .landing h5, .landing h6').evaluateAll((headings) => headings.every((heading) => heading.closest('[data-selected-content]')))).toBe(true);
await checkGlyphRefs(page);
});
test('each file immediately displays exactly its Book HTML, including links, without fetching or navigating', async ({ page }, testInfo) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('./');
await complete(page);
const book = await page.context().newPage();
const bodies = new Map<string, string>();
for (const path of documentPaths) {
await book.goto(bookURL(path, testInfo.project.use.baseURL!));
bodies.set(path, await book.locator('article.prose').innerHTML());
}
await book.close();
const landingURL = page.url();
expect(await page.locator(selectedContent).innerHTML()).toBe(bodies.get(initialPath));
const requests: string[] = [];
page.on('request', (request) => requests.push(request.url()));
for (const path of documentPaths) {
// Observe the body in the same task as the click, not after waiting for a read/reveal.
const displayed = await nodeAt(page, path).evaluate((node) => {
(node as HTMLElement).click();
const article = document.querySelector<HTMLElement>('[data-selected-content]')!;
return { html: article.innerHTML, hidden: article.hidden };
});
expect(displayed).toEqual({ html: bodies.get(path), hidden: false });
await expect(nodeAt(page, path)).toHaveAttribute('aria-selected', 'true');
expect(page.url()).toBe(landingURL);
}
expect(requests).toEqual([]);
for (const path of ['/draft', '/']) {
await nodeAt(page, path).click();
await expect(page.locator(selectedContent)).toBeHidden();
expect(await page.locator(selectedContent).innerHTML()).toBe('');
}
});
test('file selection and folder collapse work with keyboard navigation in the scrollable viewport', async ({ page }) => {
await armed(page);
await page.keyboard.press('Escape');
await complete(page);
await expect(nodeAt(page, initialPath)).toBeFocused();
await page.keyboard.press('ArrowUp');
const paths = nodeAt(page, '/design/object-tree/paths');
await expect(paths).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(nodeAt(page, '/2-core-model.md')).toBeFocused();
await expect(nodeAt(page, '/2-core-model.md')).toHaveAttribute('aria-selected', 'true');
await expect(page.locator(selectedContent + ' h1')).toHaveText('2. コアモデル');
const draft = nodeAt(page, '/draft');
await draft.focus();
await page.keyboard.press('ArrowLeft');
await expect(paths).toHaveAttribute('aria-expanded', 'false');
await expect(nodeAt(page, initialPath)).toBeHidden();
await expect(draft).toHaveAttribute('aria-expanded', 'false');
for (const path of documentPaths.filter((path) => path.startsWith('/draft/'))) await expect(nodeAt(page, path)).toBeHidden();
await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount - 5);
await expect(page.locator(selectedContent)).toBeHidden();
expect(await page.locator(selectedContent).innerHTML()).toBe('');
await checkGlyphRefs(page);
await page.keyboard.press('ArrowRight');
await expect(nodeAt(page, initialPath)).toBeVisible();
await expect(draft).toHaveAttribute('aria-expanded', 'true');
await page.keyboard.press('ArrowRight');
await expect(nodeAt(page, initialPath)).toBeFocused();
await expect(nodeAt(page, documentPaths[10])).toBeFocused();
await expect(page.locator(selectedContent)).toBeVisible();
await page.keyboard.press('ArrowLeft');
await expect(paths).toBeFocused();
await expect(draft).toBeFocused();
await expect(page.locator(selectedContent)).toBeHidden();
await page.keyboard.press('Home');
const root = nodeAt(page, '/');
await expect(root).toBeFocused();
@@ -573,165 +674,31 @@ test('tree selection, collapse and reading are keyboard accessible in the scroll
await expect(page.getByRole('treeitem')).toHaveCount(1);
expect((await trunkGeometry(page)).length).toBe(0);
await page.keyboard.press('ArrowRight');
await expect(page.getByRole('treeitem')).toHaveCount(24);
await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount);
await checkGlyphRefs(page);
await page.getByRole('button', { name: 'すべて展開', exact: true }).click();
await root.focus();
await page.keyboard.press('End');
const finalLeaf = nodeAt(page, '/review/boundary-checks/failure-tests');
const finalLeaf = nodeAt(page, documentPaths.at(-1)!);
await expect(finalLeaf).toBeFocused();
await expect(finalLeaf).toHaveAttribute('aria-selected', 'true');
const scroll = await page.locator('.tree-viewport').evaluate((viewport) => {
const node = document.activeElement!.getBoundingClientRect(), box = viewport.getBoundingClientRect();
return { top: viewport.scrollTop, inside: node.top >= box.top && node.bottom <= box.bottom && node.left >= box.left && node.right <= box.right };
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 };
});
expect(scroll.top).toBeGreaterThan(0);
if (scroll.needsScroll) expect(scroll.top).toBeGreaterThan(0);
expect(scroll.inside).toBe(true);
await page.keyboard.press('ArrowUp');
await expect(nodeAt(page, '/review/boundary-checks/validation')).toBeFocused();
await expect(nodeAt(page, '/draft/typescript-api-model.md')).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(finalLeaf).toBeFocused();
await expect(page.locator('.demo-node[tabindex="0"]')).toHaveCount(1);
await page.keyboard.press('Tab');
await expect(page.locator('.text-panel summary')).toBeFocused();
await page.keyboard.press('Enter');
await expect(page.locator('[data-object-signature]')).toBeVisible();
await expect(finalLeaf).toHaveAttribute('aria-selected', 'true');
await expect(page.locator(selectedContent)).toBeVisible();
await page.keyboard.press('Space');
await expect(finalLeaf).toHaveAttribute('aria-selected', 'true');
await expect(page.locator(selectedContent)).toBeVisible();
await page.keyboard.press('Tab');
await expect(page.locator('.call-panel summary')).toBeFocused();
await page.keyboard.press('Enter');
await expect(page.locator('[data-interface-signature]')).toBeVisible();
await page.keyboard.press('Tab');
await expect(page.getByRole('button', { name: '本文を読む' })).toBeFocused();
await page.keyboard.press('Enter');
await expect(page.locator('[data-call-state]')).toHaveText('RETURNED');
await expect(page.locator('[data-result]')).toContainText('結果不明やTransport不達でcallを自動再実行しない');
});
test('expand/collapse all preserves visible selection and rehomes hidden selection, including a closed root', async ({ page }, testInfo) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('./');
await complete(page);
const expand = page.getByRole('button', { name: 'すべて展開', exact: true });
const collapse = page.getByRole('button', { name: 'たたむ', exact: true });
await expect(nodeAt(page, initialPath)).toHaveAttribute('aria-selected', 'true');
await expand.click();
await expect(page.getByRole('treeitem')).toHaveCount(42);
await expect(page.locator('[data-tree-count]')).toHaveText('42 / 42 objects');
const labelsOnly = await page.locator('.demo-node:not(.root-mark)').evaluateAll((nodes) => nodes.every((node) =>
node.textContent === node.querySelector('.node-label')!.textContent + (node.querySelector('.node-indicator')?.textContent ?? '')
));
expect(labelsOnly).toBe(true);
await expect(page.locator('.demo-node:not([aria-expanded])')).toHaveCount(26);
await expect(nodeAt(page, initialPath)).toHaveAttribute('aria-selected', 'true');
await checkGlyphRefs(page);
await collapse.click();
await expect(page.getByRole('treeitem')).toHaveCount(5);
await expect(page.locator('[data-tree-count]')).toHaveText('5 / 42 objects');
await expect(nodeAt(page, '/')).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('[data-target]')).toHaveText('/');
await expect(page.getByRole('button', { name: '本文を読む' })).toBeDisabled();
await checkGlyphRefs(page);
await nodeAt(page, '/build').click();
await expand.click();
await expect(nodeAt(page, '/build')).toHaveAttribute('aria-selected', 'true');
await collapse.click();
await expect(nodeAt(page, '/build')).toHaveAttribute('aria-selected', 'true');
await nodeAt(page, '/').focus();
await page.keyboard.press('ArrowLeft');
await expect(page.getByRole('treeitem')).toHaveCount(1);
await collapse.click(); // Regression: collapse-all must reopen a keyboard-collapsed root.
await expect(nodeAt(page, '/')).toHaveAttribute('aria-expanded', 'true');
await expect(page.getByRole('treeitem')).toHaveCount(5);
await checkGlyphRefs(page);
await page.locator('.tree-pane').screenshot({ path: testInfo.outputPath('guide-collapse-all-root-reopened.png') });
});
test('body search exposes ancestors, preserves or rehomes selection, and handles no results', async ({ page }, testInfo) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('./');
await complete(page);
const search = page.getByRole('searchbox', { name: 'コンテンツを検索' });
const expand = page.getByRole('button', { name: 'すべて展開', exact: true });
const collapse = page.getByRole('button', { name: 'たたむ', exact: true });
await search.fill('Unicode正規化'); // Body only, not a name/title/description.
await expect(page.getByRole('treeitem')).toHaveCount(5);
await expect(page.locator('[data-tree-count]')).toHaveText('5 / 42 objects');
await expect(nodeAt(page, initialPath)).toHaveAttribute('aria-selected', 'true');
await expect(expand).toBeDisabled();
await expect(collapse).toBeDisabled();
await checkGlyphRefs(page);
await expect(nodeAt(page, '/design').locator('..').locator('.branch-spine use')).toHaveAttribute('href', '#tree-glyph-9492');
const shortTrunk = (await trunkGeometry(page)).length;
await page.locator('.tree-pane').screenshot({ path: testInfo.outputPath('guide-search-body-ancestors.png') });
await search.fill('疑似ID'); // Initially visible identity, also body-only.
await expect(nodeAt(page, '/design/object-tree/paths/identity')).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('[data-target]')).toHaveText('/design/object-tree/paths/identity');
await checkGlyphRefs(page);
await search.fill('観測cache'); // A leaf behind an initially collapsed parent.
await expect(nodeAt(page, '/build/client/cache')).toBeVisible();
await expect(nodeAt(page, '/build/client/cache')).toHaveAttribute('aria-selected', 'true');
await expect(nodeAt(page, '/build/client')).toHaveAttribute('aria-expanded', 'true');
await checkGlyphRefs(page);
await search.press('Enter');
await expect(nodeAt(page, '/build/client/cache')).toBeFocused();
await page.keyboard.press('Home');
await page.keyboard.press('ArrowRight');
await expect(nodeAt(page, '/build')).toBeFocused();
await page.keyboard.press('ArrowRight');
await expect(nodeAt(page, '/build/client')).toBeFocused();
await page.keyboard.press('ArrowRight');
await expect(nodeAt(page, '/build/client/cache')).toBeFocused();
await page.keyboard.press('ArrowLeft');
await expect(nodeAt(page, '/build/client')).toBeFocused();
await expect(nodeAt(page, '/build/client')).toHaveAttribute('aria-expanded', 'true');
await search.fill('no-guide-can-match-this-98371');
await expect(page.getByRole('treeitem')).toHaveCount(1);
await expect(page.locator('[data-tree-count]')).toHaveText('一致する項目がありません');
await expect(nodeAt(page, '/')).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('[data-target]')).toHaveText('/');
expect((await trunkGeometry(page)).length).toBe(0);
await expect(page.getByRole('button', { name: '本文を読む' })).toBeDisabled();
await page.locator('.tree-pane').screenshot({ path: testInfo.outputPath('guide-search-no-results.png') });
await search.fill('');
await expect(page.getByRole('treeitem')).toHaveCount(24);
await expect(expand).toBeEnabled();
await expect(collapse).toBeEnabled();
await expect(nodeAt(page, '/build/client')).toHaveAttribute('aria-expanded', 'false');
await expect(nodeAt(page, '/')).toHaveAttribute('aria-selected', 'true');
expect((await trunkGeometry(page)).length).toBeGreaterThan(shortTrunk);
await checkGlyphRefs(page);
});
test('reading uses explicit operations, disclosure signatures, and navigable SITE_BASE-aware sources', async ({ page }, testInfo) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('./');
await complete(page);
await expect(page.locator('[data-target]')).toHaveText(initialPath);
await expect(page.locator('[data-object-signature]')).toBeHidden();
await expect(page.locator('[data-interface-signature]')).toBeHidden();
await expect(page.locator('[data-sources]')).toBeHidden();
await expect(page.locator('[data-result]')).not.toContainText('Unicode正規化');
await page.getByText('Object signature', { exact: true }).click();
await expect(page.locator('[data-object-signature]')).toBeVisible();
await expect(page.locator('[data-object-signature]')).toContainText('object at "' + initialPath + '"');
await page.getByText('Interface signature', { exact: true }).click();
await expect(page.locator('[data-interface-signature]')).toBeVisible();
await expect(page.locator('[data-interface-signature]')).toContainText('operation read_body() -> { body: string }');
await page.getByRole('button', { name: '本文を読む' }).click();
await expect(page.locator('[data-result]')).toContainText('Unicode正規化');
await expect(page.locator('[data-call-state]')).toHaveText('RETURNED');
await expect(page.getByRole('status')).toContainText('read_body が完了');
await expect(page.locator('[data-sources]')).toBeVisible();
const links = page.locator('[data-sources] a');
await expect(links).toHaveCount(2);
const base = testInfo.project.use.baseURL!;
const expected = ['book/2.1-object-tree-schema/', 'book/3-protocol/'].map((path) => new URL(path, base).href);
expect(await links.evaluateAll((anchors) => anchors.map((a) => (a as HTMLAnchorElement).href))).toEqual(expected);
await page.locator('.call-panel').screenshot({ path: testInfo.outputPath('guide-read-signatures-sources.png') });
await links.first().click();
await expect(page).toHaveURL(expected[0]);
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
await expect(page.locator('.prose')).toContainText('Object');
await expect(page.locator(selectedContent)).toBeFocused();
});
test.describe('glyph trunk regressions', () => {
@@ -773,7 +740,7 @@ test.describe('glyph trunk regressions', () => {
await complete(page);
await exposeTree(page);
const checkEnd = async (name: string) => {
// Keyboard selection can scroll wide prose summaries horizontally. Reset
// 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));
@@ -821,68 +788,55 @@ test.describe('glyph trunk regressions', () => {
}
return trunk.length;
};
let previous = await checkEnd('expanded');
for (const name of ['design', 'build', 'operate']) {
const node = page.getByRole('treeitem', { name, exact: true });
await node.focus();
await page.keyboard.press('ArrowLeft');
await expect(node).toHaveAttribute('aria-expanded', 'false');
const length = await checkEnd(name);
expect(length).toBeLessThan(previous);
previous = length;
}
const review = page.getByRole('treeitem', { name: 'review', exact: true });
await review.focus();
const expanded = await checkEnd('expanded');
const draft = nodeAt(page, '/draft');
await draft.focus();
await page.keyboard.press('ArrowLeft');
expect(await checkEnd('review')).toBe(previous); // Last child never extends the first-column spine.
await expect(draft).toHaveAttribute('aria-expanded', 'false');
// Draft is the last root child: its descendants never extend the first-column spine.
expect(await checkEnd('draft')).toBe(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(previous);
for (const name of ['design', 'build', 'operate', 'review']) {
await page.getByRole('treeitem', { name, exact: true }).focus();
await page.keyboard.press('ArrowRight');
}
expect(await checkEnd('all-reopened')).toBeGreaterThan(previous);
expect(await checkEnd('root-reopened')).toBe(expanded);
await draft.focus();
await page.keyboard.press('ArrowRight');
expect(await checkEnd('all-reopened')).toBe(expanded);
});
});
test.describe('JavaScript-free glyph tree', () => {
test.use({ javaScriptEnabled: false });
test('all 26 authored bodies are accessible without JavaScript', async ({ page }, testInfo) => {
test('initial canonical article and every file link remain usable without JavaScript', async ({ page }, testInfo) => {
await page.goto('./');
const articles = page.locator('.static-guides details');
await expect(articles).toHaveCount(26);
await expect(page.locator('.static-guides .guide-body')).toHaveCount(26);
const bodies = await page.locator('.static-guides .guide-body').allTextContents();
expect(new Set(bodies).size).toBe(26);
for (const article of await articles.all()) {
await article.locator('summary').click();
await expect(article).toHaveAttribute('open', '');
await expect(article.locator('.guide-body')).toBeVisible();
expect((await article.locator('.guide-body').textContent())!.length).toBeGreaterThan(200);
const sources = await article.locator('a').evaluateAll((links) => links.map((a) => (a as HTMLAnchorElement).href));
expect(sources.length).toBeGreaterThan(0);
for (const source of sources) expect(source.startsWith(new URL('book/', testInfo.project.use.baseURL!).href)).toBe(true);
await article.locator('summary').click();
await expect(article.locator('.guide-body')).toBeHidden();
await expect(page.locator(selectedContent)).toBeVisible();
await expect(page.locator(selectedContent + ' h1')).toHaveText('1. コンセプト');
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();
if (path === initialPath) expect(await page.locator('article.prose').innerHTML()).toBe(initialHTML);
}
await articles.last().locator('summary').click();
await articles.last().screenshot({ path: testInfo.outputPath('no-js-final-article-body.png') });
});
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('.no-script-note')).toBeVisible();
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(23);
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);
@@ -895,35 +849,14 @@ test.describe('JavaScript-free glyph tree', () => {
});
});
test('selecting another guide cancels a read reveal and resets its state', 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('Escape');
await complete(page);
await expect(page.locator('[data-call-state]')).toHaveText('READY');
await page.getByRole('button', { name: '本文を読む' }).click();
await page.clock.runFor(32);
await expect(page.locator('[data-call-state]')).toHaveText('RETURNED');
await nodeAt(page, '/design/object-tree/paths/identity').click();
await expect(page.locator('[data-call-state]')).toHaveText('READY');
await expect(page.locator('[data-result]')).toHaveCSS('opacity', '1');
await expect(page.locator('[data-result]')).toHaveCSS('transform', 'none');
await expect(page.locator('[data-sources]')).toBeHidden();
await expect(page.locator('[data-result]')).not.toContainText('Unicode正規化');
await page.clock.runFor(500);
await expect(page.locator('[data-call-state]')).toHaveText('READY');
await page.getByRole('button', { name: '本文を読む' }).click();
await expect(page.locator('[data-result]')).toContainText('疑似ID');
});
test('reduced motion opens the working demo immediately', async ({ page }) => {
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 page.getByRole('button', { name: '本文を読む' }).click();
await expect(page.locator('[data-result]')).toContainText('Unicode正規化');
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 }) => {
@@ -941,7 +874,13 @@ 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.getByRole('heading', { level: 1 })).toBeVisible();
await expect(page.locator(selectedContent + ' h1')).toHaveText('1. コンセプト');
await expect(page.locator(selectedContent)).toBeVisible();
await nodeAt(page, '/draft/rust-api-model.md').click();
await expect(page).toHaveURL(/\/book\/draft\/rust-api-model\/$/);
await expect(page.locator('article.prose h1')).toBeVisible();
await page.goto('./');
await expect(page.locator(selectedContent)).toBeVisible();
await page.getByRole('link', { name: 'Book', exact: true }).click();
await expect(page.getByRole('heading', { level: 1 })).toContainText('通信の約束まで');
});
+1 -1
View File
@@ -4,7 +4,7 @@ test('home and book are usable without client JavaScript', async ({ page }, test
const failures: string[] = [];
page.on('response', (response) => { if (response.status() >= 400) failures.push(response.url()); });
await page.goto('./');
await expect(page.getByRole('heading', { level: 1 })).toContainText('探索できる世界へ');
await expect(page.locator('[data-selected-content] h1')).toHaveText('1. コンセプト');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '├── ');
const root = await page.locator('.root-mark').boundingBox();
-109
View File
@@ -1,109 +0,0 @@
import assert from 'node:assert/strict';
import { stat } from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import { rootContent } from '../src/lib/landing-content.ts';
import { contentsRoot } from '../plugins/document-links.mjs';
// Independent traversal: the content file owns no derived paths or UI helpers.
function traverse(node, parent, depth = 1) {
const nodePath = parent ? `${parent.path === '/' ? '' : parent.path}/${node.name}` : '/';
const entry = { node, path: nodePath, parent, depth };
return [entry, ...(node.children ?? []).flatMap((child) => traverse(child, entry, depth + 1))];
}
const entries = traverse(rootContent);
const leaves = entries.filter(({ node }) => !node.children?.length);
const byPath = new Map(entries.map((entry) => [entry.path, entry]));
function visibleNodes(node) {
return 1 + (node.open ? (node.children ?? []).reduce((count, child) => count + visibleNodes(child), 0) : 0);
}
test('the tree has unique names, canonical derived paths and single structural parents', () => {
assert.equal(rootContent.name, '');
assert.equal(entries[0].path, '/');
assert.equal(entries[0].depth, 1);
assert.equal(entries[0].parent, undefined);
assert.equal(new Set(entries.map(({ node }) => node)).size, entries.length, 'nodes cannot be reused under different parents');
assert.equal(new Set(entries.map(({ node }) => node.name)).size, entries.length, 'guide names are unique across the tree');
assert.equal(byPath.size, entries.length);
for (const { node, path: nodePath, parent, depth } of entries) {
assert.ok(node.description?.trim().length >= 15, `${nodePath}: meaningful description`);
assert.equal('parent' in node, false, 'parents are derived, not stored in content');
assert.equal('path' in node, false, 'paths are derived, not stored in content');
assert.equal('depth' in node, false, 'depth is derived, not stored in content');
if (parent) {
assert.match(node.name, /^[a-z]+(?:-[a-z]+)*$/, nodePath);
assert.ok(parent.node.children.includes(node));
assert.equal(depth, parent.depth + 1);
assert.equal(byPath.get(nodePath.slice(0, nodePath.lastIndexOf('/')) || '/'), parent);
assert.equal(nodePath.split('/').at(-1), node.name);
assert.equal(nodePath.endsWith('/'), false);
}
if (node.children) {
assert.ok(node.children.length >= 2, `${nodePath}: no padding with single-child groups`);
assert.equal(new Set(node.children.map((child) => child.name)).size, node.children.length);
}
}
});
test('four ordered work areas have substantial breadth and useful depth', (t) => {
assert.deepEqual(rootContent.children.map((node) => node.name), ['design', 'build', 'operate', 'review']);
assert.ok(entries.length >= 40 && entries.length <= 50, `${entries.length} total nodes`);
assert.ok(leaves.length >= 22 && leaves.length <= 30, `${leaves.length} substantive leaves`);
const maxDepth = Math.max(...entries.map(({ depth }) => depth));
assert.ok(maxDepth >= 5 && maxDepth <= 6, `${maxDepth} levels including root`);
for (const area of rootContent.children) {
assert.ok(area.children.length >= 2, area.name);
const areaLeaves = leaves.filter(({ path: nodePath }) => nodePath.startsWith(`/${area.name}/`));
assert.ok(areaLeaves.length >= 4, `${area.name}: substantive breadth`);
}
t.diagnostic(`nodes=${entries.length}; leaves=${leaves.length}; maxDepth=${maxDepth}; initiallyVisible=${visibleNodes(rootContent)}`);
});
test('all leaves provide distinct, structured Japanese guidance and canonical section references', async (t) => {
assert.equal(new Set(leaves.map(({ node }) => node.body)).size, leaves.length, 'no repeated filler body');
for (const { node, path: nodePath } of leaves) {
assert.ok(node.title?.trim().length >= 5, `${nodePath}: title`);
assert.match(node.title, /[ぁ-んァ-ヶ一-龯]/u);
const length = Array.from(node.body ?? '').length;
assert.ok(length >= 250 && length <= 700, `${nodePath}: ${length} body characters`);
assert.match(node.body, /[ぁ-んァ-ヶ一-龯]/u);
assert.ok(node.body.split('\n\n').length >= 3, `${nodePath}: readable sections`);
assert.ok(node.body.split('\n').filter((line) => line.startsWith('・')).length >= 3, `${nodePath}: actionable checklist`);
assert.ok(node.sources?.length >= 1, `${nodePath}: canonical sources`);
for (const source of node.sources) {
// document stores a Book ID, never a URL, fragment, filename, or API draft.
assert.match(source.document, /^\d+(?:\.\d+)?-[a-z]+(?:-[a-z]+)*$/, nodePath);
assert.ok(source.label?.trim().length > 0, `${nodePath}: section label`);
const sourcePath = path.join(contentsRoot, `${source.document}.md`);
assert.ok((await stat(sourcePath)).isFile(), `${nodePath}: existing contents/${source.document}.md`);
}
}
const bodyLengths = leaves.map(({ node }) => Array.from(node.body).length);
const sourceIds = [...new Set(leaves.flatMap(({ node }) => node.sources.map(({ document }) => document)))];
t.diagnostic(`bodyCharacters=${Math.min(...bodyLengths)}..${Math.max(...bodyLengths)}; canonicalDocuments=${sourceIds.length}; all contents files exist`);
for (const { node, path: nodePath } of entries.filter(({ node }) => node.children?.length)) {
assert.equal(node.body, undefined, `${nodePath}: branches introduce, leaves explain`);
}
});
test('the default guide is reachable through the intended initial expansion', () => {
const defaultPath = '/design/object-tree/paths/names';
const selected = byPath.get(defaultPath);
assert.ok(selected);
assert.equal(selected.node.title, 'パスと名前を決める');
assert.ok(selected.node.body);
assert.equal(selected.node.children, undefined);
assert.ok(byPath.get('/design/object-tree/paths/identity')?.node.body);
for (const nodePath of ['/', '/design', '/design/object-tree', '/design/object-tree/paths', '/build', '/build/host', '/operate', '/review']) {
assert.equal(byPath.get(nodePath)?.node.open, true, `${nodePath}: open`);
}
const visible = visibleNodes(rootContent);
assert.ok(visible >= 20 && visible <= 26, `${visible} visible nodes including root`);
let ancestor = selected.parent;
while (ancestor) {
assert.equal(ancestor.node.open, true, `${ancestor.path}: selected leaf is visible`);
ancestor = ancestor.parent;
}
});
-49
View File
@@ -1,49 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { objects, childrenOf, initialObject, initialCollapsedPaths, initialVisibleObjects, visibleObjects, treePrefix, objectSignature, interfaceSignature } from '../src/lib/landing-demo.ts';
test('flattening derives paths and exposes a read contract only for articles', () => {
assert.equal(objects.length, 42);
assert.equal(initialVisibleObjects.length, 24);
assert.equal(initialObject.path, '/design/object-tree/paths/names');
for (const object of objects) {
assert.equal(object.interface, object.body ? 'document.read' : undefined);
if (object.parent) {
assert.ok(childrenOf(object.parent).includes(object));
assert.equal(object.depth, objects.find((parent) => parent.path === object.parent).depth + 1);
}
assert.match(objectSignature(object), /interfaces: \[/);
assert.equal(interfaceSignature(object).includes('read_body() -> { body: string }'), Boolean(object.body));
}
});
test('collapse affects descendants, not the Object or its siblings', () => {
const collapsed = new Set(initialCollapsedPaths);
collapsed.add('/design');
const shown = visibleObjects(collapsed);
assert.ok(shown.some((object) => object.path === '/design'));
assert.ok(!shown.some((object) => object.path.startsWith('/design/')));
assert.ok(shown.some((object) => object.path === '/build/host/dispatch'));
assert.deepEqual(visibleObjects(new Set(['/'])).map((object) => object.path), ['/']);
assert.deepEqual(visibleObjects(new Set()), objects);
});
test('search matches body terms case-insensitively and retains only actual ancestors', () => {
const shown = visibleObjects(new Set(['/']), ' OPERATIONOUTCOMEUNKNOWN rollback ');
assert.deepEqual(shown.map((object) => object.path), ['/', '/operate', '/operate/recovery', '/operate/recovery/unknown-outcomes']);
assert.deepEqual(shown.map((object) => treePrefix(object, shown)), ['', '└── ', ' └── ', ' └── ']);
assert.deepEqual(visibleObjects(new Set(), 'no-such-guide-xyz').map((object) => object.path), ['/']);
assert.deepEqual(visibleObjects(new Set(initialCollapsedPaths), ' '), initialVisibleObjects);
});
test('prefix joins follow visible siblings with four cells per ancestor', () => {
for (const shown of [initialVisibleObjects, objects, visibleObjects(new Set(), 'validator')]) {
for (const object of shown) {
const prefix = treePrefix(object, shown);
assert.equal(prefix.length, (object.depth - 1) * 4);
if (!object.parent) continue;
const siblings = shown.filter((sibling) => sibling.parent === object.parent);
assert.ok(prefix.endsWith(siblings.at(-1) === object ? '└── ' : '├── '));
}
}
});
+75
View File
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import { contentsRoot } from '../plugins/document-links.mjs';
import { documentTree, childrenOf, visibleNodes, treePrefix, serializeTree } from '../src/lib/landing-tree.ts';
async function sourceFiles(directory, prefix = '') {
const entries = await readdir(directory, { withFileTypes: true });
return (await Promise.all(entries.map(async (entry) => {
const relative = prefix + entry.name;
if (entry.isDirectory()) return sourceFiles(path.join(directory, entry.name), relative + '/');
return entry.name.endsWith('.md') ? [relative] : [];
}))).flat();
}
const files = await sourceFiles(contentsRoot);
const documents = await Promise.all(files.map(async (file) => {
const body = await readFile(path.join(contentsRoot, file), 'utf8');
return { id: file.slice(0, -3), title: body.match(/^#\s+(.+)$/m)?.[1], href: '/book/' + file.slice(0, -3) + '/', html: body };
}));
const nodes = documentTree(documents);
test('tree mirrors only real content files and their directory parents', () => {
assert.deepEqual(nodes.filter((node) => node.href).map((node) => node.path).sort(), files.map((file) => '/' + file).sort());
const directories = new Set(['/']);
for (const file of files) {
const segments = file.split('/');
for (let length = 1; length < segments.length; length++) directories.add('/' + segments.slice(0, length).join('/'));
}
assert.deepEqual(new Set(nodes.filter((node) => !node.href).map((node) => node.path)), directories);
assert.equal(new Set(nodes.map((node) => node.path)).size, nodes.length);
for (const node of nodes.slice(1)) {
assert.equal(node.name, node.path.split('/').at(-1));
assert.equal(node.depth, node.path.split('/').length);
assert.ok(childrenOf(nodes, node.parent).includes(node));
assert.ok(nodes.findIndex((parent) => parent.path === node.parent) < nodes.indexOf(node));
}
});
test('existing titles, hrefs and content pass through without added descriptions or operations', () => {
for (const document of documents) {
const node = nodes.find((node) => node.path === '/' + document.id + '.md');
assert.equal(node.html, document.html);
assert.equal(node.title, document.title);
assert.equal(node.href, document.href);
assert.equal('description' in node, false);
assert.equal('interface' in node, false);
}
for (const folder of nodes.filter((node) => !node.href)) {
assert.equal(folder.html, undefined);
assert.equal(folder.title, undefined);
}
});
test('collapse hides descendants only and joins follow the visible tree', () => {
assert.deepEqual(visibleNodes(nodes, new Set()), nodes);
assert.deepEqual(visibleNodes(nodes, new Set(['/'])), [nodes[0]]);
const collapsed = visibleNodes(nodes, new Set(['/draft']));
assert.ok(collapsed.some((node) => node.path === '/draft'));
assert.ok(!collapsed.some((node) => node.path.startsWith('/draft/')));
for (const shown of [nodes, collapsed]) {
for (const node of shown) {
const prefix = treePrefix(node, nodes, shown);
assert.equal(prefix.length, (node.depth - 1) * 4);
if (node.parent) assert.ok(prefix.endsWith(childrenOf(shown, node.parent).at(-1) === node ? '└── ' : '├── '));
}
}
});
test('embedded data cannot close its JSON script and roundtrips exact content', () => {
const input = documentTree([{ id: 'source', title: '<title>', href: '/book/source/', html: '</script><script>alert(1)</script>\u2028&' }]);
const serialized = serializeTree(input);
assert.equal(serialized.includes('<'), false);
assert.deepEqual(JSON.parse(serialized), input);
});