openvibers/openvibe.bench Commits

Validate SVG layout and replace overlapping icon seed (#152)

OP OpenVibers committed 5 days ago · c139a7b8eb · 7 files, +308 −5

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 07262fa..026ee20 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -43,7 +43,7 @@ const { createDailyRounds } = require('./bench/daily');
const { createBattlePages } = require('./http/battle');
const { createRatingsPages } = require('./http/ratings');
const { createRenderRoutes } = require('./http/render');
--const { seedShowcaseTasks } = require('./bench/seed-tasks');
++const { seedShowcaseTasks, auditSvgEntries } = require('./bench/seed-tasks');
const { createRegistry } = require('./bench/registry');
const { createLauncher } = require('./bench/launch');
const { seedBenchmarks } = require('./bench/seed-benchmarks');
@@@ -97,6 +97,7 @@ async function createApp(opts = {}) {
await bench.seedKinds();
try { await seedShowcaseTasks(s, bench); } catch (err) { log.warn('[OpenVibe.Bench] showcase tasks not seeded:', (err && err.message) || err); }
++ try { await auditSvgEntries(s); } catch (err) { log.warn('[OpenVibe.Bench] SVG entries not audited:', (err && err.message) || err); }
try { await seedBenchmarks(s, registry); } catch (err) { log.warn('[OpenVibe.Bench] showcase benchmarks not seeded:', (err && err.message) || err); }
const accountData = accountDataLib.create({ db: s.db, log });
ddiff --git a/server/bench/jobs.js b/server/bench/jobs.js
iindex ad69664..709f1af 100644
---- a/server/bench/jobs.js
++++ b/server/bench/jobs.js
@@@ -45,13 +45,13 @@ function createBenchJobs({ s, bench, ai, config, log = console }) {
const slot = slots[i] || { provider: 'ai', model: null };
try {
const out = await ai.generate({ prompt: task.prompt, kind: kind.entry, slot, roundId: round.id, index: i });
-- await bench.addEntry({
++ const entry = await bench.addEntry({
roundId: round.id, provider: out.provider, model: out.model,
route: { provider: out.provider, model: out.model },
payload: out.payload, costUsd: out.cost_usd, ms: out.ms, tokens: out.tokens,
uiCheck: out.ui_check, runId: out.run_id,
});
-- ok++;
++ if (!entry.error) ok++;
} catch (err) {
const detail = String((err && (err.detail || err.message)) || err).slice(0, ERROR_MAX);
log.warn(`[OpenVibe.Bench] round ${round.id} output ${i} failed:`, detail);
ddiff --git a/server/bench/seed-tasks.js b/server/bench/seed-tasks.js
iindex a5cff2b..4a3faaa 100644
---- a/server/bench/seed-tasks.js
++++ b/server/bench/seed-tasks.js
@@@ -1,4 +1,6 @@
'use strict';
++const { checkSvg } = require('./svg-check');
++const LEGACY_ICON_PROMPT = 'Draw a single SVG icon set of six 24x24 line icons (search, settings, bell, user, download, close) with consistent stroke width and rounded caps, drawn as pure SVG. No script, no external references, no raster images.';
/**
* The showcase task prompts (spec 3). Ten of the forty are seeded here, across nine kinds, so the gallery has real
@@@ -10,7 +12,7 @@ const SHOWCASE_TASKS = Object.freeze([
{ kind: 'games.play', task_class: 'arcade', prompt: 'Build a one-screen snake game on a 20x20 grid. Arrow keys steer, the snake grows on food, the walls kill, and the score is the food eaten. Show the score and the high score. Canvas only, no external libraries, no network.' },
{ kind: 'games.three', task_class: 'scene', prompt: 'Build a three.js scene of a slow-rotating low-poly tree on a flat ground plane, lit by one warm key light and one cool fill, with soft shadows. The scene must be visible within three seconds and animate on its own. Import three.js from the pinned URL given in the sandbox notice. No network access otherwise.' },
{ kind: 'games.three', task_class: 'scene', prompt: 'Build a three.js scene of 200 instanced cubes falling and bouncing on a plane, in three colours. It must animate smoothly and the camera should stay fixed. No network.' },
-- { kind: 'design.svg', task_class: 'illustration', prompt: 'Draw a single SVG icon set of six 24x24 line icons (search, settings, bell, user, download, close) with consistent stroke width and rounded caps, drawn as pure SVG. No script, no external references, no raster images.' },
++ { kind: 'design.svg', task_class: 'illustration', prompt: 'Draw one SVG with viewBox="0 0 168 48" on a transparent background. Place six separate 24x24 line icons in one row: search, settings, bell, user, download, close. Draw each icon in its own group and cell, with cell origins at (0,12), (28,12), (56,12), (84,12), (112,12), and (140,12), respectively. Keep every stroke inside its cell. Use stroke-width="2", stroke-linecap="round", stroke-linejoin="round", and stroke="currentColor" consistently. Pure SVG only: no script, external references, or raster images.' },
{ kind: 'design.svg', task_class: 'illustration', prompt: 'Draw a single flat SVG illustration of a city skyline at dusk, as a poster: gradient sky, sun, layered buildings, birds, and room for a title block at the bottom. Pure SVG, no external references.' },
{ kind: 'design.motion', task_class: 'motion', prompt: 'Build a single HTML page with a pure CSS animated loading state: three pulsing bars, a shimmer sweep across a card, and a rotating ring. Loop for at least 10 seconds, then freeze on a final frame. No JavaScript required, no network.' },
{ kind: 'design.ui', task_class: 'dashboard', prompt: 'Design a full-page dark analytics dashboard: left icon sidebar, top bar with a date-range picker and a search field, four KPI cards, one wide line chart, one bar chart, and a table of recent events. Real labels, no lorem ipsum. Inline CSS only, no network, no icon fonts, no charts from a CDN.' },
@@@ -33,7 +35,24 @@ async function seedShowcaseTasks(s, bench, opts) {
const out = await bench.createTask({ kindId: t.kind, prompt: t.prompt, taskClass: t.task_class, public: true, subject });
if (out && out.ok) made.push(out.task);
}
++ // Preserve the historical task for its rounds and votes, but remove it from the public task list.
++ await s.db.exec("UPDATE bench_tasks SET public = false WHERE kind_id = 'design.svg' AND prompt = $1 AND created_by = $2 AND public = true", [LEGACY_ICON_PROMPT, subject]);
return made;
}
--module.exports = { SHOWCASE_TASKS, seedShowcaseTasks };
++/** Check entries made before the SVG layout rule was installed; keep their tasks, votes and ratings intact. */
++async function auditSvgEntries(s) {
++ const rows = await s.db.many(
++ "SELECT e.id, e.payload, e.ui_check, t.prompt FROM bench_entries e JOIN bench_rounds r ON r.id = e.round_id JOIN bench_tasks t ON t.id = r.task_id WHERE r.kind_id = 'design.svg' AND e.error IS NULL AND (e.ui_check IS NULL OR e.ui_check->'svg_layout' IS NULL)");
++ for (const row of rows) {
++ const payload = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;
++ const prior = typeof row.ui_check === 'string' ? JSON.parse(row.ui_check) : row.ui_check;
++ if (prior && prior.svg_layout) continue;
++ const check = checkSvg(payload && (payload.svg || payload.text), row.prompt);
++ const uiCheck = { ...(prior && typeof prior === 'object' && !Array.isArray(prior) ? prior : {}), svg_layout: check };
++ const error = check.status === 'failed' ? 'SVG layout check failed: ' + check.issues.join(', ') : null;
++ await s.db.exec('UPDATE bench_entries SET ui_check = $1::jsonb, error = $2 WHERE id = $3', [JSON.stringify(uiCheck), error, row.id]);
++ }
++}
++
++module.exports = { SHOWCASE_TASKS, LEGACY_ICON_PROMPT, seedShowcaseTasks, auditSvgEntries };
ddiff --git a/server/bench/svg-check.js b/server/bench/svg-check.js
nnew file mode 100644
iindex 0000000..8f53214
---- /dev/null
++++ b/server/bench/svg-check.js
@@@ -0,0 +1,173 @@
++'use strict';
++
++// Cheap layout check for generated SVGs. It checks the geometry we can measure without a browser;
++// a missing or unsupported shape is reported rather than silently treating an empty drawing as valid.
++const NUMBER = /[-+]?(?:\d*\.\d+|\d+\.?\d*)(?:e[-+]?\d+)?/gi;
++const DRAWING = new Set(['path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon']);
++const identity = [1, 0, 0, 1, 0, 0];
++const mul = (a, b) => [a[0]*b[0]+a[2]*b[1], a[1]*b[0]+a[3]*b[1], a[0]*b[2]+a[2]*b[3], a[1]*b[2]+a[3]*b[3], a[0]*b[4]+a[2]*b[5]+a[4], a[1]*b[4]+a[3]*b[5]+a[5]];
++const point = (m, x, y) => [m[0]*x+m[2]*y+m[4], m[1]*x+m[3]*y+m[5]];
++const nums = (s) => [...String(s || '').matchAll(NUMBER)].map((m) => Number(m[0]));
++const attr = (s) => Object.fromEntries([...s.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)].map((m) => [m[1], m[2] === undefined ? m[3] : m[2]]));
++const n = (a, key, fallback = 0) => a[key] == null ? fallback : Number(a[key]);
++const mod = (a) => (a % (2*Math.PI) + 2*Math.PI) % (2*Math.PI);
++
++function arcPoints(x1, y1, rx, ry, degrees, large, sweep, x2, y2) {
++ rx = Math.abs(rx); ry = Math.abs(ry);
++ if (!rx || !ry || (x1 === x2 && y1 === y2)) return [];
++ const phi = degrees * Math.PI / 180, c = Math.cos(phi), s = Math.sin(phi);
++ const dx = (x1-x2)/2, dy = (y1-y2)/2;
++ const xp = c*dx+s*dy, yp = -s*dx+c*dy;
++ const stretch = Math.sqrt(xp*xp/(rx*rx)+yp*yp/(ry*ry));
++ if (stretch > 1) { rx *= stretch; ry *= stretch; }
++ const numerator = Math.max(0, rx*rx*ry*ry-rx*rx*yp*yp-ry*ry*xp*xp);
++ const denominator = rx*rx*yp*yp+ry*ry*xp*xp;
++ const factor = (Boolean(large) === Boolean(sweep) ? -1 : 1) * Math.sqrt(numerator/denominator);
++ const cxp = factor*rx*yp/ry, cyp = -factor*ry*xp/rx;
++ const cx = c*cxp-s*cyp+(x1+x2)/2, cy = s*cxp+c*cyp+(y1+y2)/2;
++ const start = Math.atan2((yp-cyp)/ry, (xp-cxp)/rx);
++ const end = Math.atan2((-yp-cyp)/ry, (-xp-cxp)/rx);
++ const span = sweep ? mod(end-start) : mod(start-end);
++ const candidates = [Math.atan2(-s*ry, c*rx), Math.atan2(-s*ry, c*rx)+Math.PI,
++ Math.atan2(c*ry, s*rx), Math.atan2(c*ry, s*rx)+Math.PI];
++ return candidates.filter((angle) => (sweep ? mod(angle-start) : mod(start-angle)) <= span+1e-9)
++ .map((angle) => [cx+c*rx*Math.cos(angle)-s*ry*Math.sin(angle), cy+s*rx*Math.cos(angle)+c*ry*Math.sin(angle)]);
++}
++
++function transform(source) {
++ let m = identity;
++ for (const call of String(source || '').matchAll(/(matrix|translate|scale|rotate)\s*\(([^)]*)\)/g)) {
++ const v = nums(call[2]);
++ let next;
++ if (call[1] === 'matrix' && v.length === 6) next = v;
++ else if (call[1] === 'translate' && v.length >= 1) next = [1, 0, 0, 1, v[0], v[1] || 0];
++ else if (call[1] === 'scale' && v.length >= 1) next = [v[0], 0, 0, v[1] == null ? v[0] : v[1], 0, 0];
++ else if (call[1] === 'rotate' && v.length >= 1) {
++ const c = Math.cos(v[0] * Math.PI / 180), s = Math.sin(v[0] * Math.PI / 180);
++ const r = [c, s, -s, c, 0, 0];
++ next = v.length >= 3 ? mul(mul([1, 0, 0, 1, v[1], v[2]], r), [1, 0, 0, 1, -v[1], -v[2]]) : r;
++ } else return null;
++ m = mul(m, next);
++ }
++ if (String(source || '').replace(/(matrix|translate|scale|rotate)\s*\([^)]*\)/g, '').trim()) return null;
++ return m;
++}
++
++function pathPoints(d) {
++ const tokens = String(d || '').match(/[a-zA-Z]|[-+]?(?:\d*\.\d+|\d+\.?\d*)(?:e[-+]?\d+)?/gi) || [];
++ const arity = { M: 2, L: 2, H: 1, V: 1, C: 6, S: 4, Q: 4, T: 2, A: 7, Z: 0 };
++ const out = [];
++ let i = 0, command = null, x = 0, y = 0, startX = 0, startY = 0;
++ while (i < tokens.length) {
++ if (/^[a-z]$/i.test(tokens[i])) command = tokens[i++];
++ if (!command || !Object.hasOwn(arity, command.toUpperCase())) return null;
++ const upper = command.toUpperCase(), count = arity[upper];
++ if (upper === 'Z') { x = startX; y = startY; out.push([x, y]); command = null; continue; }
++ if (i + count > tokens.length || tokens.slice(i, i + count).some((t) => /^[a-z]$/i.test(t))) return null;
++ const v = tokens.slice(i, i += count).map(Number);
++ const rel = command !== upper, ox = rel ? x : 0, oy = rel ? y : 0;
++ if (upper === 'H') x = ox + v[0];
++ else if (upper === 'V') y = oy + v[0];
++ else if (upper === 'A') {
++ const x2 = ox+v[5], y2 = oy+v[6];
++ out.push(...arcPoints(x, y, v[0], v[1], v[2], v[3], v[4], x2, y2));
++ x = x2; y = y2;
++ }
++ else {
++ if (upper === 'C') out.push([ox+v[0], oy+v[1]], [ox+v[2], oy+v[3]]);
++ if (upper === 'S' || upper === 'Q') out.push([ox+v[0], oy+v[1]]);
++ x = ox + v[count-2]; y = oy + v[count-1];
++ }
++ out.push([x, y]);
++ if (upper === 'M') { startX = x; startY = y; command = rel ? 'l' : 'L'; }
++ }
++ return out;
++}
++
++function shapePoints(tag, a) {
++ if (tag === 'path') return pathPoints(a.d);
++ if (tag === 'polyline' || tag === 'polygon') {
++ const v = nums(a.points);
++ if (v.length < 2 || v.length % 2) return null;
++ return Array.from({ length: v.length / 2 }, (_, i) => [v[2*i], v[2*i+1]]);
++ }
++ if (tag === 'line') return [[n(a, 'x1'), n(a, 'y1')], [n(a, 'x2'), n(a, 'y2')]];
++ if (tag === 'rect') {
++ const x = n(a, 'x'), y = n(a, 'y'), w = n(a, 'width'), h = n(a, 'height');
++ return w < 0 || h < 0 ? null : [[x,y], [x+w,y], [x,y+h], [x+w,y+h]];
++ }
++ if (tag === 'circle' || tag === 'ellipse') {
++ const x = n(a, 'cx'), y = n(a, 'cy'), rx = n(a, tag === 'circle' ? 'r' : 'rx'), ry = tag === 'circle' ? rx : n(a, 'ry');
++ return rx < 0 || ry < 0 ? null : [[x-rx,y-ry], [x+rx,y-ry], [x-rx,y+ry], [x+rx,y+ry]];
++ }
++ return null;
++}
++
++function separateIcons(prompt) {
++ const text = String(prompt || '');
++ const match = /\b(one|two|three|four|five|six|seven|eight|nine|ten|\d+)\b[^.!?]{0,80}?\b(?:icons?|glyphs?)\b/i.exec(text)
++ || /\bicon set of\s+(one|two|three|four|five|six|seven|eight|nine|ten|\d+)\b/i.exec(text);
++ if (!match) return 0;
++ const words = ['one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten'];
++ return Number(match[1]) || words.indexOf(match[1].toLowerCase()) + 1;
++}
++
++function checkSvg(svg, prompt = '') {
++ const issues = [];
++ const source = String(svg || '');
++ const root = /<svg\b([^>]*)>/i.exec(source);
++ const box = root && nums(attr(root[1]).viewBox);
++ if (!box || box.length !== 4 || !box.every(Number.isFinite) || box[2] <= 0 || box[3] <= 0) {
++ return { status: 'failed', issues: ['missing_viewbox'] };
++ }
++ const bounds = [Infinity, Infinity, -Infinity, -Infinity];
++ const groups = [];
++ const stack = [{ tag: 'root', matrix: identity, stroke: 'none', strokeWidth: 1 }];
++ let shapes = 0;
++ for (const match of source.matchAll(/<\s*(\/?)\s*([a-z][\w:-]*)\b([^>]*)>/gi)) {
++ const closing = Boolean(match[1]), tag = match[2].toLowerCase();
++ if (closing) { if (stack.length > 1 && stack.at(-1).tag === tag) stack.pop(); continue; }
++ const a = attr(match[3]);
++ const own = transform(a.transform);
++ if (!own) { issues.push('unsupported_transform'); continue; }
++ const matrix = mul(stack.at(-1).matrix, own);
++ const stroke = a.stroke || stack.at(-1).stroke;
++ const strokeWidth = a['stroke-width'] == null ? stack.at(-1).strokeWidth : Number(a['stroke-width']);
++ if (tag === 'g') groups.push({ origin: point(matrix, 0, 0), shapes: 0 });
++ if (DRAWING.has(tag)) {
++ const pts = shapePoints(tag, a);
++ if (!pts || !pts.length || pts.some((p) => !p.every(Number.isFinite))) issues.push('unmeasurable_shape');
++ else {
++ shapes++;
++ const shapeBounds = [Infinity, Infinity, -Infinity, -Infinity];
++ for (const p of pts) {
++ const [x, y] = point(matrix, ...p);
++ shapeBounds[0] = Math.min(shapeBounds[0], x); shapeBounds[1] = Math.min(shapeBounds[1], y);
++ shapeBounds[2] = Math.max(shapeBounds[2], x); shapeBounds[3] = Math.max(shapeBounds[3], y);
++ }
++ const width = stroke !== 'none' ? strokeWidth/2 : 0;
++ if (!Number.isFinite(width) || width < 0) issues.push('unmeasurable_stroke');
++ else {
++ const dx = width*Math.hypot(matrix[0], matrix[2]), dy = width*Math.hypot(matrix[1], matrix[3]);
++ bounds[0] = Math.min(bounds[0], shapeBounds[0]-dx); bounds[1] = Math.min(bounds[1], shapeBounds[1]-dy);
++ bounds[2] = Math.max(bounds[2], shapeBounds[2]+dx); bounds[3] = Math.max(bounds[3], shapeBounds[3]+dy);
++ }
++ if (stack.at(-1).group != null) groups[stack.at(-1).group].shapes++;
++ }
++ }
++ if (!/\/\s*$/.test(match[3]) && !['path','rect','circle','ellipse','line','polyline','polygon','stop','use','image'].includes(tag)) {
++ stack.push({ tag, matrix, stroke, strokeWidth, group: tag === 'g' ? groups.length - 1 : stack.at(-1).group });
++ }
++ }
++ if (!shapes) issues.push('empty_drawing');
++ const epsilon = 0.01;
++ if (shapes && (bounds[0] < box[0]-epsilon || bounds[1] < box[1]-epsilon || bounds[2] > box[0]+box[2]+epsilon || bounds[3] > box[1]+box[3]+epsilon)) issues.push('outside_viewbox');
++ const expected = separateIcons(prompt);
++ if (expected > 1) {
++ const origins = new Set(groups.filter((g) => g.shapes).map((g) => g.origin.map((v) => v.toFixed(2)).join(',')));
++ if (origins.size < expected) issues.push('overlapping_icon_origins');
++ }
++ return { status: issues.length ? 'failed' : 'ok', issues: [...new Set(issues)], bounds: shapes ? bounds : null, viewBox: box, icon_groups: expected ? groups.length : undefined };
++}
++
++module.exports = { checkSvg, separateIcons };
ddiff --git a/server/store.js b/server/store.js
iindex 579d48b..d9c0f09 100644
---- a/server/store.js
++++ b/server/store.js
@@@ -13,6 +13,7 @@ const clipLib = require('./bench/clip');
const clipSource = require('./bench/clip-source');
const ratingLib = require('./rating');
const styleLib = require('./rating/style');
++const { checkSvg } = require('./bench/svg-check');
const BASE_ELO = ratingLib.BASE_ELO;
const K = 32;
@@@ -402,6 +403,12 @@ function createStore(s, { log = console, random = Math.random, config = null } =
return await s.tx(async () => {
const r = await db.maybe('SELECT * FROM bench_rounds WHERE id = $1 FOR UPDATE', [String(roundId || '')]);
if (!r) throw new BenchError(404, 'bench.round_not_found', 'No such Bench round.');
++ if (r.kind_id === 'design.svg' && !error) {
++ const task = await db.maybe('SELECT prompt FROM bench_tasks WHERE id = $1', [r.task_id]);
++ const check = checkSvg(payload && payload.svg, task && task.prompt);
++ uiCheck = { ...(uiCheck && typeof uiCheck === 'object' && !Array.isArray(uiCheck) ? uiCheck : {}), svg_layout: check };
++ if (check.status === 'failed') error = 'SVG layout check failed: ' + check.issues.join(', ');
++ }
if (r.kind_id === 'live.clip') {
// A clip pick: its shape checked, and the policy that chose it pinned on the entry (route), never rewritten.
const v = clipLib.validateEntry(payload, { now: s.now() });
ddiff --git a/test/daily-rounds.test.js b/test/daily-rounds.test.js
iindex bd38652..22ef205 100644
---- a/test/daily-rounds.test.js
++++ b/test/daily-rounds.test.js
@@@ -7,6 +7,7 @@
const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
const { DAILY_KIND_IDS, DAILY_PROMPTS } = require('../server/bench/daily-prompts');
++const { separateIcons } = require('../server/bench/svg-check');
const CLOCK = Date.parse('2026-11-01T09:00:00.000Z');
const clock = () => CLOCK;
@@@ -24,6 +25,14 @@ const clock = () => CLOCK;
const t = await boot({
now: clock,
++ aiOptions: { textFor: (body) => {
++ const svgBrief = DAILY_PROMPTS['design.svg'].find((p) => String(body.prompt || '').includes(p));
++ if (svgBrief) {
++ const count = separateIcons(svgBrief) || 1;
++ return `<svg viewBox="0 0 ${count * 28} 24">${Array.from({ length: count }, (_, i) => `<g transform="translate(${i * 28} 0)"><path d="M2 2 L20 20"/></g>`).join('')}</svg>`;
++ }
++ return '<!doctype html><html><body><h1>Generated output</h1></body></html>';
++ } },
env: {
BENCH_DAILY_ROUNDS: '1',
BENCH_DAILY_ROUNDS_IN_DRILL: '1',
ddiff --git a/test/svg-layout.test.js b/test/svg-layout.test.js
nnew file mode 100644
iindex 0000000..0971aa5
---- /dev/null
++++ b/test/svg-layout.test.js
@@@ -0,0 +1,94 @@
++'use strict';
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { checkSvg, separateIcons } = require('../server/bench/svg-check');
++const { SHOWCASE_TASKS, LEGACY_ICON_PROMPT: oldPrompt, seedShowcaseTasks, auditSvgEntries } = require('../server/bench/seed-tasks');
++
++const prompt = SHOWCASE_TASKS.find((t) => t.kind === 'design.svg').prompt;
++const iconSvg = (origins) => '<svg viewBox="0 0 168 48">' + origins.map((x) => `<g transform="translate(${x} 12)"><path d="M2 2 L20 20"/></g>`).join('') + '</svg>';
++const good = iconSvg([0, 28, 56, 84, 112, 140]);
++
++(async () => {
++ await check('the new prompt fixes six icon cells and is a new task identity', async () => {
++ assert.match(prompt, /viewBox="0 0 168 48"/);
++ for (const x of [0, 28, 56, 84, 112, 140]) assert.ok(prompt.includes(`(${x},12)`));
++ assert.match(prompt, /stroke-width="2"/);
++ assert.match(prompt, /stroke="currentColor"/);
++ assert.notStrictEqual(prompt, oldPrompt);
++ assert.strictEqual(separateIcons(prompt), 6);
++ assert.strictEqual(separateIcons(oldPrompt), 6);
++ });
++
++ await check('geometry and separate origins are checked without a browser', async () => {
++ assert.strictEqual(checkSvg(good, prompt).status, 'ok');
++ assert.ok(checkSvg(iconSvg([0, 0, 0, 0, 0, 0]), prompt).issues.includes('overlapping_icon_origins'));
++ assert.ok(checkSvg(iconSvg([0, 28, 56, 84, 112, 160]), prompt).issues.includes('outside_viewbox'));
++ assert.ok(checkSvg('<svg viewBox="0 0 24 24"><g transform="translate(48 0)"><rect width="20" height="20"/></g></svg>').issues.includes('outside_viewbox'));
++ assert.ok(checkSvg('<svg><path d="M0 0 L1 1"/></svg>').issues.includes('missing_viewbox'));
++ assert.ok(checkSvg('<svg viewBox="0 0 24 24"><path d="M0 0 L5 5" transform="translate(25 0)"/></svg>').issues.includes('outside_viewbox'));
++ assert.ok(checkSvg('<svg viewBox="0 0 24 24"><path d="M1 1 A20 20 0 1 1 1 20"/></svg>').issues.includes('outside_viewbox'), 'the arc crosses the edge even though its endpoints do not');
++ assert.ok(checkSvg('<svg viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path d="M0 1 L10 10"/></svg>').issues.includes('outside_viewbox'), 'the stroke also needs clearance');
++ });
++
++ const t = await boot();
++ try {
++ await check('seeding leaves existing tasks and votes under the old prompt', async () => {
++ const old = await t.ctx.bench.createTask({ kindId: 'design.svg', prompt: oldPrompt, taskClass: 'illustration', public: true, subject: 'showcase:seed' });
++ assert.ok(old.ok);
++ const before = await t.ctx.s.db.value("SELECT COUNT(*)::int AS n FROM bench_tasks WHERE kind_id = 'design.svg'");
++ await seedShowcaseTasks(t.ctx.s, t.ctx.bench);
++ const after = await t.ctx.s.db.value("SELECT COUNT(*)::int AS n FROM bench_tasks WHERE kind_id = 'design.svg'");
++ assert.strictEqual(after, before);
++ const seeded = await t.ctx.s.db.maybe('SELECT id, public FROM bench_tasks WHERE prompt = $1', [prompt]);
++ assert.ok(seeded && seeded.public);
++ assert.notStrictEqual(old.task.id, seeded.id);
++ const retired = await t.ctx.s.db.maybe('SELECT public FROM bench_tasks WHERE id = $1', [old.task.id]);
++ assert.strictEqual(retired.public, false);
++ });
++
++ await check('a failed output is recorded and never paired; earlier votes remain', async () => {
++ const old = await t.ctx.s.db.maybe('SELECT id FROM bench_tasks WHERE prompt = $1', [oldPrompt]);
++ const created = await t.ctx.bench.createRound({ taskId: old.id, offers: [{ provider: 'acme', model: 'alpha' }, { provider: 'beta', model: 'beta' }], subject: 'showcase:seed', bypassQuota: true });
++ assert.ok(created.ok, JSON.stringify(created));
++ const roundId = created.round.id;
++ const first = await t.ctx.bench.addEntry({ roundId, provider: 'acme', model: 'alpha', payload: { svg: good } });
++ const second = await t.ctx.bench.addEntry({ roundId, provider: 'beta', model: 'beta', payload: { svg: good } });
++ assert.strictEqual(first.error, null);
++ assert.strictEqual(second.error, null);
++ assert.strictEqual(first.ui_check.svg_layout.status, 'ok');
++ const voter = t.network.addUser('svg-voter');
++ const vote = await t.get(`/api/v1/rounds/${roundId}/judgements`, { as: voter, json: { judge: 'human_pairwise', pair: { left: first.id, right: second.id }, outcome: 'left' } });
++ assert.strictEqual(vote.status, 201, vote.text);
++ // Simulate an entry from before the check existed, then run the one-time audit.
++ await t.ctx.s.db.exec('UPDATE bench_entries SET payload = $1::jsonb, ui_check = NULL WHERE id = $2', [JSON.stringify({ svg: iconSvg([0, 0, 0, 0, 0, 0]) }), first.id]);
++ await auditSvgEntries(t.ctx.s);
++ const flagged = await t.ctx.s.db.maybe('SELECT error, ui_check FROM bench_entries WHERE id = $1', [first.id]);
++ assert.match(flagged.error, /overlapping_icon_origins/);
++ assert.strictEqual(flagged.ui_check.svg_layout.status, 'failed');
++ assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM bench_judgements WHERE round_id = $1', [roundId]), 1);
++ const pair = await t.get('/api/v1/judgements/next?kind=design.svg');
++ assert.strictEqual(pair.status, 204, pair.text);
++ const gallery = await t.get('/api/v1/showcase/pairs?kind=design.svg');
++ assert.ok(!gallery.json().pairs.some((p) => p.id === roundId));
++ const third = await t.ctx.bench.addEntry({ roundId, provider: 'gamma', model: 'gamma', payload: { svg: iconSvg([0, 0, 0, 0, 0, 0]) } });
++ assert.match(third.error, /overlapping_icon_origins/);
++ assert.strictEqual(third.ui_check.svg_layout.status, 'failed');
++ });
++
++ await check('the generation worker fails a round with fewer than two usable SVGs', async () => {
++ const task = await t.ctx.s.db.maybe('SELECT id FROM bench_tasks WHERE prompt = $1', [prompt]);
++ const created = await t.ctx.bench.createRound({ taskId: task.id, subject: 'showcase:seed', bypassQuota: true });
++ assert.ok(created.ok);
++ await t.tick(); // The fake AI emits HTML, which is not a usable SVG.
++ const round = await t.ctx.s.db.maybe('SELECT status FROM bench_rounds WHERE id = $1', [created.round.id]);
++ assert.strictEqual(round.status, 'failed');
++ const entries = await t.ctx.s.db.many('SELECT error, ui_check FROM bench_entries WHERE round_id = $1', [created.round.id]);
++ assert.strictEqual(entries.length, 3);
++ for (const entry of entries) {
++ assert.match(entry.error, /missing_viewbox/);
++ assert.strictEqual(entry.ui_check.svg_layout.status, 'failed');
++ }
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });