feat(FN-1667): merge fusion/fn-1667 (auto-resolved)

- test(FN-1667): complete Step 4 — verify roadmap model
- feat(FN-1667): complete Step 3 — document roadmap model
- feat(FN-1667): complete Step 2 — add roadmap ordering helpers
- feat(FN-1667): complete Step 1 — define roadmap contracts
This commit is contained in:
gsxdsm
2026-04-13 08:57:48 -07:00
parent d4f42787d1
commit f3d365ecc0
7 changed files with 930 additions and 1 deletions

View File

@@ -0,0 +1,252 @@
import { describe, expect, it } from "vitest";
import {
applyRoadmapFeatureReorder,
applyRoadmapMilestoneReorder,
moveRoadmapFeature,
normalizeRoadmapFeatureOrder,
normalizeRoadmapMilestoneOrder,
} from "./roadmap-ordering.js";
import type { RoadmapFeature, RoadmapMilestone } from "./roadmap-types.js";
function createMilestone(
id: string,
roadmapId: string,
orderIndex: number,
createdAt: string,
): RoadmapMilestone {
return {
id,
roadmapId,
title: id,
description: `${id} description`,
orderIndex,
createdAt,
updatedAt: createdAt,
};
}
function createFeature(
id: string,
milestoneId: string,
orderIndex: number,
createdAt: string,
): RoadmapFeature {
return {
id,
milestoneId,
title: id,
description: `${id} description`,
orderIndex,
createdAt,
updatedAt: createdAt,
};
}
describe("roadmap-ordering", () => {
describe("normalizeRoadmapMilestoneOrder", () => {
it("repairs milestone ordering deterministically using createdAt and id tiebreakers", () => {
const milestones = [
createMilestone("RMS-C", "RM-1", 2, "2026-04-13T00:00:02.000Z"),
createMilestone("RMS-B", "RM-1", 1, "2026-04-13T00:00:01.000Z"),
createMilestone("RMS-A", "RM-1", 1, "2026-04-13T00:00:01.000Z"),
];
const normalized = normalizeRoadmapMilestoneOrder(milestones);
expect(normalized.map((milestone) => milestone.id)).toEqual([
"RMS-A",
"RMS-B",
"RMS-C",
]);
expect(normalized.map((milestone) => milestone.orderIndex)).toEqual([0, 1, 2]);
expect(milestones.map((milestone) => milestone.orderIndex)).toEqual([2, 1, 1]);
});
it("rejects mixed-roadmap milestone scopes", () => {
const milestones = [
createMilestone("RMS-1", "RM-1", 0, "2026-04-13T00:00:00.000Z"),
createMilestone("RMS-2", "RM-2", 1, "2026-04-13T00:00:01.000Z"),
];
expect(() => normalizeRoadmapMilestoneOrder(milestones)).toThrow(
"Milestone RMS-2 does not belong to roadmap RM-1",
);
});
});
describe("applyRoadmapMilestoneReorder", () => {
it("reorders milestones and rewrites contiguous order indexes", () => {
const milestones = [
createMilestone("RMS-1", "RM-1", 0, "2026-04-13T00:00:00.000Z"),
createMilestone("RMS-2", "RM-1", 1, "2026-04-13T00:00:01.000Z"),
createMilestone("RMS-3", "RM-1", 2, "2026-04-13T00:00:02.000Z"),
];
const reordered = applyRoadmapMilestoneReorder(milestones, {
roadmapId: "RM-1",
orderedMilestoneIds: ["RMS-3", "RMS-1", "RMS-2"],
});
expect(reordered.map((milestone) => milestone.id)).toEqual([
"RMS-3",
"RMS-1",
"RMS-2",
]);
expect(reordered.map((milestone) => milestone.orderIndex)).toEqual([0, 1, 2]);
});
it("rejects duplicate milestone ids in reorder input", () => {
const milestones = [
createMilestone("RMS-1", "RM-1", 0, "2026-04-13T00:00:00.000Z"),
createMilestone("RMS-2", "RM-1", 1, "2026-04-13T00:00:01.000Z"),
];
expect(() =>
applyRoadmapMilestoneReorder(milestones, {
roadmapId: "RM-1",
orderedMilestoneIds: ["RMS-2", "RMS-2"],
}),
).toThrow("Duplicate milestone id in requested order: RMS-2");
});
});
describe("normalizeRoadmapFeatureOrder", () => {
it("repairs feature ordering deterministically using createdAt and id tiebreakers", () => {
const features = [
createFeature("RF-C", "RMS-1", 3, "2026-04-13T00:00:03.000Z"),
createFeature("RF-B", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
createFeature("RF-A", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
];
const normalized = normalizeRoadmapFeatureOrder(features);
expect(normalized.map((feature) => feature.id)).toEqual([
"RF-A",
"RF-B",
"RF-C",
]);
expect(normalized.map((feature) => feature.orderIndex)).toEqual([0, 1, 2]);
});
});
describe("applyRoadmapFeatureReorder", () => {
it("reorders features within a milestone and rewrites contiguous order indexes", () => {
const features = [
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
createFeature("RF-3", "RMS-1", 2, "2026-04-13T00:00:02.000Z"),
];
const reordered = applyRoadmapFeatureReorder(features, {
roadmapId: "RM-1",
milestoneId: "RMS-1",
orderedFeatureIds: ["RF-2", "RF-3", "RF-1"],
});
expect(reordered.map((feature) => feature.id)).toEqual([
"RF-2",
"RF-3",
"RF-1",
]);
expect(reordered.map((feature) => feature.orderIndex)).toEqual([0, 1, 2]);
});
it("rejects partial feature reorder payloads", () => {
const features = [
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
];
expect(() =>
applyRoadmapFeatureReorder(features, {
roadmapId: "RM-1",
milestoneId: "RMS-1",
orderedFeatureIds: ["RF-2"],
}),
).toThrow("Expected 2 feature ids but received 1");
});
});
describe("moveRoadmapFeature", () => {
it("moves a feature across milestones and normalizes both milestone orders", () => {
const features = [
createFeature("RF-1", "RMS-SOURCE", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-SOURCE", 1, "2026-04-13T00:00:01.000Z"),
createFeature("RF-3", "RMS-TARGET", 0, "2026-04-13T00:00:02.000Z"),
createFeature("RF-4", "RMS-TARGET", 1, "2026-04-13T00:00:03.000Z"),
];
const result = moveRoadmapFeature(features, {
roadmapId: "RM-1",
featureId: "RF-2",
fromMilestoneId: "RMS-SOURCE",
toMilestoneId: "RMS-TARGET",
targetOrderIndex: 1,
});
expect(result.movedFeature).toMatchObject({
id: "RF-2",
milestoneId: "RMS-TARGET",
orderIndex: 1,
});
expect(result.sourceMilestoneFeatures.map((feature) => feature.id)).toEqual([
"RF-1",
]);
expect(result.sourceMilestoneFeatures.map((feature) => feature.orderIndex)).toEqual([0]);
expect(result.targetMilestoneFeatures.map((feature) => feature.id)).toEqual([
"RF-3",
"RF-2",
"RF-4",
]);
expect(result.targetMilestoneFeatures.map((feature) => feature.orderIndex)).toEqual([
0,
1,
2,
]);
expect(result.affectedFeatures).toHaveLength(4);
});
it("clamps same-milestone moves into range and returns a single normalized list", () => {
const features = [
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
createFeature("RF-3", "RMS-1", 2, "2026-04-13T00:00:02.000Z"),
];
const result = moveRoadmapFeature(features, {
roadmapId: "RM-1",
featureId: "RF-1",
fromMilestoneId: "RMS-1",
toMilestoneId: "RMS-1",
targetOrderIndex: 99,
});
expect(result.sourceMilestoneFeatures.map((feature) => feature.id)).toEqual([
"RF-2",
"RF-3",
"RF-1",
]);
expect(result.targetMilestoneFeatures).toEqual(result.sourceMilestoneFeatures);
expect(result.movedFeature.orderIndex).toBe(2);
});
it("rejects features outside the affected milestone scope", () => {
const features = [
createFeature("RF-1", "RMS-SOURCE", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-OTHER", 0, "2026-04-13T00:00:01.000Z"),
];
expect(() =>
moveRoadmapFeature(features, {
roadmapId: "RM-1",
featureId: "RF-1",
fromMilestoneId: "RMS-SOURCE",
toMilestoneId: "RMS-TARGET",
targetOrderIndex: 0,
}),
).toThrow(
"Feature RF-2 is outside the affected milestone scope (RMS-SOURCE → RMS-TARGET)",
);
});
});
});