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>
178 lines
5.9 KiB
TypeScript
178 lines
5.9 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import type { GenerateOutputV2 } from '@banner-studio/types';
|
|
import type { PerSizeConstraints } from '@banner-studio/prompts';
|
|
import { generateAgent, computeOverflows } from '../src/ai-orchestration/generate-agent.js';
|
|
|
|
const constraints: PerSizeConstraints = {
|
|
'300x600': { headline: 60, subheadline: 110, cta_text: 18, legal: 80 },
|
|
'300x250': { headline: 35, subheadline: 60, cta_text: 14 },
|
|
'728x90': { headline: 36, subheadline: 70, cta_text: 14 },
|
|
'160x600': { headline: 28, subheadline: 70, cta_text: 14, legal: 60 }
|
|
};
|
|
|
|
const ctx = {
|
|
subject: 'velvet sofa',
|
|
audience_hint: 'design-aware buyers',
|
|
tone_hint: 'premium, calm',
|
|
key_benefits: ['handmade', 'lifetime frame'],
|
|
cta_intent: 'shop now'
|
|
};
|
|
|
|
const fittingOutput: GenerateOutputV2 = {
|
|
per_size: {
|
|
'300x600': {
|
|
headline: 'Velvet sofas, made to last decades',
|
|
subheadline: 'Handmade frames, decade-long warranty, free white-glove delivery.',
|
|
cta_text: 'Shop velvet sofas',
|
|
legal: 'Terms apply.'
|
|
},
|
|
'300x250': {
|
|
headline: 'Velvet sofas built to last',
|
|
subheadline: 'Handmade frames, decade warranty.',
|
|
cta_text: 'Shop now'
|
|
},
|
|
'728x90': {
|
|
headline: 'Velvet sofas, made to last',
|
|
subheadline: 'Handmade frames, decade-long warranty.',
|
|
cta_text: 'Shop now'
|
|
},
|
|
'160x600': {
|
|
headline: 'Velvet sofas',
|
|
subheadline: 'Handmade frames, decade warranty.',
|
|
cta_text: 'Shop now',
|
|
legal: 'Terms apply.'
|
|
}
|
|
},
|
|
rationale: {
|
|
copy: 'tight benefit-led wording',
|
|
asset: 'velvet sofa hero',
|
|
variant: 'standard',
|
|
animation: 'fade cascade'
|
|
}
|
|
};
|
|
|
|
const tooLongOutput: GenerateOutputV2 = {
|
|
per_size: {
|
|
'300x600': {
|
|
headline: 'A headline that is much much much too long to fit any reasonable banner budget here',
|
|
subheadline: 'sub',
|
|
cta_text: 'Shop',
|
|
legal: 'OK'
|
|
},
|
|
'300x250': {
|
|
headline: 'Another headline that is way too long to fit in the rectangle budget',
|
|
subheadline: 'sub',
|
|
cta_text: 'Shop'
|
|
},
|
|
'728x90': {
|
|
headline: 'Yet another headline that is too long for the leaderboard budget here',
|
|
subheadline: 'sub',
|
|
cta_text: 'Shop'
|
|
},
|
|
'160x600': {
|
|
headline: 'A skyscraper headline that overflows the budget',
|
|
subheadline: 'sub',
|
|
cta_text: 'Shop',
|
|
legal: 'OK'
|
|
}
|
|
},
|
|
rationale: { copy: '', asset: '', variant: '', animation: '' }
|
|
};
|
|
|
|
describe('generateAgent', () => {
|
|
it('retries once when the first attempt overflows and succeeds the second time', async () => {
|
|
const call = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(tooLongOutput)
|
|
.mockResolvedValueOnce(fittingOutput);
|
|
|
|
const res = await generateAgent({
|
|
context: ctx,
|
|
constraints,
|
|
callClaude: call as unknown as Parameters<typeof generateAgent>[0]['callClaude'],
|
|
maxAttempts: 2
|
|
});
|
|
|
|
expect(call).toHaveBeenCalledTimes(2);
|
|
expect(res.status).toBe('ok');
|
|
if (res.status === 'ok') {
|
|
expect(res.output.per_size['300x600']!.headline).toBe('Velvet sofas, made to last decades');
|
|
expect(res.attempts).toBe(2);
|
|
}
|
|
});
|
|
|
|
it('returns too_long after maxAttempts without fit', async () => {
|
|
const call = vi.fn().mockResolvedValue(tooLongOutput);
|
|
const res = await generateAgent({
|
|
context: ctx,
|
|
constraints,
|
|
callClaude: call as unknown as Parameters<typeof generateAgent>[0]['callClaude'],
|
|
maxAttempts: 2
|
|
});
|
|
|
|
expect(call).toHaveBeenCalledTimes(2);
|
|
expect(res.status).toBe('too_long');
|
|
if (res.status === 'too_long') {
|
|
const headlineOverflows = res.overflows.filter((o) => o.field === 'headline');
|
|
expect(headlineOverflows.length).toBeGreaterThan(0);
|
|
// At least one overflow should carry an artboard_id.
|
|
expect(headlineOverflows[0]!.artboard_id).toMatch(/\d+x\d+/);
|
|
}
|
|
});
|
|
|
|
it('succeeds on the first attempt and does not retry', async () => {
|
|
const call = vi.fn().mockResolvedValueOnce(fittingOutput);
|
|
const res = await generateAgent({
|
|
context: ctx,
|
|
constraints,
|
|
callClaude: call as unknown as Parameters<typeof generateAgent>[0]['callClaude'],
|
|
maxAttempts: 2
|
|
});
|
|
expect(call).toHaveBeenCalledTimes(1);
|
|
expect(res.status).toBe('ok');
|
|
});
|
|
});
|
|
|
|
describe('computeOverflows', () => {
|
|
it('reports per-size, per-field overflows independently', () => {
|
|
const partial: GenerateOutputV2 = {
|
|
per_size: {
|
|
'300x250': {
|
|
headline: 'a'.repeat(40), // limit 35 — over by 5
|
|
subheadline: 'b'.repeat(40), // limit 60 — fits
|
|
cta_text: 'c'.repeat(20) // limit 14 — over by 6
|
|
}
|
|
},
|
|
rationale: { copy: '', asset: '', variant: '', animation: '' }
|
|
};
|
|
const overflows = computeOverflows(partial, constraints);
|
|
expect(overflows).toHaveLength(2);
|
|
const headlineOver = overflows.find((o) => o.field === 'headline')!;
|
|
expect(headlineOver.artboard_id).toBe('300x250');
|
|
expect(headlineOver.actual).toBe(40);
|
|
expect(headlineOver.limit).toBe(35);
|
|
const ctaOver = overflows.find((o) => o.field === 'cta_text')!;
|
|
expect(ctaOver.actual).toBe(20);
|
|
expect(ctaOver.limit).toBe(14);
|
|
});
|
|
|
|
it('skips fields that are not present in either copy or constraints', () => {
|
|
const partial: GenerateOutputV2 = {
|
|
per_size: {
|
|
'300x250': { headline: 'short', cta_text: 'Shop' } // no subheadline, no legal
|
|
},
|
|
rationale: { copy: '', asset: '', variant: '', animation: '' }
|
|
};
|
|
expect(computeOverflows(partial, constraints)).toEqual([]);
|
|
});
|
|
|
|
it('ignores sizes the agent returned that were not asked for', () => {
|
|
const partial: GenerateOutputV2 = {
|
|
per_size: {
|
|
'unknown-size': { headline: 'a'.repeat(999), cta_text: 'a'.repeat(999) }
|
|
},
|
|
rationale: { copy: '', asset: '', variant: '', animation: '' }
|
|
};
|
|
expect(computeOverflows(partial, constraints)).toEqual([]);
|
|
});
|
|
});
|