* fix(#4294): reserve a full progress bar for 100% and give the render half one owner Six call sites each carried `Math.round((percent / 100) * width)` inline, and every copy rounded to a full bar before the percent reached 100: from 95 up at width 10, from 98 up at width 20. A project at 19/20 plans drew the same bar as a shipped one beside a number that said otherwise, and an out-of-range percent threw `RangeError` from the unguarded `'░'.repeat`. ADR-3180 Decision 7 gave the completion-RATIO derivation one owner (`clampPercentFromFraction`). This gives the RENDER half the same: `progressBarFilledCells` / `renderProgressBar` in phase-lifecycle.cts, with the `progress` table and bar renderers, the stats renderer, the gsd2 import writer, and #4231's `formatProgressMachineSegment` (which now serves both STATE.md writers) all drawing through it. Contract: below 100 the fill is held one cell short of the width, so only the saturating percents move (95-99 at width 10, 98-99 at width 20) and every other value in 0-100 renders as before — pinned by an exhaustive comparison against the legacy formula at both widths. Null / non-finite renders an empty bar; out-of-range is clamped, never thrown. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012hbFn24VWaxJBw8DUWjAmU * chore(#4294): set changeset fragment pr to 4473 * docs(#4294): correct the pre-fix inline call-site count to five The kernel's doc comment said SIX call sites carried their own `Math.round((percent / 100) * width)`. The base tree has five: three in `commands.cts` plus one each in `gsd2-import.cts` and `formatProgressMachineSegment`, the latter two using `/ 10` with the width already substituted (`pct` and `clamped` respectively). The six is #4294's count of consumers -- it counts `cmdStateUpdateProgress` and `syncCore` separately, but #4231 had already routed both through `formatProgressMachineSegment` (as it does `applyPostSyncPreservation`), so by this branch's base they share one copy. The comment now states the tree's count and records where the six comes from, so neither number reads as an error later. Comment-only; no behaviour change, and no change to compiled output. --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
99 lines
4.5 KiB
JavaScript
99 lines
4.5 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Property-based tests for the #4213 progress-surface helpers
|
|
*
|
|
* Module: gsd-core/bin/lib/state-transition.cjs
|
|
* Exported: formatProgressMachineSegment(percent),
|
|
* stateReplaceProgressPercent(content, percent)
|
|
*
|
|
* Properties tested:
|
|
* (a) formatProgressMachineSegment: never throws on any finite input
|
|
* (the review's RangeError case — persisted frontmatter values are
|
|
* hand-editable and only finiteness-checked upstream)
|
|
* (b) formatProgressMachineSegment: always returns a well-formed
|
|
* `[bar] NN%` segment with an exactly-10-glyph bar, 0-100 percent
|
|
* (c) stateReplaceProgressPercent: idempotent — a second application with
|
|
* the same percent is a no-op
|
|
* (d) stateReplaceProgressPercent: round-trip — the written bar re-parses
|
|
* to the same (clamped) percent for any 0-100 input
|
|
*/
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fc = require('./helpers/fast-check-setup.cjs');
|
|
|
|
const {
|
|
formatProgressMachineSegment,
|
|
stateReplaceProgressPercent,
|
|
} = require('../gsd-core/bin/lib/state-transition.cjs');
|
|
|
|
const SEGMENT_RE = /^\[(█{0,10}░{0,10})\] (\d{1,3})%$/;
|
|
|
|
function clampToPercentRange(n) {
|
|
return Math.max(0, Math.min(100, Math.round(n)));
|
|
}
|
|
|
|
describe('formatProgressMachineSegment properties (#4213 review finding)', () => {
|
|
// (a) + (b) never throws, always well-formed, for ANY finite number.
|
|
test('property: never throws and always yields a well-formed segment for any finite percent', () => {
|
|
fc.assert(
|
|
fc.property(fc.double({ noDefaultInfinity: true, noNaN: true }), (percent) => {
|
|
const segment = formatProgressMachineSegment(percent);
|
|
const match = SEGMENT_RE.exec(segment);
|
|
assert.ok(match, `segment must match [bar] NN%, got ${segment}`);
|
|
assert.equal(match[1].length, 10, 'bar must be exactly 10 glyphs');
|
|
const printed = Number(match[2]);
|
|
assert.ok(printed >= 0 && printed <= 100, `printed percent must be clamped, got ${printed}`);
|
|
}),
|
|
);
|
|
});
|
|
|
|
// Bar fill agrees with the printed percent at the 0-100 boundaries reviewers read.
|
|
test('boundary literals: 0, 100, 105, -30', () => {
|
|
assert.equal(formatProgressMachineSegment(0), '[░░░░░░░░░░] 0%');
|
|
assert.equal(formatProgressMachineSegment(100), '[██████████] 100%');
|
|
assert.equal(formatProgressMachineSegment(105), '[██████████] 100%');
|
|
assert.equal(formatProgressMachineSegment(-30), '[░░░░░░░░░░] 0%');
|
|
});
|
|
|
|
// #4294: the bar is drawn by the render kernel, so a sub-100 percent can no
|
|
// longer round up to a full bar beside a number that says otherwise.
|
|
test('#4294 boundary literals: 95 and 99 hold one cell short of full; 94 is unchanged', () => {
|
|
assert.equal(formatProgressMachineSegment(94), '[█████████░] 94%');
|
|
assert.equal(formatProgressMachineSegment(95), '[█████████░] 95%');
|
|
assert.equal(formatProgressMachineSegment(99), '[█████████░] 99%');
|
|
});
|
|
});
|
|
|
|
describe('stateReplaceProgressPercent properties (#4213 review finding)', () => {
|
|
// Any percent the writer can be handed, applied to a representative body.
|
|
const anyPercent = fc.integer({ min: -1000, max: 1000 });
|
|
|
|
// (c) idempotency: applying twice with the same percent changes nothing more.
|
|
test('property: idempotent on a second application with the same percent', () => {
|
|
fc.assert(
|
|
fc.property(anyPercent, (percent) => {
|
|
const content = '# Project State\n\n**Progress:** [█████░░░░░] 50% (2/4 plans done)\n';
|
|
const once = stateReplaceProgressPercent(content, percent);
|
|
const twice = stateReplaceProgressPercent(once, percent);
|
|
assert.equal(twice, once);
|
|
}),
|
|
);
|
|
});
|
|
|
|
// (d) round-trip: the segment the helper writes re-parses to the same
|
|
// clamped percent it printed for any in-range input.
|
|
test('property: written segment re-parses to the clamped percent (0-100)', () => {
|
|
fc.assert(
|
|
fc.property(fc.integer({ min: 0, max: 100 }), (percent) => {
|
|
const content = 'Progress: [██░░░░░░░░] 20%\n';
|
|
const updated = stateReplaceProgressPercent(content, percent);
|
|
const match = /\[(█{0,10}░{0,10})\] (\d{1,3})%/.exec(updated);
|
|
assert.ok(match, `rewritten body must carry a machine segment, got ${updated}`);
|
|
assert.equal(Number(match[2]), clampToPercentRange(percent));
|
|
}),
|
|
);
|
|
});
|
|
});
|