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.
88 lines
3.2 KiB
TypeScript
88 lines
3.2 KiB
TypeScript
import { beforeAll, describe, expect, it } from 'vitest';
|
|
import { resolveLayout } from '../src/index.js';
|
|
import { ensureEngineReady } from './_setup.js';
|
|
import { fixture } from './fixtures/demo-300x250.fixture.js';
|
|
|
|
describe('resolveLayout — integration against demo fixture', () => {
|
|
beforeAll(async () => {
|
|
await ensureEngineReady();
|
|
});
|
|
|
|
it('resolves a short headline without overflow or sibling pushes', () => {
|
|
const out = resolveLayout(fixture, {
|
|
headline: 'Hi',
|
|
subheadline: 'Sub',
|
|
cta: 'Go'
|
|
});
|
|
|
|
expect(out).toHaveLength(1);
|
|
const artboard = out[0]!;
|
|
expect(artboard.artboard_id).toBe('300x250');
|
|
|
|
const headline = artboard.layers.find((l) => l.layer_id === 'headline');
|
|
expect(headline).toBeDefined();
|
|
// A trivial single-word headline should fit at or near max font size and
|
|
// never trigger overflow.
|
|
expect(headline!.layout_log?.overflow_triggered).toBe(false);
|
|
expect(headline!.layout_log?.siblings_pushed).toEqual([]);
|
|
expect(headline!.computed_font_size).toBeGreaterThanOrEqual(18);
|
|
expect(headline!.computed_font_size).toBeLessThanOrEqual(28);
|
|
});
|
|
|
|
it('shrinks or pushes when headline is too long', () => {
|
|
const out = resolveLayout(fixture, {
|
|
headline:
|
|
'A really long headline copy that should not fit at the original font size',
|
|
subheadline: 'Sub copy goes here',
|
|
cta: 'Shop now'
|
|
});
|
|
|
|
const headline = out[0]!.layers.find((l) => l.layer_id === 'headline')!;
|
|
// Either shrunk or overflowed — both are valid outcomes given the layout.
|
|
const shrunkOrOverflowed =
|
|
headline.layout_log!.font_size_reduced ||
|
|
headline.layout_log!.overflow_triggered;
|
|
expect(shrunkOrOverflowed).toBe(true);
|
|
expect(headline.character_count).toBeGreaterThan(30);
|
|
});
|
|
|
|
it('populates layout_log on every text layer', () => {
|
|
const out = resolveLayout(fixture, {
|
|
headline: 'Hi',
|
|
subheadline: 'Sub',
|
|
cta: 'Go'
|
|
});
|
|
const textLayers = out[0]!.layers.filter((l) => l.type === 'text');
|
|
expect(textLayers.length).toBeGreaterThan(0);
|
|
for (const l of textLayers) {
|
|
expect(l.layout_log).toBeDefined();
|
|
expect(typeof l.layout_log!.original_height).toBe('number');
|
|
expect(typeof l.layout_log!.computed_height).toBe('number');
|
|
}
|
|
});
|
|
|
|
it('parity baseline: snapshot of the resolved layer geometry', () => {
|
|
const out = resolveLayout(fixture, {
|
|
headline: 'Hello world',
|
|
subheadline: 'Subtitle line',
|
|
cta: 'Shop now'
|
|
});
|
|
|
|
// Snapshot only fields that are stable across dropflow patch releases:
|
|
// ids, types, computed coords. Heights and font sizes can drift by a
|
|
// pixel on font shaper updates and are checked structurally elsewhere.
|
|
const sanitized = out.map((ab) => ({
|
|
artboard_id: ab.artboard_id,
|
|
layers: ab.layers.map((l) => ({
|
|
layer_id: l.layer_id,
|
|
type: l.type,
|
|
computed_x: l.computed_x,
|
|
computed_y: l.computed_y,
|
|
character_count: l.character_count,
|
|
font_size_reduced: l.layout_log?.font_size_reduced ?? null,
|
|
overflow_triggered: l.layout_log?.overflow_triggered ?? null
|
|
}))
|
|
}));
|
|
expect(sanitized).toMatchSnapshot();
|
|
});
|
|
});
|