import { describe, expect, it } from 'vitest'; import type { DocsMetadata, ItemMeta } from '../extractor/types'; import { buildLeaves, countEntries, getPackage, groupPackages, resolveEntry, search } from './docs-index'; function item(partial: Partial & Pick): ItemMeta { return { since: '', signatures: [], params: [], returns: null, typeParams: [], examples: [], methods: [], properties: [], hasDemo: false, demoSource: '', hasTests: false, relatedTypes: [], sourcePath: '', entryPoint: '.', ...partial, }; } const metadata: DocsMetadata = { generatedAt: '2026-06-08T00:00:00.000Z', packages: [ { name: '@robonen/stdlib', version: '1.0.0', description: 'Standard library utilities', slug: 'stdlib', kind: 'api', group: 'core', entryPoints: ['.'], categories: [ { name: 'Numbers', slug: 'numbers', items: [ item({ name: 'clamp', slug: 'clamp', kind: 'function', description: 'Clamp a number to a range' }), item({ name: 'debounce', slug: 'debounce', kind: 'function', description: 'Debounce a function call' }), ], }, ], components: [], sections: [], }, { name: '@robonen/toolkit', version: '2.0.0', description: 'Vue composables', slug: 'toolkit', kind: 'components', group: 'vue', entryPoints: ['.'], categories: [], components: [ { name: 'useClipboard', slug: 'use-clipboard', description: 'Reactive clipboard access', entryPoint: './use-clipboard', parts: [], hasDemo: false, demoSource: '', sourcePath: '', }, ], sections: [], }, { name: '@robonen/eslint', version: '3.0.0', description: 'Shared ESLint config', slug: 'eslint', kind: 'guide', group: 'configs', entryPoints: ['.'], categories: [], components: [], sections: [ { title: 'Imports preset', slug: 'imports', markdown: '# Imports\nSorts and dedupes imports.' }, ], }, ], }; describe('buildLeaves', () => { it('flattens every leaf across all package kinds', () => { const leaves = buildLeaves(metadata); expect(leaves).toHaveLength(4); expect(leaves.map(l => l.slug).sort()).toEqual(['clamp', 'debounce', 'imports', 'use-clipboard']); }); it('tags each leaf with the right badge', () => { const byName = new Map(buildLeaves(metadata).map(l => [l.name, l.badge])); expect(byName.get('clamp')).toBe('function'); expect(byName.get('useClipboard')).toBe('component'); expect(byName.get('Imports preset')).toBe('guide'); }); }); describe('search', () => { const leaves = buildLeaves(metadata); it('returns empty for a blank query', () => { expect(search(leaves, ' ')).toEqual([]); }); it('ranks an exact name match first', () => { const hits = search(leaves, 'clamp'); expect(hits[0]?.name).toBe('clamp'); expect(hits[0]?.packageSlug).toBe('stdlib'); }); it('matches on description body, not just names', () => { const hits = search(leaves, 'clipboard'); expect(hits.map(h => h.name)).toContain('useClipboard'); }); it('applies AND semantics across tokens', () => { expect(search(leaves, 'clamp clipboard')).toEqual([]); }); it('honours the limit', () => { expect(search(leaves, 'a', 1)).toHaveLength(1); }); }); describe('getPackage / resolveEntry', () => { const leaves = buildLeaves(metadata); it('finds a package by slug, case-insensitively', () => { expect(getPackage(metadata, 'STDLIB')?.name).toBe('@robonen/stdlib'); expect(getPackage(metadata, 'nope')).toBeUndefined(); }); it('resolves an api item by slug or exported name', () => { expect(resolveEntry(leaves, 'stdlib', 'clamp')?.kind).toBe('api'); expect(resolveEntry(leaves, 'stdlib', 'Clamp')?.kind).toBe('api'); }); it('resolves a component by slug and by name', () => { expect(resolveEntry(leaves, 'toolkit', 'use-clipboard')?.kind).toBe('components'); expect(resolveEntry(leaves, 'toolkit', 'useClipboard')?.kind).toBe('components'); }); it('resolves a guide section', () => { const entry = resolveEntry(leaves, 'eslint', 'imports'); expect(entry?.kind).toBe('guide'); }); it('returns undefined for an unknown item', () => { expect(resolveEntry(leaves, 'stdlib', 'missing')).toBeUndefined(); }); }); describe('slug uniqueness & collisions', () => { // A function and a co-located type/interface whose names differ only in case // both slugify to the same value — the real extractor produces these in // @robonen/writekit and @robonen/vue. const colliding: DocsMetadata = { generatedAt: '2026-06-08T00:00:00.000Z', packages: [ { name: '@robonen/writekit', version: '1.0.0', description: 'Writekit', slug: 'writekit', kind: 'api', group: 'vue', entryPoints: ['.'], categories: [ { name: 'Model', slug: 'model', items: [ item({ name: 'position', slug: 'position', kind: 'function', description: 'Create a position' }), item({ name: 'Position', slug: 'position', kind: 'interface', description: 'A position' }), ], }, ], components: [], sections: [], }, ], }; it('disambiguates colliding slugs so every (package, slug) pair is unique', () => { const leaves = buildLeaves(colliding); const slugs = leaves.map(l => l.slug); expect(slugs).toEqual(['position', 'position-interface']); expect(new Set(slugs).size).toBe(slugs.length); }); it('reaches both colliding symbols — function and interface — independently', () => { const leaves = buildLeaves(colliding); // Exact case-sensitive name disambiguates the function from the interface. const fn = resolveEntry(leaves, 'writekit', 'position'); const iface = resolveEntry(leaves, 'writekit', 'Position'); expect(fn?.kind === 'api' && fn.item.kind).toBe('function'); expect(iface?.kind === 'api' && iface.item.kind).toBe('interface'); // The disambiguated slug also resolves the interface directly. const bySlug = resolveEntry(leaves, 'writekit', 'position-interface'); expect(bySlug?.kind === 'api' && bySlug.item.kind).toBe('interface'); }); it('throws when a slug contains a URI-reserved character', () => { const bad: DocsMetadata = { generatedAt: '2026-06-08T00:00:00.000Z', packages: [{ name: '@robonen/x', version: '1.0.0', description: '', slug: 'x', kind: 'guide', group: 'infra', entryPoints: ['.'], categories: [], components: [], sections: [{ title: 'Nested', slug: 'rules/no-foo', markdown: '#' }], }], }; expect(() => buildLeaves(bad)).toThrow(/reserved URI character/); }); }); describe('grouping helpers', () => { it('orders groups core → vue → configs and drops empties', () => { expect(groupPackages(metadata).map(g => g.group)).toEqual(['core', 'vue', 'configs']); }); it('counts entries per package kind', () => { expect(countEntries(metadata.packages[0]!)).toBe(2); expect(countEntries(metadata.packages[1]!)).toBe(1); expect(countEntries(metadata.packages[2]!)).toBe(1); }); });