import { describe, expect, it } from 'vitest'; import { buildVaultLayout, type PageNode } from '../src/engine/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); }); });