Every canvas position write (Auto-arrange, Save positions, drags, freeze pins)
used to send one PATCH per technology, all at once. On a large tree that was
dozens of concurrent full document updates, which exhausted the database pool
and could crash the server.
- Add POST /api/tech-tree/positions: validates `{ positions: [{ id, x, y }] }`
(null x/y clears a pin, capped at 2000) and applies every position in a
single UPDATE on the tree position columns. Access mirrors the technologies
update rule (technologies:update or Intelligence membership).
- Route all canvas position writes through it; failures now surface in the
toolbar instead of being swallowed.
- Auto-arrange is optimistic: the canvas re-lays out on click and the pins
are restored if the save fails. A spinner status chip shows while a layout
save runs, and layout controls and dragging are locked until it finishes.
- Integration tests for validation and the bulk write; the e2e checks the busy
indicator and that no per-node PATCHes are sent.
137 lines
4 KiB
TypeScript
137 lines
4 KiB
TypeScript
import { getPayload, Payload } from "payload";
|
|
import config from "@/payload.config";
|
|
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
MAX_POSITION_UPDATES,
|
|
parsePositionUpdates,
|
|
writeTreePositions,
|
|
} from "@/lib/tech-tree/positions";
|
|
|
|
const RUN = `ttpos-${Date.now().toString(36)}`;
|
|
const TIMEOUT = 30_000;
|
|
|
|
describe("tech tree position bulk-write validation", () => {
|
|
it("accepts pins and clears", () => {
|
|
const result = parsePositionUpdates({
|
|
positions: [
|
|
{ id: 1, x: 10.4, y: 20.6 },
|
|
{ id: 2, x: null, y: null },
|
|
],
|
|
});
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
updates: [
|
|
{ id: 1, x: 10, y: 21 },
|
|
{ id: 2, x: null, y: null },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("keeps the last write for a repeated id", () => {
|
|
const result = parsePositionUpdates({
|
|
positions: [
|
|
{ id: 3, x: 1, y: 1 },
|
|
{ id: 3, x: null, y: null },
|
|
],
|
|
});
|
|
expect(result).toEqual({ ok: true, updates: [{ id: 3, x: null, y: null }] });
|
|
});
|
|
|
|
it("rejects malformed bodies", () => {
|
|
expect(parsePositionUpdates(null).ok).toBe(false);
|
|
expect(parsePositionUpdates({ positions: "nope" }).ok).toBe(false);
|
|
expect(parsePositionUpdates({ positions: [{ id: 0, x: 1, y: 1 }] }).ok).toBe(false);
|
|
expect(parsePositionUpdates({ positions: [{ id: 1.5, x: 1, y: 1 }] }).ok).toBe(false);
|
|
// Half-cleared and non-finite coordinates are both invalid.
|
|
expect(parsePositionUpdates({ positions: [{ id: 1, x: 5, y: null }] }).ok).toBe(false);
|
|
expect(parsePositionUpdates({ positions: [{ id: 1, x: Number.NaN, y: 1 }] }).ok).toBe(false);
|
|
});
|
|
|
|
it("caps the batch size", () => {
|
|
const positions = Array.from({ length: MAX_POSITION_UPDATES + 1 }, (_, i) => ({
|
|
id: i + 1,
|
|
x: 0,
|
|
y: 0,
|
|
}));
|
|
expect(parsePositionUpdates({ positions }).ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("tech tree position bulk write (database)", () => {
|
|
let payload: Payload;
|
|
const ids: number[] = [];
|
|
|
|
beforeAll(async () => {
|
|
payload = await getPayload({ config });
|
|
for (const label of ["a", "b", "c"]) {
|
|
const doc = await payload.create({
|
|
collection: "technologies",
|
|
data: {
|
|
name: `${RUN}-${label}`,
|
|
summary: "Bulk position write fixture.",
|
|
type: "upgrade",
|
|
approvalStatus: "in_progress",
|
|
researchCosts: { minimumResearchDuration: 1 },
|
|
},
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
});
|
|
ids.push(doc.id);
|
|
}
|
|
}, TIMEOUT);
|
|
|
|
afterAll(async () => {
|
|
for (const id of ids) {
|
|
await payload.delete({ collection: "technologies", id, overrideAccess: true });
|
|
}
|
|
}, TIMEOUT);
|
|
|
|
const positionOf = async (id: number) => {
|
|
const doc = await payload.findByID({
|
|
collection: "technologies",
|
|
id,
|
|
overrideAccess: true,
|
|
depth: 0,
|
|
});
|
|
return doc.treePosition ?? null;
|
|
};
|
|
|
|
it(
|
|
"pins many technologies in one write and clears them again",
|
|
async () => {
|
|
const pinned = await writeTreePositions(payload, [
|
|
{ id: ids[0], x: 46, y: 92 },
|
|
{ id: ids[1], x: 368, y: 184 },
|
|
{ id: ids[2], x: 0, y: 0 },
|
|
]);
|
|
expect(pinned).toBe(3);
|
|
expect(await positionOf(ids[0])).toMatchObject({ x: 46, y: 92 });
|
|
expect(await positionOf(ids[1])).toMatchObject({ x: 368, y: 184 });
|
|
expect(await positionOf(ids[2])).toMatchObject({ x: 0, y: 0 });
|
|
|
|
// Auto-arrange path: clear every pin in a single statement.
|
|
const cleared = await writeTreePositions(
|
|
payload,
|
|
ids.map((id) => ({ id, x: null, y: null })),
|
|
);
|
|
expect(cleared).toBe(3);
|
|
for (const id of ids) {
|
|
const position = await positionOf(id);
|
|
expect(position?.x ?? null).toBeNull();
|
|
expect(position?.y ?? null).toBeNull();
|
|
}
|
|
},
|
|
TIMEOUT,
|
|
);
|
|
|
|
it(
|
|
"ignores ids that do not exist",
|
|
async () => {
|
|
const touched = await writeTreePositions(payload, [{ id: 999_999_999, x: 1, y: 1 }]);
|
|
expect(touched).toBe(0);
|
|
},
|
|
TIMEOUT,
|
|
);
|
|
});
|