openpencil/apps/web/server/utils/__tests__/codegen-plan-store.test.ts
Kayshen-X a7d73ebb62 feat(ai): pencil-style agentic design tool-loop, multi-chat tabs, #27 panel restyle
Built-in design generation now runs as an agentic MCP tool-loop (reusing the
agent-rs BuiltInProvider), gated behind OPENPENCIL_DESIGN_AGENT_LOOP / the
Settings experimental toggle; the orchestrator stays the default.

- design-agent system prompt + in-process design toolset (parity-locked with
  the MCP surface) + flag-gated Intent::Design routing
- spawn_agents execution as sequential sub-loops + live creation-mode badges
  (per-agent glow + 'N/M designing...' header)
- new MCP tools: get_guidelines, ToolSearch, get_screenshot, get_editor_state,
  export_nodes, spawn_agents; style-guide local audit
- #27 AI panel restyle: rounded tool cards + green check-rings, gray user
  bubbles, model-pill bottom toolbar, header, empty-state pills, the
  PARALLEL AGENTS (agent_team_size) 1x-6x chip dropdown
- multi-chat tabs: ChatSessions model (Deref-to-active) + tab row UI
  (switch / close / + / Cmd+T) with each run bound to its tab

Large checkpoint commit spanning the working tree (Rust shell crates).
2026-07-02 21:21:06 +08:00

179 lines
5.2 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
createPlan,
getPlan,
submitChunkResult,
assemblePlan,
cleanPlan,
} from '../codegen-plan-store';
import type { CodePlanFromAI, ChunkResult, PenNode } from '@zseven-w/pen-types';
const mockNodes: PenNode[] = [
{ id: 'n1', type: 'frame', name: 'Hero', x: 0, y: 0, width: 800, height: 600 } as PenNode,
{ id: 'n2', type: 'rectangle', name: 'Card', x: 0, y: 0, width: 200, height: 150 } as PenNode,
];
const validPlan: CodePlanFromAI = {
chunks: [
{
id: 'c1',
name: 'Hero',
nodeIds: ['n1'],
role: 'section',
suggestedComponentName: 'Hero',
dependencies: [],
exposedSlots: [],
},
{
id: 'c2',
name: 'Card',
nodeIds: ['n2'],
role: 'component',
suggestedComponentName: 'Card',
dependencies: ['c1'],
},
],
sharedStyles: [],
rootLayout: { direction: 'vertical', gap: 16, responsive: true },
};
describe('codegen-plan-store', () => {
it('createPlan returns planId and sorted executionPlan', () => {
const result = createPlan(validPlan, mockNodes);
expect(result.planId).toBeTruthy();
expect(result.executionPlan).toHaveLength(2);
expect(result.executionPlan[0].id).toBe('c1');
expect(result.executionPlan[1].id).toBe('c2');
expect(result.warnings).toHaveLength(0);
cleanPlan(result.planId);
});
it('createPlan rejects duplicate chunkIds', () => {
const badPlan = {
...validPlan,
chunks: [{ ...validPlan.chunks[0] }, { ...validPlan.chunks[1], id: 'c1' }],
};
expect(() => createPlan(badPlan, mockNodes)).toThrow('Duplicate chunkId: c1');
});
it('createPlan rejects empty nodeIds', () => {
const badPlan = {
...validPlan,
chunks: [{ ...validPlan.chunks[0], nodeIds: [] }],
};
expect(() => createPlan(badPlan, mockNodes)).toThrow('has no nodeIds');
});
it('createPlan rejects unknown dependency', () => {
const badPlan = {
...validPlan,
chunks: [{ ...validPlan.chunks[0], dependencies: ['unknown'] }],
};
expect(() => createPlan(badPlan, mockNodes)).toThrow('depends on unknown chunk');
});
it('createPlan rejects circular dependency', () => {
const badPlan: CodePlanFromAI = {
...validPlan,
chunks: [
{ ...validPlan.chunks[0], dependencies: ['c2'] },
{ ...validPlan.chunks[1], dependencies: ['c1'] },
],
};
expect(() => createPlan(badPlan, mockNodes)).toThrow('Circular dependency');
});
it('createPlan rejects missing nodeId in document', () => {
const badPlan: CodePlanFromAI = {
...validPlan,
chunks: [{ ...validPlan.chunks[0], nodeIds: ['missing'] }],
};
expect(() => createPlan(badPlan, [])).toThrow('not found in document');
});
it('createPlan warns on shared nodeIds', () => {
const sharedPlan: CodePlanFromAI = {
...validPlan,
chunks: [
{ ...validPlan.chunks[0], nodeIds: ['n1'] },
{ ...validPlan.chunks[1], nodeIds: ['n1'], dependencies: [] },
],
};
const result = createPlan(sharedPlan, mockNodes);
expect(result.warnings.length).toBeGreaterThan(0);
expect(result.warnings[0]).toContain('claimed by chunks');
cleanPlan(result.planId);
});
it('submitChunkResult stores result and returns next chunk', () => {
const { planId } = createPlan(validPlan, mockNodes);
const chunkResult: ChunkResult = {
chunkId: 'c1',
code: 'function Hero() {}',
contract: {
chunkId: 'c1',
componentName: 'Hero',
exportedProps: [],
slots: [],
cssClasses: [],
cssVariables: [],
imports: [],
},
};
const submitResult = submitChunkResult(planId, chunkResult);
expect(submitResult.validation.valid).toBe(true);
expect(submitResult.nextChunk).toBeDefined();
expect(submitResult.nextChunk!.id).toBe('c2');
cleanPlan(planId);
});
it('assemblePlan returns all results and clears cache', () => {
const { planId } = createPlan(validPlan, mockNodes);
const cr1: ChunkResult = {
chunkId: 'c1',
code: 'function Hero() {}',
contract: {
chunkId: 'c1',
componentName: 'Hero',
exportedProps: [],
slots: [],
cssClasses: [],
cssVariables: [],
imports: [],
},
};
const cr2: ChunkResult = {
chunkId: 'c2',
code: 'function Card() {}',
contract: {
chunkId: 'c2',
componentName: 'Card',
exportedProps: [],
slots: [],
cssClasses: [],
cssVariables: [],
imports: [],
},
};
submitChunkResult(planId, cr1);
submitChunkResult(planId, cr2);
const assembled = assemblePlan(planId, 'react');
expect(assembled.chunks).toHaveLength(2);
expect(assembled.degraded).toBe(false);
// Terminal operation — plan should be cleared
expect(getPlan(planId)).toBeUndefined();
});
it('cleanPlan deletes existing plan', () => {
const { planId } = createPlan(validPlan, mockNodes);
expect(cleanPlan(planId)).toEqual({ ok: true, deleted: true });
expect(getPlan(planId)).toBeUndefined();
});
it('cleanPlan returns deleted=false for unknown plan', () => {
expect(cleanPlan('nonexistent')).toEqual({ ok: true, deleted: false });
});
});