Files
docmost-sync/test/layout.test.ts
vvzvlad c6edd73324 refactor(pull): extract tested vault-layout module; harden pull; close review findings
Address the Increment-1 code review (3 warnings + suggestions).

- layout: new pure src/layout.ts (buildVaultLayout) — page-tree -> vault paths,
  sibling + full-path collision disambiguation (sanitized ~slugId suffix), parent
  cycle guard; pull.ts is now a thin I/O loop
- layout: resolve orphan/root collisions at the NAME stage so an orphan ancestor
  can't desync its children's folder segments (fixes review Major); covered by test
- pull: per-page try/catch (one bad page no longer aborts the mirror), bounded
  concurrency (6), progress logging, process.exitCode=1 on partial mirror
- security: filename disambiguation suffix now passes through sanitizeTitle
- docs: AGENTS.md -> Increment 1 status/structure/run targets; pull.ts meta-block
  comment; collectRecentSince JSDoc (lexicographic UTC-ISO precondition)
- tests: layout (9), markdown-document round-trip (no comments block, SPEC §3),
  firstDivergence; export firstDivergence. 49 tests green.
2026-06-16 21:09:40 +03:00

145 lines
6.2 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { buildVaultLayout, type PageNode } from '../src/layout.js';
describe('buildVaultLayout', () => {
it('disambiguates two siblings with the same sanitized title via ~slugId', () => {
const pages: PageNode[] = [
{ id: 'p1', title: 'Notes', slugId: 'slug-a', parentPageId: null },
{ id: 'p2', title: 'Notes', slugId: 'slug-b', parentPageId: null },
];
const layout = buildVaultLayout(pages);
expect(layout.get('p1')).toEqual({ segments: [], stem: 'Notes' });
expect(layout.get('p2')).toEqual({ segments: [], stem: 'Notes ~slug-b' });
});
it('falls back to ~id when a colliding sibling has no slugId', () => {
const pages: PageNode[] = [
{ id: 'p1', title: 'Notes', parentPageId: null },
{ id: 'p2', title: 'Notes', parentPageId: null },
];
const layout = buildVaultLayout(pages);
expect(layout.get('p1')?.stem).toBe('Notes');
expect(layout.get('p2')?.stem).toBe('Notes ~p2');
});
it('does NOT collide identical titles under DIFFERENT parents (distinct segments)', () => {
const pages: PageNode[] = [
{ id: 'a', title: 'Alpha', parentPageId: null },
{ id: 'b', title: 'Beta', parentPageId: null },
{ id: 'a1', title: 'Notes', parentPageId: 'a' },
{ id: 'b1', title: 'Notes', parentPageId: 'b' },
];
const layout = buildVaultLayout(pages);
// Same stem, but different folder segments => no disambiguation needed.
expect(layout.get('a1')).toEqual({ segments: ['Alpha'], stem: 'Notes' });
expect(layout.get('b1')).toEqual({ segments: ['Beta'], stem: 'Notes' });
});
it('terminates on a 2-node parent cycle and yields a finite result', () => {
const pages: PageNode[] = [
{ id: 'a', title: 'A', parentPageId: 'b' },
{ id: 'b', title: 'B', parentPageId: 'a' },
];
const layout = buildVaultLayout(pages);
// Both resolve to a finite path; the visited-guard breaks the cycle.
expect(layout.size).toBe(2);
const a = layout.get('a');
const b = layout.get('b');
expect(a).toBeDefined();
expect(b).toBeDefined();
// Each node's segment chain is bounded (no infinite walk).
expect(a!.segments.length).toBeLessThanOrEqual(2);
expect(b!.segments.length).toBeLessThanOrEqual(2);
});
it('maps a root page (parentPageId null) to empty segments', () => {
const pages: PageNode[] = [{ id: 'root', title: 'Home', parentPageId: null }];
const layout = buildVaultLayout(pages);
expect(layout.get('root')).toEqual({ segments: [], stem: 'Home' });
});
it('emits ancestors in root->leaf order for a deep chain', () => {
const pages: PageNode[] = [
{ id: 'g', title: 'Grand', parentPageId: null },
{ id: 'p', title: 'Parent', parentPageId: 'g' },
{ id: 'c', title: 'Child', parentPageId: 'p' },
];
const layout = buildVaultLayout(pages);
expect(layout.get('c')).toEqual({
segments: ['Grand', 'Parent'],
stem: 'Child',
});
});
it('disambiguates two orphan-parent pages with the same title at the path level', () => {
// Both parents are OUTSIDE the input set, so both pages bucket at the root
// with segments: []. Sibling-scoping cannot see this (different parentKeys),
// so the final full-path pass must produce DISTINCT paths.
const pages: PageNode[] = [
{ id: 'x', title: 'Orphan', slugId: 'sx', parentPageId: 'missing-1' },
{ id: 'y', title: 'Orphan', slugId: 'sy', parentPageId: 'missing-2' },
];
const layout = buildVaultLayout(pages);
const ex = layout.get('x')!;
const ey = layout.get('y')!;
const pathOf = (e: { segments: string[]; stem: string }) =>
[...e.segments, e.stem].join('/');
expect(pathOf(ex)).not.toBe(pathOf(ey));
// The first keeps the plain stem; the later one is re-stemmed.
expect(ex.stem).toBe('Orphan');
expect(ey.stem).toBe('Orphan ~sy');
});
it('sanitizes a slugId containing a path separator before using it as a suffix', () => {
// A crafted slugId with "/" must NOT leak a path separator into the stem.
const pages: PageNode[] = [
{ id: 'p1', title: 'Notes', slugId: 'a/b', parentPageId: null },
{ id: 'p2', title: 'Notes', slugId: 'c/d', parentPageId: null },
];
const layout = buildVaultLayout(pages);
const stem = layout.get('p2')!.stem;
expect(stem).not.toContain('/');
expect(stem).not.toContain('\\');
// The "/" was replaced by sanitizeTitle's dash substitution.
expect(stem).toBe('Notes ~c-d');
});
it('disambiguates two ORPHAN ancestors at the NAME pass so their children stay in sync', () => {
// Two orphan PARENTS share the same title but live under DIFFERENT missing
// parents, so sibling-scoping by raw parentPageId would never compare them.
// Both bucket at the vault root, so they MUST be disambiguated in the name
// pass (sharing the "__root__" bucket) BEFORE any child folder segment is
// computed from the parent name — otherwise re-stemming a parent post-hoc
// would desync its child's folder from the parent file.
const pages: PageNode[] = [
{ id: 'p1', title: 'Dup', slugId: 's1', parentPageId: 'missing-1' },
{ id: 'p2', title: 'Dup', slugId: 's2', parentPageId: 'missing-2' },
{ id: 'c1', title: 'Child', parentPageId: 'p1' },
{ id: 'c2', title: 'Child', parentPageId: 'p2' },
];
const layout = buildVaultLayout(pages);
const p1 = layout.get('p1')!;
const p2 = layout.get('p2')!;
const c1 = layout.get('c1')!;
const c2 = layout.get('c2')!;
// The two orphan parents get DISTINCT stems, both at the root.
expect(p1.segments).toEqual([]);
expect(p2.segments).toEqual([]);
expect(p1.stem).toBe('Dup');
expect(p2.stem).toBe('Dup ~s2');
expect(p1.stem).not.toBe(p2.stem);
// Each child's folder segment EXACTLY equals its parent's resolved stem
// (no desync): the parent name is final before segments are built.
expect(c1.segments).toEqual([p1.stem]);
expect(c2.segments).toEqual([p2.stem]);
// All four full paths are unique.
const pathOf = (e: { segments: string[]; stem: string }) =>
[...e.segments, e.stem].join('/');
const paths = [p1, p2, c1, c2].map(pathOf);
expect(new Set(paths).size).toBe(paths.length);
});
});