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
284 lines
10 KiB
JavaScript
284 lines
10 KiB
JavaScript
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 16–23, 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);
|
||
});
|
||
});
|