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:
252
packages/core/src/roadmap-ordering.test.ts
Normal file
252
packages/core/src/roadmap-ordering.test.ts
Normal 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)",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user