fix(gsd): follow CONTRIBUTING standards for #3565

- Move new coercion tests to standalone file using node:test +
  node:assert/strict (per CONTRIBUTING testing standards)
- Remove tests from legacy complete-slice.test.ts to avoid mixing
  test frameworks in the same file
This commit is contained in:
Jeremy 2026-04-05 13:32:56 -05:00
parent 6046a31c6f
commit e210b7efdf
2 changed files with 212 additions and 91 deletions

View file

@ -0,0 +1,212 @@
// GSD Extension — String coercion regression tests for complete-slice/task tools
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { describe, test, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import {
openDatabase,
closeDatabase,
insertMilestone,
insertSlice,
insertTask,
} from "../gsd-db.ts";
import { handleCompleteSlice } from "../tools/complete-slice.ts";
import type { CompleteSliceParams } from "../types.ts";
// ─── Helpers ─────────────────────────────────────────────────────────────
/**
* The splitPair coercion logic extracted from db-tools.ts sliceCompleteExecute.
* Duplicated here so we can unit-test it directly.
*/
function splitPair(s: string): [string, string] {
const m = s.match(/^(.+?)\s*(?:—|-)\s+(.+)$/);
return m ? [m[1].trim(), m[2].trim()] : [s.trim(), ""];
}
function makeValidSliceParams(): CompleteSliceParams {
return {
sliceId: "S01",
milestoneId: "M001",
sliceTitle: "Test Slice",
oneLiner: "Implemented test slice",
narrative: "Built and tested.",
verification: "All tests pass.",
deviations: "None.",
knownLimitations: "None.",
followUps: "None.",
keyFiles: ["src/foo.ts"],
keyDecisions: ["D001"],
patternsEstablished: [],
observabilitySurfaces: [],
provides: ["test handler"],
requirementsSurfaced: [],
drillDownPaths: [],
affects: [],
requirementsAdvanced: [{ id: "R001", how: "Handler validates" }],
requirementsValidated: [],
requirementsInvalidated: [],
filesModified: [{ path: "src/foo.ts", description: "Handler" }],
requires: [],
uatContent: "## Smoke Test\n\nVerify all assertions pass.",
};
}
// ─── splitPair unit tests ────────────────────────────────────────────────
describe("splitPair coercion helper (#3565)", () => {
test("plain string without delimiter returns string + empty", () => {
const [a, b] = splitPair("src/foo.ts");
assert.equal(a, "src/foo.ts");
assert.equal(b, "");
});
test("em-dash delimiter parses both parts", () => {
const [id, how] = splitPair("R001 — Handler validates task completion");
assert.equal(id, "R001");
assert.equal(how, "Handler validates task completion");
});
test("hyphen delimiter parses both parts", () => {
const [id, proof] = splitPair("R002 - Tests pass");
assert.equal(id, "R002");
assert.equal(proof, "Tests pass");
});
test("string with no space around hyphen is treated as plain", () => {
// e.g. a file path like "src/foo-bar.ts" should not split
const [a, b] = splitPair("src/foo-bar.ts");
assert.equal(a, "src/foo-bar.ts");
assert.equal(b, "");
});
test("whitespace is trimmed from both parts", () => {
const [id, how] = splitPair(" R003 — Trimmed value ");
assert.equal(id, "R003");
assert.equal(how, "Trimmed value");
});
});
// ─── verificationEvidence sentinel tests ─────────────────────────────────
describe("verificationEvidence sentinel coercion (#3565)", () => {
function coerceEvidence(v: any) {
return typeof v === "string"
? { command: v, exitCode: -1, verdict: "unknown (coerced from string)", durationMs: 0 }
: v;
}
test("string input produces non-passing sentinel", () => {
const result = coerceEvidence("npm test");
assert.equal(result.command, "npm test");
assert.equal(result.exitCode, -1);
assert.equal(result.verdict, "unknown (coerced from string)");
assert.equal(result.durationMs, 0);
});
test("object input passes through unchanged", () => {
const obj = { command: "npm test", exitCode: 0, verdict: "pass", durationMs: 1234 };
const result = coerceEvidence(obj);
assert.equal(result.exitCode, 0);
assert.equal(result.verdict, "pass");
assert.equal(result.durationMs, 1234);
});
test("sentinel exitCode is not 0 (must not fabricate success)", () => {
const result = coerceEvidence("anything");
assert.notEqual(result.exitCode, 0, "exitCode must not be 0 for coerced strings");
assert.ok(
!result.verdict.includes("pass"),
"verdict must not contain 'pass' for coerced strings",
);
});
});
// ─── Handler integration with coerced params ─────────────────────────────
describe("handleCompleteSlice with coerced string arrays (#3565)", () => {
let dbPath: string;
let basePath: string;
beforeEach(() => {
dbPath = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), "gsd-coerce-")),
"test.db",
);
openDatabase(dbPath);
basePath = fs.mkdtempSync(path.join(os.tmpdir(), "gsd-coerce-handler-"));
const sliceDir = path.join(basePath, ".gsd", "milestones", "M001", "slices", "S01", "tasks");
fs.mkdirSync(sliceDir, { recursive: true });
const roadmapPath = path.join(basePath, ".gsd", "milestones", "M001", "M001-ROADMAP.md");
fs.writeFileSync(
roadmapPath,
[
"# M001: Test Milestone",
"",
"## Slices",
"",
'- [ ] **S01: Test Slice** `risk:medium` `depends:[]`',
" - After this: basic functionality works",
].join("\n"),
);
insertMilestone({ id: "M001" });
insertSlice({ id: "S01", milestoneId: "M001" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete", title: "Task 1" });
});
afterEach(() => {
closeDatabase();
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
fs.rmSync(basePath, { recursive: true, force: true });
});
test("handler succeeds with coerced filesModified and requirementsAdvanced", async () => {
const params = makeValidSliceParams();
// Simulate coercion from plain strings
params.filesModified = ["src/foo.ts", "src/bar.ts"].map((f) => {
const [p, d] = splitPair(f);
return { path: p, description: d };
});
params.requirementsAdvanced = ["R001 — Handler validates task completion"].map((r) => {
const [id, how] = splitPair(r);
return { id, how };
});
const result = await handleCompleteSlice(params, basePath);
assert.ok(!("error" in result), "handler should succeed");
if (!("error" in result)) {
const summary = fs.readFileSync(result.summaryPath, "utf-8");
assert.match(summary, /src\/foo\.ts/);
assert.match(summary, /R001/);
assert.match(summary, /Handler validates task completion/);
}
});
test("handler succeeds with coerced requires and requirementsValidated", async () => {
const params = makeValidSliceParams();
params.requires = ["S00 — Provided base infrastructure"].map((r) => {
const [slice, provides] = splitPair(r);
return { slice, provides };
});
params.requirementsValidated = ["R002 - Tests pass"].map((r) => {
const [id, proof] = splitPair(r);
return { id, proof };
});
const result = await handleCompleteSlice(params, basePath);
assert.ok(!("error" in result), "handler should succeed");
if (!("error" in result)) {
const summary = fs.readFileSync(result.summaryPath, "utf-8");
assert.match(summary, /S00/);
assert.match(summary, /Provided base infrastructure/);
assert.match(summary, /R002/);
assert.match(summary, /Tests pass/);
}
});
});

View file

@ -406,97 +406,6 @@ console.log('\n=== complete-slice: handler with missing roadmap ===');
cleanup(dbPath);
}
// ═══════════════════════════════════════════════════════════════════════════
// complete-slice: Handler accepts string coercion for object arrays (#3541)
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== complete-slice: handler accepts string-coerced arrays (#3541) ===');
{
const dbPath = tempDbPath();
openDatabase(dbPath);
const { basePath } = createTempProject();
// Set up DB state
insertMilestone({ id: 'M001' });
insertSlice({ id: 'S01', milestoneId: 'M001' });
insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', status: 'complete', title: 'Task 1' });
// Simulate the coercion logic from sliceCompleteExecute: parse "key — value" format
const splitPair = (s: string): [string, string] => {
const m = s.match(/^(.+?)\s*(?:—|-)\s+(.+)$/);
return m ? [m[1].trim(), m[2].trim()] : [s.trim(), ""];
};
const params = makeValidSliceParams();
const coerced = { ...params };
// Plain strings without delimiter — second field should be empty
coerced.filesModified = ['src/foo.ts', 'src/bar.ts'].map((f: string) => {
const [p, d] = splitPair(f);
return { path: p, description: d };
});
assertEq(coerced.filesModified[0].path, 'src/foo.ts', 'plain string: path preserved');
assertEq(coerced.filesModified[0].description, '', 'plain string: description empty');
// Strings with "—" delimiter — should parse both parts
coerced.requirementsAdvanced = ['R001 — Handler validates task completion'].map((r: string) => {
const [id, how] = splitPair(r);
return { id, how };
});
assertEq(coerced.requirementsAdvanced[0].id, 'R001', 'delimited string: id parsed');
assertEq(coerced.requirementsAdvanced[0].how, 'Handler validates task completion', 'delimited string: how parsed');
// Strings with "- " delimiter — should also parse
coerced.requirementsValidated = ['R002 - Tests pass'].map((r: string) => {
const [id, proof] = splitPair(r);
return { id, proof };
});
assertEq(coerced.requirementsValidated[0].id, 'R002', 'hyphen delimiter: id parsed');
assertEq(coerced.requirementsValidated[0].proof, 'Tests pass', 'hyphen delimiter: proof parsed');
coerced.requires = ['S00'].map((r: string) => {
const [slice, provides] = splitPair(r);
return { slice, provides };
});
coerced.requirementsInvalidated = [];
const result = await handleCompleteSlice(coerced, basePath);
assertTrue(!('error' in result), 'handler should succeed with coerced string arrays');
if (!('error' in result)) {
const summaryContent = fs.readFileSync(result.summaryPath, 'utf-8');
assertMatch(summaryContent, /src\/foo\.ts/, 'summary should list coerced file path');
assertMatch(summaryContent, /R001/, 'summary should list coerced requirement id');
assertMatch(summaryContent, /Handler validates task completion/, 'summary should list parsed requirement detail');
}
cleanupDir(basePath);
cleanup(dbPath);
}
// ═══════════════════════════════════════════════════════════════════════════
// complete-slice: verificationEvidence coercion uses sentinel values (#3541)
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== complete-slice: verificationEvidence sentinel values (#3541) ===');
{
// Verify the coercion logic produces non-passing sentinel values
const coerce = (v: any) =>
typeof v === "string" ? { command: v, exitCode: -1, verdict: "unknown (coerced from string)", durationMs: 0 } : v;
const coerced = coerce("npm test");
assertEq(coerced.command, 'npm test', 'sentinel: command preserved');
assertEq(coerced.exitCode, -1, 'sentinel: exitCode is -1, not 0');
assertEq(coerced.verdict, 'unknown (coerced from string)', 'sentinel: verdict is unknown, not pass');
assertEq(coerced.durationMs, 0, 'sentinel: durationMs is 0');
// Object inputs pass through unchanged
const obj = { command: "npm test", exitCode: 0, verdict: "pass", durationMs: 1234 };
const passthrough = coerce(obj);
assertEq(passthrough.exitCode, 0, 'object passthrough: exitCode unchanged');
assertEq(passthrough.verdict, 'pass', 'object passthrough: verdict unchanged');
}
// ═══════════════════════════════════════════════════════════════════════════
// complete-slice: step 13 specifies write tool for PROJECT.md (#2946)
// ═══════════════════════════════════════════════════════════════════════════