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.
This commit is contained in:
parent
47b2988ae7
commit
80ad24580a
5 changed files with 371 additions and 45 deletions
52
src/app/api/tech-tree/positions/route.ts
Normal file
52
src/app/api/tech-tree/positions/route.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
|
||||||
|
import { parsePositionUpdates, writeTreePositions } from "@/lib/tech-tree/positions";
|
||||||
|
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
|
||||||
|
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk Tech Tree position write: `{ positions: [{ id, x, y }] }`, where a
|
||||||
|
* null x/y returns a technology to the automatic layout. Used by every canvas
|
||||||
|
* position write (drags, Save positions, Auto-arrange, freeze pins) so they
|
||||||
|
* cost one request and one SQL statement instead of a PATCH per node.
|
||||||
|
*
|
||||||
|
* Access mirrors the technologies collection's update rule: the
|
||||||
|
* `technologies:update` permission OR Intelligence division membership.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const payload = await getPayload({ config: await config });
|
||||||
|
const { user } = await payload.auth({ headers: req.headers, canSetHeaders: false });
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const allowed =
|
||||||
|
(await hasPermission(payload, user, "technologies:update")) ||
|
||||||
|
(await hasIntelligenceQualification(payload, user));
|
||||||
|
if (!allowed) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const parsed = parsePositionUpdates(body);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
return NextResponse.json({ error: parsed.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await writeTreePositions(payload, parsed.updates);
|
||||||
|
return NextResponse.json({ ok: true, updated });
|
||||||
|
} catch (err) {
|
||||||
|
payload.logger.error(`[TechTree] Bulk position write failed: ${err}`);
|
||||||
|
return NextResponse.json({ error: "Could not save positions." }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,14 @@ import {
|
||||||
ReactFlowProvider,
|
ReactFlowProvider,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import "@xyflow/react/dist/style.css";
|
import "@xyflow/react/dist/style.css";
|
||||||
import { LayoutGridIcon, MagnetIcon, PlusIcon, SaveIcon, TriangleAlertIcon } from "lucide-react";
|
import {
|
||||||
|
LayoutGridIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
MagnetIcon,
|
||||||
|
PlusIcon,
|
||||||
|
SaveIcon,
|
||||||
|
TriangleAlertIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
|
|
@ -239,6 +246,27 @@ function buildFlowEdges(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist canvas positions in ONE request (one SQL statement server-side).
|
||||||
|
* Never PATCH per node: dozens of concurrent document updates exhausted the
|
||||||
|
* database pool and could take the server down. Null x/y clears a pin.
|
||||||
|
*/
|
||||||
|
async function saveTreePositions(
|
||||||
|
updates: { id: number; x: number | null; y: number | null }[],
|
||||||
|
): Promise<void> {
|
||||||
|
if (updates.length === 0) return;
|
||||||
|
const res = await fetch("/api/tech-tree/positions", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ positions: updates }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => null);
|
||||||
|
throw new Error(body?.error || `Could not save positions (HTTP ${res.status}).`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchCollection<T>(slug: string): Promise<T[]> {
|
async function fetchCollection<T>(slug: string): Promise<T[]> {
|
||||||
const res = await fetch(`/api/${slug}?limit=0&depth=0&sort=name`, {
|
const res = await fetch(`/api/${slug}?limit=0&depth=0&sort=name`, {
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
|
|
@ -255,7 +283,9 @@ function CanvasInner({ data }: { data: TechTreeData }) {
|
||||||
const [resources, setResources] = React.useState(data.resources);
|
const [resources, setResources] = React.useState(data.resources);
|
||||||
const [selectedId, setSelectedId] = React.useState<string | null>(null);
|
const [selectedId, setSelectedId] = React.useState<string | null>(null);
|
||||||
const [createOpen, setCreateOpen] = React.useState(false);
|
const [createOpen, setCreateOpen] = React.useState(false);
|
||||||
const [arranging, setArranging] = React.useState(false);
|
// Label of the layout write in flight ("Arranging...", "Saving positions...");
|
||||||
|
// null when idle. Drives the toolbar status chip and locks layout controls.
|
||||||
|
const [layoutBusy, setLayoutBusy] = React.useState<string | null>(null);
|
||||||
|
|
||||||
// Manual edge routing, loaded from this browser's storage on mount. Empty =
|
// Manual edge routing, loaded from this browser's storage on mount. Empty =
|
||||||
// the edge draws its automatic path.
|
// the edge draws its automatic path.
|
||||||
|
|
@ -314,16 +344,8 @@ function CanvasInner({ data }: { data: TechTreeData }) {
|
||||||
/** Persist freeze pins (skipping ids whose own PATCH already carries one). */
|
/** Persist freeze pins (skipping ids whose own PATCH already carries one). */
|
||||||
const persistPins = React.useCallback(
|
const persistPins = React.useCallback(
|
||||||
async (pins: Map<number, CanvasPosition>, skip: Set<number> = new Set()) => {
|
async (pins: Map<number, CanvasPosition>, skip: Set<number> = new Set()) => {
|
||||||
await Promise.all(
|
await saveTreePositions(
|
||||||
[...pins]
|
[...pins].filter(([id]) => !skip.has(id)).map(([id, pin]) => ({ id, x: pin.x, y: pin.y })),
|
||||||
.filter(([id]) => !skip.has(id))
|
|
||||||
.map(([id, pin]) =>
|
|
||||||
fetch(`/api/technologies/${id}`, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ treePosition: pin }),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
|
|
@ -476,7 +498,7 @@ function CanvasInner({ data }: { data: TechTreeData }) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist one or more card positions to `treePosition` (optimistic local
|
* Persist one or more card positions to `treePosition` (optimistic local
|
||||||
* update first, then PATCH each). Used by card drags (a multi-selection
|
* update first, then one bulk write). Used by card drags (a multi-selection
|
||||||
* moves several cards at once) and by category-band drags (every member).
|
* moves several cards at once) and by category-band drags (every member).
|
||||||
*/
|
*/
|
||||||
const persistPositions = React.useCallback(
|
const persistPositions = React.useCallback(
|
||||||
|
|
@ -489,17 +511,12 @@ function CanvasInner({ data }: { data: TechTreeData }) {
|
||||||
return p ? { ...t, treePosition: { x: p.x, y: p.y } } : t;
|
return p ? { ...t, treePosition: { x: p.x, y: p.y } } : t;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await Promise.all(
|
try {
|
||||||
positions.map((p) =>
|
await saveTreePositions(positions);
|
||||||
fetch(`/api/technologies/${p.id}`, {
|
} catch (e) {
|
||||||
method: "PATCH",
|
// The optimistic position stays on screen; a reload shows what is stored.
|
||||||
headers: { "Content-Type": "application/json" },
|
setConnectError(e instanceof Error ? e.message : "Could not save positions.");
|
||||||
body: JSON.stringify({ treePosition: { x: p.x, y: p.y } }),
|
}
|
||||||
}).catch(() => {
|
|
||||||
// Optimistic position stays locally; a reload restores the stored value.
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
@ -599,39 +616,48 @@ function CanvasInner({ data }: { data: TechTreeData }) {
|
||||||
setSaveStatus("Nothing to save.");
|
setSaveStatus("Nothing to save.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (layoutBusy) return;
|
||||||
setTechs((prev) =>
|
setTechs((prev) =>
|
||||||
prev.map((t) => {
|
prev.map((t) => {
|
||||||
const pin = pins.get(t.id);
|
const pin = pins.get(t.id);
|
||||||
return pin ? { ...t, treePosition: pin } : t;
|
return pin ? { ...t, treePosition: pin } : t;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
setLayoutBusy("Saving positions...");
|
||||||
|
setConnectError(null);
|
||||||
try {
|
try {
|
||||||
await persistPins(pins);
|
await persistPins(pins);
|
||||||
setSaveStatus(`Positions saved. ${pins.size} node(s).`);
|
setSaveStatus(`Positions saved. ${pins.size} node(s).`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setSaveStatus("Could not save positions.");
|
setConnectError(e instanceof Error ? e.message : "Could not save positions.");
|
||||||
|
} finally {
|
||||||
|
setLayoutBusy(null);
|
||||||
}
|
}
|
||||||
}, [techs, currentPositions, persistPins]);
|
}, [techs, currentPositions, persistPins, layoutBusy]);
|
||||||
|
|
||||||
const handleAutoArrange = React.useCallback(async () => {
|
const handleAutoArrange = React.useCallback(async () => {
|
||||||
const withPositions = techs.filter((t) => t.treePosition !== null);
|
const pinned = techs.filter((t) => t.treePosition !== null);
|
||||||
if (withPositions.length === 0) return;
|
if (pinned.length === 0 || layoutBusy) return;
|
||||||
setArranging(true);
|
const previous = new Map(pinned.map((t) => [t.id, t.treePosition]));
|
||||||
|
|
||||||
|
// Optimistic: re-lay the canvas out at once so the click visibly does
|
||||||
|
// something, then persist every cleared pin in a single request.
|
||||||
|
setLayoutBusy("Arranging...");
|
||||||
|
setConnectError(null);
|
||||||
|
setTechs((prev) => prev.map((t) => (t.treePosition ? { ...t, treePosition: null } : t)));
|
||||||
try {
|
try {
|
||||||
await Promise.all(
|
await saveTreePositions(pinned.map((t) => ({ id: t.id, x: null, y: null })));
|
||||||
withPositions.map((t) =>
|
setSaveStatus(`Layout reset. ${pinned.length} node(s) re-arranged.`);
|
||||||
fetch(`/api/technologies/${t.id}`, {
|
} catch (e) {
|
||||||
method: "PATCH",
|
// Put the pins back so the canvas matches what is actually stored.
|
||||||
headers: { "Content-Type": "application/json" },
|
setTechs((prev) =>
|
||||||
body: JSON.stringify({ treePosition: { x: null, y: null } }),
|
prev.map((t) => (previous.has(t.id) ? { ...t, treePosition: previous.get(t.id) ?? null } : t)),
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
setTechs((prev) => prev.map((t) => ({ ...t, treePosition: null })));
|
setConnectError(e instanceof Error ? e.message : "Could not reset the layout.");
|
||||||
} finally {
|
} finally {
|
||||||
setArranging(false);
|
setLayoutBusy(null);
|
||||||
}
|
}
|
||||||
}, [techs]);
|
}, [techs, layoutBusy]);
|
||||||
|
|
||||||
const techById = React.useMemo(
|
const techById = React.useMemo(
|
||||||
() => new Map(techs.map((t) => [String(t.id), t])),
|
() => new Map(techs.map((t) => [String(t.id), t])),
|
||||||
|
|
@ -656,7 +682,9 @@ function CanvasInner({ data }: { data: TechTreeData }) {
|
||||||
if (options.freeze) {
|
if (options.freeze) {
|
||||||
const pins = pinsToFreeze(currentPositions(), nextTechs, nextCategories);
|
const pins = pinsToFreeze(currentPositions(), nextTechs, nextCategories);
|
||||||
nextTechs = applyPins(nextTechs, pins);
|
nextTechs = applyPins(nextTechs, pins);
|
||||||
void persistPins(pins);
|
persistPins(pins).catch((e: unknown) =>
|
||||||
|
setConnectError(e instanceof Error ? e.message : "Could not save positions."),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
setTechs(nextTechs);
|
setTechs(nextTechs);
|
||||||
setCategories(nextCategories);
|
setCategories(nextCategories);
|
||||||
|
|
@ -701,6 +729,9 @@ function CanvasInner({ data }: { data: TechTreeData }) {
|
||||||
fitView
|
fitView
|
||||||
fitViewOptions={{ padding: 0.15, maxZoom: 0.85 }}
|
fitViewOptions={{ padding: 0.15, maxZoom: 0.85 }}
|
||||||
minZoom={0.08}
|
minZoom={0.08}
|
||||||
|
// Freeze dragging while a layout write is in flight so a drag cannot
|
||||||
|
// race (and be overwritten by) the bulk save.
|
||||||
|
nodesDraggable={layoutBusy === null}
|
||||||
snapToGrid={snapToGrid}
|
snapToGrid={snapToGrid}
|
||||||
snapGrid={[SNAP_GRID, SNAP_GRID]}
|
snapGrid={[SNAP_GRID, SNAP_GRID]}
|
||||||
nodesConnectable={data.canUpdate}
|
nodesConnectable={data.canUpdate}
|
||||||
|
|
@ -771,7 +802,17 @@ function CanvasInner({ data }: { data: TechTreeData }) {
|
||||||
{connectError}
|
{connectError}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{saveStatus ? (
|
{layoutBusy ? (
|
||||||
|
<span
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className="flex items-center gap-1.5 font-mono text-[10px] tracking-widest text-[#67e8f9] uppercase"
|
||||||
|
data-testid="tech-tree-layout-busy"
|
||||||
|
>
|
||||||
|
<Loader2Icon className="size-3 animate-spin" />
|
||||||
|
{layoutBusy}
|
||||||
|
</span>
|
||||||
|
) : saveStatus ? (
|
||||||
<span
|
<span
|
||||||
className="font-mono text-[10px] tracking-widest text-emerald-400 uppercase"
|
className="font-mono text-[10px] tracking-widest text-emerald-400 uppercase"
|
||||||
data-testid="tech-tree-save-status"
|
data-testid="tech-tree-save-status"
|
||||||
|
|
@ -802,6 +843,7 @@ function CanvasInner({ data }: { data: TechTreeData }) {
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={handleSavePositions}
|
onClick={handleSavePositions}
|
||||||
|
disabled={layoutBusy !== null}
|
||||||
title="Save the current positions of every node so the layout stays the same on your next visit"
|
title="Save the current positions of every node so the layout stays the same on your next visit"
|
||||||
data-testid="tech-tree-save-positions"
|
data-testid="tech-tree-save-positions"
|
||||||
className={TACTICAL_BUTTON}
|
className={TACTICAL_BUTTON}
|
||||||
|
|
@ -815,13 +857,13 @@ function CanvasInner({ data }: { data: TechTreeData }) {
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={handleAutoArrange}
|
onClick={handleAutoArrange}
|
||||||
disabled={arranging}
|
disabled={layoutBusy !== null}
|
||||||
title="Clear manual node positions and restore the automatic layout"
|
title="Clear manual node positions and restore the automatic layout"
|
||||||
data-testid="tech-tree-auto-arrange"
|
data-testid="tech-tree-auto-arrange"
|
||||||
className={TACTICAL_BUTTON}
|
className={TACTICAL_BUTTON}
|
||||||
>
|
>
|
||||||
<LayoutGridIcon className="size-3.5" />
|
<LayoutGridIcon className="size-3.5" />
|
||||||
{arranging ? "Arranging..." : "Auto-arrange"}
|
Auto-arrange
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{data.canCreate ? (
|
{data.canCreate ? (
|
||||||
|
|
|
||||||
81
src/lib/tech-tree/positions.ts
Normal file
81
src/lib/tech-tree/positions.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { sql } from "@payloadcms/db-postgres";
|
||||||
|
import type { Payload } from "payload";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk Tech Tree position writes.
|
||||||
|
*
|
||||||
|
* The canvas used to persist positions with one REST PATCH per technology, all
|
||||||
|
* fired at once. Auto-arrange or Save positions on a 60+ node tree therefore
|
||||||
|
* hit the server with 60+ concurrent full document updates (auth, permission
|
||||||
|
* lookups, populated responses each), which exhausted the database pool and
|
||||||
|
* could take the server down. Every canvas position write now goes through
|
||||||
|
* `writeTreePositions`: one request, one SQL statement.
|
||||||
|
*
|
||||||
|
* Only the `tree_position_x` / `tree_position_y` columns are touched. The
|
||||||
|
* technologies collection has no hooks, so bypassing the Local API loses
|
||||||
|
* nothing; `updated_at` is bumped to keep the documents honest.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Hard cap per request so a malformed client cannot build a giant statement. */
|
||||||
|
export const MAX_POSITION_UPDATES = 2000;
|
||||||
|
|
||||||
|
export type TreePositionUpdate = {
|
||||||
|
id: number;
|
||||||
|
/** Both numbers to pin a node, both null to return it to the automatic layout. */
|
||||||
|
x: number | null;
|
||||||
|
y: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParseResult =
|
||||||
|
| { ok: true; updates: TreePositionUpdate[] }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
/** Validate a `{ positions: [...] }` request body. Pure. */
|
||||||
|
export function parsePositionUpdates(body: unknown): ParseResult {
|
||||||
|
const positions = (body as { positions?: unknown } | null)?.positions;
|
||||||
|
if (!Array.isArray(positions)) return { ok: false, error: "positions must be an array." };
|
||||||
|
if (positions.length > MAX_POSITION_UPDATES) {
|
||||||
|
return { ok: false, error: `At most ${MAX_POSITION_UPDATES} positions per request.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const byId = new Map<number, TreePositionUpdate>();
|
||||||
|
for (const entry of positions) {
|
||||||
|
const { id, x, y } = (entry ?? {}) as { id?: unknown; x?: unknown; y?: unknown };
|
||||||
|
if (typeof id !== "number" || !Number.isInteger(id) || id <= 0) {
|
||||||
|
return { ok: false, error: "Each position needs a positive integer id." };
|
||||||
|
}
|
||||||
|
const cleared = x === null && y === null;
|
||||||
|
const pinned =
|
||||||
|
typeof x === "number" && Number.isFinite(x) && typeof y === "number" && Number.isFinite(y);
|
||||||
|
if (!cleared && !pinned) {
|
||||||
|
return { ok: false, error: `Position for ${id} must have numeric x and y, or both null.` };
|
||||||
|
}
|
||||||
|
// Last write wins for a repeated id.
|
||||||
|
byId.set(
|
||||||
|
id,
|
||||||
|
cleared
|
||||||
|
? { id, x: null, y: null }
|
||||||
|
: { id, x: Math.round(x as number), y: Math.round(y as number) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { ok: true, updates: [...byId.values()] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write every position in a single UPDATE. Returns the number of rows touched. */
|
||||||
|
export async function writeTreePositions(
|
||||||
|
payload: Payload,
|
||||||
|
updates: TreePositionUpdate[],
|
||||||
|
): Promise<number> {
|
||||||
|
if (updates.length === 0) return 0;
|
||||||
|
const values = sql.join(
|
||||||
|
updates.map((u) => sql`(${u.id}::integer, ${u.x}::numeric, ${u.y}::numeric)`),
|
||||||
|
sql`, `,
|
||||||
|
);
|
||||||
|
const result = await payload.db.drizzle.execute(sql`
|
||||||
|
UPDATE technologies AS t
|
||||||
|
SET tree_position_x = v.x, tree_position_y = v.y, updated_at = now()
|
||||||
|
FROM (VALUES ${values}) AS v(id, x, y)
|
||||||
|
WHERE t.id = v.id
|
||||||
|
`);
|
||||||
|
return (result as { rowCount?: number | null }).rowCount ?? 0;
|
||||||
|
}
|
||||||
|
|
@ -238,8 +238,22 @@ test.describe("Tech Tree admin view", () => {
|
||||||
new RegExp(`translate\\(${stored.x}px,\\s*${stored.y}px\\)`),
|
new RegExp(`translate\\(${stored.x}px,\\s*${stored.y}px\\)`),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Auto-arrange clears the manual position.
|
// Auto-arrange clears the manual position with ONE bulk request (never a
|
||||||
|
// PATCH per node) and shows a busy indicator while it saves. The save is
|
||||||
|
// held briefly so the indicator is observable.
|
||||||
|
let perNodePatches = 0;
|
||||||
|
page.on("request", (req) => {
|
||||||
|
if (req.method() === "PATCH" && req.url().includes("/api/technologies/")) perNodePatches += 1;
|
||||||
|
});
|
||||||
|
await page.route("**/api/tech-tree/positions", async (route) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
await page.getByTestId("tech-tree-auto-arrange").click();
|
await page.getByTestId("tech-tree-auto-arrange").click();
|
||||||
|
await expect(page.getByTestId("tech-tree-layout-busy")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("tech-tree-layout-busy")).toHaveCount(0, { timeout: 15_000 });
|
||||||
|
await page.unroute("**/api/tech-tree/positions");
|
||||||
|
expect(perNodePatches).toBe(0);
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
|
|
|
||||||
137
tests/int/tech-tree-positions.int.spec.ts
Normal file
137
tests/int/tech-tree-positions.int.spec.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue