Expands the slice from a single 300x250 banner to four IAB sizes (300x600, 300x250, 728x90, 160x600) driven by a designer-authored TypeSystem and a per-row strip review surface. Layout engine - TypeSystem with role-based typography (headline/subheadline/cta/legal) and piecewise size-class derivation: half_page / rectangle / leaderboard / skyscraper / mobile_banner. - resolveLayout now derives per-size font/leading from the role + artboard size, then clamps to a legibility floor and emits a constraint_signal when copy does not fit at the floor. - Four reference templates with character constraints per size. AI pipeline (Shape B) - One extract + one generate per feed row; generate returns per-size copy keyed by artboard_id plus a shared rationale block. - Constraint-signal retry: orchestrator tightens per-(artboard, field) limits and re-calls generate before giving up. - orchestrateRow returns specs[] + rationale + constraint_signals. Review UI - /review renders one strip per feed row, all four sizes side-by-side at true pixel dimensions, synced on a single GSAP master timeline. - AiReasoningDrawer shows a per-size copy table, shared rationale, and any constraint signals that fired. - /api/generate response grouped by row; /api/export accepts the same shape and writes exports/row-N/artboard_id.zip. Render worker - render-to-zip / render-many accept optional subdir + filename overrides so multi-size exports can be grouped by feed row. Docs - VERTICAL_SLICE and BUILD_SEQUENCE updated for the multi-size scope. - RESOLVED_FEED.md documents the V1 Resolved Creative Feed proposal. - SLICE_DEVIATIONS.md records where the slice diverges from V1. Tests: 56 pass (28 layout-engine + 14 api-lib + 14 render-worker). Web app: tsc clean, next build succeeds. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
74 lines
2.4 KiB
TypeScript
74 lines
2.4 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { DEMO_TEMPLATE_300x250 } from '@banner-studio/layout-engine';
|
|
import { routeNode } from '../src/ai-orchestration/route-node.js';
|
|
import { assemble } from '../src/ai-orchestration/assemble.js';
|
|
|
|
describe('assemble', () => {
|
|
it('stamps version_id, generated_at, ai_reasoning, click_destinations and copy map', () => {
|
|
const routed = routeNode({
|
|
template: DEMO_TEMPLATE_300x250,
|
|
hero_image_url: 'https://cdn.example/hero.jpg'
|
|
});
|
|
|
|
const { spec, copyMap } = assemble({
|
|
routed,
|
|
copy: {
|
|
headline: 'Test Headline',
|
|
subheadline: 'Test sub.',
|
|
cta_text: 'Shop now'
|
|
},
|
|
rationale: {
|
|
copy: 'tight benefit-led wording',
|
|
asset: 'product hero with neutral background',
|
|
variant: 'standard 300x250 variant',
|
|
animation: 'fade-in cascade'
|
|
},
|
|
campaign_id: 'camp-123',
|
|
click_url: 'https://example.com/landing'
|
|
});
|
|
|
|
expect(spec.campaign_id).toBe('camp-123');
|
|
expect(spec.version_id).toMatch(/^[-0-9a-f]{8,}/);
|
|
expect(new Date(spec.generated_at).getTime()).not.toBeNaN();
|
|
|
|
expect(spec.ai_reasoning.copy_rationale).toBe('tight benefit-led wording');
|
|
expect(spec.ai_reasoning.asset_selection).toBe('product hero with neutral background');
|
|
expect(spec.ai_reasoning.variant_selection).toBe('standard 300x250 variant');
|
|
expect(spec.ai_reasoning.animation_rationale).toBe('fade-in cascade');
|
|
|
|
expect(spec.click_destinations).toEqual([
|
|
{ id: 'cta', url: 'https://example.com/landing' }
|
|
]);
|
|
|
|
expect(copyMap).toEqual({
|
|
headline: 'Test Headline',
|
|
subheadline: 'Test sub.',
|
|
cta: 'Shop now'
|
|
});
|
|
});
|
|
|
|
it('omits subheadline and legal from the copy map when not provided', () => {
|
|
const routed = routeNode({
|
|
template: DEMO_TEMPLATE_300x250,
|
|
hero_image_url: 'https://cdn.example/hero.jpg'
|
|
});
|
|
|
|
const { copyMap } = assemble({
|
|
routed,
|
|
copy: {
|
|
headline: 'Just headline and CTA',
|
|
cta_text: 'Shop'
|
|
},
|
|
rationale: { copy: '', asset: '', variant: '', animation: '' },
|
|
campaign_id: 'camp',
|
|
click_url: 'https://example.com'
|
|
});
|
|
|
|
expect(copyMap).toEqual({
|
|
headline: 'Just headline and CTA',
|
|
cta: 'Shop'
|
|
});
|
|
expect(copyMap.subheadline).toBeUndefined();
|
|
expect(copyMap.legal).toBeUndefined();
|
|
});
|
|
});
|