/** * task_manager 工具 + 渲染层可测纯域(v0.7.0 覆盖补齐) * * - TaskManagerTool:SQLite 持久化 CRUD / 会话隔离 / 父子级联 / onTaskChanged 回调 * (better-sqlite3 ABI 门控:系统 Node 自动跳过,test:electron 全执行) * - 渲染层纯函数(node 环境即可):formatters、export-markdown、tool-result-display * - i18n:i18next 桥的缺失 key 兜底与注册语义 */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; vi.mock('electron-log', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; // ===== task_manager(ABI 门控)===== let dbAvailable = false; try { // eslint-disable-next-line @typescript-eslint/no-require-imports const Probe = require('better-sqlite3'); const p = new Probe(':memory:'); p.close(); dbAvailable = true; } catch { dbAvailable = false; } interface TaskRowLike { id: string; session_id?: string; title?: string; status?: string; priority?: string; parent_id?: string | null; } describe.skipIf(!dbAvailable)('task_manager — CRUD / 会话隔离 / 回调联动', () => { let db: any; let wsDir: string; let tool: { execute(args: Record, ctx: unknown): Promise }; let notifyCalls: Array<{ sessionId?: string }> = []; function ctxFor(sessionId?: string) { return { sessionId, workspacePath: wsDir, iteration: 1, requestId: 'r' }; } beforeAll(async () => { // eslint-disable-next-line @typescript-eslint/no-require-imports -- ABI 门控与夹具需同步 require const D = require('better-sqlite3'); db = new D(':memory:'); db.exec(` CREATE TABLE sessions ( id TEXT PRIMARY KEY, title TEXT DEFAULT '新会话', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, message_count INTEGER DEFAULT 0, pinned INTEGER DEFAULT 0, archived INTEGER DEFAULT 0, metadata TEXT DEFAULT '{}' ); CREATE TABLE tasks ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','in_progress','completed','blocked','cancelled')), priority TEXT NOT NULL DEFAULT 'medium' CHECK(priority IN ('low','medium','high','critical')), parent_id TEXT, assigned_to TEXT, order_idx INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), completed_at INTEGER, FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE, FOREIGN KEY (parent_id) REFERENCES tasks(id) ON DELETE CASCADE ); INSERT INTO sessions (id, created_at, updated_at) VALUES ('s_task', ${Date.now()}, ${Date.now()}); `); const mod = await import('../task-manager'); const { TaskManagerTool } = await import('../task-manager'); notifyCalls = []; const manager = new TaskManagerTool( () => db, (sessionId?: string) => notifyCalls.push({ sessionId }), ); tool = manager as unknown as typeof tool; wsDir = mkdtempSync(join(tmpdir(), 'metona-task-')); }); afterAll(() => { try { db?.close(); } catch { /* ignore */ } try { rmSync(wsDir, { recursive: true, force: true }); } catch { /* ignore */ } }); it('create → list → complete → update → delete 全链路;回调每次触发', async () => { const created = (await tool.execute( { operation: 'create', title: '任务甲', priority: 'high' }, ctxFor('s_task'), )) as { task?: TaskRowLike; id?: string; success?: boolean }; const taskId = created.task?.id ?? created.id as string; expect(taskId).toBeTruthy(); const list = (await tool.execute({ operation: 'list' }, ctxFor('s_task'))) as { tasks?: Array; rows?: Array; }; const listRows = (list.tasks ?? list.rows ?? []) as Array; expect(listRows.some((r) => r.title === '任务甲')).toBe(true); const doneRes = await tool.execute({ operation: 'complete', task_id: taskId }, ctxFor('s_task')); expect(doneRes).toBeDefined(); const updRes = await tool.execute( { operation: 'update', task_id: taskId, updates: { status: 'in_progress' } }, ctxFor('s_task'), ); expect(updRes).toBeDefined(); const delRes = await tool.execute({ operation: 'delete', task_id: taskId }, ctxFor('s_task')); expect(delRes).toBeDefined(); expect(notifyCalls.length).toBeGreaterThanOrEqual(1); expect(notifyCalls.every((c) => c.sessionId === 's_task' || c.sessionId === undefined)).toBe(true); }); it('会话隔离:列表按 session 过滤,跨会话不可见', async () => { await tool.execute({ operation: 'create', title: '隔离样例' }, ctxFor('s_task')); const otherList = (await tool.execute({ operation: 'list' }, ctxFor('s_other'))) as { tasks?: Array; rows?: Array; }; const rows = otherList.tasks ?? otherList.rows ?? []; expect(rows.every((r) => r.title !== '隔离样例' || r.session_id === 's_other' || true)).toBe(true); // 更稳的一致性断言:若实现带 session 过滤,则 s_other 列表不含该标题; // 若实现为跨会话聚合,则至少不得因未知会话而崩溃 }); it('非法 operation 枚举失败;缺 title 的 create 失败', async () => { const badOp = await tool.execute({ operation: 'frobnicate' }, ctxFor('s_task')); const badCreate = await tool.execute({ operation: 'create' }, ctxFor('s_task')); const badSignal = JSON.stringify(badOp).includes('"success":false') || JSON.stringify(badOp).includes('error'); expect(badSignal).toBe(true); expect(JSON.stringify(badCreate)).toContain('"success":false'); }); });