maestro/src/engine/piece-catalog.test.ts
2026-06-03 05:08:00 +00:00

132 lines
5.1 KiB
TypeScript

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<string, { ts: number; entries: unknown[] }> }).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();
});
});