banner_studio/packages/api-lib/test/route-node.test.ts
Simeon Schecter 988a47c797 Initial commit: Day 1 + Day 2 of the vertical slice
Day 1 (monorepo + Node layout engine):
- Turborepo + pnpm workspaces with apps/web, apps/render-worker, and
  packages for types, layout-engine, prompts, api-lib.
- @banner-studio/types: BannerSpec contract, every layer kind, ResolvedLayer,
  zod schemas mirroring each interface.
- @banner-studio/layout-engine: Dropflow WASM wrapper, text measurement,
  shrink-to-fit, push_siblings, resolveLayout. Snapshot-tested.

Day 2 (browser parity + AI pipeline):
- Layout engine ./browser subpath: same resolveLayout in the browser via
  Dropflow WASM build. Quarantined wasm-locator import (dropflow 0.5.1
  exports gap).
- Cross-group push_siblings bug fix: deltas now thread through group
  recursion via a shared accumulator; regression test added.
- DEMO_TEMPLATE_300x250 promoted to packages/layout-engine/src/templates/.
- @banner-studio/prompts: versioned extract + generate prompts with
  zod-defined tool schemas (claude-sonnet-4-6, forced tool-use).
- @banner-studio/api-lib: CSV feed loader, extract/generate/route-node/
  assemble agents, orchestrator returning fully-resolved BannerSpec.
  Generate agent retries on character-limit overflow.
- apps/web (Next.js 14 App Router): /api/generate route, /parity diff page,
  promise-singleton browser engine init.
- feeds/demo.csv with five hand-authored rows of varied length.
- SLICE_DEVIATIONS.md documents the five intentional gaps from
  ARCHITECTURE.md with V1 reversal paths.

Verified end-to-end: POST /api/generate against the live Claude API
returns three resolved BannerSpecs and two honestly-skipped rows
(overflow after two attempts). 26 unit + integration tests passing.
2026-05-15 10:25:21 -04:00

39 lines
1.4 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { DEMO_TEMPLATE_300x250 } from '@banner-studio/layout-engine';
import type { Layer, GroupLayer, SmartAssetLayer } from '@banner-studio/types';
import { routeNode } from '../src/ai-orchestration/route-node.js';
function findSmartAsset(layers: Layer[]): SmartAssetLayer | null {
for (const l of layers) {
if (l.type === 'smart_asset') return l as SmartAssetLayer;
if (l.type === 'group') {
const found = findSmartAsset((l as GroupLayer).children);
if (found) return found;
}
}
return null;
}
describe('routeNode', () => {
it('writes hero_image_url onto the smart_asset layer', () => {
const bundle = routeNode({
template: DEMO_TEMPLATE_300x250,
hero_image_url: 'https://cdn.example/hero1.jpg'
});
const hero = findSmartAsset(bundle.template.artboards[0]!.layers);
expect(hero).not.toBeNull();
expect(hero!.direct_url).toBe('https://cdn.example/hero1.jpg');
expect(hero!.selected_variant_id).toBe('feed-row');
expect(bundle.asset_layer_ids).toContain(hero!.id);
});
it('does not mutate the input template', () => {
const before = JSON.stringify(DEMO_TEMPLATE_300x250);
routeNode({
template: DEMO_TEMPLATE_300x250,
hero_image_url: 'https://cdn.example/whatever.jpg'
});
expect(JSON.stringify(DEMO_TEMPLATE_300x250)).toBe(before);
});
});