From 5f7fd508a5e43d4cc5d3ee4205fbdb45cdfc3033 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Thu, 1 Oct 2026 10:04:07 -0400 Subject: [PATCH] test(tech-tree): add integration and e2e coverage - `tech-tree-layout.int.spec.ts`: pure layout (wrapping, manual overrides, category bands, forward-edge warnings, cycles, catalog-stability). - `tech-tree-prereq-edits.int.spec.ts`: add/remove/switch prerequisites with cycle rejection and the pin-on-connect stability contract. - `tech-tree-stability.int.spec.ts`: freeze pins every relocated card on a data change, plus edge visuals (per-path colors, per-source lanes). - `tech-categories.int.spec.ts`: CRUD, RBAC, division gating, FK set-null, position round-trip. - `tech-tree.e2e.spec.ts`: nav entry, render, preview/panel edits, both quick-create paths, drag persistence + Auto-arrange, save positions surviving a reload, connect/disconnect, and multi-column wrapping. --- tests/e2e/tech-tree.e2e.spec.ts | 512 +++++++++++++++++++ tests/int/tech-categories.int.spec.ts | 400 +++++++++++++++ tests/int/tech-tree-layout.int.spec.ts | 245 +++++++++ tests/int/tech-tree-prereq-edits.int.spec.ts | 110 ++++ tests/int/tech-tree-stability.int.spec.ts | 121 +++++ 5 files changed, 1388 insertions(+) create mode 100644 tests/e2e/tech-tree.e2e.spec.ts create mode 100644 tests/int/tech-categories.int.spec.ts create mode 100644 tests/int/tech-tree-layout.int.spec.ts create mode 100644 tests/int/tech-tree-prereq-edits.int.spec.ts create mode 100644 tests/int/tech-tree-stability.int.spec.ts diff --git a/tests/e2e/tech-tree.e2e.spec.ts b/tests/e2e/tech-tree.e2e.spec.ts new file mode 100644 index 0000000..b5a306d --- /dev/null +++ b/tests/e2e/tech-tree.e2e.spec.ts @@ -0,0 +1,512 @@ +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); + }); +}); diff --git a/tests/int/tech-categories.int.spec.ts b/tests/int/tech-categories.int.spec.ts new file mode 100644 index 0000000..b235b4c --- /dev/null +++ b/tests/int/tech-categories.int.spec.ts @@ -0,0 +1,400 @@ +import { getPayload, Payload } from "payload"; +import config from "@/payload.config"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import type { Role, TechCategory, Technology, User } from "@/payload-types"; +import { scopedAdminPageAccess } from "@/utils/access-control/divisionAccess"; +import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions"; + +let payload: Payload; + +const RUN = `techcat-${Date.now().toString(36)}`; +const TIMEOUT = 30_000; + +describe("Tech Categories (collection + RBAC + tech tree data model)", () => { + const roleIds: number[] = []; + const userIds: number[] = []; + const categoryIds: number[] = []; + const technologyIds: number[] = []; + const createdQualificationIds: number[] = []; + + let plainUser: User; + let intelUser: User; + let superUser: User; + let categoryManagerUser: User; + + const makeRole = async (label: string, extra: Partial = {}): Promise => { + const role = (await payload.create({ + collection: "roles", + data: { name: `${RUN}-${label}`, slug: `${RUN}-${label}`, ...extra }, + overrideAccess: true, + depth: 0, + })) as unknown as Role; + roleIds.push(role.id); + return role; + }; + + const makeUser = async (label: string, roleId: number): Promise => { + const user = (await payload.create({ + collection: "users", + data: { + username: `${RUN}-${label}`, + discordUsername: `${RUN}-${label}`, + displayName: label.toUpperCase(), + steamId: `7656119${Math.floor(Math.random() * 1e9)}`, + password: "Test123", + roleDocs: [roleId], + }, + overrideAccess: true, + depth: 0, + })) as unknown as User; + userIds.push(user.id); + return user; + }; + + const findOrCreateQualification = async (name: string): Promise => { + const found = (await payload.find({ + collection: "qualifications", + where: { name: { equals: name } }, + limit: 1, + depth: 0, + overrideAccess: true, + })) as unknown as { docs: Array<{ id: number }> }; + if (found.docs.length > 0) return found.docs[0].id; + const created = (await payload.create({ + collection: "qualifications", + data: { name }, + overrideAccess: true, + depth: 0, + })) as unknown as { id: number }; + createdQualificationIds.push(created.id); + return created.id; + }; + + const grantQualification = async (user: User, qualificationId: number) => { + const profile = (await payload.find({ + collection: "profiles", + where: { user: { equals: user.id } }, + limit: 1, + depth: 0, + overrideAccess: true, + })) as unknown as { docs: Array<{ id: number }> }; + expect(profile.docs.length).toBeGreaterThan(0); + await payload.update({ + collection: "profiles", + id: profile.docs[0].id, + data: { progression: { qualifications: [qualificationId] } }, + overrideAccess: true, + depth: 0, + }); + }; + + const expectAccessDenied = async (fn: () => Promise) => { + try { + await fn(); + } catch (e) { + const name = (e as { name?: string })?.name ?? ""; + const message = e instanceof Error ? e.message : ""; + expect( + name === "AccessError" || + name === "Forbidden" || + /not permitted|not allowed|access denied/i.test(message), + ).toBe(true); + return; + } + throw new Error("Expected operation to be denied"); + }; + + beforeAll(async () => { + const payloadConfig = await config; + payload = await getPayload({ config: payloadConfig }); + invalidatePermissionCache(); + + const bareRole = await makeRole("bare", { permissions: [] }); + const superRole = await makeRole("super", { isSuperuser: true }); + const managerRole = await makeRole("techcat-manager", { + permissions: [ + "tech-categories:create", + "tech-categories:read", + "tech-categories:update", + "tech-categories:delete", + ], + }); + + plainUser = await makeUser("plain", bareRole.id); + intelUser = await makeUser("intel", bareRole.id); + superUser = await makeUser("super", superRole.id); + categoryManagerUser = await makeUser("manager", managerRole.id); + + const intelligenceId = await findOrCreateQualification("Intelligence"); + await grantQualification(intelUser, intelligenceId); + }, TIMEOUT); + + afterAll(async () => { + if (!payload) return; + for (const id of technologyIds) { + await payload.delete({ collection: "technologies", id, overrideAccess: true }).catch(() => {}); + } + for (const id of categoryIds) { + await payload + .delete({ collection: "tech-categories", id, overrideAccess: true }) + .catch(() => {}); + } + for (const id of userIds) { + const profiles = await payload + .find({ + collection: "profiles", + where: { user: { equals: id } }, + limit: 5, + depth: 0, + overrideAccess: true, + }) + .catch(() => null); + for (const p of profiles?.docs ?? []) { + await payload.delete({ collection: "profiles", id: p.id, overrideAccess: true }).catch(() => {}); + } + await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {}); + } + for (const id of roleIds) { + await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {}); + } + for (const id of createdQualificationIds) { + await payload + .delete({ collection: "qualifications", id, overrideAccess: true }) + .catch(() => {}); + } + }); + + describe("CRUD (overrideAccess sanity)", () => { + it("creates a category with name, position, and optional hex color", async () => { + const category = (await payload.create({ + collection: "tech-categories", + data: { name: `${RUN}-Tier 1`, position: 1, color: "#22d3ee" }, + overrideAccess: true, + depth: 0, + })) as unknown as TechCategory; + expect(category.id).toBeGreaterThan(0); + expect(category.position).toBe(1); + expect(category.color).toBe("#22d3ee"); + categoryIds.push(category.id); + }, TIMEOUT); + + it("rejects duplicate names (unique)", async () => { + await expect( + payload.create({ + collection: "tech-categories", + data: { name: `${RUN}-Tier 1`, position: 2 }, + overrideAccess: true, + depth: 0, + }), + ).rejects.toThrow(); + }, TIMEOUT); + + it("rejects non-hex color values", async () => { + await expect( + payload.create({ + collection: "tech-categories", + data: { name: `${RUN}-bad-color`, position: 3, color: "not-a-color" }, + overrideAccess: true, + depth: 0, + }), + ).rejects.toThrow(); + }, TIMEOUT); + }); + + describe("permission gating", () => { + it("plain users cannot create or read categories (no permission, no qualification)", async () => { + await expectAccessDenied(() => + payload.create({ + collection: "tech-categories", + data: { name: `${RUN}-plain`, position: 1 }, + user: plainUser, + overrideAccess: false, + depth: 0, + }), + ); + await expectAccessDenied(() => + payload.find({ + collection: "tech-categories", + user: plainUser, + overrideAccess: false, + depth: 0, + }), + ); + }, TIMEOUT); + + it("intelligence-qualified users create and read via the division bypass", async () => { + const created = (await payload.create({ + collection: "tech-categories", + data: { name: `${RUN}-intel`, position: 2 }, + user: intelUser, + overrideAccess: false, + depth: 0, + })) as unknown as TechCategory; + categoryIds.push(created.id); + expect(created.id).toBeGreaterThan(0); + + const found = (await payload.find({ + collection: "tech-categories", + user: intelUser, + overrideAccess: false, + depth: 0, + })) as unknown as { docs: Array<{ id: number }> }; + expect(found.docs.some((d) => d.id === created.id)).toBe(true); + }, TIMEOUT); + + it("users holding explicit tech-categories permissions pass without a qualification", async () => { + const created = (await payload.create({ + collection: "tech-categories", + data: { name: `${RUN}-manager`, position: 3 }, + user: categoryManagerUser, + overrideAccess: false, + depth: 0, + })) as unknown as TechCategory; + categoryIds.push(created.id); + expect(created.id).toBeGreaterThan(0); + }, TIMEOUT); + + it("superusers always pass", async () => { + const created = (await payload.create({ + collection: "tech-categories", + data: { name: `${RUN}-super`, position: 4 }, + user: superUser, + overrideAccess: false, + depth: 0, + })) as unknown as TechCategory; + categoryIds.push(created.id); + expect(created.id).toBeGreaterThan(0); + }, TIMEOUT); + + it("the tech-categories admin page follows the intelligence division like technologies", async () => { + // scopedAdminPageAccess consults DIVISION_ADMIN_QUALIFICATIONS; assert + // the BEHAVIOR: an intelligence-qualified user passes an admin-panel + // request for tech-categories, a plain user does not, superusers pass. + const decision = async (user: User | null): Promise => { + const fn = scopedAdminPageAccess("tech-categories"); + return Boolean( + await (fn as (args: { req: unknown }) => Promise)({ + req: { user, payload, pathname: "/admin/collections/tech-categories" }, + }), + ); + }; + expect(await decision(intelUser)).toBe(true); + expect(await decision(plainUser)).toBe(false); + expect(await decision(superUser)).toBe(true); + expect(await decision(null)).toBe(false); + }, TIMEOUT); + }); + + describe("technologies category relationship", () => { + it("assigns a category to a technology and reads it back", async () => { + const category = (await payload.create({ + collection: "tech-categories", + data: { name: `${RUN}-Tier 2`, position: 2 }, + overrideAccess: true, + depth: 0, + })) as unknown as TechCategory; + categoryIds.push(category.id); + + const tech = (await payload.create({ + collection: "technologies", + data: { + name: `${RUN}-tech`, + type: "upgrade", + summary: "Test technology for the tree.", + category: category.id, + approvalStatus: "in_progress", + researchCosts: { minimumResearchDuration: 1, maximumResearchDuration: 10 }, + }, + overrideAccess: true, + depth: 0, + })) as unknown as Technology; + technologyIds.push(tech.id); + + const fetched = (await payload.findByID({ + collection: "technologies", + id: tech.id, + depth: 0, + overrideAccess: true, + })) as unknown as Technology; + expect(fetched.category).toBe(category.id); + }, TIMEOUT); + + it("deleting a category leaves the technology with no category (FK set null)", async () => { + const category = (await payload.create({ + collection: "tech-categories", + data: { name: `${RUN}-Tier 9`, position: 9 }, + overrideAccess: true, + depth: 0, + })) as unknown as TechCategory; + + const tech = (await payload.create({ + collection: "technologies", + data: { + name: `${RUN}-tech-orphan`, + type: "asset", + summary: "Orphaned on category delete.", + category: category.id, + approvalStatus: "in_progress", + researchCosts: { minimumResearchDuration: 1, maximumResearchDuration: 10 }, + }, + overrideAccess: true, + depth: 0, + })) as unknown as Technology; + technologyIds.push(tech.id); + + await payload.delete({ + collection: "tech-categories", + id: category.id, + overrideAccess: true, + }); + + const fetched = (await payload.findByID({ + collection: "technologies", + id: tech.id, + depth: 0, + overrideAccess: true, + })) as unknown as Technology; + expect(fetched.category === null || fetched.category === undefined).toBe(true); + }, TIMEOUT); + }); + + describe("tech tree manual positions", () => { + it("stores a dragged tree position and clears it again", async () => { + const tech = (await payload.create({ + collection: "technologies", + data: { + name: `${RUN}-positioned`, + type: "upgrade", + summary: "Manual canvas position round-trip.", + approvalStatus: "in_progress", + researchCosts: { minimumResearchDuration: 1, maximumResearchDuration: 10 }, + treePosition: { x: 420, y: 260 }, + }, + overrideAccess: true, + depth: 0, + })) as unknown as Technology; + technologyIds.push(tech.id); + + const fetched = (await payload.findByID({ + collection: "technologies", + id: tech.id, + depth: 0, + overrideAccess: true, + })) as unknown as Technology; + expect(fetched.treePosition?.x).toBe(420); + expect(fetched.treePosition?.y).toBe(260); + + const cleared = (await payload.update({ + collection: "technologies", + id: tech.id, + data: { treePosition: { x: null, y: null } }, + overrideAccess: true, + depth: 0, + })) as unknown as Technology; + expect(cleared.treePosition?.x ?? null).toBeNull(); + expect(cleared.treePosition?.y ?? null).toBeNull(); + }, TIMEOUT); + }); +}); diff --git a/tests/int/tech-tree-layout.int.spec.ts b/tests/int/tech-tree-layout.int.spec.ts new file mode 100644 index 0000000..e860f94 --- /dev/null +++ b/tests/int/tech-tree-layout.int.spec.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from "vitest"; + +import { + MAX_ROWS_PER_COLUMN, + computeLayout, +} from "@/components/admin/tech-tree/layout"; +import { + COLUMN_STEP, + NODE_WIDTH, + ROW_STEP, + type TreeCategory, + type TreeTech, +} from "@/components/admin/tech-tree/types"; + +const tech = (over: Partial & { id: number }): TreeTech => ({ + name: `Tech ${over.id}`, + type: "upgrade", + approvalStatus: "in_progress", + summary: "", + categoryId: null, + prereqAnd: [], + prereqOr: [], + resourceCosts: [], + treePosition: null, + ...over, +}); + +const cat = (id: number, name: string, position: number): TreeCategory => ({ + id, + name, + position, + color: null, +}); + +describe("tech tree layout (pure)", () => { + it("wraps a long uncategorized column into multiple physical columns", () => { + const techs = Array.from({ length: 25 }, (_, i) => tech({ id: i + 1 })); + const layout = computeLayout(techs, []); + + const xs = [...new Set(layout.nodes.map((n) => n.x))].sort((a, b) => a - b); + expect(xs).toEqual([0, COLUMN_STEP, COLUMN_STEP * 2]); + expect(layout.columns).toBe(3); + + // First column holds MAX_ROWS_PER_COLUMN nodes, rows 0..N-1. + const firstColumnRows = layout.nodes.filter((n) => n.x === 0).map((n) => n.y / ROW_STEP); + expect(firstColumnRows.sort((a, b) => a - b)).toEqual( + Array.from({ length: MAX_ROWS_PER_COLUMN }, (_, i) => i), + ); + // The final column holds the remainder. + const lastColumnCount = layout.nodes.filter((n) => n.x === COLUMN_STEP * 2).length; + expect(lastColumnCount).toBe(25 - MAX_ROWS_PER_COLUMN * 2); + }); + + it("orders category columns by explicit position, then name", () => { + const categories = [cat(10, "Tier 2", 2), cat(11, "Tier 1", 1), cat(12, "Alpha", 2)]; + const techs = [ + tech({ id: 1, categoryId: 10 }), + tech({ id: 2, categoryId: 11 }), + tech({ id: 3, categoryId: 12 }), + ]; + const layout = computeLayout(techs, categories); + const xOf = (id: number) => layout.nodes.find((n) => n.techId === id)!.x; + + // Tier 1 (position 1) is leftmost. + expect(xOf(2)).toBe(0); + // Position 2 ties break alphabetically: "Alpha" before "Tier 2". + expect(xOf(3)).toBe(COLUMN_STEP); + expect(xOf(1)).toBe(COLUMN_STEP * 2); + }); + + it("places uncategorized techs in a trailing zone after all categories", () => { + const categories = [cat(1, "Tier 1", 1), cat(2, "Tier 2", 2)]; + const techs = [ + tech({ id: 1, categoryId: 1 }), + tech({ id: 2, categoryId: 2 }), + tech({ id: 3 }), + ]; + const layout = computeLayout(techs, categories); + const xOf = (id: number) => layout.nodes.find((n) => n.techId === id)!.x; + expect(xOf(3)).toBe(COLUMN_STEP * 2); // after both category columns + }); + + it("places a dependent one column right of its parent, at the parent's row", () => { + const techs = [tech({ id: 1 }), tech({ id: 2, prereqAnd: [1] })]; + const layout = computeLayout(techs, []); + const parent = layout.nodes.find((n) => n.techId === 1)!; + const child = layout.nodes.find((n) => n.techId === 2)!; + expect(child.column).toBe(parent.column + 1); + expect(child.row).toBe(parent.row); + // Short, local edge: exactly one column apart. + expect(child.x - parent.x).toBe(COLUMN_STEP); + }); + + it("keeps the chain propagating rightwards for grandchildren", () => { + const techs = [ + tech({ id: 1 }), + tech({ id: 2, prereqAnd: [1] }), + tech({ id: 3, prereqAnd: [2] }), + ]; + const layout = computeLayout(techs, []); + const col = (id: number) => layout.nodes.find((n) => n.techId === id)!.column; + expect(col(2)).toBe(col(1) + 1); + expect(col(3)).toBe(col(2) + 1); + }); + + it("links stay beside their parent even when the root grid fills its columns", () => { + // 25 roots fill three 10-row columns; a dependent of the FIRST root must + // still land immediately to its right, not after the whole grid. + const roots = Array.from({ length: 25 }, (_, i) => tech({ id: i + 1 })); + const techs = [...roots, tech({ id: 100, prereqAnd: [1] })]; + const layout = computeLayout(techs, []); + const parent = layout.nodes.find((n) => n.techId === 1)!; + const child = layout.nodes.find((n) => n.techId === 100)!; + // Adjacent column, and right next to the parent vertically (it may grow + // upwards into a free slot rather than being pushed to a distant column). + expect(child.column).toBe(parent.column + 1); + expect(Math.abs(child.row - parent.row)).toBeLessThanOrEqual(1); + }); + + it("keeps the catalog grid stable when a root becomes a dependent", () => { + const roots = ["Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot"].map((name, i) => + tech({ id: i + 1, name }), + ); + const before = computeLayout(roots, []); + const posBefore = (layout: ReturnType, id: number) => + layout.nodes.find((n) => n.techId === id)!; + + // Foxtrot (id 6) becomes Charlie's prerequisite: Charlie (id 3) leaves the + // catalog and becomes a dependent. + const after = computeLayout( + roots.map((t) => (t.id === 3 ? { ...t, prereqAnd: [6] } : t)), + [], + ); + + // Every other root keeps its exact slot: no re-shuffling on connect. + for (const id of [1, 2, 4, 5, 6]) { + expect(after.nodes.find((n) => n.techId === id)!.x).toBe(posBefore(before, id).x); + expect(after.nodes.find((n) => n.techId === id)!.y).toBe(posBefore(before, id).y); + } + // Charlie's catalog slot is now empty, and it sits right of its prerequisite. + const foxtrot = after.nodes.find((n) => n.techId === 6)!; + const charlie = after.nodes.find((n) => n.techId === 3)!; + expect(charlie.column).toBe(foxtrot.column + 1); + expect(charlie.row).toBe(foxtrot.row); + }); + + it("keeps a pinned dependent exactly where it was placed", () => { + const techs = [ + tech({ id: 1 }), + tech({ id: 2, prereqAnd: [1], treePosition: { x: 1234, y: 567 } }), + ]; + const layout = computeLayout(techs, []); + const pinned = layout.nodes.find((n) => n.techId === 2)!; + expect(pinned.x).toBe(1234); + expect(pinned.y).toBe(567); + }); + + it("uses the average parent row for multi-parent dependents", () => { + const techs = [ + tech({ id: 1 }), + tech({ id: 2 }), + tech({ id: 3 }), + tech({ id: 4 }), + tech({ id: 5 }), + tech({ id: 6, prereqAnd: [1, 5] }), + ]; + const layout = computeLayout(techs, []); + const row = (id: number) => layout.nodes.find((n) => n.techId === id)!.row; + const child = layout.nodes.find((n) => n.techId === 6)!; + expect(child.row).toBe(Math.round((row(1) + row(5)) / 2)); + expect(child.column).toBe(1); + }); + + it("stacks siblings without overlapping them", () => { + const techs = [ + tech({ id: 1 }), + tech({ id: 2, prereqAnd: [1] }), + tech({ id: 3, prereqAnd: [1] }), + tech({ id: 4, prereqAnd: [1] }), + ]; + const layout = computeLayout(techs, []); + const siblings = layout.nodes.filter((n) => [2, 3, 4].includes(n.techId)); + expect(new Set(siblings.map((n) => n.column)).size).toBe(1); + expect(new Set(siblings.map((n) => n.row)).size).toBe(3); + }); + + it("honors manual treePosition overrides and grows the band to contain them", () => { + const categories = [cat(1, "Tier 1", 1)]; + const techs = [ + tech({ id: 1, categoryId: 1 }), + tech({ id: 2, categoryId: 1, treePosition: { x: 900, y: 400 } }), + ]; + const layout = computeLayout(techs, categories); + + const manual = layout.nodes.find((n) => n.techId === 2)!; + expect(manual.x).toBe(900); + expect(manual.y).toBe(400); + + const band = layout.bands.find((b) => b.key === "cat-1")!; + // Band spans from the auto-positioned node to the dragged node. + expect(band.x + band.width).toBeGreaterThanOrEqual(900 + NODE_WIDTH); + }); + + it("flags a prerequisite that sits in a later column", () => { + const categories = [cat(1, "Tier 1", 1), cat(2, "Tier 2", 2)]; + const techs = [ + tech({ id: 1, categoryId: 1, prereqAnd: [2] }), // depends on a Tier 2 tech + tech({ id: 2, categoryId: 2 }), + ]; + const layout = computeLayout(techs, categories); + expect(layout.warnings.map((w) => w.techId)).toContain(1); + }); + + it("does not flag forward-pointing prerequisites", () => { + const categories = [cat(1, "Tier 1", 1), cat(2, "Tier 2", 2)]; + const techs = [ + tech({ id: 1, categoryId: 1 }), + tech({ id: 2, categoryId: 2, prereqAnd: [1] }), + ]; + const layout = computeLayout(techs, categories); + expect(layout.warnings).toEqual([]); + }); + + it("survives prerequisite cycles", () => { + const techs = [ + tech({ id: 1, prereqAnd: [2] }), + tech({ id: 2, prereqAnd: [1] }), + ]; + const layout = computeLayout(techs, []); + expect(layout.nodes).toHaveLength(2); + }); + + it("omits bands for categories with no members", () => { + const categories = [cat(1, "Tier 1", 1), cat(2, "Empty Tier", 2)]; + const techs = [tech({ id: 1, categoryId: 1 })]; + const layout = computeLayout(techs, categories); + expect(layout.bands.map((b) => b.key)).toEqual(["cat-1"]); + }); + + it("ignores dangling category references by treating them as uncategorized", () => { + const techs = [tech({ id: 1, categoryId: 999 })]; + const layout = computeLayout(techs, [cat(1, "Tier 1", 1)]); + expect(layout.bands.map((b) => b.key)).toEqual(["uncategorized"]); + }); +}); diff --git a/tests/int/tech-tree-prereq-edits.int.spec.ts b/tests/int/tech-tree-prereq-edits.int.spec.ts new file mode 100644 index 0000000..f0ef3a3 --- /dev/null +++ b/tests/int/tech-tree-prereq-edits.int.spec.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; + +import { + addPrerequisite, + buildPrereqRows, + dependsOn, + removePrerequisite, + setPrerequisiteKind, + withPrereqs, +} from "@/components/admin/tech-tree/prereqEdits"; +import type { TreeTech } from "@/components/admin/tech-tree/types"; + +const tech = (over: Partial & { id: number }): TreeTech => ({ + name: `Tech ${over.id}`, + type: "upgrade", + approvalStatus: "in_progress", + summary: "", + categoryId: null, + prereqAnd: [], + prereqOr: [], + resourceCosts: [], + treePosition: null, + ...over, +}); + +describe("tech tree prerequisite edits (pure)", () => { + it("adds an AND prerequisite", () => { + const techs = [tech({ id: 1 }), tech({ id: 2 })]; + const result = addPrerequisite(techs, 2, 1, "and"); + expect(result.ok).toBe(true); + if (result.ok) expect(result.lists).toEqual({ and: [1], or: [] }); + }); + + it("adds an OR prerequisite", () => { + const techs = [tech({ id: 1 }), tech({ id: 2 })]; + const result = addPrerequisite(techs, 2, 1, "or"); + expect(result.ok).toBe(true); + if (result.ok) expect(result.lists).toEqual({ and: [], or: [1] }); + }); + + it("rejects self references", () => { + const result = addPrerequisite([tech({ id: 1 })], 1, 1, "and"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toMatch(/own prerequisite/i); + }); + + it("rejects duplicates", () => { + const techs = [tech({ id: 1 }), tech({ id: 2, prereqAnd: [1] })]; + const result = addPrerequisite(techs, 2, 1, "or"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toMatch(/already a prerequisite/i); + }); + + it("rejects a direct cycle", () => { + const techs = [tech({ id: 1, prereqAnd: [2] }), tech({ id: 2 })]; + // Adding 1 as a prerequisite of 2 would close 2 -> 1 -> 2. + const result = addPrerequisite(techs, 2, 1, "and"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toMatch(/circular/i); + }); + + it("rejects a transitive cycle", () => { + const techs = [ + tech({ id: 1 }), + tech({ id: 2, prereqAnd: [1] }), + tech({ id: 3, prereqAnd: [2] }), + ]; + // 3 depends on 1 transitively; making 3 a prerequisite of 1 closes a loop. + expect(dependsOn(techs, 3, 1)).toBe(true); + const result = addPrerequisite(techs, 1, 3, "and"); + expect(result.ok).toBe(false); + }); + + it("allows a forward chain", () => { + const techs = [tech({ id: 1 }), tech({ id: 2, prereqAnd: [1] }), tech({ id: 3 })]; + const result = addPrerequisite(techs, 3, 2, "and"); + expect(result.ok).toBe(true); + if (result.ok) expect(result.lists).toEqual({ and: [2], or: [] }); + }); + + it("removes a prerequisite from both groups", () => { + const techs = [tech({ id: 1 }), tech({ id: 2, prereqAnd: [1], prereqOr: [1, 3] })]; + const result = removePrerequisite(techs, 2, 1); + expect(result.ok).toBe(true); + if (result.ok) expect(result.lists).toEqual({ and: [], or: [3] }); + }); + + it("moves a prerequisite between AND and OR", () => { + const techs = [tech({ id: 1 }), tech({ id: 2, prereqAnd: [1] })]; + const toOr = setPrerequisiteKind(techs, 2, 1, "or"); + expect(toOr.ok).toBe(true); + if (toOr.ok) expect(toOr.lists).toEqual({ and: [], or: [1] }); + + const back = setPrerequisiteKind(techs, 2, 1, "and"); + expect(back.ok).toBe(true); + if (back.ok) expect(back.lists).toEqual({ and: [1], or: [] }); + }); + + it("builds a single document-shaped prerequisite row", () => { + expect(buildPrereqRows({ and: [1, 2], or: [3] })).toEqual([ + { requiresAnd: { technology: [1, 2] }, requiresOr: { technology: [3] } }, + ]); + }); + + it("withPrereqs returns a tech carrying the new lists", () => { + const updated = withPrereqs(tech({ id: 1 }), { and: [4], or: [5] }); + expect(updated.prereqAnd).toEqual([4]); + expect(updated.prereqOr).toEqual([5]); + }); +}); diff --git a/tests/int/tech-tree-stability.int.spec.ts b/tests/int/tech-tree-stability.int.spec.ts new file mode 100644 index 0000000..be042da --- /dev/null +++ b/tests/int/tech-tree-stability.int.spec.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; + +import { edgeVisuals, PATH_PALETTE } from "@/components/admin/tech-tree/edgeVisuals"; +import { applyPins, pinsToFreeze } from "@/components/admin/tech-tree/freeze"; +import { computeLayout } from "@/components/admin/tech-tree/layout"; +import { COLUMN_STEP, type TreeTech } from "@/components/admin/tech-tree/types"; + +const tech = (over: Partial & { id: number }): TreeTech => ({ + name: `Tech ${String(over.id).padStart(2, "0")}`, + type: "upgrade", + approvalStatus: "in_progress", + summary: "", + categoryId: null, + prereqAnd: [], + prereqOr: [], + resourceCosts: [], + treePosition: null, + ...over, +}); + +const positionsOf = (techs: TreeTech[]) => + new Map(computeLayout(techs, []).nodes.map((n) => [n.techId, { x: n.x, y: n.y }])); + +describe("tech tree stability (freeze on data change)", () => { + it("pins every card the new layout would move, including the target's dependents", () => { + // B (2) already has a dependent C (3). Linking A (1) -> B relocates B, + // and C follows B in the ordered layout: both must be pinned in place. + const before = [ + tech({ id: 1 }), + tech({ id: 2 }), + tech({ id: 3, prereqAnd: [2] }), + tech({ id: 4 }), + ]; + const current = positionsOf(before); + const after = before.map((t) => (t.id === 2 ? { ...t, prereqAnd: [1] } : t)); + + const pins = pinsToFreeze(current, after, []); + expect(pins.has(2)).toBe(true); + expect(pins.has(3)).toBe(true); + + // With the pins applied, NOTHING on screen moves. + const frozen = positionsOf(applyPins(after, pins)); + for (const [id, pos] of current) { + expect(frozen.get(id)).toEqual(pos); + } + }); + + it("does not pin cards whose position is unchanged", () => { + const before = [tech({ id: 1 }), tech({ id: 2 }), tech({ id: 3 })]; + const current = positionsOf(before); + const pins = pinsToFreeze(current, before, []); + expect(pins.size).toBe(0); + }); + + it("skips technologies that have no on-screen position yet (just created)", () => { + const before = [tech({ id: 1 }), tech({ id: 3 })]; + const current = positionsOf(before); + // A new tech whose name sorts between the others would shift catalog ranks. + const after = [...before, tech({ id: 2 })]; + const pins = pinsToFreeze(current, after, []); + expect(pins.has(2)).toBe(false); + const frozen = positionsOf(applyPins(after, pins)); + expect(frozen.get(1)).toEqual(current.get(1)); + expect(frozen.get(3)).toEqual(current.get(3)); + }); +}); + +describe("tech tree edge visuals (path colors + lanes)", () => { + it("colors separate progression paths differently and keeps one color per chain", () => { + const techs = [ + tech({ id: 1 }), + tech({ id: 2, prereqAnd: [1] }), + tech({ id: 3, prereqAnd: [2] }), + tech({ id: 10 }), + tech({ id: 11, prereqAnd: [10] }), + ]; + const edges = [ + { id: "a", source: 1, target: 2 }, + { id: "b", source: 2, target: 3 }, + { id: "c", source: 10, target: 11 }, + ]; + const visuals = edgeVisuals(techs, edges, positionsOf(techs), COLUMN_STEP); + expect(visuals.get("a")!.color).toBe(visuals.get("b")!.color); + expect(visuals.get("a")!.color).not.toBe(visuals.get("c")!.color); + expect(PATH_PALETTE).toContain(visuals.get("c")!.color); + }); + + it("gives different sources crossing the same gap their own lanes", () => { + const techs = [ + tech({ id: 1 }), + tech({ id: 2 }), + tech({ id: 3, prereqAnd: [1] }), + tech({ id: 4, prereqAnd: [2] }), + ]; + const edges = [ + { id: "a", source: 1, target: 3 }, + { id: "b", source: 2, target: 4 }, + ]; + const visuals = edgeVisuals(techs, edges, positionsOf(techs), COLUMN_STEP); + expect(visuals.get("a")!.stepPosition).not.toBe(visuals.get("b")!.stepPosition); + for (const v of visuals.values()) { + expect(v.stepPosition).toBeGreaterThanOrEqual(0.2); + expect(v.stepPosition).toBeLessThanOrEqual(0.8); + } + }); + + it("keeps edges from the same source in one lane (a fan-out bus)", () => { + const techs = [ + tech({ id: 1 }), + tech({ id: 2, prereqAnd: [1] }), + tech({ id: 3, prereqAnd: [1] }), + ]; + const edges = [ + { id: "a", source: 1, target: 2 }, + { id: "b", source: 1, target: 3 }, + ]; + const visuals = edgeVisuals(techs, edges, positionsOf(techs), COLUMN_STEP); + expect(visuals.get("a")!.stepPosition).toBe(0.5); + expect(visuals.get("b")!.stepPosition).toBe(0.5); + }); +});