feat(FN-4613): complete Step 3 — guard acceptance visibility regressions
Fusion-Task-Id: FN-4613 Fusion-Task-Lineage: 07ecbe9c-cfee-42d8-b9d2-8b99b2cce9e6
This commit is contained in:
@@ -824,30 +824,56 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSelectedMission(data);
|
setSelectedMission(data);
|
||||||
// Auto-expand first milestone and slice
|
|
||||||
if (data.milestones.length > 0) {
|
if (data.milestones.length > 0) {
|
||||||
const firstMilestoneId = data.milestones[0].id;
|
const firstMilestoneId = data.milestones[0].id;
|
||||||
setSelectedMilestoneId(firstMilestoneId);
|
const milestoneIds = new Set(data.milestones.map((milestone) => milestone.id));
|
||||||
|
const selectedMilestoneStillExists = selectedMilestoneIdRef.current
|
||||||
|
&& milestoneIds.has(selectedMilestoneIdRef.current);
|
||||||
|
const nextSelectedMilestoneId = selectedMilestoneStillExists
|
||||||
|
? selectedMilestoneIdRef.current
|
||||||
|
: firstMilestoneId;
|
||||||
|
|
||||||
|
setSelectedMilestoneId(nextSelectedMilestoneId);
|
||||||
setValidationRoundsExpanded(true);
|
setValidationRoundsExpanded(true);
|
||||||
setExpandedMilestones(new Set([firstMilestoneId]));
|
|
||||||
// Load assertions and validation rollup for the first milestone (inline to avoid forward ref)
|
// FN-4613: preserve user-expanded milestones/slices across refetch instead of resetting to first-only.
|
||||||
fetchAssertions(firstMilestoneId, projectId).then((assertions) => {
|
setExpandedMilestones((prev) => {
|
||||||
|
const next = new Set(Array.from(prev).filter((milestoneId) => milestoneIds.has(milestoneId)));
|
||||||
|
next.add(nextSelectedMilestoneId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableSliceIds = new Set(
|
||||||
|
data.milestones.flatMap((milestone) => milestone.slices.map((slice) => slice.id)),
|
||||||
|
);
|
||||||
|
const selectedMilestone = data.milestones.find((milestone) => milestone.id === nextSelectedMilestoneId);
|
||||||
|
const selectedMilestoneFirstSliceId = selectedMilestone?.slices[0]?.id ?? null;
|
||||||
|
setExpandedSlices((prev) => {
|
||||||
|
const next = new Set(Array.from(prev).filter((sliceId) => availableSliceIds.has(sliceId)));
|
||||||
|
if (selectedMilestoneFirstSliceId) {
|
||||||
|
const hasExpandedSliceForSelectedMilestone = selectedMilestone?.slices.some((slice) => next.has(slice.id)) ?? false;
|
||||||
|
if (!hasExpandedSliceForSelectedMilestone) {
|
||||||
|
next.add(selectedMilestoneFirstSliceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load assertions and validation rollup for the selected milestone.
|
||||||
|
fetchAssertions(nextSelectedMilestoneId, projectId).then((assertions) => {
|
||||||
setAssertionsByMilestone((prev) => {
|
setAssertionsByMilestone((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.set(firstMilestoneId, assertions);
|
next.set(nextSelectedMilestoneId, assertions);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}).catch(() => { /* silently fail */ });
|
}).catch(() => { /* silently fail */ });
|
||||||
fetchMilestoneValidation(firstMilestoneId, projectId).then((rollup) => {
|
fetchMilestoneValidation(nextSelectedMilestoneId, projectId).then((rollup) => {
|
||||||
setValidationRollupByMilestone((prev) => {
|
setValidationRollupByMilestone((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.set(firstMilestoneId, rollup);
|
next.set(nextSelectedMilestoneId, rollup);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}).catch(() => { /* silently fail */ });
|
}).catch(() => { /* silently fail */ });
|
||||||
if (data.milestones[0].slices.length > 0) {
|
|
||||||
setExpandedSlices(new Set([data.milestones[0].slices[0].id]));
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setSelectedMilestoneId(null);
|
setSelectedMilestoneId(null);
|
||||||
setValidationTelemetry(null);
|
setValidationTelemetry(null);
|
||||||
@@ -898,6 +924,18 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setValidationRoundsExpanded(true);
|
setValidationRoundsExpanded(true);
|
||||||
|
if (!selectedMilestoneId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// FN-4613: selecting a milestone from any navigation path keeps its acceptance criteria visible.
|
||||||
|
setExpandedMilestones((prev) => {
|
||||||
|
if (prev.has(selectedMilestoneId)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(selectedMilestoneId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}, [selectedMilestoneId]);
|
}, [selectedMilestoneId]);
|
||||||
|
|
||||||
const refreshValidationTelemetry = useCallback((milestoneId: string) => {
|
const refreshValidationTelemetry = useCallback((milestoneId: string) => {
|
||||||
|
|||||||
@@ -4234,6 +4234,127 @@ describe("MissionManager", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("FN-4613: persisted acceptance criteria visibility", () => {
|
||||||
|
const createFn4613MissionDetail = () => {
|
||||||
|
const missionDetail = JSON.parse(JSON.stringify(mockMissionDetail)) as typeof mockMissionDetail;
|
||||||
|
missionDetail.milestones = [
|
||||||
|
{
|
||||||
|
...missionDetail.milestones[0],
|
||||||
|
id: "MS-001",
|
||||||
|
title: "Milestone One",
|
||||||
|
acceptanceCriteria: "",
|
||||||
|
slices: [
|
||||||
|
{
|
||||||
|
...missionDetail.milestones[0].slices[0],
|
||||||
|
id: "SL-001",
|
||||||
|
title: "Slice One",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
...missionDetail.milestones[0].slices[0].features[0],
|
||||||
|
id: "F-001",
|
||||||
|
title: "Feature One",
|
||||||
|
acceptanceCriteria: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...missionDetail.milestones[0],
|
||||||
|
id: "MS-002",
|
||||||
|
title: "Milestone Two",
|
||||||
|
acceptanceCriteria: "Milestone two acceptance criteria",
|
||||||
|
slices: [
|
||||||
|
{
|
||||||
|
...missionDetail.milestones[0].slices[0],
|
||||||
|
id: "SL-002",
|
||||||
|
title: "Slice Two",
|
||||||
|
milestoneId: "MS-002",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
...missionDetail.milestones[0].slices[0].features[0],
|
||||||
|
id: "F-002",
|
||||||
|
title: "Feature Two",
|
||||||
|
sliceId: "SL-002",
|
||||||
|
acceptanceCriteria: "Feature two acceptance criteria",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return missionDetail;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("keeps non-first milestone acceptance discoverable on initial load", async () => {
|
||||||
|
globalThis.fetch = createDetailFetchMockForMissionDetail(createFn4613MissionDetail());
|
||||||
|
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByText("Build Auth System"));
|
||||||
|
await waitForDetailLoaded("Milestone One");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Milestone Two"));
|
||||||
|
expect(screen.getByText("Milestone two acceptance criteria", { exact: false })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves expanded non-first milestone after mission detail refetch", async () => {
|
||||||
|
globalThis.EventSource = MockEventSource as unknown as typeof globalThis.EventSource;
|
||||||
|
const missionDetail = createFn4613MissionDetail();
|
||||||
|
const fetchMock = createDetailFetchMockForMissionDetail(missionDetail);
|
||||||
|
globalThis.fetch = fetchMock;
|
||||||
|
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByText("Build Auth System"));
|
||||||
|
await waitForDetailLoaded("Milestone One");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Milestone Two"));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Milestone two acceptance criteria", { exact: false })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
for (const source of MockEventSource.instances) {
|
||||||
|
source.emit("mission:updated", { id: "M-001", title: "Build Auth System", status: "active" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Milestone two acceptance criteria", { exact: false })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(fetchMock.mock.calls.filter((call) => String(call[0]).includes("/api/missions/M-001")).length).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces feature acceptance for selected milestone without requiring slice expansion", async () => {
|
||||||
|
const missionDetail = createFn4613MissionDetail();
|
||||||
|
missionDetail.milestones[1].acceptanceCriteria = "";
|
||||||
|
globalThis.fetch = createDetailFetchMockForMissionDetail(missionDetail);
|
||||||
|
|
||||||
|
render(<MissionManager isOpen={true} isInline={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
fireEvent.click(await screen.findByText("Build Auth System"));
|
||||||
|
await waitForDetailLoaded("Milestone One");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Milestone Two"));
|
||||||
|
const rollup = await screen.findByTestId("milestone-feature-acceptance-rollup");
|
||||||
|
expect(within(rollup).getByText("Feature Two")).toBeInTheDocument();
|
||||||
|
expect(within(rollup).getByText("Feature two acceptance criteria", { exact: false })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps milestone acceptance precedence over feature rollup", async () => {
|
||||||
|
const missionDetail = createFn4613MissionDetail();
|
||||||
|
globalThis.fetch = createDetailFetchMockForMissionDetail(missionDetail);
|
||||||
|
|
||||||
|
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
fireEvent.click(await screen.findByText("Build Auth System"));
|
||||||
|
await waitForDetailLoaded("Milestone One");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Milestone Two"));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Milestone two acceptance criteria", { exact: false })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId("milestone-feature-acceptance-rollup")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("milestone assertions empty-state", () => {
|
describe("milestone assertions empty-state", () => {
|
||||||
const emptyAssertionsCopy = "No assertions defined. Add one to define completion criteria.";
|
const emptyAssertionsCopy = "No assertions defined. Add one to define completion criteria.";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user