From 9087345dec9d528c86dbeeaa27c97c919de6bf76 Mon Sep 17 00:00:00 2001 From: Fini Date: Wed, 22 Apr 2026 07:05:00 +0800 Subject: [PATCH] test(ai): batch_design DSL browser executor integration (8 cases) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairs with the #44 commit (browser-safe DSL executor). Every test stubs fetch to reject so any regression falling back to HTTP fails loudly. Coverage: - Single I() at root → frame inserted, no HTTP - Binding chain: root + nested child land in correct parent - U() update applied: properties merged on bound node - 6-op realistic screen (nav + cards + divider): order preserved - Multi-op batch → exactly ONE undo entry (dispatcher's startBatch/endBatch wrap survives the browser path) - Malformed op in the middle: per-line errors surfaced as status=failed (not opaque HTTP 500) - Empty DSL: zero ops, applied + no insertions - G() without fetcher: image node inserted with empty src (the apps/web scanAndFillImages pipeline enriches later) Complements the 5 static browser-safety checks in pen-mcp (`batch-design-dsl-browser-safe.test.ts`) — that file gates the import tree, this one gates the runtime behavior. Total: 3033 → 3041 passing. --- .../batch-design-dsl-browser.test.ts | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 apps/web/src/services/ai/__tests__/batch-design-dsl-browser.test.ts diff --git a/apps/web/src/services/ai/__tests__/batch-design-dsl-browser.test.ts b/apps/web/src/services/ai/__tests__/batch-design-dsl-browser.test.ts new file mode 100644 index 000000000..b170d6917 --- /dev/null +++ b/apps/web/src/services/ai/__tests__/batch-design-dsl-browser.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { dispatchElementToolCall } from '../element-tools-dispatcher'; +import { useDocumentStore } from '@/stores/document-store'; +import { useHistoryStore } from '@/stores/history-store'; +import type { DesignOutputShape } from '../design-parser'; + +/** + * Integration coverage for the browser-safe batch_design DSL path + * (closes #44). Every test stubs `fetch` to reject so that any + * regression falling back to the HTTP path fails loudly — the whole + * point of #44 is to keep batch_design on the hot in-browser path. + * + * Exercises real DSL shapes the AI emits, including binding chains + * (`root=I(...); child=I(root, ...)`), U() updates, and multi-op + * batches. Complements the 5 browser-safety static checks in pen-mcp + * (`batch-design-dsl-browser-safe.test.ts`) which verify the import + * tree; this file verifies the runtime behavior. + */ + +function dslShape(dsl: string): DesignOutputShape { + return { + kind: 'batch-design-dsl', + dsl, + raw: `${dsl}`, + }; +} + +describe('batch_design DSL — browser executor integration', () => { + beforeEach(() => { + useHistoryStore.getState().clear(); + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.reject(new Error('browser path must not hit HTTP'))), + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('single I() at root: frame inserted + store reflects it + no HTTP', async () => { + const result = await dispatchElementToolCall( + dslShape( + 'root=I(null, {"type":"frame","name":"Test","width":300,"height":200,"layout":"vertical"})', + ), + ); + expect(result.status).toBe('applied'); + expect(result.route).toBe('batch-design-dsl'); + expect(result.insertedNodes.length).toBe(1); + + const inserted = result.insertedNodes[0]; + expect(inserted.type).toBe('frame'); + expect(inserted.name).toBe('Test'); + }); + + it('binding chain: root + nested child land in correct parent', async () => { + const dsl = [ + 'root=I(null, {"type":"frame","name":"Root","width":400,"height":300,"layout":"vertical"})', + 'child=I(root, {"type":"rectangle","name":"Child","width":100,"height":60,"fill":[{"type":"solid","color":"#2563EB"}]})', + ].join('\n'); + + const result = await dispatchElementToolCall(dslShape(dsl)); + expect(result.status).toBe('applied'); + expect(result.insertedNodes.length).toBe(2); + + // Walk store to verify the nested structure + const rootNode = result.insertedNodes[0] as { id: string }; + const found = useDocumentStore.getState().getNodeById(rootNode.id) as + | { children?: Array<{ name?: string }> } + | undefined; + expect(found?.children?.length).toBe(1); + expect(found?.children?.[0].name).toBe('Child'); + }); + + it('U() update applied: properties merged onto bound node', async () => { + const dsl = [ + 'root=I(null, {"type":"frame","name":"Updatable","width":200,"height":200,"layout":"none"})', + 'U(root, {"cornerRadius":16,"fill":[{"type":"solid","color":"#EF4444"}]})', + ].join('\n'); + + const result = await dispatchElementToolCall(dslShape(dsl)); + expect(result.status).toBe('applied'); + + const root = result.insertedNodes[0] as { + id: string; + cornerRadius?: number; + fill?: Array<{ color: string }>; + }; + const fresh = useDocumentStore.getState().getNodeById(root.id) as { + cornerRadius?: number; + fill?: Array<{ color: string }>; + }; + expect(fresh.cornerRadius).toBe(16); + expect(fresh.fill?.[0].color).toBe('#EF4444'); + }); + + it('6-op realistic screen: top nav + 2 cards + divider', async () => { + const dsl = [ + 'screen=I(null, {"type":"frame","name":"Screen","width":375,"height":812,"layout":"vertical","gap":0})', + 'nav=I(screen, {"type":"frame","name":"Nav","width":"fill_container","height":56,"layout":"horizontal"})', + 'title=I(nav, {"type":"text","name":"Title","content":"Settings","fontSize":18,"fontWeight":600})', + 'c1=I(screen, {"type":"frame","name":"Card A","width":"fill_container","height":80,"layout":"vertical","padding":[16,16]})', + 'd1=I(screen, {"type":"rectangle","name":"Divider","width":"fill_container","height":1,"fill":[{"type":"solid","color":"#E2E8F0"}]})', + 'c2=I(screen, {"type":"frame","name":"Card B","width":"fill_container","height":80,"layout":"vertical","padding":[16,16]})', + ].join('\n'); + + const result = await dispatchElementToolCall(dslShape(dsl)); + expect(result.status).toBe('applied'); + expect(result.insertedNodes.length).toBe(6); + + const screen = result.insertedNodes[0] as { id: string }; + const fresh = useDocumentStore.getState().getNodeById(screen.id) as + | { children?: Array<{ name?: string }> } + | undefined; + expect(fresh?.children?.length).toBe(4); // nav + c1 + d1 + c2 + const childNames = (fresh?.children ?? []).map((c) => c.name); + expect(childNames).toEqual(['Nav', 'Card A', 'Divider', 'Card B']); + }); + + it('multi-op DSL → one undo entry (batch wrap survived)', async () => { + const before = useHistoryStore.getState().undoStack.length; + + const dsl = [ + 'a=I(null, {"type":"frame","name":"A","width":100,"height":100,"layout":"none"})', + 'b=I(null, {"type":"frame","name":"B","width":100,"height":100,"layout":"none"})', + 'c=I(null, {"type":"frame","name":"C","width":100,"height":100,"layout":"none"})', + ].join('\n'); + + await dispatchElementToolCall(dslShape(dsl)); + + // Dispatcher wraps every call in startBatch/endBatch. 3 root-level + // frames but ONE undo entry. + const after = useHistoryStore.getState().undoStack.length; + expect(after - before).toBe(1); + }); + + it('malformed op in the middle: earlier ops apply, bad op surfaces as failed', async () => { + const dsl = [ + 'good1=I(null, {"type":"frame","name":"Good 1","width":100,"height":100,"layout":"none"})', + 'bad=I(nonsense syntax that is definitely not valid', + 'good2=I(null, {"type":"frame","name":"Good 2","width":100,"height":100,"layout":"none"})', + ].join('\n'); + + const result = await dispatchElementToolCall(dslShape(dsl)); + // Any per-op error → dispatcher returns failed for the whole batch. + // This is the documented contract: the caller / user can see which + // line broke and retry. + expect(result.status).toBe('failed'); + expect(result.message).toContain('failing operation'); + }); + + it('empty DSL string: zero ops, status=applied, no insertions', async () => { + const result = await dispatchElementToolCall(dslShape('')); + expect(result.status).toBe('applied'); + expect(result.insertedNodes).toEqual([]); + }); + + it('G() op without fetcher: image node inserted with empty src', async () => { + // G() regex requires a quoted parent; use a seeded root frame + // as the anchor so the insert actually lands in the tree. + const dsl = [ + 'root=I(null, {"type":"frame","name":"Gallery","width":375,"height":400,"layout":"vertical"})', + // G's parent arg MUST be quoted — the regex requires `""`. + // resolveRef strips quotes + looks up the binding, so "root" + // resolves to the frame inserted above. + 'img=G("root", "search", "cat photo")', + ].join('\n'); + const result = await dispatchElementToolCall(dslShape(dsl)); + expect(result.status).toBe('applied'); + // 1 frame + 1 image = 2 ops + expect(result.insertedNodes.length).toBe(2); + + const img = result.insertedNodes[1] as { type?: string; src?: string }; + expect(img.type).toBe('image'); + // Browser path omits imageSearchFetcher → empty src for downstream + // scanAndFillImages pass to enrich. + expect(img.src).toBe(''); + }); +});