test(sf): full lifecycle coverage for ADR-011 P2 escalation feature
21 vitest tests covering the entire escalation chain shipped this session. Each contract claim from prior PDD specs gets at least one verifying test: buildEscalationArtifact validation (4) - option count outside [2,4] → throws - duplicate option ids → throws - recommendation referencing unknown id → throws - happy path → version=1, taskId set, ISO createdAt writeEscalationArtifact + DB flag flips (3) - continueWithDefault=false → escalation_pending=1 - continueWithDefault=true → escalation_awaiting_review=1 - two writes flip the pair atomically (mutually exclusive) detectPendingEscalation (4) - empty slice → null - paused task → returns task id - awaiting_review tasks DO NOT pause - resolved (respondedAt set) tasks DO NOT pause resolveEscalation (5) - 'accept' selects recommendation - explicit option id resolves with userRationale persisted - invalid choice → status=invalid-choice with valid list - re-resolve → already-resolved - unknown task → not-found claimOverrideForInjection carry-forward (5) - no escalation → null - pending (unresolved) → null - resolved → returns block + sourceTaskId + sets DB flag=1 - second claim → null (race-safe idempotent) - clearTaskEscalationFlags preserves artifact path (audit trail) Provides regression protection for the full producer→consumer→ resolution→carry-forward path. All 21 pass against current head. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
125496ce36
commit
aa67c1453c
1 changed files with 301 additions and 0 deletions
301
src/resources/extensions/sf/tests/escalation-feature.test.ts
Normal file
301
src/resources/extensions/sf/tests/escalation-feature.test.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
/**
|
||||
* Full lifecycle tests for ADR-011 P2 mid-execution escalation (gsd-2 ADR).
|
||||
*
|
||||
* Covers:
|
||||
* - DB schema columns (is_sketch is for ADR-011 P1, not tested here)
|
||||
* - buildEscalationArtifact validation rules
|
||||
* - writeEscalationArtifact persists artifact + flips DB flag based on
|
||||
* continueWithDefault
|
||||
* - detectPendingEscalation only returns paused tasks (not awaiting-review)
|
||||
* - resolveEscalation: accept / option-id / invalid-choice / already-resolved /
|
||||
* not-found contract
|
||||
* - claimOverrideForInjection: race-safe atomic claim, idempotent, returns
|
||||
* null for unresolved escalations
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, test } from "vitest";
|
||||
|
||||
import {
|
||||
buildEscalationArtifact,
|
||||
claimOverrideForInjection,
|
||||
detectPendingEscalation,
|
||||
readEscalationArtifact,
|
||||
resolveEscalation,
|
||||
writeEscalationArtifact,
|
||||
} from "../escalation.ts";
|
||||
import {
|
||||
clearTaskEscalationFlags,
|
||||
closeDatabase,
|
||||
getSliceTasks,
|
||||
getTask,
|
||||
insertMilestone,
|
||||
insertSlice,
|
||||
insertTask,
|
||||
openDatabase,
|
||||
} from "../sf-db.ts";
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "sf-escalation-test-"));
|
||||
mkdirSync(join(dir, ".sf", "milestones", "M001", "slices", "S01", "tasks"), {
|
||||
recursive: true,
|
||||
});
|
||||
openDatabase(join(dir, ".sf", "sf.db"));
|
||||
insertMilestone({ id: "M001", title: "Test", status: "pending" });
|
||||
insertSlice({
|
||||
milestoneId: "M001",
|
||||
id: "S01",
|
||||
title: "Test Slice",
|
||||
risk: "medium",
|
||||
});
|
||||
insertTask({
|
||||
milestoneId: "M001",
|
||||
sliceId: "S01",
|
||||
id: "T01",
|
||||
title: "Task 1",
|
||||
});
|
||||
insertTask({
|
||||
milestoneId: "M001",
|
||||
sliceId: "S01",
|
||||
id: "T02",
|
||||
title: "Task 2",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const baseArtifact = {
|
||||
taskId: "T01",
|
||||
sliceId: "S01",
|
||||
milestoneId: "M001",
|
||||
question: "Overwrite or fail?",
|
||||
options: [
|
||||
{ id: "overwrite", label: "Overwrite", tradeoffs: "lose data" },
|
||||
{ id: "fail", label: "Fail", tradeoffs: "block progress" },
|
||||
],
|
||||
recommendation: "fail",
|
||||
recommendationRationale: "data loss is irreversible",
|
||||
continueWithDefault: false,
|
||||
};
|
||||
|
||||
describe("buildEscalationArtifact validation", () => {
|
||||
test("requires 2-4 options", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
buildEscalationArtifact({
|
||||
...baseArtifact,
|
||||
options: [{ id: "a", label: "A", tradeoffs: "" }],
|
||||
}),
|
||||
/between 2 and 4/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
buildEscalationArtifact({
|
||||
...baseArtifact,
|
||||
options: [
|
||||
{ id: "a", label: "A", tradeoffs: "" },
|
||||
{ id: "b", label: "B", tradeoffs: "" },
|
||||
{ id: "c", label: "C", tradeoffs: "" },
|
||||
{ id: "d", label: "D", tradeoffs: "" },
|
||||
{ id: "e", label: "E", tradeoffs: "" },
|
||||
],
|
||||
}),
|
||||
/between 2 and 4/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects duplicate option ids", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
buildEscalationArtifact({
|
||||
...baseArtifact,
|
||||
options: [
|
||||
{ id: "a", label: "A", tradeoffs: "" },
|
||||
{ id: "a", label: "A2", tradeoffs: "" },
|
||||
],
|
||||
}),
|
||||
/unique ids/,
|
||||
);
|
||||
});
|
||||
|
||||
test("recommendation must reference an option id", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
buildEscalationArtifact({
|
||||
...baseArtifact,
|
||||
recommendation: "nonexistent",
|
||||
}),
|
||||
/not one of the option ids/,
|
||||
);
|
||||
});
|
||||
|
||||
test("happy path returns artifact with version=1 + createdAt", () => {
|
||||
const art = buildEscalationArtifact(baseArtifact);
|
||||
assert.equal(art.version, 1);
|
||||
assert.equal(art.taskId, "T01");
|
||||
assert.equal(art.recommendation, "fail");
|
||||
assert.match(art.createdAt, /^\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeEscalationArtifact + DB flag flip", () => {
|
||||
test("continueWithDefault=false → escalation_pending=1", () => {
|
||||
const art = buildEscalationArtifact(baseArtifact);
|
||||
const path = writeEscalationArtifact(dir, art);
|
||||
assert.match(path, /T01-ESCALATION\.json$/);
|
||||
const t = getTask("M001", "S01", "T01");
|
||||
assert.equal(t?.escalation_pending, 1);
|
||||
assert.equal(t?.escalation_awaiting_review, 0);
|
||||
assert.equal(t?.escalation_artifact_path, path);
|
||||
});
|
||||
|
||||
test("continueWithDefault=true → awaiting_review (no pause)", () => {
|
||||
const art = buildEscalationArtifact({
|
||||
...baseArtifact,
|
||||
continueWithDefault: true,
|
||||
});
|
||||
writeEscalationArtifact(dir, art);
|
||||
const t = getTask("M001", "S01", "T01");
|
||||
assert.equal(t?.escalation_pending, 0);
|
||||
assert.equal(t?.escalation_awaiting_review, 1);
|
||||
});
|
||||
|
||||
test("two writes flip the flag pair atomically (mutually exclusive)", () => {
|
||||
writeEscalationArtifact(dir, buildEscalationArtifact(baseArtifact));
|
||||
const t1 = getTask("M001", "S01", "T01");
|
||||
assert.equal(t1?.escalation_pending, 1);
|
||||
assert.equal(t1?.escalation_awaiting_review, 0);
|
||||
writeEscalationArtifact(
|
||||
dir,
|
||||
buildEscalationArtifact({ ...baseArtifact, continueWithDefault: true }),
|
||||
);
|
||||
const t2 = getTask("M001", "S01", "T01");
|
||||
assert.equal(t2?.escalation_pending, 0);
|
||||
assert.equal(t2?.escalation_awaiting_review, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectPendingEscalation", () => {
|
||||
test("empty slice → null", () => {
|
||||
assert.equal(detectPendingEscalation(getSliceTasks("M001", "S01"), dir), null);
|
||||
});
|
||||
|
||||
test("paused task → returns task id", () => {
|
||||
writeEscalationArtifact(dir, buildEscalationArtifact(baseArtifact));
|
||||
assert.equal(
|
||||
detectPendingEscalation(getSliceTasks("M001", "S01"), dir),
|
||||
"T01",
|
||||
);
|
||||
});
|
||||
|
||||
test("awaiting_review does NOT trigger pause", () => {
|
||||
writeEscalationArtifact(
|
||||
dir,
|
||||
buildEscalationArtifact({ ...baseArtifact, continueWithDefault: true }),
|
||||
);
|
||||
assert.equal(detectPendingEscalation(getSliceTasks("M001", "S01"), dir), null);
|
||||
});
|
||||
|
||||
test("respondedAt artifact does NOT trigger pause", () => {
|
||||
writeEscalationArtifact(dir, buildEscalationArtifact(baseArtifact));
|
||||
resolveEscalation(dir, "M001", "S01", "T01", "fail", "rationale");
|
||||
assert.equal(detectPendingEscalation(getSliceTasks("M001", "S01"), dir), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveEscalation", () => {
|
||||
beforeEach(() => {
|
||||
writeEscalationArtifact(dir, buildEscalationArtifact(baseArtifact));
|
||||
});
|
||||
|
||||
test("accept selects the recommended option", () => {
|
||||
const r = resolveEscalation(dir, "M001", "S01", "T01", "accept", "");
|
||||
assert.equal(r.status, "resolved");
|
||||
assert.equal(r.chosenOption?.id, "fail"); // recommendation
|
||||
const t = getTask("M001", "S01", "T01");
|
||||
assert.equal(t?.escalation_pending, 0);
|
||||
});
|
||||
|
||||
test("explicit option-id resolution works", () => {
|
||||
const r = resolveEscalation(
|
||||
dir,
|
||||
"M001",
|
||||
"S01",
|
||||
"T01",
|
||||
"overwrite",
|
||||
"caller knows best",
|
||||
);
|
||||
assert.equal(r.status, "resolved");
|
||||
assert.equal(r.chosenOption?.id, "overwrite");
|
||||
const art = readEscalationArtifact(
|
||||
getTask("M001", "S01", "T01")!.escalation_artifact_path!,
|
||||
);
|
||||
assert.equal(art?.userChoice, "overwrite");
|
||||
assert.equal(art?.userRationale, "caller knows best");
|
||||
});
|
||||
|
||||
test("invalid choice → invalid-choice with valid list", () => {
|
||||
const r = resolveEscalation(dir, "M001", "S01", "T01", "banana", "");
|
||||
assert.equal(r.status, "invalid-choice");
|
||||
assert.match(r.message, /accept, overwrite, fail/);
|
||||
});
|
||||
|
||||
test("re-resolve already-resolved → already-resolved status", () => {
|
||||
resolveEscalation(dir, "M001", "S01", "T01", "fail", "");
|
||||
const r = resolveEscalation(dir, "M001", "S01", "T01", "overwrite", "");
|
||||
assert.equal(r.status, "already-resolved");
|
||||
});
|
||||
|
||||
test("unknown task → not-found", () => {
|
||||
const r = resolveEscalation(dir, "M001", "S01", "T99", "accept", "");
|
||||
assert.equal(r.status, "not-found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("claimOverrideForInjection (carry-forward)", () => {
|
||||
test("no escalation in slice → null", () => {
|
||||
assert.equal(claimOverrideForInjection(dir, "M001", "S01"), null);
|
||||
});
|
||||
|
||||
test("pending (unresolved) escalation → null (no carry-forward yet)", () => {
|
||||
writeEscalationArtifact(dir, buildEscalationArtifact(baseArtifact));
|
||||
assert.equal(claimOverrideForInjection(dir, "M001", "S01"), null);
|
||||
});
|
||||
|
||||
test("resolved escalation → returns block + sourceTaskId, sets DB flag", () => {
|
||||
writeEscalationArtifact(dir, buildEscalationArtifact(baseArtifact));
|
||||
resolveEscalation(dir, "M001", "S01", "T01", "fail", "rationale");
|
||||
const r = claimOverrideForInjection(dir, "M001", "S01");
|
||||
assert.ok(r);
|
||||
assert.equal(r?.sourceTaskId, "T01");
|
||||
assert.match(r?.injectionBlock ?? "", /Escalation Override/);
|
||||
assert.match(r?.injectionBlock ?? "", /User rationale/);
|
||||
const t = getTask("M001", "S01", "T01");
|
||||
assert.equal(t?.escalation_override_applied, 1);
|
||||
});
|
||||
|
||||
test("second claim → null (race-safe idempotent)", () => {
|
||||
writeEscalationArtifact(dir, buildEscalationArtifact(baseArtifact));
|
||||
resolveEscalation(dir, "M001", "S01", "T01", "fail", "");
|
||||
assert.ok(claimOverrideForInjection(dir, "M001", "S01"));
|
||||
assert.equal(claimOverrideForInjection(dir, "M001", "S01"), null);
|
||||
});
|
||||
|
||||
test("clearTaskEscalationFlags preserves artifact path for audit trail", () => {
|
||||
writeEscalationArtifact(dir, buildEscalationArtifact(baseArtifact));
|
||||
const before = getTask("M001", "S01", "T01")!.escalation_artifact_path;
|
||||
clearTaskEscalationFlags("M001", "S01", "T01");
|
||||
const after = getTask("M001", "S01", "T01");
|
||||
assert.equal(after?.escalation_pending, 0);
|
||||
assert.equal(after?.escalation_awaiting_review, 0);
|
||||
assert.equal(after?.escalation_artifact_path, before);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue