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.
This commit is contained in:
parent
89482798f4
commit
5f7fd508a5
5 changed files with 1388 additions and 0 deletions
512
tests/e2e/tech-tree.e2e.spec.ts
Normal file
512
tests/e2e/tech-tree.e2e.spec.ts
Normal file
|
|
@ -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<string, unknown>) {
|
||||
const res = await page.request.post(`/api/${slug}`, { data });
|
||||
expect(res.ok(), `${slug} create failed: ${res.status()} ${await res.text()}`).toBe(true);
|
||||
const body = await res.json();
|
||||
return body.doc as { id: number };
|
||||
}
|
||||
|
||||
async function gotoTree(page: import("@playwright/test").Page) {
|
||||
await page.goto("/admin/tech-tree");
|
||||
await expect(page.getByTestId("tech-tree-canvas")).toBeVisible();
|
||||
}
|
||||
|
||||
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<number, { x: number; y: number }>();
|
||||
for (const id of ids) before.set(id, (await transform(id))!);
|
||||
|
||||
// Commit every node's current position.
|
||||
await page.getByTestId("tech-tree-save-positions").click();
|
||||
await expect(page.getByTestId("tech-tree-save-status")).toContainText("saved");
|
||||
|
||||
// Each node's treePosition is now persisted.
|
||||
for (const id of ids) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const res = await page.request.get(`/api/technologies/${id}`);
|
||||
return (await res.json())?.treePosition?.x ?? null;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.not.toBeNull();
|
||||
}
|
||||
|
||||
// A reload keeps every node in its saved spot (nothing auto-resets).
|
||||
await gotoTree(page);
|
||||
for (const id of ids) {
|
||||
await expect.poll(() => transform(id), { timeout: 15_000 }).toEqual(before.get(id));
|
||||
}
|
||||
|
||||
// Positions were saved, so Auto-arrange is now offered.
|
||||
await expect(page.getByTestId("tech-tree-auto-arrange")).toBeVisible();
|
||||
|
||||
// The e2e suite shares one database (only truncated at global setup), so
|
||||
// this test cleans up its seeded nodes. Leaving them behind, with their
|
||||
// pinned positions, makes the connections test that follows trip up.
|
||||
for (const id of ids) {
|
||||
const del = await page.request.delete(`/api/technologies/${id}`);
|
||||
expect(del.ok(), `cleanup delete ${id} failed: ${del.status()}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("connections and the lite editor persist through the REST API", async ({ page }) => {
|
||||
await login(page);
|
||||
|
||||
const costs = { minimumResearchDuration: 1, maximumResearchDuration: 10 };
|
||||
const alpha = await createDoc(page, "technologies", {
|
||||
name: "Tech Tree Link Alpha",
|
||||
type: "upgrade",
|
||||
summary: "Link source.",
|
||||
category: null,
|
||||
researchCosts: costs,
|
||||
});
|
||||
const beta = await createDoc(page, "technologies", {
|
||||
name: "Tech Tree Link Beta",
|
||||
type: "upgrade",
|
||||
summary: "Link target.",
|
||||
category: null,
|
||||
researchCosts: costs,
|
||||
});
|
||||
|
||||
await gotoTree(page);
|
||||
await expect(page.getByTestId(`tech-node-${alpha.id}`)).toBeVisible();
|
||||
|
||||
// Drag from Alpha's source handle onto Beta to create an AND prerequisite.
|
||||
const sourceHandle = page.locator(
|
||||
`.react-flow__node[data-id="${alpha.id}"] .react-flow__handle-right`,
|
||||
);
|
||||
const targetNode = page.getByTestId(`tech-node-${beta.id}`);
|
||||
const targetTransformBeforeConnect = await page
|
||||
.locator(`.react-flow__node[data-id="${beta.id}"]`)
|
||||
.evaluate((el) => (el as HTMLElement).style.transform);
|
||||
const handleBox = (await sourceHandle.boundingBox())!;
|
||||
const targetBox = (await targetNode.boundingBox())!;
|
||||
await page.mouse.move(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, {
|
||||
steps: 20,
|
||||
});
|
||||
await page.mouse.up();
|
||||
|
||||
const edgeId = `and-${alpha.id}-${beta.id}`;
|
||||
await expect(page.locator(`.react-flow__edge[data-id="${edgeId}"]`)).toBeAttached();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const res = await page.request.get(`/api/technologies/${beta.id}?depth=0`);
|
||||
const body = await res.json();
|
||||
const rows = body?.requirements?.prerequisiteTechnologies ?? [];
|
||||
const ids = rows.flatMap((row: { requiresAnd?: { technology?: unknown[] } }) =>
|
||||
(row.requiresAnd?.technology ?? []).map((t) => (typeof t === "number" ? t : (t as { id: number }).id)),
|
||||
);
|
||||
return ids;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toContain(alpha.id);
|
||||
|
||||
// UX contract: the connected node must NOT jump. Its on-canvas position is
|
||||
// unchanged (pinned), and the pin is persisted so a reload keeps it.
|
||||
const wrapperSel = `.react-flow__node[data-id="${beta.id}"]`;
|
||||
const positionAfterConnect = await page
|
||||
.locator(wrapperSel)
|
||||
.evaluate((el) => (el as HTMLElement).style.transform);
|
||||
expect(positionAfterConnect).toBe(targetTransformBeforeConnect);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const res = await page.request.get(`/api/technologies/${beta.id}?depth=0`);
|
||||
const body = await res.json();
|
||||
return body?.treePosition?.x ?? null;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.not.toBeNull();
|
||||
|
||||
// Auto-arrange clears the pin and moves it to the ordered position
|
||||
// (one column right of its prerequisite).
|
||||
await page.getByTestId("tech-tree-auto-arrange").click();
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const res = await page.request.get(`/api/technologies/${beta.id}?depth=0`);
|
||||
return (await res.json())?.treePosition?.x ?? null;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBeNull();
|
||||
const parentX = await page
|
||||
.locator(`.react-flow__node[data-id="${alpha.id}"]`)
|
||||
.evaluate((el) => (el as HTMLElement).style.transform.match(/translate\((-?[\d.]+)px/)![1]);
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
page
|
||||
.locator(wrapperSel)
|
||||
.evaluate((el) => (el as HTMLElement).style.transform.match(/translate\((-?[\d.]+)px/)![1]),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.not.toBe(parentX);
|
||||
|
||||
// Removing the edge (select + Backspace) drops the prerequisite. React Flow
|
||||
// hit-tests edges through its wide invisible interaction path.
|
||||
const edge = page.locator(`.react-flow__edge[data-id="${edgeId}"]`);
|
||||
const onPath = await page.evaluate((id) => {
|
||||
const pathEl = document.querySelector(
|
||||
`.react-flow__edge[data-id="${id}"] path.react-flow__edge-path`,
|
||||
) as SVGPathElement | null;
|
||||
if (!pathEl) return null;
|
||||
const local = pathEl.getPointAtLength(pathEl.getTotalLength() / 2);
|
||||
const svg = pathEl.ownerSVGElement!;
|
||||
const pt = svg.createSVGPoint();
|
||||
pt.x = local.x;
|
||||
pt.y = local.y;
|
||||
const screen = pt.matrixTransform(pathEl.getScreenCTM()!);
|
||||
return { x: screen.x, y: screen.y };
|
||||
}, edgeId);
|
||||
expect(onPath).not.toBeNull();
|
||||
await page.mouse.click(onPath!.x, onPath!.y);
|
||||
await expect(edge).toHaveClass(/selected/);
|
||||
await page.keyboard.press("Backspace");
|
||||
await expect(page.locator(`.react-flow__edge[data-id="${edgeId}"]`)).toHaveCount(0);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const res = await page.request.get(`/api/technologies/${beta.id}?depth=0`);
|
||||
const body = await res.json();
|
||||
const rows = body?.requirements?.prerequisiteTechnologies ?? [];
|
||||
const ids = rows.flatMap((row: { requiresAnd?: { technology?: unknown[] } }) =>
|
||||
(row.requiresAnd?.technology ?? []).map((t) => (typeof t === "number" ? t : (t as { id: number }).id)),
|
||||
);
|
||||
return ids;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.not.toContain(alpha.id);
|
||||
|
||||
// The lite panel edits and saves fields.
|
||||
await page.getByTestId(`tech-node-${beta.id}`).click();
|
||||
const panel = page.getByTestId("tech-preview-panel");
|
||||
await expect(panel).toBeVisible();
|
||||
await page.getByTestId("tech-panel-summary").fill("Edited from the lite panel.");
|
||||
await page.getByTestId("tech-panel-save").click();
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const res = await page.request.get(`/api/technologies/${beta.id}`);
|
||||
return (await res.json())?.summary ?? null;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe("Edited from the lite panel.");
|
||||
|
||||
// The panel also edits the name, reflected on the node card.
|
||||
await page.getByTestId("tech-panel-name").fill("Tech Tree Link Beta Renamed");
|
||||
await page.getByTestId("tech-panel-save").click();
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const res = await page.request.get(`/api/technologies/${beta.id}`);
|
||||
return (await res.json())?.name ?? null;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe("Tech Tree Link Beta Renamed");
|
||||
await expect(page.getByTestId(`tech-node-${beta.id}`)).toContainText(
|
||||
"Tech Tree Link Beta Renamed",
|
||||
);
|
||||
});
|
||||
|
||||
test("a large uncategorized pool wraps into multiple columns instead of one strip", async ({
|
||||
page,
|
||||
}) => {
|
||||
await login(page);
|
||||
|
||||
const costs = { minimumResearchDuration: 1, maximumResearchDuration: 10 };
|
||||
const ids: number[] = [];
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
const doc = await createDoc(page, "technologies", {
|
||||
name: `Tech Tree Wrap Seed ${String(i).padStart(2, "0")}`,
|
||||
type: "upgrade",
|
||||
summary: "Uncategorized wrapping regression seed.",
|
||||
category: null,
|
||||
researchCosts: costs,
|
||||
});
|
||||
ids.push(doc.id);
|
||||
}
|
||||
|
||||
await gotoTree(page);
|
||||
await expect(page.getByTestId(`tech-node-${ids[0]}`)).toBeVisible();
|
||||
|
||||
// 12 uncategorized techs exceed MAX_ROWS_PER_COLUMN (10), so at least two
|
||||
// distinct x offsets must exist (previously: one 1700px-tall column).
|
||||
const xs = new Set<string>();
|
||||
for (const id of ids) {
|
||||
const style = (await page.locator(`.react-flow__node[data-id="${id}"]`).getAttribute("style")) ?? "";
|
||||
const match = style.match(/translate\((-?[\d.]+)px/);
|
||||
if (match) xs.add(match[1]);
|
||||
}
|
||||
expect(xs.size).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
400
tests/int/tech-categories.int.spec.ts
Normal file
400
tests/int/tech-categories.int.spec.ts
Normal file
|
|
@ -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<Role> = {}): Promise<Role> => {
|
||||
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<User> => {
|
||||
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<number> => {
|
||||
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<unknown>) => {
|
||||
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<boolean> => {
|
||||
const fn = scopedAdminPageAccess("tech-categories");
|
||||
return Boolean(
|
||||
await (fn as (args: { req: unknown }) => Promise<unknown>)({
|
||||
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);
|
||||
});
|
||||
});
|
||||
245
tests/int/tech-tree-layout.int.spec.ts
Normal file
245
tests/int/tech-tree-layout.int.spec.ts
Normal file
|
|
@ -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<TreeTech> & { 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<typeof computeLayout>, 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"]);
|
||||
});
|
||||
});
|
||||
110
tests/int/tech-tree-prereq-edits.int.spec.ts
Normal file
110
tests/int/tech-tree-prereq-edits.int.spec.ts
Normal file
|
|
@ -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<TreeTech> & { 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]);
|
||||
});
|
||||
});
|
||||
121
tests/int/tech-tree-stability.int.spec.ts
Normal file
121
tests/int/tech-tree-stability.int.spec.ts
Normal file
|
|
@ -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<TreeTech> & { 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue