Files
ProdDash/tools/layout-roundtrip.test.js
Waters ChurchandClaude Fable 5.1 2af7ef27ba Grid density: cut the board finer or coarser, per browser
Twelve columns fit a booth monitor and nothing else well: on a 4K wall a
three-wide tile is nearly a thousand pixels, and on a small screen four tiles
across is the most the grid allows. A stepper in the Layouts menu now cuts
the board into 8, 10, 12, 16, 20 or 24 columns. Row height follows the
column width, so cells keep their shape and a 3×2 tile is still a 3×2 tile,
only smaller — finer lets tiles be smaller so more fit, coarser suits a
screen read close up.

It is this browser's choice, not the server's: two displays at one location
can want different densities, so it saves with the layout — the local one
and a named one on the server — and a named layout comes back at the density
it was arranged on, which the load message says. An older layout with no
density is the default twelve.

Tiles keep their grid units when the density changes; the board simply gets
denser or coarser around them, and a tile now past the right edge is pulled
in. A module's minSize is enforced in cells as declared, so on a finer grid a
tile can be smaller than it could at twelve, which is the point; a new tile's
defaultSize is scaled to the density so it lands at about the size its author
meant. Leaving fullscreen goes back to the chosen density rather than the
stylesheet's twelve — that reset predates any notion of a chosen density.

Shell 1.9.0. The round-trip test carries gridCols and drops a value off the
scale.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 10:19:49 -04:00

160 lines
7.1 KiB
JavaScript

'use strict';
/**
* Round-trips a layout through the server's layouts API and checks that
* nothing is lost on the way: a fully-populated tile, a tile field the server
* has never heard of, the one-back copy a save leaves behind, restore, the
* tile cap and delete.
*
* node tools/layout-roundtrip.test.js
*
* Plain Node, no dependencies. Starts server.js on a spare port with a
* temporary data directory, talks to it over HTTP, exits non-zero on the
* first failure and always stops the server it started.
*
* It exists because of one bug: for seventeen days the server rebuilt each
* saved tile from the fields it knew and dropped the rest — including
* `variant`, so every solo timer saved to a booth came back as "All timers".
* A field added on the client must survive a server that has not heard of
* it, and this is where that is checked.
*/
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const fs = require('node:fs');
const net = require('node:net');
const os = require('node:os');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
function freePort() {
return new Promise((resolve, reject) => {
const probe = net.createServer();
probe.listen(0, '127.0.0.1', () => {
const { port } = probe.address();
probe.close(() => resolve(port));
});
probe.on('error', reject);
});
}
async function waitFor(url, ms = 15000) {
const deadline = Date.now() + ms;
let last = '';
while (Date.now() < deadline) {
try {
const r = await fetch(url);
if (r.ok) return;
last = 'HTTP ' + r.status;
} catch (err) {
last = err.message;
}
await new Promise((r) => setTimeout(r, 200));
}
throw new Error('server did not come up: ' + last);
}
(async () => {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proddash-layout-test-'));
const port = await freePort();
const child = spawn(process.execPath, ['server.js'], {
cwd: ROOT,
env: { ...process.env, PRODDASH_DATA_DIR: dataDir, PORT: String(port), PRODDASH_NO_REMOTE_CHECK: '1', PRODDASH_LAUNCHER: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
});
let log = '';
child.stdout.on('data', (d) => { log += d; });
child.stderr.on('data', (d) => { log += d; });
const base = `http://127.0.0.1:${port}`;
const api = async (p, init = {}) => {
const r = await fetch(base + p, { headers: { 'Content-Type': 'application/json' }, ...init });
return { status: r.status, body: await r.json().catch(() => ({})) };
};
const layoutsDir = path.join(dataDir, 'layouts');
let failed = false;
try {
await waitFor(base + '/api/theme');
// Every field the shell writes today, one it will write tomorrow, and one
// it has stopped writing.
const tile = {
id: 't1', module: 'propresenter-timers', x: 4, y: 2, w: 3, h: 2,
settings: { showNames: false, __tint: '#112233' },
variant: 'solo:pco-plan:service', title: 'Service', headPinned: true, minSize: { w: 2, h: 1 },
futureField: 'kept', futureFlag: true, futureBox: { a: 1 },
headHidden: true,
};
let r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { autoHideHeads: true, gridCols: 16, tiles: [tile] } }) });
assert.equal(r.status, 200, 'save: ' + JSON.stringify(r.body));
assert.equal(r.body.replaced, false, 'a first save replaces nothing');
r = await api('/api/layouts/Booth%20A');
assert.equal(r.status, 200);
const got = r.body.layout;
assert.equal(got.v, 2, 'the file carries a schema version');
assert.equal(got.autoHideHeads, true);
assert.equal(got.gridCols, 16, 'grid density travels with the layout');
const t = got.tiles[0];
for (const k of ['id', 'module', 'x', 'y', 'w', 'h', 'variant', 'title', 'headPinned']) assert.deepEqual(t[k], tile[k], k);
assert.deepEqual(t.settings, tile.settings, 'settings');
assert.deepEqual(t.minSize, tile.minSize, 'minSize');
assert.equal(t.futureField, 'kept', 'an unknown string field survives');
assert.equal(t.futureFlag, true, 'an unknown boolean survives');
assert.deepEqual(t.futureBox, { a: 1 }, 'an unknown object survives');
assert.equal('headHidden' in t, false, 'a retired field is dropped');
// Saving over the name keeps the copy it replaced.
r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { gridCols: 13, tiles: [{ ...tile, title: 'Service v2' }] } }) });
assert.equal(r.status, 200);
assert.equal(r.body.replaced, true, 'the second save reports a replacement');
assert.ok(fs.existsSync(path.join(layoutsDir, 'Booth A.previous.json')), 'the replaced copy is kept');
r = await api('/api/layouts');
assert.deepEqual(r.body.layouts.map((l) => [l.name, l.hasPrevious]), [['Booth A', true]],
'the listing shows one layout with a previous copy — not two layouts');
// Restore swaps them, so a second restore undoes the first. Admin is open
// here (no PIN), which is what a fresh data directory gives.
r = await api('/api/layouts/Booth%20A/restore', { method: 'POST', body: '{}' });
assert.equal(r.status, 200, 'restore: ' + JSON.stringify(r.body));
r = await api('/api/layouts/Booth%20A');
assert.equal(r.body.layout.tiles[0].title, 'Service', 'restore brings the earlier save back');
r = await api('/api/layouts/Booth%20A/restore', { method: 'POST', body: '{}' });
assert.equal(r.status, 200);
r = await api('/api/layouts/Booth%20A');
assert.equal(r.body.layout.tiles[0].title, 'Service v2', 'restoring again goes forward again');
assert.equal('gridCols' in r.body.layout, false, 'a density the shell does not offer is dropped, not stored');
r = await api('/api/layouts/Never%20Saved/restore', { method: 'POST', body: '{}' });
assert.equal(r.status, 404, 'nothing to restore is said, not faked');
// Too many tiles is refused, not trimmed.
const many = Array.from({ length: 101 }, (_, i) => ({ ...tile, id: 't' + i }));
r = await api('/api/layouts/Too%20Big', { method: 'PUT', body: JSON.stringify({ layout: { tiles: many } }) });
assert.equal(r.status, 400);
assert.match(String(r.body.error || ''), /100 tiles/, 'the refusal names the cap');
assert.equal(fs.existsSync(path.join(layoutsDir, 'Too Big.json')), false, 'nothing was written');
// Delete takes the previous copy with it.
r = await api('/api/layouts/Booth%20A', { method: 'DELETE' });
assert.equal(r.status, 200);
assert.equal(fs.existsSync(path.join(layoutsDir, 'Booth A.json')), false);
assert.equal(fs.existsSync(path.join(layoutsDir, 'Booth A.previous.json')), false, 'the previous copy is gone too');
r = await api('/api/layouts');
assert.deepEqual(r.body.layouts, [], 'nothing left listed');
console.log('layout round-trip: every check passed');
} catch (err) {
failed = true;
console.error('FAIL:', err.message);
console.error('--- last of the server log ---\n' + log.slice(-2000));
} finally {
child.kill('SIGTERM');
await new Promise((r) => setTimeout(r, 400));
fs.rmSync(dataDir, { recursive: true, force: true });
}
process.exit(failed ? 1 : 0);
})();