feat(FN-4604): complete Step 1 — add milestone acceptance derivation helper
Fusion-Task-Id: FN-4604 Fusion-Task-Lineage: 71752cd0-eadb-4434-a2a3-d9fff562ea63
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
import { MissionStore } from "../mission-store.js";
|
import { MissionStore, deriveMilestoneAcceptanceCriteriaFromFeatures } from "../mission-store.js";
|
||||||
import { Database } from "../db.js";
|
import { Database } from "../db.js";
|
||||||
|
import type { MissionFeature } from "../mission-types.js";
|
||||||
import { mkdtempSync } from "node:fs";
|
import { mkdtempSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
@@ -916,6 +917,77 @@ describe("MissionStore", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("milestone acceptance criteria derivation", () => {
|
||||||
|
const makeFeature = (overrides: Partial<MissionFeature>): MissionFeature => ({
|
||||||
|
id: "F-1",
|
||||||
|
sliceId: "SL-1",
|
||||||
|
title: "Feature",
|
||||||
|
status: "defined",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives milestone acceptance from feature acceptance criteria", () => {
|
||||||
|
const derived = deriveMilestoneAcceptanceCriteriaFromFeatures([
|
||||||
|
makeFeature({ title: "Login", acceptanceCriteria: " Auth succeeds " }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(derived).toBe("- Login: Auth succeeds");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to feature description when acceptance criteria is blank", () => {
|
||||||
|
const derived = deriveMilestoneAcceptanceCriteriaFromFeatures([
|
||||||
|
makeFeature({ title: "Login", acceptanceCriteria: " ", description: " Works across browsers " }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(derived).toBe("- Login: Works across browsers");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips features without acceptance text and returns undefined when none contribute", () => {
|
||||||
|
const derived = deriveMilestoneAcceptanceCriteriaFromFeatures([
|
||||||
|
makeFeature({ title: "Login", acceptanceCriteria: "", description: " " }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(derived).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not overwrite explicit milestone acceptance criteria", () => {
|
||||||
|
const mission = store.createMission({ title: "Mission" });
|
||||||
|
const milestone = store.addMilestone(mission.id, {
|
||||||
|
title: "Milestone",
|
||||||
|
acceptanceCriteria: "Explicit criteria",
|
||||||
|
});
|
||||||
|
const slice = store.addSlice(milestone.id, { title: "Slice" });
|
||||||
|
store.addFeature(slice.id, {
|
||||||
|
title: "Feature",
|
||||||
|
acceptanceCriteria: "Feature criteria",
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = store.applyDerivedMilestoneAcceptanceCriteria(milestone.id);
|
||||||
|
expect(updated.acceptanceCriteria).toBe("Explicit criteria");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves explicit milestone criteria when re-applied after feature changes", () => {
|
||||||
|
const mission = store.createMission({ title: "Mission" });
|
||||||
|
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
|
||||||
|
const slice = store.addSlice(milestone.id, { title: "Slice" });
|
||||||
|
const feature = store.addFeature(slice.id, {
|
||||||
|
title: "Feature",
|
||||||
|
acceptanceCriteria: "Initial acceptance",
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstDerived = store.applyDerivedMilestoneAcceptanceCriteria(milestone.id);
|
||||||
|
expect(firstDerived.acceptanceCriteria).toBe("- Feature: Initial acceptance");
|
||||||
|
|
||||||
|
store.updateMilestone(milestone.id, { acceptanceCriteria: "Manual lock" });
|
||||||
|
store.updateFeature(feature.id, { acceptanceCriteria: "Changed acceptance" });
|
||||||
|
|
||||||
|
const preserved = store.applyDerivedMilestoneAcceptanceCriteria(milestone.id);
|
||||||
|
expect(preserved.acceptanceCriteria).toBe("Manual lock");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── Slice CRUD Tests ──────────────────────────────────────────────────
|
// ── Slice CRUD Tests ──────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("Slice CRUD", () => {
|
describe("Slice CRUD", () => {
|
||||||
|
|||||||
@@ -62,6 +62,32 @@ import {
|
|||||||
*/
|
*/
|
||||||
const DEFAULT_IMPLEMENTATION_RETRY_BUDGET = 3;
|
const DEFAULT_IMPLEMENTATION_RETRY_BUDGET = 3;
|
||||||
|
|
||||||
|
export function deriveMilestoneAcceptanceCriteriaFromFeatures(features: MissionFeature[]): string | undefined {
|
||||||
|
const lines = features
|
||||||
|
.map((feature) => {
|
||||||
|
const acceptance = feature.acceptanceCriteria?.trim();
|
||||||
|
const description = feature.description?.trim();
|
||||||
|
const text = acceptance && acceptance.length > 0
|
||||||
|
? acceptance
|
||||||
|
: description && description.length > 0
|
||||||
|
? description
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `- ${feature.title}: ${text}`;
|
||||||
|
})
|
||||||
|
.filter((line): line is string => Boolean(line));
|
||||||
|
|
||||||
|
if (lines.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
// ── Mission Summary Type ─────────────────────────────────────────────
|
// ── Mission Summary Type ─────────────────────────────────────────────
|
||||||
|
|
||||||
/** Status summary for a mission, computed from its hierarchy. */
|
/** Status summary for a mission, computed from its hierarchy. */
|
||||||
@@ -1341,6 +1367,26 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
return this.updateMilestone(id, { interviewState: state });
|
return this.updateMilestone(id, { interviewState: state });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyDerivedMilestoneAcceptanceCriteria(milestoneId: string): Milestone {
|
||||||
|
const milestone = this.getMilestone(milestoneId);
|
||||||
|
if (!milestone) {
|
||||||
|
throw new Error(`Milestone ${milestoneId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (milestone.acceptanceCriteria?.trim()) {
|
||||||
|
return milestone;
|
||||||
|
}
|
||||||
|
|
||||||
|
const features = this.listSlices(milestoneId).flatMap((slice) => this.listFeatures(slice.id));
|
||||||
|
const derivedAcceptanceCriteria = deriveMilestoneAcceptanceCriteriaFromFeatures(features);
|
||||||
|
|
||||||
|
if (!derivedAcceptanceCriteria) {
|
||||||
|
return milestone;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.updateMilestone(milestoneId, { acceptanceCriteria: derivedAcceptanceCriteria });
|
||||||
|
}
|
||||||
|
|
||||||
// ── Slice Operations ───────────────────────────────────────────────
|
// ── Slice Operations ───────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1664,6 +1710,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
// update the parent milestone and mission statuses. Calling recomputeSliceStatus
|
// update the parent milestone and mission statuses. Calling recomputeSliceStatus
|
||||||
// here ensures the full chain is updated atomically when a feature is added.
|
// here ensures the full chain is updated atomically when a feature is added.
|
||||||
this.recomputeSliceStatus(sliceId);
|
this.recomputeSliceStatus(sliceId);
|
||||||
|
this.applyDerivedMilestoneAcceptanceCriteria(slice.milestoneId);
|
||||||
|
|
||||||
return feature;
|
return feature;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user