1
0
Fork 0
polaris-task-force/tests/int/tech-tree-positions.int.spec.ts
Z8MB1E 80ad24580a fix(tech-tree): save canvas positions in one request so auto-arrange cannot overload the server
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.
2026-10-03 04:08:29 -04:00

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,
);
});