test(state): add tests for extracted deriveStateFromDb helpers

Cover the composable helpers extracted from deriveStateFromDb:
reconcileDiskToDb, buildCompletenessSet, buildRegistryAndFindActive,
handleNoActiveMilestone, resolveSliceDependencies, reconcileSliceTasks,
detectBlockers, checkReplanTrigger, checkInterruptedWork, and queue
order sorting.
This commit is contained in:
Jeremy 2026-04-11 16:00:28 -05:00
parent c63220ab72
commit 647056aa7d

View file

@ -0,0 +1,436 @@
// GSD Extension — Tests for extracted deriveStateFromDb helper functions
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
//
// Tests the composable helpers extracted from deriveStateFromDb:
// reconcileDiskToDb, buildCompletenessSet, buildRegistryAndFindActive,
// handleNoActiveMilestone, resolveSliceDependencies, reconcileSliceTasks,
// detectBlockers, checkReplanTrigger, checkInterruptedWork
//
// Helpers are private — exercised through deriveStateFromDb integration.
import { describe, test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { invalidateStateCache, deriveStateFromDb } from '../state.ts';
import {
openDatabase,
closeDatabase,
insertMilestone,
insertSlice,
insertTask,
updateTaskStatus,
} from '../gsd-db.ts';
// ─── Fixture Helpers ───────────────────────────────────────────────────────
function createFixtureBase(): string {
const base = mkdtempSync(join(tmpdir(), 'gsd-helpers-'));
mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true });
return base;
}
function writeFile(base: string, relativePath: string, content: string): void {
const full = join(base, '.gsd', relativePath);
mkdirSync(join(full, '..'), { recursive: true });
writeFileSync(full, content);
}
function cleanup(base: string): void {
rmSync(base, { recursive: true, force: true });
}
const ROADMAP_CONTENT = `# M001: Test Milestone
**Vision:** Test helpers.
## Slices
- [ ] **S01: First Slice** \`risk:low\` \`depends:[]\`
> After this: Slice done.
- [ ] **S02: Second Slice** \`risk:low\` \`depends:[S01]\`
> After this: All done.
`;
const PLAN_CONTENT = `# S01: First Slice
**Goal:** Test executing.
**Demo:** Tests pass.
## Tasks
- [ ] **T01: First Task** \`est:10m\`
First task description.
- [x] **T02: Done Task** \`est:10m\`
Already done.
`;
// ═══════════════════════════════════════════════════════════════════════════
// Tests
// ═══════════════════════════════════════════════════════════════════════════
describe('derive-state-helpers', () => {
// ─── handleNoActiveMilestone: all parked ─────────────────────────────
test('handleNoActiveMilestone: all milestones parked returns pre-planning with unpark hint', async () => {
const base = createFixtureBase();
try {
writeFile(base, 'milestones/M001/M001-CONTEXT.md', '# M001\n\nContext.');
writeFile(base, 'milestones/M001/M001-PARKED.md', 'Parked.');
writeFile(base, 'milestones/M002/M002-CONTEXT.md', '# M002\n\nContext.');
writeFile(base, 'milestones/M002/M002-PARKED.md', 'Also parked.');
openDatabase(':memory:');
insertMilestone({ id: 'M001', title: 'First', status: 'parked' });
insertMilestone({ id: 'M002', title: 'Second', status: 'parked' });
invalidateStateCache();
const state = await deriveStateFromDb(base);
assert.equal(state.phase, 'pre-planning', 'all-parked: phase is pre-planning');
assert.equal(state.activeMilestone, null, 'all-parked: no active milestone');
assert.ok(state.nextAction.includes('parked'), 'all-parked: nextAction mentions parked');
assert.ok(state.nextAction.includes('unpark'), 'all-parked: nextAction hints unpark');
assert.equal(state.registry.length, 2, 'all-parked: both in registry');
assert.ok(state.registry.every(e => e.status === 'parked'), 'all-parked: all registry entries parked');
} finally {
closeDatabase();
cleanup(base);
}
});
// ─── handleNoActiveMilestone: all complete with active requirements ──
test('handleNoActiveMilestone: all complete with unmapped requirements', async () => {
const base = createFixtureBase();
try {
writeFile(base, 'milestones/M001/M001-SUMMARY.md', '# M001 Summary\n\nDone.');
writeFile(base, 'REQUIREMENTS.md', `# Requirements\n\n## Active\n\n### R001 — Unmapped\n- Status: active\n- Description: Not mapped.\n`);
openDatabase(':memory:');
insertMilestone({ id: 'M001', title: 'First', status: 'complete' });
invalidateStateCache();
const state = await deriveStateFromDb(base);
assert.equal(state.phase, 'complete', 'complete-reqs: phase is complete');
assert.ok(state.nextAction.includes('1 active requirement'), 'complete-reqs: nextAction notes unmapped reqs');
assert.equal(state.requirements?.active, 1, 'complete-reqs: requirements.active = 1');
} finally {
closeDatabase();
cleanup(base);
}
});
// ─── resolveSliceDependencies: GSD_SLICE_LOCK with missing slice ────
test('resolveSliceDependencies: GSD_SLICE_LOCK pointing to non-existent slice returns blocked', async () => {
const base = createFixtureBase();
const origLock = process.env.GSD_SLICE_LOCK;
try {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', '');
writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan');
openDatabase(':memory:');
insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'active', risk: 'low', depends: [] });
insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'First Task', status: 'pending' });
process.env.GSD_SLICE_LOCK = 'S99';
invalidateStateCache();
const state = await deriveStateFromDb(base);
assert.equal(state.phase, 'blocked', 'slice-lock-miss: phase is blocked');
assert.ok(state.blockers.some(b => b.includes('GSD_SLICE_LOCK=S99')), 'slice-lock-miss: blocker mentions lock');
} finally {
if (origLock !== undefined) process.env.GSD_SLICE_LOCK = origLock;
else delete process.env.GSD_SLICE_LOCK;
closeDatabase();
cleanup(base);
}
});
// ─── resolveSliceDependencies: GSD_SLICE_LOCK with valid slice ──────
test('resolveSliceDependencies: GSD_SLICE_LOCK targeting valid slice bypasses deps', async () => {
const base = createFixtureBase();
const origLock = process.env.GSD_SLICE_LOCK;
try {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
// S02 depends on S01 but we lock to S02 directly
writeFile(base, 'milestones/M001/slices/S02/S02-PLAN.md', `# S02\n\n**Goal:** Test.\n**Demo:** Pass.\n\n## Tasks\n\n- [ ] **T01: Task** \`est:5m\`\n Do thing.\n`);
writeFile(base, 'milestones/M001/slices/S02/tasks/.gitkeep', '');
writeFile(base, 'milestones/M001/slices/S02/tasks/T01-PLAN.md', '# T01 Plan');
openDatabase(':memory:');
insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'pending', risk: 'low', depends: [] });
insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'pending', risk: 'low', depends: ['S01'] });
insertTask({ id: 'T01', sliceId: 'S02', milestoneId: 'M001', title: 'Task', status: 'pending' });
process.env.GSD_SLICE_LOCK = 'S02';
invalidateStateCache();
const state = await deriveStateFromDb(base);
assert.equal(state.activeSlice?.id, 'S02', 'slice-lock-valid: activeSlice is S02 (locked)');
assert.equal(state.phase, 'executing', 'slice-lock-valid: phase is executing');
} finally {
if (origLock !== undefined) process.env.GSD_SLICE_LOCK = origLock;
else delete process.env.GSD_SLICE_LOCK;
closeDatabase();
cleanup(base);
}
});
// ─── reconcileSliceTasks: plan file imports tasks when DB empty ──────
test('reconcileSliceTasks: imports tasks from plan file when DB has zero tasks (#3600)', async () => {
const base = createFixtureBase();
try {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', '');
writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan');
openDatabase(':memory:');
insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'active', risk: 'low', depends: [] });
insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'pending', risk: 'low', depends: ['S01'] });
// No tasks inserted — reconcileSliceTasks should import from plan file
invalidateStateCache();
const state = await deriveStateFromDb(base);
// Plan has T01 (pending) and T02 (done) — reconciliation imports both
assert.equal(state.phase, 'executing', 'task-reconcile: phase is executing (tasks imported)');
assert.equal(state.activeTask?.id, 'T01', 'task-reconcile: activeTask is T01');
assert.equal(state.progress?.tasks?.total, 2, 'task-reconcile: total tasks = 2');
assert.equal(state.progress?.tasks?.done, 1, 'task-reconcile: done tasks = 1 (T02 was [x])');
} finally {
closeDatabase();
cleanup(base);
}
});
// ─── reconcileSliceTasks: stale task reconciled from disk summary ────
test('reconcileSliceTasks: stale pending task reconciled to complete when disk SUMMARY exists (#2514)', async () => {
const base = createFixtureBase();
try {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', '');
writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan');
// T01 has a summary on disk but DB still says pending
writeFile(base, 'milestones/M001/slices/S01/tasks/T01-SUMMARY.md', '# T01 Summary\n\nDone on disk.');
openDatabase(':memory:');
insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'active', risk: 'low', depends: [] });
insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'pending', risk: 'low', depends: ['S01'] });
insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'First Task', status: 'pending' });
insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Done Task', status: 'complete' });
invalidateStateCache();
const state = await deriveStateFromDb(base);
// T01 should have been reconciled to complete (SUMMARY exists on disk)
// Both tasks complete → phase should be summarizing
assert.equal(state.phase, 'summarizing', 'stale-task: phase is summarizing (T01 reconciled)');
assert.equal(state.activeTask, null, 'stale-task: no active task (all done)');
assert.equal(state.progress?.tasks?.done, 2, 'stale-task: tasks.done = 2');
} finally {
closeDatabase();
cleanup(base);
}
});
// ─── detectBlockers: blocker_discovered triggers replanning ──────────
test('detectBlockers: task with blocker_discovered triggers replanning-slice', async () => {
const base = createFixtureBase();
try {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', '');
writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan');
// T02 completed with blocker discovered — written in summary frontmatter
writeFile(base, 'milestones/M001/slices/S01/tasks/T02-SUMMARY.md',
'---\nblocker_discovered: true\n---\n\n# T02 Summary\n\nFound a blocker.');
openDatabase(':memory:');
insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'active', risk: 'low', depends: [] });
insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'pending', risk: 'low', depends: ['S01'] });
insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'First Task', status: 'pending' });
insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Done Task', status: 'complete' });
invalidateStateCache();
const state = await deriveStateFromDb(base);
assert.equal(state.phase, 'replanning-slice', 'blocker: phase is replanning-slice');
assert.ok(state.blockers.some(b => b.includes('T02')), 'blocker: blockers mention T02');
} finally {
closeDatabase();
cleanup(base);
}
});
// ─── checkInterruptedWork: continue.md triggers resume hint ─────────
test('checkInterruptedWork: continue.md present triggers resume nextAction', async () => {
const base = createFixtureBase();
try {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', '');
writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan');
writeFile(base, 'milestones/M001/slices/S01/S01-CONTINUE.md', 'Resume from here.');
openDatabase(':memory:');
insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'First', status: 'active', risk: 'low', depends: [] });
insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Second', status: 'pending', risk: 'low', depends: ['S01'] });
insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'First Task', status: 'pending' });
insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Done Task', status: 'complete' });
invalidateStateCache();
const state = await deriveStateFromDb(base);
assert.equal(state.phase, 'executing', 'continue: phase is still executing');
assert.ok(state.nextAction.includes('Resume interrupted work'), 'continue: nextAction mentions resume');
assert.ok(state.nextAction.includes('continue.md'), 'continue: nextAction mentions continue.md');
} finally {
closeDatabase();
cleanup(base);
}
});
// ─── buildCompletenessSet: SUMMARY-on-disk marks complete ───────────
test('buildCompletenessSet: milestone with SUMMARY on disk treated as complete', async () => {
const base = createFixtureBase();
try {
// M001 has summary on disk but DB status is still 'active'
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
writeFile(base, 'milestones/M001/M001-SUMMARY.md', '# M001 Summary\n\nDone.');
// M002 is the real active milestone
writeFile(base, 'milestones/M002/M002-CONTEXT.md', '# M002\n\nActive.');
openDatabase(':memory:');
insertMilestone({ id: 'M001', title: 'First', status: 'active' });
insertMilestone({ id: 'M002', title: 'Second', status: 'active' });
invalidateStateCache();
const state = await deriveStateFromDb(base);
// M001 should be complete (summary on disk), M002 should be active
const m1 = state.registry.find(e => e.id === 'M001');
assert.equal(m1?.status, 'complete', 'summary-disk: M001 marked complete via disk SUMMARY');
assert.equal(state.activeMilestone?.id, 'M002', 'summary-disk: M002 is active');
} finally {
closeDatabase();
cleanup(base);
}
});
// ─── reconcileDiskToDb: disk slices synced into DB (#2533) ──────────
test('reconcileDiskToDb: slices in ROADMAP.md but missing from DB are auto-inserted (#2533)', async () => {
const base = createFixtureBase();
try {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
openDatabase(':memory:');
insertMilestone({ id: 'M001', title: 'Test', status: 'active' });
// No slices inserted — reconcileDiskToDb should insert from roadmap
invalidateStateCache();
const state = await deriveStateFromDb(base);
// Slices should have been reconciled from roadmap, S01 should be the active slice
assert.equal(state.activeMilestone?.id, 'M001', 'slice-reconcile: M001 is active');
assert.equal(state.activeSlice?.id, 'S01', 'slice-reconcile: S01 reconciled and active');
assert.ok((state.progress?.slices?.total ?? 0) >= 2, 'slice-reconcile: at least 2 slices reconciled');
} finally {
closeDatabase();
cleanup(base);
}
});
// ─── Queue order: milestones sorted by custom queue order ───────────
test('deriveStateFromDb respects custom queue order from QUEUE-ORDER.json', async () => {
const base = createFixtureBase();
try {
// M003 should come first per queue order, M001 second
const queueOrder = JSON.stringify({ order: ['M003', 'M001', 'M002'], updatedAt: new Date().toISOString() });
writeFileSync(join(base, '.gsd', 'QUEUE-ORDER.json'), queueOrder);
writeFile(base, 'milestones/M001/M001-CONTEXT.md', '# M001\n\nContext.');
writeFile(base, 'milestones/M002/M002-CONTEXT.md', '# M002\n\nContext.');
writeFile(base, 'milestones/M003/M003-CONTEXT.md', '# M003\n\nContext.');
openDatabase(':memory:');
// Insert in natural order — queue ordering should override
insertMilestone({ id: 'M001', title: 'First', status: 'active' });
insertMilestone({ id: 'M002', title: 'Second', status: 'active' });
insertMilestone({ id: 'M003', title: 'Third', status: 'active' });
invalidateStateCache();
const state = await deriveStateFromDb(base);
// M003 should be the active milestone (first in queue)
assert.equal(state.activeMilestone?.id, 'M003', 'queue-order: M003 is active (first in queue)');
assert.equal(state.registry[0]?.id, 'M003', 'queue-order: registry[0] is M003');
} finally {
closeDatabase();
cleanup(base);
}
});
// ─── handleAllSlicesDone: needs-remediation re-triggers validation ──
test('handleAllSlicesDone: needs-remediation verdict triggers validating-milestone', async () => {
const base = createFixtureBase();
try {
const doneRoadmap = `# M001: Remediation Test\n\n**Vision:** Test.\n\n## Slices\n\n- [x] **S01: Done** \`risk:low\` \`depends:[]\`\n > Done.\n`;
writeFile(base, 'milestones/M001/M001-ROADMAP.md', doneRoadmap);
writeFile(base, 'milestones/M001/M001-VALIDATION.md',
'---\nverdict: needs-remediation\nremediation_round: 1\n---\n\n# Validation\nNeeds remediation.');
openDatabase(':memory:');
insertMilestone({ id: 'M001', title: 'Remediation Test', status: 'active' });
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Done', status: 'complete', risk: 'low', depends: [] });
invalidateStateCache();
const state = await deriveStateFromDb(base);
assert.equal(state.phase, 'validating-milestone', 'remediation: phase is validating-milestone');
assert.equal(state.activeMilestone?.id, 'M001', 'remediation: activeMilestone is M001');
} finally {
closeDatabase();
cleanup(base);
}
});
// ─── Deferred queued shell: shell milestone deferred, real one promoted ──
test('buildRegistryAndFindActive: queued shell deferred, later real milestone becomes active (#3470)', async () => {
const base = createFixtureBase();
try {
// M001: queued shell — no content, no slices
mkdirSync(join(base, '.gsd', 'milestones', 'M001'), { recursive: true });
// M002: real milestone with context
writeFile(base, 'milestones/M002/M002-CONTEXT.md', '# M002: Real\n\nActive milestone.');
openDatabase(':memory:');
insertMilestone({ id: 'M001', title: 'Shell', status: 'queued' });
insertMilestone({ id: 'M002', title: 'Real', status: 'active' });
invalidateStateCache();
const state = await deriveStateFromDb(base);
// M002 should be active (M001 queued shell deferred)
assert.equal(state.activeMilestone?.id, 'M002', 'deferred-shell: M002 is active (shell deferred)');
} finally {
closeDatabase();
cleanup(base);
}
});
});