From bd6c3e48f97d1ac9c5c86d9542b9ae2aaed782cd Mon Sep 17 00:00:00 2001 From: Cody Anderson Date: Sat, 11 Jul 2026 11:33:05 -0600 Subject: [PATCH] test(#2163): fast-check property tests for the porcelain-v2 parser Claude-Session: https://claude.ai/code/session_01Hme55Pvq6BhpgwBcyC5HAg --- tests/gsd-statusline-git.property.test.cjs | 196 +++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 tests/gsd-statusline-git.property.test.cjs diff --git a/tests/gsd-statusline-git.property.test.cjs b/tests/gsd-statusline-git.property.test.cjs new file mode 100644 index 000000000..07c731dbc --- /dev/null +++ b/tests/gsd-statusline-git.property.test.cjs @@ -0,0 +1,196 @@ +'use strict'; + +/** + * Property-based tests for the statusline git-segment parser (#2163) + * + * Module: hooks/gsd-statusline.js + * Exported: parseGitStatus(text), buildGitSegment(info) + * + * Properties tested: + * (a) parseGitStatus: never throws on any input; returns null or the full + * typed shape with non-negative counts (total robustness) + * (b) round-trip: synthetic porcelain-v2 output for a generated repo state + * parses back to exactly that state + * (c) prefix-robustness: unknown-prefix lines (future porcelain extensions) + * inserted anywhere leave the parsed result unchanged + * (d) no-branch-header: any input lacking '# branch.head' parses to null + * (e) buildGitSegment: never throws; '' iff branch absent; ✓ iff clean+synced + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fc = require('./helpers/fast-check-setup.cjs'); + +const { parseGitStatus, buildGitSegment } = require('../hooks/gsd-statusline.js'); + +const INFO_KEYS = ['branch', 'ahead', 'behind', 'staged', 'unstaged', 'untracked']; +const COUNT_KEYS = ['ahead', 'behind', 'staged', 'unstaged', 'untracked']; + +// Branch names from a path-safe alphabet (no whitespace/control chars, which +// git itself forbids in ref names). +const arbBranch = fc.stringMatching(/^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,40}$/); + +// A generated repo state with bounded counts. +const arbState = fc.record({ + branch: arbBranch, + ahead: fc.nat({ max: 99 }), + behind: fc.nat({ max: 99 }), + staged: fc.nat({ max: 20 }), + unstagedChanged: fc.nat({ max: 20 }), + unmerged: fc.nat({ max: 5 }), + untracked: fc.nat({ max: 20 }), +}); + +/** + * Render synthetic porcelain v2 --branch output for a generated state. + * Staged entries get XY 'M.', unstaged 'M' entries '.M', unmerged 'u UU'. + */ +function renderPorcelain(s) { + const lines = [ + '# branch.oid 0123456789abcdef0123456789abcdef01234567', + `# branch.head ${s.branch}`, + `# branch.upstream origin/${s.branch}`, + `# branch.ab +${s.ahead} -${s.behind}`, + ]; + for (let i = 0; i < s.staged; i++) { + lines.push(`1 M. N... 100644 100644 100644 0123456 0123456 staged-${i}.txt`); + } + for (let i = 0; i < s.unstagedChanged; i++) { + lines.push(`1 .M N... 100644 100644 100644 0123456 0123456 unstaged-${i}.txt`); + } + for (let i = 0; i < s.unmerged; i++) { + lines.push(`u UU N... 100644 100644 100644 100644 0123456 0123456 0123456 conflict-${i}.txt`); + } + for (let i = 0; i < s.untracked; i++) { + lines.push(`? new-${i}.txt`); + } + return lines.join('\n') + '\n'; +} + +describe('gsd-statusline git segment: parseGitStatus properties', () => { + // (a) Total: never throws, result is null or the full typed shape + test('property: never throws and returns null or a well-formed info object', () => { + fc.assert( + fc.property( + fc.oneof( + fc.constant(null), + fc.constant(undefined), + fc.constant(42), + fc.constant({}), + fc.string({ unit: 'binary', maxLength: 300 }), + fc.string({ unit: 'grapheme-composite', maxLength: 300 }), + // line soup: random lines joined with \n + fc.array(fc.string({ maxLength: 60 }), { maxLength: 30 }).map((a) => a.join('\n')) + ), + (input) => { + let result; + assert.doesNotThrow(() => { result = parseGitStatus(input); }); + if (result !== null) { + assert.deepEqual(Object.keys(result).sort(), [...INFO_KEYS].sort()); + for (const k of COUNT_KEYS) { + assert.equal(typeof result[k], 'number'); + assert.ok(result[k] >= 0, `${k} must be >= 0`); + } + assert.equal(typeof result.branch, 'string'); + assert.ok(result.branch.length > 0); + } + } + ) + ); + }); + + // (b) Round-trip: synthetic porcelain for a state parses back exactly + test('property: round-trip through synthetic porcelain v2 output', () => { + fc.assert( + fc.property(arbState, (s) => { + const parsed = parseGitStatus(renderPorcelain(s)); + assert.ok(parsed, 'well-formed porcelain must parse'); + assert.equal(parsed.branch, s.branch); + assert.equal(parsed.ahead, s.ahead); + assert.equal(parsed.behind, s.behind); + assert.equal(parsed.staged, s.staged); + // unmerged (conflict) entries count as unstaged + assert.equal(parsed.unstaged, s.unstagedChanged + s.unmerged); + assert.equal(parsed.untracked, s.untracked); + }) + ); + }); + + // (c) Prefix-robustness: unknown-prefix lines never change the result + test('property: unknown-prefix lines inserted anywhere are ignored', () => { + // Lines whose first two chars are none of the recognized prefixes + // ('# ', '1 ', '2 ', 'u ', '? '). + const arbUnknownLine = fc + .string({ maxLength: 50 }) + .map((s) => `z ${s}`); + fc.assert( + fc.property( + arbState, + fc.array(arbUnknownLine, { minLength: 1, maxLength: 10 }), + fc.nat({ max: 1000 }), + (s, extras, seedPos) => { + const baseline = parseGitStatus(renderPorcelain(s)); + const lines = renderPorcelain(s).split('\n'); + // deterministic insertion positions derived from seedPos + extras.forEach((extra, i) => { + const pos = (seedPos + i * 7) % (lines.length + 1); + lines.splice(pos, 0, extra); + }); + const withExtras = parseGitStatus(lines.join('\n')); + assert.deepEqual(withExtras, baseline); + } + ) + ); + }); + + // (d) No-branch-header: input without '# branch.head' parses to null + test('property: input lacking a branch.head header parses to null', () => { + fc.assert( + fc.property( + fc.array(fc.string({ maxLength: 60 }), { maxLength: 30 }), + (rawLines) => { + const text = rawLines + .filter((l) => !l.startsWith('# branch.head ')) + .join('\n'); + assert.equal(parseGitStatus(text), null); + } + ) + ); + }); +}); + +describe('gsd-statusline git segment: buildGitSegment properties', () => { + // (e) Never throws; '' iff no branch; clean+synced renders the ✓ marker + test('property: never throws and is empty exactly when branch is absent', () => { + fc.assert( + fc.property( + fc.oneof( + fc.constant(null), + fc.constant(undefined), + fc.constant({}), + arbState.map((s) => ({ + branch: s.branch, + ahead: s.ahead, + behind: s.behind, + staged: s.staged, + unstaged: s.unstagedChanged + s.unmerged, + untracked: s.untracked, + })) + ), + (info) => { + let seg; + assert.doesNotThrow(() => { seg = buildGitSegment(info); }); + assert.equal(typeof seg, 'string'); + if (!info || !info.branch) { + assert.equal(seg, ''); + } else { + assert.ok(seg.includes(info.branch)); + const dirty = + info.staged || info.unstaged || info.untracked || info.ahead || info.behind; + assert.equal(seg.includes('✓'), !dirty); + } + } + ) + ); + }); +});