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[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[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[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([]); }); });