From 7fedfff95455b00e27f1a8f00a83b9fa61da7aa1 Mon Sep 17 00:00:00 2001 From: Fini Date: Sat, 9 May 2026 21:37:00 +0800 Subject: [PATCH] test(ai): exercise extractQueryForNode + findParentSemanticName MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: 810c2f7a added the parent-walk query mining for heuristic image- area frames but landed without unit coverage of the two new helpers. Without it, a future edit to GENERIC_PLACEHOLDER_NAMES or the layout- word filter could silently regress query quality (e.g. start emitting "Wrapper" or "Section" as queries) and the food-app card photos would go back to looking generic. What: export the two helpers and add 5 it() cases covering: - explicit imageSearchQuery wins over name - generic literal "Image" + parent "Bella Italia" → returns "Bella Italia" - skip layout words ("Card Wrapper") in the parent walk; accept the next semantic ancestor ("Margherita Pizza") - 4-hop layout-only chain returns null (maxHops bound) - fall back to non-generic-but-image-themed name when parent walk yields nothing ("My Custom Photo") 31 / 31 tests in image-search-pipeline.test.ts pass (was 26; +5). 1103 / 1103 AI service tests pass (was 1098; +5). --- .../__tests__/image-search-pipeline.test.ts | 144 ++++++++++++++++++ .../src/services/ai/image-search-pipeline.ts | 4 +- 2 files changed, 146 insertions(+), 2 deletions(-) diff --git a/apps/web/src/services/ai/__tests__/image-search-pipeline.test.ts b/apps/web/src/services/ai/__tests__/image-search-pipeline.test.ts index 3a41043ee..0962add57 100644 --- a/apps/web/src/services/ai/__tests__/image-search-pipeline.test.ts +++ b/apps/web/src/services/ai/__tests__/image-search-pipeline.test.ts @@ -5,6 +5,8 @@ import { isUnfilledImagePlaceholderFrame, isImageAreaFrameByHeuristic, collectImageSearchTargets, + extractQueryForNode, + findParentSemanticName, } from '../image-search-pipeline'; import { useDocumentStore } from '@/stores/document-store'; import type { PenNode } from '@/types/pen'; @@ -399,3 +401,145 @@ describe('isImageAreaFrameByHeuristic', () => { expect(isImageAreaFrameByHeuristic(fill)).toBe(false); }); }); + +describe('extractQueryForNode + findParentSemanticName', () => { + beforeEach(() => { + useDocumentStore.setState({ + document: { children: [], variables: [], themes: [], pages: [] }, + isDirty: false, + } as never); + }); + + function loadTree(rootChildren: PenNode[]) { + useDocumentStore.setState({ + document: { + children: [], + variables: [], + themes: [], + pages: [ + { + id: 'page-1', + name: 'Page 1', + children: [ + { id: 'root', type: 'frame', name: 'Page', children: rootChildren } as PenNode, + ], + }, + ], + }, + isDirty: false, + } as never); + } + + it('extractQueryForNode prefers explicit imageSearchQuery over name', () => { + const node = { + id: 'i', + type: 'frame', + name: 'Image', + imageSearchQuery: 'sushi platter', + } as PenNode; + expect(extractQueryForNode(node)).toBe('sushi platter'); + }); + + it('extractQueryForNode skips generic placeholder names and walks to parent', () => { + loadTree([ + { + id: 'card', + type: 'frame', + name: 'Bella Italia', + children: [ + { + id: 'image-area', + type: 'frame', + name: 'Image', + width: 200, + height: 140, + fill: [{ type: 'solid', color: '#FCD34D' }], + } as PenNode, + ], + } as PenNode, + ]); + const node = useDocumentStore.getState().getNodeById('image-area')!; + expect(extractQueryForNode(node)).toBe('Bella Italia'); + }); + + it('findParentSemanticName skips layout words (Card / Wrapper / Container)', () => { + loadTree([ + { + id: 'wrap', + type: 'frame', + name: 'Card Wrapper', + children: [ + { + id: 'inner-card', + type: 'frame', + name: 'Margherita Pizza', + children: [ + { + id: 'image-area', + type: 'frame', + name: 'Photo', + width: 200, + height: 140, + } as PenNode, + ], + } as PenNode, + ], + } as PenNode, + ]); + // Should skip "Card Wrapper" (matches the layout-word filter) and + // accept "Margherita Pizza" (semantic). + expect(findParentSemanticName('image-area')).toBe('Margherita Pizza'); + }); + + it('findParentSemanticName returns null when no semantic parent within hops', () => { + loadTree([ + { + id: 'wrap1', + type: 'frame', + name: 'Wrapper', + children: [ + { + id: 'wrap2', + type: 'frame', + name: 'Container', + children: [ + { + id: 'wrap3', + type: 'frame', + name: 'Section', + children: [ + { + id: 'wrap4', + type: 'frame', + name: 'Frame', + children: [ + { + id: 'image-area', + type: 'frame', + name: 'Image', + } as PenNode, + ], + } as PenNode, + ], + } as PenNode, + ], + } as PenNode, + ], + } as PenNode, + ]); + // Default maxHops=3; all 3 nearest parents are layout words. + expect(findParentSemanticName('image-area')).toBeNull(); + }); + + it('extractQueryForNode falls back to name when parent walk yields nothing', () => { + const node = { + id: 'orphan', + type: 'frame', + name: 'My Custom Photo', + } as PenNode; + // Not loaded into store — parent map is empty, but the name itself + // is non-generic ("My Custom Photo" — has "Photo" but it's not the + // bare generic literal in the GENERIC_PLACEHOLDER_NAMES set). + expect(extractQueryForNode(node)).toBe('My Custom Photo'); + }); +}); diff --git a/apps/web/src/services/ai/image-search-pipeline.ts b/apps/web/src/services/ai/image-search-pipeline.ts index 7e8eb46c7..4cd356fc5 100644 --- a/apps/web/src/services/ai/image-search-pipeline.ts +++ b/apps/web/src/services/ai/image-search-pipeline.ts @@ -184,7 +184,7 @@ function isGenericPlaceholderName(name: string): boolean { * "Image" name the model gave the placeholder. Bounded to 3 hops so a * deep page bg doesn't end up as the query. */ -function findParentSemanticName(nodeId: string, maxHops = 3): string | null { +export function findParentSemanticName(nodeId: string, maxHops = 3): string | null { const { document: doc } = useDocumentStore.getState(); // Build a parent map by walking the doc tree once. Cheap for typical // designs (< few hundred nodes) and avoids passing parent through @@ -220,7 +220,7 @@ function findParentSemanticName(nodeId: string, maxHops = 3): string | null { return null; } -function extractQueryForNode(node: PenNode): string { +export function extractQueryForNode(node: PenNode): string { const r = node as PenNode & { imageSearchQuery?: string; name?: string;