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:
Fusion
2026-05-15 07:32:47 -07:00
committed by gsxdsm
parent 75c327ca45
commit 87ff4db2c6
2 changed files with 120 additions and 1 deletions

View File

@@ -1,6 +1,7 @@
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 type { MissionFeature } from "../mission-types.js";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
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 ──────────────────────────────────────────────────
describe("Slice CRUD", () => {

View File

@@ -62,6 +62,32 @@ import {
*/
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 ─────────────────────────────────────────────
/** 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 });
}
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 ───────────────────────────────────────────────
/**
@@ -1664,6 +1710,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
// update the parent milestone and mission statuses. Calling recomputeSliceStatus
// here ensures the full chain is updated atomically when a feature is added.
this.recomputeSliceStatus(sliceId);
this.applyDerivedMilestoneAcceptanceCriteria(slice.milestoneId);
return feature;
}