1
0
Fork 0
polaris-task-force/tests/e2e/tech-tree.e2e.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

767 lines
30 KiB
TypeScript

import { expect, test } from "@playwright/test";
/**
* Tech Tree admin view (/admin/tech-tree): custom React Flow view over the
* technologies collection. Covers the Goal's evidence list: nav entry under
* Intelligence, node cards + category bands + AND/OR prerequisite edges,
* preview side panel, edit in the full-screen admin edit view, and canvas
* quick-create (existing + inline-created category) with the new tech visible
* in the tree afterwards.
*/
test.describe("Tech Tree admin view", () => {
let cat1Id: number;
let cat2Id: number;
let alphaId: number;
let betaId: number;
let gammaId: number;
async function login(page: import("@playwright/test").Page) {
await page.goto("/login");
await page.getByLabel("Username").fill("dev");
await page.getByLabel("Password").fill("Test123");
await page.getByRole("button", { name: "Log in" }).click();
await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 60_000 });
}
async function createDoc(page: import("@playwright/test").Page, slug: string, data: Record<string, unknown>) {
const res = await page.request.post(`/api/${slug}`, { data });
expect(res.ok(), `${slug} create failed: ${res.status()} ${await res.text()}`).toBe(true);
const body = await res.json();
return body.doc as { id: number };
}
async function gotoTree(page: import("@playwright/test").Page) {
await page.goto("/admin/tech-tree");
await expect(page.getByTestId("tech-tree-canvas")).toBeVisible();
}
/**
* Delete every technology and category so a test starts from a predictable,
* empty tree. The suite shares one database and only truncates at global
* setup, so without this the drag tests inherit a large tree from earlier
* tests and their targets land off-screen or under the side panel.
*/
async function clearTree(page: import("@playwright/test").Page) {
const techRes = await page.request.get("/api/technologies?limit=0&depth=0");
const techs = ((await techRes.json()).docs ?? []) as { id: number }[];
// Two passes: a prerequisite deleted before its dependents can fail, so a
// second pass removes whatever survived the first.
for (const pass of [0, 1]) {
for (const t of techs) await page.request.delete(`/api/technologies/${t.id}`);
void pass;
}
const catRes = await page.request.get("/api/tech-categories?limit=0&depth=0");
const cats = ((await catRes.json()).docs ?? []) as { id: number }[];
for (const c of cats) await page.request.delete(`/api/tech-categories/${c.id}`);
}
test("nav entry, tree render, preview panel, edit navigation, quick-create", async ({
page,
}) => {
await login(page);
// Seed categories + technologies through the REST API (cookie-authed).
const cat1 = await createDoc(page, "tech-categories", {
name: "Tier 1 (E2E)",
position: 1,
});
cat1Id = cat1.id;
const cat2 = await createDoc(page, "tech-categories", {
name: "Tier 2 (E2E)",
position: 2,
});
cat2Id = cat2.id;
const costs = { minimumResearchDuration: 1, maximumResearchDuration: 10 };
const alpha = await createDoc(page, "technologies", {
name: "Tech Tree Seed Alpha",
type: "upgrade",
summary: "Uncategorized seed technology.",
category: null,
researchCosts: costs,
});
alphaId = alpha.id;
const beta = await createDoc(page, "technologies", {
name: "Tech Tree Seed Beta",
type: "asset",
summary: "Tier 1 seed asset.",
category: cat1Id,
researchCosts: costs,
});
betaId = beta.id;
const gamma = await createDoc(page, "technologies", {
name: "Tech Tree Seed Gamma",
type: "upgrade",
summary: "Tier 2 seed with an AND prerequisite.",
category: cat2Id,
requirements: {
prerequisiteTechnologies: [{ requiresAnd: { technology: [betaId] } }],
},
researchCosts: costs,
});
gammaId = gamma.id;
await gotoTree(page);
// Nav entry under Intelligence, next to the technologies collection link.
const techTreeNav = page.locator("#nav-tech-tree");
await expect(techTreeNav).toHaveText("Tech Tree");
// It sits inside the same group as the Technologies collection link.
await expect(techTreeNav.locator("xpath=ancestor::*[@class][1]//a[@id='nav-technologies']")).toBeVisible();
// Nodes, category bands, and the AND prerequisite edge render.
await expect(page.getByTestId(`tech-node-${alphaId}`)).toBeVisible();
await expect(page.getByTestId(`tech-node-${betaId}`)).toBeVisible();
await expect(page.getByTestId(`tech-node-${gammaId}`)).toBeVisible();
await expect(page.getByTestId("tech-band-uncategorized")).toContainText("Uncategorized");
await expect(page.getByTestId(`tech-band-cat-${cat1Id}`)).toContainText("Tier 1 (E2E)");
await expect(page.getByTestId(`tech-band-cat-${cat2Id}`)).toContainText("Tier 2 (E2E)");
await expect(
page.locator(`.react-flow__edge[data-id="and-${betaId}-${gammaId}"]`),
).toBeAttached();
// Preview panel: click the Gamma node.
await page.getByTestId(`tech-node-${gammaId}`).click();
const panel = page.getByTestId("tech-preview-panel");
await expect(panel).toBeVisible();
await expect(panel).toContainText("Tech Tree Seed Gamma");
await expect(panel).toContainText("Tier 2 (E2E)");
await expect(panel).toContainText("Tree Seed Beta");
await expect(panel).toContainText("TECH DOSSIER");
// Edit opens the full-screen admin edit view for that document.
await page.getByTestId("tech-preview-edit").click();
await page.waitForURL(`**/admin/collections/technologies/${gammaId}`, { timeout: 60_000 });
await expect(page.locator("#field-name")).toHaveValue("Tech Tree Seed Gamma");
// Quick-create with an EXISTING category: node must appear in the tree.
await gotoTree(page);
await page.getByTestId("tech-tree-create").click();
const dialog = page.getByTestId("tech-tree-create-dialog");
await expect(dialog).toBeVisible();
await page.getByTestId("tech-create-name").fill("Tech Tree Seed Delta");
await page.getByTestId("tech-create-summary").fill("Quick-created with an existing category.");
await page.getByTestId("tech-category-input").fill("Tier 1");
await page.getByRole("button", { name: /Tier 1 \(E2E\)/ }).click();
await expect(page.getByTestId("tech-category-value")).toContainText("Tier 1 (E2E)");
await page.getByTestId("tech-create-submit").click();
await page.waitForURL(/\/admin\/collections\/technologies\/\d+/, { timeout: 60_000 });
await gotoTree(page);
const deltaDocs = await page.request.get(
"/api/technologies?where[name][equals]=Tech Tree Seed Delta&limit=1",
);
const deltaBody = await deltaDocs.json();
const deltaId = deltaBody.docs?.[0]?.id as number;
expect(deltaId).toBeTruthy();
await expect(page.getByTestId(`tech-node-${deltaId}`)).toBeVisible();
// Quick-create with an INLINE-CREATED category ("enter values").
await page.getByTestId("tech-tree-create").click();
await expect(dialog).toBeVisible();
await page.getByTestId("tech-create-name").fill("Tech Tree Seed Epsilon");
await page.getByTestId("tech-create-summary").fill("Quick-created with a new inline category.");
await page.getByTestId("tech-category-input").fill("Tier 3 (E2E)");
await page.getByTestId("tech-category-create-option").click();
await page.getByTestId("tech-create-submit").click();
await page.waitForURL(/\/admin\/collections\/technologies\/\d+/, { timeout: 60_000 });
// The new category exists and its band contains the new tech.
const newCatRes = await page.request.get(
"/api/tech-categories?where[name][equals]=Tier 3 (E2E)&limit=1",
);
const newCatBody = await newCatRes.json();
const newCatId = newCatBody.docs?.[0]?.id as number;
expect(newCatId).toBeTruthy();
await gotoTree(page);
await expect(page.getByTestId(`tech-band-cat-${newCatId}`)).toContainText("Tier 3 (E2E)");
const epsilonRes = await page.request.get(
"/api/technologies?where[name][equals]=Tech Tree Seed Epsilon&limit=1",
);
const epsilonBody = await epsilonRes.json();
const epsilonId = epsilonBody.docs?.[0]?.id as number;
expect(epsilonId).toBeTruthy();
await expect(page.getByTestId(`tech-node-${epsilonId}`)).toBeVisible();
});
test("nodes drag to a persisted position and Auto-arrange restores the layout", async ({
page,
}) => {
await login(page);
const costs = { minimumResearchDuration: 1, maximumResearchDuration: 10 };
const draggable = await createDoc(page, "technologies", {
name: "Tech Tree Drag Target",
type: "upgrade",
summary: "Dragged around by the e2e spec.",
category: null,
researchCosts: costs,
});
await gotoTree(page);
const node = page.getByTestId(`tech-node-${draggable.id}`);
await expect(node).toBeVisible();
// Drag the node with real pointer events.
const before = (await node.boundingBox())!;
await page.mouse.move(before.x + before.width / 2, before.y + before.height / 2);
await page.mouse.down();
await page.mouse.move(before.x + before.width / 2 + 240, before.y + before.height / 2 + 160, {
steps: 12,
});
await page.mouse.up();
// The position is persisted to the document.
await expect
.poll(
async () => {
const res = await page.request.get(`/api/technologies/${draggable.id}`);
const body = await res.json();
return body?.treePosition?.x ?? null;
},
{ timeout: 15_000 },
)
.not.toBeNull();
const storedRes = await page.request.get(`/api/technologies/${draggable.id}`);
const stored = (await storedRes.json()).treePosition as { x: number; y: number };
expect(stored.y).toBeGreaterThan(0);
// Snap-to-grid is on by default, so the stored position lands on the 46px
// grid (which divides the layout steps, keeping cards aligned).
expect(stored.x % 46).toBe(0);
expect(stored.y % 46).toBe(0);
// A reload renders the node at the stored coordinates.
await gotoTree(page);
const wrapper = page.locator(`.react-flow__node[data-id="${draggable.id}"]`);
await expect(wrapper).toHaveAttribute(
"style",
new RegExp(`translate\\(${stored.x}px,\\s*${stored.y}px\\)`),
);
// 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 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
.poll(
async () => {
const res = await page.request.get(`/api/technologies/${draggable.id}`);
const body = await res.json();
return body?.treePosition?.x ?? null;
},
{ timeout: 15_000 },
)
.toBeNull();
await expect(page.getByTestId("tech-tree-auto-arrange")).toHaveCount(0);
});
test("save positions commits every node so the layout survives a reload", async ({
page,
}) => {
await login(page);
const costs = { minimumResearchDuration: 1, maximumResearchDuration: 10 };
const a = await createDoc(page, "technologies", {
name: "Tech Tree Save A",
type: "upgrade",
summary: "Save target A.",
category: null,
researchCosts: costs,
});
const b = await createDoc(page, "technologies", {
name: "Tech Tree Save B",
type: "upgrade",
summary: "Save target B.",
category: null,
researchCosts: costs,
});
const c = await createDoc(page, "technologies", {
name: "Tech Tree Save C",
type: "upgrade",
summary: "Save target C.",
category: null,
researchCosts: costs,
});
await gotoTree(page);
const ids = [a.id, b.id, c.id];
for (const id of ids) {
await expect(page.getByTestId(`tech-node-${id}`)).toBeVisible();
}
// Record where every node currently sits.
const transform = (id: number) =>
page.locator(`.react-flow__node[data-id="${id}"]`).evaluate((el) => {
const m = (el as HTMLElement).style.transform.match(
/translate\(([-\d.]+)px,\s*([-\d.]+)px\)/,
);
return m ? { x: parseFloat(m[1]), y: parseFloat(m[2]) } : null;
});
const before = new Map<number, { x: number; y: number }>();
for (const id of ids) before.set(id, (await transform(id))!);
// Commit every node's current position.
await page.getByTestId("tech-tree-save-positions").click();
await expect(page.getByTestId("tech-tree-save-status")).toContainText("saved");
// Each node's treePosition is now persisted.
for (const id of ids) {
await expect
.poll(
async () => {
const res = await page.request.get(`/api/technologies/${id}`);
return (await res.json())?.treePosition?.x ?? null;
},
{ timeout: 15_000 },
)
.not.toBeNull();
}
// A reload keeps every node in its saved spot (nothing auto-resets).
await gotoTree(page);
for (const id of ids) {
await expect.poll(() => transform(id), { timeout: 15_000 }).toEqual(before.get(id));
}
// Positions were saved, so Auto-arrange is now offered.
await expect(page.getByTestId("tech-tree-auto-arrange")).toBeVisible();
// The e2e suite shares one database (only truncated at global setup), so
// this test cleans up its seeded nodes. Leaving them behind, with their
// pinned positions, makes the connections test that follows trip up.
for (const id of ids) {
const del = await page.request.delete(`/api/technologies/${id}`);
expect(del.ok(), `cleanup delete ${id} failed: ${del.status()}`).toBe(true);
}
});
test("dragging a multi-selection persists every moved node", async ({ page }) => {
await login(page);
await clearTree(page);
const costs = { minimumResearchDuration: 1, maximumResearchDuration: 10 };
const a = await createDoc(page, "technologies", {
name: "Tech Tree Multi A",
type: "upgrade",
summary: "Multi-drag A.",
category: null,
researchCosts: costs,
});
const b = await createDoc(page, "technologies", {
name: "Tech Tree Multi B",
type: "upgrade",
summary: "Multi-drag B.",
category: null,
researchCosts: costs,
});
await gotoTree(page);
const nodeA = page.getByTestId(`tech-node-${a.id}`);
const nodeB = page.getByTestId(`tech-node-${b.id}`);
await expect(nodeA).toBeVisible();
await expect(nodeB).toBeVisible();
const transform = (id: number) =>
page.locator(`.react-flow__node[data-id="${id}"]`).evaluate((el) => {
const m = (el as HTMLElement).style.transform.match(
/translate\(([-\d.]+)px,\s*([-\d.]+)px\)/,
);
return m ? { x: parseFloat(m[1]), y: parseFloat(m[2]) } : null;
});
const beforeA = (await transform(a.id))!;
const beforeB = (await transform(b.id))!;
// Multi-select with the platform multi-select key (Control on Linux).
await nodeA.click({ modifiers: ["Control"] });
await nodeB.click({ modifiers: ["Control"] });
await expect(page.locator(".react-flow__node.selected")).toHaveCount(2);
// Drag one selected node; React Flow moves the whole selection.
const boxA = (await nodeA.boundingBox())!;
await page.mouse.move(boxA.x + boxA.width / 2, boxA.y + boxA.height / 2);
await page.mouse.down();
await page.mouse.move(boxA.x + boxA.width / 2 + 220, boxA.y + boxA.height / 2 + 140, {
steps: 15,
});
await page.mouse.up();
// Both cards moved on screen...
await expect.poll(async () => (await transform(a.id))!.x, { timeout: 10_000 }).not.toBe(beforeA.x);
await expect.poll(async () => (await transform(b.id))!.x, { timeout: 10_000 }).not.toBe(beforeB.x);
// ...and both persisted (the bug was that only the node under the pointer did).
for (const id of [a.id, b.id]) {
await expect
.poll(
async () => {
const res = await page.request.get(`/api/technologies/${id}?depth=0`);
return (await res.json())?.treePosition?.x ?? null;
},
{ timeout: 15_000 },
)
.not.toBeNull();
}
for (const id of [a.id, b.id]) {
const del = await page.request.delete(`/api/technologies/${id}`);
expect(del.ok()).toBe(true);
}
});
test("dragging a category header moves all of its technologies", async ({ page }) => {
await login(page);
await clearTree(page);
const costs = { minimumResearchDuration: 1, maximumResearchDuration: 10 };
const cat = await createDoc(page, "tech-categories", { name: "Band Drag (E2E)", position: 9 });
const a = await createDoc(page, "technologies", {
name: "Tech Tree Band A",
type: "upgrade",
summary: "Band-dragged A.",
category: cat.id,
researchCosts: costs,
});
const b = await createDoc(page, "technologies", {
name: "Tech Tree Band B",
type: "upgrade",
summary: "Band-dragged B.",
category: cat.id,
researchCosts: costs,
});
await gotoTree(page);
await expect(page.getByTestId(`tech-node-${a.id}`)).toBeVisible();
await expect(page.getByTestId(`tech-node-${b.id}`)).toBeVisible();
const transform = (id: number) =>
page.locator(`.react-flow__node[data-id="${id}"]`).evaluate((el) => {
const m = (el as HTMLElement).style.transform.match(
/translate\(([-\d.]+)px,\s*([-\d.]+)px\)/,
);
return m ? { x: parseFloat(m[1]), y: parseFloat(m[2]) } : null;
});
const beforeA = (await transform(a.id))!;
const beforeB = (await transform(b.id))!;
// Drag the band's header (its drag handle); the whole category moves with it.
const header = page.locator(
`.react-flow__node[data-id="cat-${cat.id}"] .tech-band-drag-handle`,
);
await expect(header).toBeVisible();
const box = (await header.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 180, box.y + box.height / 2 + 120, {
steps: 15,
});
await page.mouse.up();
// Both member cards shifted by (about) the same delta.
await expect
.poll(async () => (await transform(a.id))!.x - beforeA.x, { timeout: 10_000 })
.toBeGreaterThan(50);
await expect
.poll(async () => (await transform(b.id))!.x - beforeB.x, { timeout: 10_000 })
.toBeGreaterThan(50);
const afterA = (await transform(a.id))!;
const afterB = (await transform(b.id))!;
expect(Math.abs(afterA.x - beforeA.x - (afterB.x - beforeB.x))).toBeLessThanOrEqual(2);
// Positions persisted.
for (const id of [a.id, b.id]) {
await expect
.poll(
async () => {
const res = await page.request.get(`/api/technologies/${id}?depth=0`);
return (await res.json())?.treePosition?.x ?? null;
},
{ timeout: 15_000 },
)
.not.toBeNull();
}
for (const id of [a.id, b.id]) {
await page.request.delete(`/api/technologies/${id}`);
}
const delCat = await page.request.delete(`/api/tech-categories/${cat.id}`);
expect(delCat.ok()).toBe(true);
});
test("adjusting a connection line adds a draggable bend that persists", async ({ page }) => {
await login(page);
await clearTree(page);
const costs = { minimumResearchDuration: 1, maximumResearchDuration: 10 };
const a = await createDoc(page, "technologies", {
name: "Tech Tree Route A",
type: "upgrade",
summary: "Route source.",
category: null,
researchCosts: costs,
});
const b = await createDoc(page, "technologies", {
name: "Tech Tree Route B",
type: "upgrade",
summary: "Route target.",
category: null,
requirements: { prerequisiteTechnologies: [{ requiresAnd: { technology: [a.id] } }] },
researchCosts: costs,
});
await gotoTree(page);
const edgeId = `and-${a.id}-${b.id}`;
const edgePath = () =>
page
.locator(`.react-flow__edge[data-id="${edgeId}"] path.react-flow__edge-path`)
.getAttribute("d");
const before = await edgePath();
// Select the edge (click its path) to reveal the "+" bend handles.
const point = await page.evaluate((id) => {
const p = document.querySelector(
`.react-flow__edge[data-id="${id}"] path.react-flow__edge-path`,
) as SVGPathElement | null;
if (!p) return null;
const local = p.getPointAtLength(p.getTotalLength() / 2);
const svg = p.ownerSVGElement!;
const pt = svg.createSVGPoint();
pt.x = local.x;
pt.y = local.y;
const screen = pt.matrixTransform(p.getScreenCTM()!);
return { x: screen.x, y: screen.y };
}, edgeId);
expect(point).not.toBeNull();
await page.mouse.click(point!.x, point!.y);
await expect(page.locator(`.react-flow__edge[data-id="${edgeId}"].selected`)).toHaveCount(1);
// Add a bend, then drag it to reroute the line.
await page.getByTestId("tech-edge-add-bend").first().click();
await expect(page.getByTestId("tech-edge-waypoint")).toHaveCount(1);
const wp = page.getByTestId("tech-edge-waypoint").first();
const box = (await wp.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 120, box.y + box.height / 2 - 90, { steps: 10 });
await page.mouse.up();
await expect.poll(edgePath, { timeout: 10_000 }).not.toBe(before);
// The route is stored for this browser and survives a reload.
await gotoTree(page);
await expect(page.getByTestId("tech-edge-waypoint")).toHaveCount(1);
for (const id of [a.id, b.id]) await page.request.delete(`/api/technologies/${id}`);
});
test("connections and the lite editor persist through the REST API", async ({ page }) => {
await login(page);
const costs = { minimumResearchDuration: 1, maximumResearchDuration: 10 };
const alpha = await createDoc(page, "technologies", {
name: "Tech Tree Link Alpha",
type: "upgrade",
summary: "Link source.",
category: null,
researchCosts: costs,
});
const beta = await createDoc(page, "technologies", {
name: "Tech Tree Link Beta",
type: "upgrade",
summary: "Link target.",
category: null,
researchCosts: costs,
});
await gotoTree(page);
await expect(page.getByTestId(`tech-node-${alpha.id}`)).toBeVisible();
// Drag from Alpha's source handle onto Beta to create an AND prerequisite.
const sourceHandle = page.locator(
`.react-flow__node[data-id="${alpha.id}"] .react-flow__handle-right`,
);
const targetNode = page.getByTestId(`tech-node-${beta.id}`);
const targetTransformBeforeConnect = await page
.locator(`.react-flow__node[data-id="${beta.id}"]`)
.evaluate((el) => (el as HTMLElement).style.transform);
const handleBox = (await sourceHandle.boundingBox())!;
const targetBox = (await targetNode.boundingBox())!;
await page.mouse.move(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2);
await page.mouse.down();
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, {
steps: 20,
});
await page.mouse.up();
const edgeId = `and-${alpha.id}-${beta.id}`;
await expect(page.locator(`.react-flow__edge[data-id="${edgeId}"]`)).toBeAttached();
await expect
.poll(
async () => {
const res = await page.request.get(`/api/technologies/${beta.id}?depth=0`);
const body = await res.json();
const rows = body?.requirements?.prerequisiteTechnologies ?? [];
const ids = rows.flatMap((row: { requiresAnd?: { technology?: unknown[] } }) =>
(row.requiresAnd?.technology ?? []).map((t) => (typeof t === "number" ? t : (t as { id: number }).id)),
);
return ids;
},
{ timeout: 15_000 },
)
.toContain(alpha.id);
// UX contract: the connected node must NOT jump. Its on-canvas position is
// unchanged (pinned), and the pin is persisted so a reload keeps it.
const wrapperSel = `.react-flow__node[data-id="${beta.id}"]`;
const positionAfterConnect = await page
.locator(wrapperSel)
.evaluate((el) => (el as HTMLElement).style.transform);
expect(positionAfterConnect).toBe(targetTransformBeforeConnect);
await expect
.poll(
async () => {
const res = await page.request.get(`/api/technologies/${beta.id}?depth=0`);
const body = await res.json();
return body?.treePosition?.x ?? null;
},
{ timeout: 15_000 },
)
.not.toBeNull();
// Auto-arrange clears the pin and moves it to the ordered position
// (one column right of its prerequisite).
await page.getByTestId("tech-tree-auto-arrange").click();
await expect
.poll(
async () => {
const res = await page.request.get(`/api/technologies/${beta.id}?depth=0`);
return (await res.json())?.treePosition?.x ?? null;
},
{ timeout: 15_000 },
)
.toBeNull();
const parentX = await page
.locator(`.react-flow__node[data-id="${alpha.id}"]`)
.evaluate((el) => (el as HTMLElement).style.transform.match(/translate\((-?[\d.]+)px/)![1]);
await expect
.poll(
async () =>
page
.locator(wrapperSel)
.evaluate((el) => (el as HTMLElement).style.transform.match(/translate\((-?[\d.]+)px/)![1]),
{ timeout: 15_000 },
)
.not.toBe(parentX);
// Removing the edge (select + Backspace) drops the prerequisite. React Flow
// hit-tests edges through its wide invisible interaction path.
const edge = page.locator(`.react-flow__edge[data-id="${edgeId}"]`);
const onPath = await page.evaluate((id) => {
const pathEl = document.querySelector(
`.react-flow__edge[data-id="${id}"] path.react-flow__edge-path`,
) as SVGPathElement | null;
if (!pathEl) return null;
const local = pathEl.getPointAtLength(pathEl.getTotalLength() / 2);
const svg = pathEl.ownerSVGElement!;
const pt = svg.createSVGPoint();
pt.x = local.x;
pt.y = local.y;
const screen = pt.matrixTransform(pathEl.getScreenCTM()!);
return { x: screen.x, y: screen.y };
}, edgeId);
expect(onPath).not.toBeNull();
await page.mouse.click(onPath!.x, onPath!.y);
await expect(edge).toHaveClass(/selected/);
await page.keyboard.press("Backspace");
await expect(page.locator(`.react-flow__edge[data-id="${edgeId}"]`)).toHaveCount(0);
await expect
.poll(
async () => {
const res = await page.request.get(`/api/technologies/${beta.id}?depth=0`);
const body = await res.json();
const rows = body?.requirements?.prerequisiteTechnologies ?? [];
const ids = rows.flatMap((row: { requiresAnd?: { technology?: unknown[] } }) =>
(row.requiresAnd?.technology ?? []).map((t) => (typeof t === "number" ? t : (t as { id: number }).id)),
);
return ids;
},
{ timeout: 15_000 },
)
.not.toContain(alpha.id);
// The lite panel edits and saves fields.
await page.getByTestId(`tech-node-${beta.id}`).click();
const panel = page.getByTestId("tech-preview-panel");
await expect(panel).toBeVisible();
await page.getByTestId("tech-panel-summary").fill("Edited from the lite panel.");
await page.getByTestId("tech-panel-save").click();
await expect
.poll(
async () => {
const res = await page.request.get(`/api/technologies/${beta.id}`);
return (await res.json())?.summary ?? null;
},
{ timeout: 15_000 },
)
.toBe("Edited from the lite panel.");
// The panel also edits the name, reflected on the node card.
await page.getByTestId("tech-panel-name").fill("Tech Tree Link Beta Renamed");
await page.getByTestId("tech-panel-save").click();
await expect
.poll(
async () => {
const res = await page.request.get(`/api/technologies/${beta.id}`);
return (await res.json())?.name ?? null;
},
{ timeout: 15_000 },
)
.toBe("Tech Tree Link Beta Renamed");
await expect(page.getByTestId(`tech-node-${beta.id}`)).toContainText(
"Tech Tree Link Beta Renamed",
);
});
test("a large uncategorized pool wraps into multiple columns instead of one strip", async ({
page,
}) => {
await login(page);
const costs = { minimumResearchDuration: 1, maximumResearchDuration: 10 };
const ids: number[] = [];
for (let i = 0; i < 12; i += 1) {
const doc = await createDoc(page, "technologies", {
name: `Tech Tree Wrap Seed ${String(i).padStart(2, "0")}`,
type: "upgrade",
summary: "Uncategorized wrapping regression seed.",
category: null,
researchCosts: costs,
});
ids.push(doc.id);
}
await gotoTree(page);
await expect(page.getByTestId(`tech-node-${ids[0]}`)).toBeVisible();
// 12 uncategorized techs exceed MAX_ROWS_PER_COLUMN (10), so at least two
// distinct x offsets must exist (previously: one 1700px-tall column).
const xs = new Set<string>();
for (const id of ids) {
const style = (await page.locator(`.react-flow__node[data-id="${id}"]`).getAttribute("style")) ?? "";
const match = style.match(/translate\((-?[\d.]+)px/);
if (match) xs.add(match[1]);
}
expect(xs.size).toBeGreaterThanOrEqual(2);
});
});