import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { PieceCatalog } from './piece-catalog.js'; // Minimal valid piece YAML helper function makeYaml(name: string, description: string, keywords: string[] = []): string { const kwBlock = keywords.length > 0 ? `triggers:\n keywords:\n${keywords.map(k => ` - ${k}`).join('\n')}\n` : ''; return `name: ${name}\ndescription: |\n ${description}\n${kwBlock}movements: []\n`; } let tmpRoot: string; let builtinDir: string; let dataDir: string; beforeEach(() => { tmpRoot = mkdtempSync(join(tmpdir(), 'piece-catalog-')); builtinDir = join(tmpRoot, 'pieces'); dataDir = join(tmpRoot, 'data'); mkdirSync(builtinDir, { recursive: true }); mkdirSync(dataDir, { recursive: true }); }); afterEach(() => { rmSync(tmpRoot, { recursive: true, force: true }); }); describe('PieceCatalog', () => { it('returns built-ins when user has no custom pieces', () => { writeFileSync(join(builtinDir, 'chat.yaml'), makeYaml('chat', 'Chat piece')); writeFileSync(join(builtinDir, 'research.yaml'), makeYaml('research', 'Research piece', ['調査'])); const catalog = new PieceCatalog(builtinDir, dataDir); const entries = catalog.getForUser('user-1'); expect(entries).toHaveLength(2); const chat = entries.find(e => e.name === 'chat'); expect(chat).toBeDefined(); expect(chat!.source).toBe('builtin'); expect(chat!.description).toContain('Chat piece'); const research = entries.find(e => e.name === 'research'); expect(research!.keywords).toContain('調査'); }); it('layers user pieces on top of built-ins (same name → custom wins)', () => { writeFileSync(join(builtinDir, 'chat.yaml'), makeYaml('chat', 'Built-in chat')); writeFileSync(join(builtinDir, 'general.yaml'), makeYaml('general', 'Built-in general')); // Create user custom pieces dir const userPiecesDir = join(dataDir, 'user-2', 'pieces'); mkdirSync(userPiecesDir, { recursive: true }); writeFileSync(join(userPiecesDir, 'chat.yaml'), makeYaml('chat', 'Custom chat override')); writeFileSync(join(userPiecesDir, 'my-custom.yaml'), makeYaml('my-custom', 'Custom-only piece')); const catalog = new PieceCatalog(builtinDir, dataDir); const entries = catalog.getForUser('user-2'); // Should have: chat (custom), general (builtin), my-custom (custom) = 3 expect(entries).toHaveLength(3); const chat = entries.find(e => e.name === 'chat'); expect(chat!.source).toBe('custom'); expect(chat!.description).toContain('Custom chat override'); const general = entries.find(e => e.name === 'general'); expect(general!.source).toBe('builtin'); const myCustom = entries.find(e => e.name === 'my-custom'); expect(myCustom!.source).toBe('custom'); }); it('invalidate(userId) causes the next call to re-read disk', () => { writeFileSync(join(builtinDir, 'chat.yaml'), makeYaml('chat', 'Built-in chat')); const userPiecesDir = join(dataDir, 'user-3', 'pieces'); mkdirSync(userPiecesDir, { recursive: true }); const catalog = new PieceCatalog(builtinDir, dataDir); // First call: no custom piece → only 1 entry const before = catalog.getForUser('user-3'); expect(before).toHaveLength(1); // Write a custom piece to disk writeFileSync(join(userPiecesDir, 'new-piece.yaml'), makeYaml('new-piece', 'New custom piece')); // Without invalidate, result should be cached (still 1 entry) const cached = catalog.getForUser('user-3'); expect(cached).toHaveLength(1); // After invalidate, next call should re-read disk and see 2 entries catalog.invalidate('user-3'); const after = catalog.getForUser('user-3'); expect(after).toHaveLength(2); expect(after.find(e => e.name === 'new-piece')).toBeDefined(); }); it('caches within TTL (no second disk read until TTL expires)', () => { writeFileSync(join(builtinDir, 'chat.yaml'), makeYaml('chat', 'Built-in chat')); const userPiecesDir = join(dataDir, 'user-4', 'pieces'); mkdirSync(userPiecesDir, { recursive: true }); const catalog = new PieceCatalog(builtinDir, dataDir); // First call populates cache const first = catalog.getForUser('user-4'); expect(first).toHaveLength(1); // Write a custom piece without invalidating writeFileSync(join(userPiecesDir, 'surprise.yaml'), makeYaml('surprise', 'Should not be visible yet')); // Second call within TTL should return cached result (1 entry, not 2) const second = catalog.getForUser('user-4'); expect(second).toHaveLength(1); expect(second).toBe(first); // same array reference → cache hit // Simulate TTL expiry by back-dating the cache entry const entry = (catalog as unknown as { cache: Map }).cache.get('user-4')!; entry.ts = Date.now() - 61_000; // After TTL expiry, re-reads disk and sees the new piece const third = catalog.getForUser('user-4'); expect(third).toHaveLength(2); expect(third.find((e: { name: string }) => e.name === 'surprise')).toBeDefined(); }); });