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.
108 lines
3.7 KiB
TypeScript
108 lines
3.7 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { applyPushSiblings } from '../src/index.js';
|
|
import type { Layer, TextLayer } from '@banner-studio/types';
|
|
|
|
function textLayer(
|
|
id: string,
|
|
y: number,
|
|
pushSiblings: TextLayer['behavior']['push_siblings']
|
|
): TextLayer {
|
|
return {
|
|
id,
|
|
name: id,
|
|
type: 'text',
|
|
x: 0,
|
|
y,
|
|
width: 100,
|
|
height: 20,
|
|
z_index: 1,
|
|
visible: true,
|
|
locked: false,
|
|
opacity: 1,
|
|
rotation: 0,
|
|
anchors: {},
|
|
content_field: id,
|
|
typography: {
|
|
font_family: 'Inter',
|
|
font_size: 16,
|
|
font_weight: 400,
|
|
line_height: 1.2,
|
|
color: '#000'
|
|
},
|
|
behavior: {
|
|
auto_resize: false,
|
|
min_font_size: 12,
|
|
max_font_size: 16,
|
|
expansion_direction: 'down',
|
|
overflow_behavior: 'clip',
|
|
push_siblings: pushSiblings,
|
|
padding: { top: 0, right: 0, bottom: 0, left: 0 }
|
|
},
|
|
character_constraints: { per_artboard: {}, warning_threshold: 1, hard_enforce: false },
|
|
copy_bound: true
|
|
};
|
|
}
|
|
|
|
describe('applyPushSiblings', () => {
|
|
it('returns empty deltas when delta_px is 0', () => {
|
|
const layers: Layer[] = [
|
|
textLayer('a', 0, [{ layer_id: 'b', direction: 'down', maintain_gap: 0, max_push: 50 }]),
|
|
textLayer('b', 30, [])
|
|
];
|
|
const r = applyPushSiblings({ layers, expanded_layer_id: 'a', delta_px: 0 });
|
|
expect(r.position_deltas).toEqual({});
|
|
expect(r.overflowed_layers).toEqual([]);
|
|
});
|
|
|
|
it('pushes a sibling down by delta when below max_push', () => {
|
|
const layers: Layer[] = [
|
|
textLayer('a', 0, [{ layer_id: 'b', direction: 'down', maintain_gap: 0, max_push: 50 }]),
|
|
textLayer('b', 30, [])
|
|
];
|
|
const r = applyPushSiblings({ layers, expanded_layer_id: 'a', delta_px: 20 });
|
|
expect(r.position_deltas.b).toEqual({ dx: 0, dy: 20 });
|
|
expect(r.overflowed_layers).toEqual([]);
|
|
});
|
|
|
|
it('caps the push at max_push and reports overflow', () => {
|
|
const layers: Layer[] = [
|
|
textLayer('a', 0, [{ layer_id: 'b', direction: 'down', maintain_gap: 0, max_push: 10 }]),
|
|
textLayer('b', 30, [])
|
|
];
|
|
const r = applyPushSiblings({ layers, expanded_layer_id: 'a', delta_px: 30 });
|
|
expect(r.position_deltas.b).toEqual({ dx: 0, dy: 10 });
|
|
expect(r.overflowed_layers).toContain('b');
|
|
});
|
|
|
|
it('cascades push rules with the applied (capped) delta', () => {
|
|
const layers: Layer[] = [
|
|
textLayer('a', 0, [{ layer_id: 'b', direction: 'down', maintain_gap: 0, max_push: 100 }]),
|
|
textLayer('b', 30, [{ layer_id: 'c', direction: 'down', maintain_gap: 0, max_push: 100 }]),
|
|
textLayer('c', 60, [])
|
|
];
|
|
const r = applyPushSiblings({ layers, expanded_layer_id: 'a', delta_px: 15 });
|
|
expect(r.position_deltas.b).toEqual({ dx: 0, dy: 15 });
|
|
expect(r.position_deltas.c).toEqual({ dx: 0, dy: 15 });
|
|
expect(r.overflowed_layers).toEqual([]);
|
|
});
|
|
|
|
it('supports direction: up', () => {
|
|
const layers: Layer[] = [
|
|
textLayer('a', 50, [{ layer_id: 'b', direction: 'up', maintain_gap: 0, max_push: 50 }]),
|
|
textLayer('b', 0, [])
|
|
];
|
|
const r = applyPushSiblings({ layers, expanded_layer_id: 'a', delta_px: 10 });
|
|
expect(r.position_deltas.b).toEqual({ dx: 0, dy: -10 });
|
|
});
|
|
|
|
it('does not loop if rules form a cycle', () => {
|
|
const layers: Layer[] = [
|
|
textLayer('a', 0, [{ layer_id: 'b', direction: 'down', maintain_gap: 0, max_push: 100 }]),
|
|
textLayer('b', 30, [{ layer_id: 'a', direction: 'down', maintain_gap: 0, max_push: 100 }])
|
|
];
|
|
const r = applyPushSiblings({ layers, expanded_layer_id: 'a', delta_px: 10 });
|
|
expect(r.position_deltas.b).toEqual({ dx: 0, dy: 10 });
|
|
// 'a' was the originating layer; the cycle attempt is dropped by the visited set.
|
|
expect(r.position_deltas.a).toBeUndefined();
|
|
});
|
|
});
|