132 lines
5.1 KiB
TypeScript
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();
|
|
});
|
|
});
|