banner_studio/packages/layout-engine/test/resolve-layout.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

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