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) { 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(); 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(); 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); }); });