Files
msd-core/tests/quick-batch-dispatch.property.test.cjs
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

170 lines
7.9 KiB
JavaScript

'use strict';
/**
* quick-batch-dispatch.property.test.cjs — Property-based tests for the
* quick-batch dispatch decision core (#3676, Phase 4 of epic #3344 /
* ADR-1239 "Quick-batch binding").
*
* Test matrix rows 51-53 (`.msd/phase/feat-3676-quick-batch-command-workflow/
* 50-test-matrix.md`):
* 51 — post-planning `updateBatchItems` wave recompute (src/quick-batch.cts)
* terminates and every item lands in exactly one wave, for any valid
* (acyclic, in-batch-only) generated depends_on/files_modified
* assignment — same invariant class as Phase 3's row 32, now exercised
* through the Phase-4 recompute path.
* 52 — merge order for any wave, under any interleaving of leaf-completion
* timing, always matches the deterministic input order computeWaves
* assigned (src/quick-batch-dispatch.cts's computeMergeOrder).
* 53 — for any sequence of refused-then-accepted spawns, total fan-out
* never exceeds effective capacity at any point in time
* (src/quick-batch-dispatch.cts's computeSpawnPlan).
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const fc = require('./helpers/fast-check-setup.cjs');
const { createBatch, updateBatchItems } = require('../msd-core/bin/lib/quick-batch.cjs');
const { computeMergeOrder, computeSpawnPlan } = require('../msd-core/bin/lib/quick-batch-dispatch.cjs');
const { cleanup } = require('./helpers.cjs');
function mkTmpProject() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'quick-batch-dispatch-prop-'));
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
return dir;
}
/** A small acyclic dependency graph: item i may depend on any j < i (DAG by construction). */
const dagItemsArb = fc.integer({ min: 2, max: 7 }).chain((n) =>
fc.tuple(
...Array.from({ length: n }, (_, i) =>
fc.record({
dependsOnPrevious: fc.subarray(Array.from({ length: i }, (_, j) => j), { maxLength: i }),
files: fc.array(fc.constantFrom('f0', 'f1', 'f2', 'f3'), { maxLength: 2 }),
}),
),
),
);
describe('quick-batch-dispatch: property — post-planning wave recompute totality (row 51)', () => {
test('property: for any valid (acyclic, in-batch-only) post-planning assignment, updateBatchItems recompute terminates and every item lands in exactly one wave', () => {
fc.assert(fc.property(
dagItemsArb,
(rawItems) => {
const dir = mkTmpProject();
try {
const items = rawItems.map((_, i) => ({ description: `item-${i}`, clientId: `c${i}` }));
const created = createBatch(dir, items);
assert.equal(created.ok, true);
const byClient = created.value.manifest.items; // same order as `items`
const updates = rawItems.map((it, i) => ({
quickId: byClient[i].quick_id,
dependsOn: it.dependsOnPrevious.map((j) => byClient[j].quick_id),
plannedFiles: it.files,
}));
const result = updateBatchItems(dir, created.value.batchId, updates);
assert.equal(result.ok, true, result.ok ? '' : result.reason);
const manifestItems = result.value.manifest.items;
// Termination + totality: every item has a defined, non-negative wave.
for (const it of manifestItems) {
assert.ok(Number.isInteger(it.wave) && it.wave >= 0, `item ${it.quick_id} has an invalid wave ${it.wave}`);
}
// Exactly one wave each: group by wave index and confirm the counts
// sum to the total item count with no gaps between 0..maxWave.
const maxWave = Math.max(...manifestItems.map((it) => it.wave));
const counted = new Array(maxWave + 1).fill(0);
for (const it of manifestItems) counted[it.wave] += 1;
assert.ok(counted.every((c) => c > 0), 'no empty wave gaps');
assert.equal(counted.reduce((a, b) => a + b, 0), manifestItems.length);
// DAG readiness: every dependency's wave strictly precedes its dependent's.
const waveOf = new Map(manifestItems.map((it) => [it.quick_id, it.wave]));
for (const it of manifestItems) {
for (const dep of it.depends_on) {
assert.ok(waveOf.get(dep) < waveOf.get(it.quick_id), `${dep} must strictly precede ${it.quick_id}`);
}
}
} finally {
cleanup(dir);
}
},
), { numRuns: 25 }); // filesystem-backed property — bounded below the global default
});
});
describe('quick-batch-dispatch: property — deterministic merge order under any completion interleaving (row 52)', () => {
test('property: computeMergeOrder always returns the maximal READY prefix of waveOrder, never a completion-order result', () => {
fc.assert(fc.property(
fc.uniqueArray(fc.string({ minLength: 1, maxLength: 6 }).filter((s) => s.trim() === s && s.length > 0), { minLength: 1, maxLength: 10 }),
fc.array(fc.boolean(), { minLength: 0, maxLength: 10 }),
(waveOrder, readyFlags) => {
// Build an arbitrary "ready" subset — readyFlags[i] says whether
// waveOrder[i] finished its leaf, in NO particular arrival order
// (that's the point: the function must not depend on arrival order,
// only on the ORIGINAL waveOrder position).
const readyIds = new Set(waveOrder.filter((_, i) => readyFlags[i] === true));
const result = computeMergeOrder(waveOrder, readyIds);
// Independently derive the expected maximal ready PREFIX.
const expected = [];
for (const id of waveOrder) {
if (!readyIds.has(id)) break;
expected.push(id);
}
assert.deepEqual(result, expected);
// The result is always a genuine prefix of waveOrder (same relative order).
assert.deepEqual(result, waveOrder.slice(0, result.length));
},
), { numRuns: 100 });
});
});
describe('quick-batch-dispatch: property — spawn backpressure never exceeds capacity (row 53)', () => {
test('property: for any sequence of refused-then-accepted spawn rounds, in-flight + newly spawned never exceeds capacity', () => {
fc.assert(fc.property(
fc.integer({ min: 1, max: 8 }), // capacity
fc.array(
fc.record({
eligibleCount: fc.integer({ min: 0, max: 10 }),
refusedCount: fc.integer({ min: 0, max: 10 }),
currentInFlight: fc.integer({ min: 0, max: 12 }),
}),
{ minLength: 1, maxLength: 15 },
),
(capacity, rounds) => {
for (const round of rounds) {
const eligibleIds = Array.from({ length: round.eligibleCount }, (_, i) => `e${i}`);
const refusedIds = eligibleIds.slice(0, Math.min(round.refusedCount, eligibleIds.length));
const result = computeSpawnPlan({
eligibleIds,
capacity,
currentInFlight: round.currentInFlight,
refused: refusedIds,
});
assert.ok(
round.currentInFlight + result.spawn.length <= Math.max(capacity, round.currentInFlight),
'spawning never increases fan-out beyond the capacity ceiling (once already over capacity, nothing new spawns)',
);
assert.ok(result.spawn.length <= Math.max(capacity - round.currentInFlight, 0), 'spawn count never exceeds remaining capacity');
// Every refused id is in pending, never in spawn.
for (const id of refusedIds) {
assert.ok(!result.spawn.includes(id), `refused id ${id} must never be spawned`);
assert.ok(result.pending.includes(id), `refused id ${id} must return to pending`);
}
// spawn and pending partition eligibleIds exactly (no id lost, none duplicated).
assert.deepEqual([...result.spawn, ...result.pending].sort(), eligibleIds.slice().sort());
}
},
), { numRuns: 100 });
});
});