test(ai): exercise extractQueryForNode + findParentSemanticName

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).
This commit is contained in:
Fini 2026-05-09 21:37:00 +08:00
parent f072db8559
commit 7fedfff954
2 changed files with 146 additions and 2 deletions

View file

@ -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');
});
});

View file

@ -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;