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(); } 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); // 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. await page.getByTestId("tech-tree-auto-arrange").click(); 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("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); }); });