feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user