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:
parent
c63220ab72
commit
647056aa7d
1 changed files with 436 additions and 0 deletions
436
src/resources/extensions/gsd/tests/derive-state-helpers.test.ts
Normal file
436
src/resources/extensions/gsd/tests/derive-state-helpers.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue