6ff413078b
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
228 lines
7.9 KiB
TypeScript
228 lines
7.9 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import type { StoreApi } from "zustand";
|
|
import type { AppState } from "@/lib/state/store";
|
|
import { registerKanbanHandlers } from "./kanban";
|
|
|
|
// kanban.test.ts is already at the file-length limit, so task.reordered gets
|
|
// its own file rather than growing that one further.
|
|
|
|
const WORKFLOW_ID = "wf1";
|
|
const STEP_ID = "step-1";
|
|
|
|
type FakeTask = {
|
|
id: string;
|
|
workflowStepId: string;
|
|
position: number;
|
|
wipAdmitted?: boolean;
|
|
queuedForStepId?: string;
|
|
};
|
|
|
|
function makeTask(id: string, position: number): FakeTask {
|
|
return { id, workflowStepId: STEP_ID, position, wipAdmitted: true };
|
|
}
|
|
|
|
function makeQueuedTask(id: string, position: number): FakeTask {
|
|
return { id, workflowStepId: STEP_ID, position, wipAdmitted: false, queuedForStepId: STEP_ID };
|
|
}
|
|
|
|
function makeStore(overrides: {
|
|
tasks?: FakeTask[];
|
|
orderRevisionByStepId?: Record<string, number>;
|
|
pendingReorderBandKeys?: Record<string, true>;
|
|
}) {
|
|
const tasks = overrides.tasks ?? [makeTask("a", 0), makeTask("b", 1), makeTask("c", 2)];
|
|
// Independently cloned per side: a handler that mutates only kanban.tasks
|
|
// (or only the snapshot) must not make a synchronization assertion pass
|
|
// by accident because both sides happen to share one array/objects.
|
|
const snapshotTasks = tasks.map((task) => ({ ...task }));
|
|
let state = {
|
|
kanban: { workflowId: WORKFLOW_ID, steps: [], tasks: tasks.map((task) => ({ ...task })) },
|
|
kanbanMulti: {
|
|
isLoading: false,
|
|
orderRevisionByStepId: overrides.orderRevisionByStepId ?? {},
|
|
pendingReorderBandKeys: overrides.pendingReorderBandKeys ?? {},
|
|
withheldReorderByBandKey: {},
|
|
snapshots: {
|
|
[WORKFLOW_ID]: {
|
|
workflowId: WORKFLOW_ID,
|
|
workflowName: "WF1",
|
|
steps: [],
|
|
tasks: snapshotTasks,
|
|
},
|
|
},
|
|
},
|
|
} as unknown as AppState;
|
|
|
|
return {
|
|
getState: () => state,
|
|
setState: (updater: AppState | ((s: AppState) => AppState)) => {
|
|
state =
|
|
typeof updater === "function" ? (updater as (s: AppState) => AppState)(state) : updater;
|
|
},
|
|
subscribe: () => () => {},
|
|
destroy: () => {},
|
|
getInitialState: () => state,
|
|
} as unknown as StoreApi<AppState>;
|
|
}
|
|
|
|
function makeReorderedMessage(
|
|
revision: number,
|
|
tasks: Array<{ id: string; position: number }>,
|
|
band: "admitted" | "queued" = "admitted",
|
|
) {
|
|
return {
|
|
id: "msg-1",
|
|
type: "notification" as const,
|
|
action: "task.reordered" as const,
|
|
payload: { workflow_step_id: STEP_ID, band, revision, tasks },
|
|
};
|
|
}
|
|
|
|
describe("task.reordered handler", () => {
|
|
it("applies positions to kanban.tasks and the matching snapshot, and records the revision", () => {
|
|
const store = makeStore({});
|
|
const handler = registerKanbanHandlers(store)["task.reordered"]!;
|
|
|
|
handler(
|
|
makeReorderedMessage(1, [
|
|
{ id: "c", position: 0 },
|
|
{ id: "a", position: 1 },
|
|
{ id: "b", position: 2 },
|
|
]),
|
|
);
|
|
|
|
const tasks = store.getState().kanban.tasks;
|
|
expect(tasks.find((t) => t.id === "c")?.position).toBe(0);
|
|
expect(tasks.find((t) => t.id === "a")?.position).toBe(1);
|
|
expect(tasks.find((t) => t.id === "b")?.position).toBe(2);
|
|
const snapshotTasks = store.getState().kanbanMulti.snapshots[WORKFLOW_ID]?.tasks;
|
|
expect(snapshotTasks?.find((t) => t.id === "c")?.position).toBe(0);
|
|
expect(store.getState().kanbanMulti.orderRevisionByStepId[STEP_ID]).toBe(1);
|
|
});
|
|
|
|
it("ignores an event whose revision is not strictly greater than the recorded one", () => {
|
|
const store = makeStore({ orderRevisionByStepId: { [STEP_ID]: 5 } });
|
|
const handler = registerKanbanHandlers(store)["task.reordered"]!;
|
|
|
|
handler(makeReorderedMessage(5, [{ id: "a", position: 2 }]));
|
|
|
|
expect(store.getState().kanban.tasks.find((t) => t.id === "a")?.position).toBe(0);
|
|
expect(store.getState().kanbanMulti.orderRevisionByStepId[STEP_ID]).toBe(5);
|
|
});
|
|
|
|
it("applies the first order it ever receives for a step with no recorded revision", () => {
|
|
const store = makeStore({});
|
|
const handler = registerKanbanHandlers(store)["task.reordered"]!;
|
|
|
|
handler(makeReorderedMessage(0, [{ id: "a", position: 9 }]));
|
|
|
|
expect(store.getState().kanban.tasks.find((t) => t.id === "a")?.position).toBe(9);
|
|
expect(store.getState().kanbanMulti.orderRevisionByStepId[STEP_ID]).toBe(0);
|
|
});
|
|
|
|
it("does not apply an event for a band with a request currently in flight (AC.27)", () => {
|
|
const store = makeStore({ pendingReorderBandKeys: { [`${STEP_ID}:admitted`]: true } });
|
|
const handler = registerKanbanHandlers(store)["task.reordered"]!;
|
|
|
|
handler(makeReorderedMessage(1, [{ id: "a", position: 9 }]));
|
|
|
|
expect(store.getState().kanban.tasks.find((t) => t.id === "a")?.position).toBe(0);
|
|
expect(store.getState().kanbanMulti.orderRevisionByStepId[STEP_ID]).toBeUndefined();
|
|
});
|
|
|
|
it("still applies to a different band on the same step while the other band is pending", () => {
|
|
const store = makeStore({ pendingReorderBandKeys: { [`${STEP_ID}:queued`]: true } });
|
|
const handler = registerKanbanHandlers(store)["task.reordered"]!;
|
|
|
|
handler(makeReorderedMessage(1, [{ id: "a", position: 9 }], "admitted"));
|
|
|
|
expect(store.getState().kanban.tasks.find((t) => t.id === "a")?.position).toBe(9);
|
|
});
|
|
|
|
it("applies the sibling band's positions from the same whole-step payload even when the event's own band matches the in-flight one (AC.27)", () => {
|
|
const store = makeStore({
|
|
tasks: [makeTask("a", 0), makeQueuedTask("q", 1)],
|
|
pendingReorderBandKeys: { [`${STEP_ID}:admitted`]: true },
|
|
});
|
|
const handler = registerKanbanHandlers(store)["task.reordered"]!;
|
|
|
|
handler(
|
|
makeReorderedMessage(
|
|
1,
|
|
[
|
|
{ id: "a", position: 9 },
|
|
{ id: "q", position: 5 },
|
|
],
|
|
"admitted",
|
|
),
|
|
);
|
|
|
|
const tasks = store.getState().kanban.tasks;
|
|
expect(tasks.find((t) => t.id === "a")?.position).toBe(0);
|
|
expect(tasks.find((t) => t.id === "q")?.position).toBe(5);
|
|
expect(store.getState().kanbanMulti.withheldReorderByBandKey[`${STEP_ID}:admitted`]).toEqual({
|
|
revision: 1,
|
|
tasks: [{ id: "a", position: 9 }],
|
|
});
|
|
});
|
|
|
|
it("holds the pending band's positions from an event for the other band instead of applying them wholesale", () => {
|
|
const store = makeStore({
|
|
tasks: [makeTask("a", 0), makeQueuedTask("q", 1)],
|
|
pendingReorderBandKeys: { [`${STEP_ID}:admitted`]: true },
|
|
});
|
|
const handler = registerKanbanHandlers(store)["task.reordered"]!;
|
|
|
|
handler(
|
|
makeReorderedMessage(
|
|
1,
|
|
[
|
|
{ id: "a", position: 9 },
|
|
{ id: "q", position: 5 },
|
|
],
|
|
"queued",
|
|
),
|
|
);
|
|
|
|
const tasks = store.getState().kanban.tasks;
|
|
expect(tasks.find((t) => t.id === "a")?.position).toBe(0);
|
|
expect(tasks.find((t) => t.id === "q")?.position).toBe(5);
|
|
expect(store.getState().kanbanMulti.orderRevisionByStepId[STEP_ID]).toBe(1);
|
|
expect(store.getState().kanbanMulti.withheldReorderByBandKey[`${STEP_ID}:admitted`]).toEqual({
|
|
revision: 1,
|
|
tasks: [{ id: "a", position: 9 }],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("task.reordered revision buffering", () => {
|
|
it("keeps the newest held revision when an older event arrives later", () => {
|
|
const store = makeStore({ pendingReorderBandKeys: { [`${STEP_ID}:admitted`]: true } });
|
|
const handler = registerKanbanHandlers(store)["task.reordered"]!;
|
|
|
|
handler(
|
|
makeReorderedMessage(3, [
|
|
{ id: "a", position: 30 },
|
|
{ id: "b", position: 31 },
|
|
{ id: "c", position: 32 },
|
|
]),
|
|
);
|
|
handler(
|
|
makeReorderedMessage(2, [
|
|
{ id: "a", position: 20 },
|
|
{ id: "b", position: 21 },
|
|
{ id: "c", position: 22 },
|
|
]),
|
|
);
|
|
|
|
expect(store.getState().kanbanMulti.withheldReorderByBandKey[`${STEP_ID}:admitted`]).toEqual({
|
|
revision: 3,
|
|
tasks: [
|
|
{ id: "a", position: 30 },
|
|
{ id: "b", position: 31 },
|
|
{ id: "c", position: 32 },
|
|
],
|
|
});
|
|
});
|
|
});
|