Files
ttrpg-initiative-tracker/shared/tests/static.no-sort.test.js
david raistrick cb41d9ec8f fix(turn): slot-not-sort in add/update; static guard against stray .sort()
addParticipant + updateParticipant used sortParticipantsByInitiative (stable
sort, tie-break = original array index). That destroyed manually-dragged tie
order when a drag moved a same-init pair — violated the slot-not-sort design
(docs/INITIATIVE_ORDERING.md: 'Re-slotting on add/edit must preserve
drag-established tie order').

Fix: new slotIndexForInit(list, init) returns splice index into the CURRENT
list (initiative-descending). addParticipant inserts there; updateParticipant
re-inserts only when initiative changed (unrelated edits keep slot — avoids
mid-round rotation dupes surfaced by the 100-round combat test).

Tests:
- turn.slot-not-sort.test.js: drag tie order survives add/edit (4 cases).
- static.no-sort.test.js: errs if .sort( added outside allowlist
  (sortParticipantsByInitiative only). Verified by injecting a stray sort —
  guard caught it.
2026-07-04 15:40:39 -04:00

71 lines
2.4 KiB
JavaScript

// STATIC GUARD: no `.sort(` introduced in shared/turn.js outside the ONE
// allowed function (sortParticipantsByInitiative, used by startEncounter to
// freeze the list once). Slot-not-sort design (docs/INITIATIVE_ORDERING.md):
// mutations = insert/move, never wholesale re-sort. A stray `.sort(` after
// start destroys drag tie-break order.
//
// This test errs the moment someone adds `.sort(` anywhere but the allowlist.
// Maintenance: add a function to ALLOWED only if it runs at startEncounter
// (one-time freeze), NOT in add/update/reorder/nextTurn paths.
'use strict';
const fs = require('fs');
const path = require('path');
const SRC = fs.readFileSync(path.join(__dirname, '..', 'turn.js'), 'utf8');
// Functions permitted to call .sort(. Listed so intent is explicit + reviewed.
const ALLOWED_SORT_FUNCTIONS = new Set([
'sortParticipantsByInitiative',
]);
// Collect top-level function bodies by brace counting.
// Matches `function NAME(` decls AND `const NAME = ... =>` arrow fns.
// Returns [{ name, body }].
function collectBody(src, fromIdx) {
let i = fromIdx;
while (i < src.length && src[i] !== '{') i++;
let depth = 0;
const start = i;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') { depth--; if (depth === 0) break; }
}
return src.slice(start, i + 1);
}
function extractFunctions(src) {
const out = [];
const fnRe = /\bfunction\s+([A-Za-z0-9_$]+)\s*\(/g;
const arrowRe = /(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*[^=;]*?=>/g;
let m;
while ((m = fnRe.exec(src)) !== null) {
out.push({ name: m[1], body: collectBody(src, m.index + m[0].length) });
}
while ((m = arrowRe.exec(src)) !== null) {
out.push({ name: m[1], body: collectBody(src, m.index + m[0].length) });
}
return out;
}
describe('STATIC: no .sort( outside allowlist (slot-not-sort design)', () => {
test('every .sort( call lives in an allowed function', () => {
const fns = extractFunctions(SRC);
const offenders = [];
for (const { name, body } of fns) {
if (!ALLOWED_SORT_FUNCTIONS.has(name) && /\.sort\(/.test(body)) {
offenders.push(name);
}
}
expect(offenders).toEqual([]);
});
test('allowed sort function is declared (no silent allowlist drift)', () => {
const declared = new Set(extractFunctions(SRC).map(f => f.name));
for (const name of ALLOWED_SORT_FUNCTIONS) {
expect(declared.has(name)).toBe(true);
}
});
});