linkedin-studio/render/__tests__/build-figur.test.mjs
Kjell Tore Guttormsen c4434ed789 feat(linkedin-studio): N2 — build-figur renderer (SVG/HTML->PNG, 3 mål, token-seam) [skip-docs]
TDD (33 nye tester, render-suite 20->53/0 = ny floor, 4 ekte e2e-renders):
- render/build-figur.mjs: SVG/HTML -> PNG via headless Chrome; tre mål
  (article 1200xauto, carousel 1080x1350, single 1200x1200), overstyrbare
  via --width/--height; CLI + importerbar modul.
- Token-seam: design-tokens leses fra
  LINKEDIN_STUDIO_DATA/profile/brand-tokens.json (brukerdata), nøytrale
  defaults i modulen — merkevare aldri hardkodet i repoet. Injiseres som
  CSS-variabler (--figur-*).
- Warn-validering (aldri hard fail): min 24pt tekst, fargebudsjett
  (maks 2 ikke-nøytrale farger).
- Robust Chrome-oppdagelse: app-bundle-stier -> PATH -> install-hint
  (prober injiserbare for test).
- macOS-quirk (verifisert lokalt, Chrome 150): headless skriver PNG-en men
  avslutter aldri -> stderr-watchdog («written to file») + kill + verifisering
  av output. Søk-først utført (kjent headless-ustabilitet på macOS).
- Fixture: nøytral demo-SVG (placeholder-søylediagram) under
  render/__tests__/fixtures/.

Alle seks suiter grønne: test-runner 138/0, trends 245/0, brain 134/0,
hooks 140/0, tests 35/0, render 53/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: 26f26c3d-be91-47c4-bbdf-4694ba063b67
2026-07-16 20:26:19 +02:00

284 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import {
TARGETS,
DEFAULT_TOKENS,
deriveDimensions,
loadBrandTokens,
tokensToCss,
wrapSvg,
injectTokens,
parseSvgMeta,
validateFigure,
resolveChrome,
parseArgs,
renderFigure,
} from '../build-figur.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FIXTURE_SVG = path.join(__dirname, 'fixtures', 'demo-figur.svg');
function tmpdir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'build-figur-test-'));
}
// Leser bredde/høyde fra PNG-IHDR (byte 1623, big-endian).
function pngDimensions(file) {
const buf = fs.readFileSync(file);
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
}
describe('TARGETS + deriveDimensions', () => {
test('carousel er 1080×1350 (fast)', () => {
assert.equal(TARGETS.carousel.width, 1080);
assert.equal(TARGETS.carousel.height, 1350);
});
test('article er 1200 bred med innholdsstyrt høyde', () => {
assert.equal(TARGETS.article.width, 1200);
assert.equal(TARGETS.article.height, null);
});
test('single er kvadratisk 1200×1200', () => {
assert.equal(TARGETS.single.width, 1200);
assert.equal(TARGETS.single.height, 1200);
});
test('article + aspekt 16:9 gir avledet høyde 675', () => {
const d = deriveDimensions('article', 1200 / 675, {});
assert.equal(d.width, 1200);
assert.equal(d.height, 675);
assert.equal(d.warnings.length, 0);
});
test('article uten aspekt gir fallback-høyde med warning', () => {
const d = deriveDimensions('article', null, {});
assert.equal(d.width, 1200);
assert.ok(d.height > 0);
assert.ok(d.warnings.some((w) => /høyde/i.test(w)));
});
test('--width/--height-overstyringer vinner over målet', () => {
const d = deriveDimensions('carousel', null, { width: 800, height: 600 });
assert.equal(d.width, 800);
assert.equal(d.height, 600);
});
test('ukjent mål kaster med gyldige mål i meldingen', () => {
assert.throws(() => deriveDimensions('poster', null, {}), /article/);
});
});
describe('loadBrandTokens — token-seam (brukerdata, aldri hardkodet)', () => {
test('uten brand-tokens.json: nøytrale defaults, source=default', () => {
const dir = tmpdir();
const r = loadBrandTokens(dir);
assert.equal(r.source, 'default');
assert.deepEqual(r.tokens, DEFAULT_TOKENS);
assert.equal(r.warnings.length, 0);
});
test('gyldig fil merges over defaults, source=user', () => {
const dir = tmpdir();
fs.mkdirSync(path.join(dir, 'profile'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'profile', 'brand-tokens.json'),
JSON.stringify({ accent: '#123456' }),
'utf8'
);
const r = loadBrandTokens(dir);
assert.equal(r.source, 'user');
assert.equal(r.tokens.accent, '#123456');
// umerkede felter beholder defaults
assert.equal(r.tokens.background, DEFAULT_TOKENS.background);
});
test('ugyldig JSON: defaults + warning, kaster aldri', () => {
const dir = tmpdir();
fs.mkdirSync(path.join(dir, 'profile'), { recursive: true });
fs.writeFileSync(path.join(dir, 'profile', 'brand-tokens.json'), '{not json', 'utf8');
let r;
assert.doesNotThrow(() => { r = loadBrandTokens(dir); });
assert.equal(r.source, 'default');
assert.ok(r.warnings.some((w) => /brand-tokens\.json/.test(w)));
});
});
describe('tokensToCss + wrapSvg + injectTokens — CSS-injeksjon', () => {
test('tokensToCss produserer --figur-variabler for alle tokens', () => {
const css = tokensToCss(DEFAULT_TOKENS);
assert.match(css, /:root\{/);
for (const key of Object.keys(DEFAULT_TOKENS)) {
assert.ok(css.includes(`--figur-${key}`), `mangler --figur-${key}`);
}
});
test('egendefinerte tokenverdier reflekteres i CSS-en', () => {
const css = tokensToCss({ ...DEFAULT_TOKENS, accent: '#ABCDEF' });
assert.match(css, /--figur-accent:\s*#ABCDEF/);
});
test('wrapSvg bygger komplett HTML med svg + tokens + mål-dimensjoner', () => {
const html = wrapSvg('<svg viewBox="0 0 10 10"></svg>', { width: 1080, height: 1350 }, DEFAULT_TOKENS);
assert.match(html, /<!DOCTYPE html>/i);
assert.match(html, /<svg viewBox="0 0 10 10">/);
assert.match(html, /--figur-accent/);
assert.match(html, /width:\s*1080px/);
assert.match(html, /height:\s*1350px/);
});
test('injectTokens legger style rett etter <head> når den finnes', () => {
const out = injectTokens('<html><head><title>x</title></head><body></body></html>', DEFAULT_TOKENS);
assert.match(out, /<head>\s*<style>[^<]*--figur-ink/);
});
test('injectTokens prepender style når <head> mangler', () => {
const out = injectTokens('<p>bare et fragment</p>', DEFAULT_TOKENS);
assert.ok(out.trimStart().startsWith('<style>'));
assert.match(out, /<p>bare et fragment<\/p>/);
});
});
describe('parseSvgMeta — aspekt fra SVG-kilden', () => {
test('viewBox gir aspekt', () => {
const aspect = parseSvgMeta('<svg viewBox="0 0 1200 675"></svg>');
assert.ok(Math.abs(aspect - 1200 / 675) < 1e-9);
});
test('width/height-attributter gir aspekt', () => {
const aspect = parseSvgMeta('<svg width="800px" height="400"></svg>');
assert.equal(aspect, 2);
});
test('uten dimensjonsinfo: null', () => {
assert.equal(parseSvgMeta('<svg><rect/></svg>'), null);
});
});
describe('validateFigure — designregler som warn, aldri hard fail', () => {
test('font-size under 24 gir warning', () => {
const w = validateFigure('<svg><text font-size="14">liten</text></svg>');
assert.ok(w.some((x) => /24/.test(x)));
});
test('font-size ≥ 24 gir ingen tekst-warning', () => {
const w = validateFigure('<svg><text font-size="28">ok</text><text style="font-size: 32px">ok</text></svg>');
assert.ok(!w.some((x) => /24/.test(x)));
});
test('mer enn 2 ikke-nøytrale farger gir fargebudsjett-warning', () => {
const w = validateFigure('<svg><rect fill="#9A3324"/><rect fill="#2F6F9F"/><rect fill="#22AA55"/></svg>');
assert.ok(w.some((x) => /farge/i.test(x)));
});
test('nøytraler (gråtoner/hvit/sort) telles ikke mot fargebudsjettet', () => {
const w = validateFigure(
'<svg><rect fill="#FFFFFF"/><rect fill="#1A1A1A"/><rect fill="#888888"/><rect fill="#9A3324" font-size="30"/></svg>'
);
assert.ok(!w.some((x) => /farge/i.test(x)));
});
});
describe('resolveChrome — robust binær-oppdagelse (mockbar)', () => {
test('finner app-bundle-sti via exists-probe', () => {
const r = resolveChrome({
exists: (p) => p.includes('Google Chrome.app'),
which: () => null,
});
assert.equal(r.available, true);
assert.match(r.path, /Google Chrome/);
});
test('faller tilbake til PATH via which-probe', () => {
const r = resolveChrome({
exists: () => false,
which: (cmd) => (cmd === 'chromium' ? '/usr/local/bin/chromium' : null),
});
assert.equal(r.available, true);
assert.equal(r.path, '/usr/local/bin/chromium');
});
test('ingen kandidater: available=false med install-hint', () => {
const r = resolveChrome({ exists: () => false, which: () => null });
assert.equal(r.available, false);
assert.match(r.hint, /Chrome/);
});
});
describe('parseArgs — CLI-kontrakt', () => {
test('defaults: target=article, out avledes senere (null)', () => {
const r = parseArgs(['fig.svg']);
assert.equal(r.error, undefined);
assert.equal(r.input, 'fig.svg');
assert.equal(r.target, 'article');
assert.equal(r.out, null);
});
test('--target/--out/--width/--height parses (tall som tall)', () => {
const r = parseArgs(['fig.svg', '--target', 'carousel', '--out', '/tmp/x.png', '--width', '900', '--height', '1200']);
assert.equal(r.target, 'carousel');
assert.equal(r.out, '/tmp/x.png');
assert.equal(r.width, 900);
assert.equal(r.height, 1200);
});
test('ugyldig target gir error med gyldige mål', () => {
const r = parseArgs(['fig.svg', '--target', 'poster']);
assert.match(r.error, /article/);
});
test('manglende input gir usage-error', () => {
const r = parseArgs([]);
assert.match(r.error, /[Bb]ruk/);
});
});
// ---------------------------------------------------------------------------
// Ende-til-ende (krever ekte Chrome — skippes CI-vennlig når binær mangler)
// ---------------------------------------------------------------------------
const chrome = resolveChrome();
describe('renderFigure — ende-til-ende via headless Chrome', { skip: !chrome.available && 'Chrome ikke funnet' }, () => {
test('article: fixture-SVG → PNG med 1200px bredde og aspekt-avledet høyde', async () => {
const out = path.join(tmpdir(), 'fig-article.png');
const r = await renderFigure({ input: FIXTURE_SVG, target: 'article', out, dataDir: tmpdir() });
assert.equal(r.out, out);
const dim = pngDimensions(out);
assert.equal(dim.width, 1200);
assert.equal(dim.height, 675); // fixture er 16:9 (viewBox 1200×675)
});
test('carousel: fixture-SVG → PNG 1080×1350', async () => {
const out = path.join(tmpdir(), 'fig-carousel.png');
await renderFigure({ input: FIXTURE_SVG, target: 'carousel', out, dataDir: tmpdir() });
const dim = pngDimensions(out);
assert.equal(dim.width, 1080);
assert.equal(dim.height, 1350);
});
test('render uten brand-tokens.json lykkes med nøytrale defaults', async () => {
const out = path.join(tmpdir(), 'fig-default-tokens.png');
const r = await renderFigure({ input: FIXTURE_SVG, target: 'single', out, dataDir: tmpdir() });
assert.equal(r.tokensSource, 'default');
assert.ok(fs.existsSync(out));
// PNG-magic
const buf = fs.readFileSync(out);
assert.equal(buf.readUInt32BE(0), 0x89504e47);
});
test('CLI: node build-figur.mjs <fixture> --target carousel --out <png>', () => {
const out = path.join(tmpdir(), 'fig-cli.png');
const script = path.join(__dirname, '..', 'build-figur.mjs');
execFileSync(process.execPath, [script, FIXTURE_SVG, '--target', 'carousel', '--out', out], {
stdio: ['ignore', 'pipe', 'pipe'],
});
const dim = pngDimensions(out);
assert.equal(dim.width, 1080);
assert.equal(dim.height, 1350);
});
});