Files
msd-core/sdk/src/query/state-document.test.ts
Tom Boucher bfd7ddbad3 feat(3530): STATE.md Document Module via generator (Phase 1 of #3524) (#3531)
* feat(3530): STATE.md Document Module via generator (Phase 1 of #3524)

Phase 1 of the CJS↔SDK hard-seam migration (parent #3524).
Converts the hand-synced state-document.cjs/state-document.ts pair
into a generator-driven seam, modeled on the existing
command-aliases.generated.* precedent.

What landed:
- sdk/src/query/state-document.ts is the source of truth.
- sdk/scripts/gen-state-document.ts emits
  get-shit-done/bin/lib/state-document.generated.cjs from the
  compiled SDK dist via Function.prototype.toString() inspection
  for the 7 public exports and 3 internal helpers.
- sdk/scripts/check-state-document-fresh.mjs is the CI freshness
  gate; pre-commit hook also runs it when relevant files change.
- get-shit-done/bin/lib/state-document.cjs is reduced to a one-line
  re-export from state-document.generated.cjs so existing callers
  (state.cjs, workstream-inventory.cjs, init.cjs) need no changes.
- New CI step in .github/workflows/test.yml after the existing alias
  drift check.
- sdk/package.json: gen:state-document, check:state-document-fresh
  scripts. tsx added as devDep.
- Root package.json: proxy script for the freshness check.
- CONTEXT.md: one-sentence amendment on STATE.md Document Module
  recording the source-of-truth file path.

Tests:
- sdk/src/query/state-document.test.ts: 34 vitest fixtures across
  the 7 public exports (TDD pinning safety net).
- tests/state-document-generator.test.cjs: 31 node:test parity
  assertions comparing SDK source vs generated CJS for every
  fixture.
- Full suite: 9177/9177 pass (baseline was 9146; +31 new tests).

One subtle behavior change worth flagging: the old hand-written
state-document.cjs used String(str) coercion inside escapeRegex,
which the SDK source does not. The generator faithfully matches
the SDK (the source of truth per ADR-3524), so the new CJS no
longer coerces non-string input to string before regex-escaping.
No current caller passes non-string input, so no observable
regression in the test suite. Flagged in the PR body for
reviewers.

Closes #3530.

* fix(3530): address state-document review findings
2026-05-14 22:17:20 -04:00

198 lines
6.7 KiB
TypeScript

/**
* Unit tests for STATE.md Document Module — stateExtractField.
*/
import { describe, it, expect } from 'vitest';
import {
stateExtractField,
stateReplaceField,
stateReplaceFieldWithFallback,
normalizeStateStatus,
computeProgressPercent,
shouldPreserveExistingProgress,
normalizeProgressNumbers,
} from './state-document.js';
describe('stateExtractField', () => {
it('extracts value from bold pattern', () => {
const content = 'Some content\n**FieldName:** the value\nMore content';
expect(stateExtractField(content, 'FieldName')).toBe('the value');
});
it('extracts value from plain pattern', () => {
const content = 'Some content\nFieldName: the value\nMore content';
expect(stateExtractField(content, 'FieldName')).toBe('the value');
});
it('returns null when field is missing', () => {
const content = 'Some content\nOtherField: something\nMore content';
expect(stateExtractField(content, 'FieldName')).toBeNull();
});
});
describe('stateReplaceField', () => {
it('replaces value in bold pattern', () => {
const content = 'Some content\n**Status:** old value\nMore content';
const result = stateReplaceField(content, 'Status', 'new value');
expect(result).toBe('Some content\n**Status:** new value\nMore content');
});
it('replaces value in plain pattern', () => {
const content = 'Some content\nStatus: old value\nMore content';
const result = stateReplaceField(content, 'Status', 'new value');
expect(result).toBe('Some content\nStatus: new value\nMore content');
});
it('returns null when field is missing', () => {
const content = 'Some content\nOtherField: something\nMore content';
const result = stateReplaceField(content, 'Status', 'new value');
expect(result).toBeNull();
});
});
describe('stateReplaceFieldWithFallback', () => {
it('replaces primary field when it exists', () => {
const content = 'Status: old\nState: backup';
const result = stateReplaceFieldWithFallback(content, 'Status', 'State', 'new');
expect(result).toBe('Status: new\nState: backup');
});
it('replaces fallback field when primary is missing', () => {
const content = 'Other: something\nState: backup';
const result = stateReplaceFieldWithFallback(content, 'Status', 'State', 'new');
expect(result).toBe('Other: something\nState: new');
});
it('returns content unchanged when neither field exists', () => {
const content = 'Other: something\nAnother: value';
const result = stateReplaceFieldWithFallback(content, 'Status', 'State', 'new');
expect(result).toBe(content);
});
});
describe('normalizeStateStatus', () => {
it('returns paused for status containing "paused"', () => {
expect(normalizeStateStatus('paused')).toBe('paused');
});
it('returns paused for status containing "stopped"', () => {
expect(normalizeStateStatus('stopped')).toBe('paused');
});
it('returns paused when non-null pausedAt is provided', () => {
expect(normalizeStateStatus('active', '2024-01-01')).toBe('paused');
});
it('returns executing for status containing "executing"', () => {
expect(normalizeStateStatus('executing')).toBe('executing');
});
it('returns executing for status "in progress"', () => {
expect(normalizeStateStatus('in progress')).toBe('executing');
});
it('returns executing for status "ready to execute"', () => {
expect(normalizeStateStatus('ready to execute')).toBe('executing');
});
it('returns planning for status containing "planning"', () => {
expect(normalizeStateStatus('planning')).toBe('planning');
});
it('returns discussing for status containing "discussing"', () => {
expect(normalizeStateStatus('discussing')).toBe('discussing');
});
it('returns verifying for status containing "verif"', () => {
expect(normalizeStateStatus('verifying')).toBe('verifying');
});
it('returns completed for status containing "complete"', () => {
expect(normalizeStateStatus('completed')).toBe('completed');
});
it('returns completed for status containing "done"', () => {
expect(normalizeStateStatus('done')).toBe('completed');
});
it('returns unknown for unrecognized status', () => {
expect(normalizeStateStatus('something-else')).toBe('something-else');
});
it('returns unknown for null status', () => {
expect(normalizeStateStatus(null)).toBe('unknown');
});
});
describe('computeProgressPercent', () => {
it('uses only plans data when phases data is absent', () => {
expect(computeProgressPercent(3, 10, null, null)).toBe(30);
});
it('uses only phases data when plans data is absent', () => {
expect(computeProgressPercent(null, null, 2, 4)).toBe(50);
});
it('uses minimum fraction when both plans and phases data are present', () => {
// plans: 8/10 = 80%, phases: 3/10 = 30% → min = 30%
expect(computeProgressPercent(8, 10, 3, 10)).toBe(30);
});
it('returns null when neither plans nor phases data is present', () => {
expect(computeProgressPercent(null, null, null, null)).toBeNull();
});
it('returns null when total is 0 (treated as no data)', () => {
expect(computeProgressPercent(0, 0, null, null)).toBeNull();
});
});
describe('shouldPreserveExistingProgress', () => {
it('returns true when existing total_phases exceeds derived', () => {
expect(shouldPreserveExistingProgress({ total_phases: 10 }, { total_phases: 5 })).toBe(true);
});
it('returns false when derived exceeds existing', () => {
expect(shouldPreserveExistingProgress({ total_phases: 5 }, { total_phases: 10 })).toBe(false);
});
it('returns false when existingProgress is not an object', () => {
expect(shouldPreserveExistingProgress(null, { total_phases: 5 })).toBe(false);
});
it('returns false when both are null', () => {
expect(shouldPreserveExistingProgress(null, null)).toBe(false);
});
});
describe('normalizeProgressNumbers', () => {
it('coerces all five tracked keys to numbers', () => {
const input = {
total_phases: '10',
completed_phases: '3',
total_plans: '5',
completed_plans: '2',
percent: '60',
};
expect(normalizeProgressNumbers(input)).toEqual({
total_phases: 10,
completed_phases: 3,
total_plans: 5,
completed_plans: 2,
percent: 60,
});
});
it('returns non-object input unchanged', () => {
expect(normalizeProgressNumbers(null)).toBeNull();
expect(normalizeProgressNumbers('string')).toBe('string');
});
it('preserves extra keys untouched', () => {
const input = { total_phases: '4', extra_key: 'hello' };
const result = normalizeProgressNumbers(input) as Record<string, unknown>;
expect(result.total_phases).toBe(4);
expect(result.extra_key).toBe('hello');
});
});