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

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