diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..482354ca --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Robopipe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 868d8182..0e5b2be4 100644 --- a/README.md +++ b/README.md @@ -115,3 +115,9 @@ Robopipe values all your feedback. If you encounter any problems with the app, p ## 👫 Community Join our [Robopipe subreddit](https://www.reddit.com/r/robopipe/) to share your apps, ask any questions regarding Robopipe, get help debugging your apps, or simply to read more about Robopipe from our users. + +## 📄 License + +Robopipe Studio is released under the [MIT License](LICENSE). + +This software was created with the support of the Faculty of Information Technology, CTU in Prague. More information at [fit.cvut.cz](https://fit.cvut.cz). \ No newline at end of file diff --git a/apps/web/e2e/clipboard.spec.ts b/apps/web/e2e/clipboard.spec.ts new file mode 100644 index 00000000..91a5d053 --- /dev/null +++ b/apps/web/e2e/clipboard.spec.ts @@ -0,0 +1,102 @@ +import { expect, test } from "@playwright/test"; +import { + addNodeAt, + canvasCentre, + createLimitWithChildren, + getConnections, + getNodes, + gotoEditor, + nodeById, +} from "./helpers"; + +test.describe("Copy, paste & cut", () => { + test.beforeEach(async ({ page }) => { + await gotoEditor(page); + }); + + test("copy + paste duplicates the selected node", async ({ page }) => { + const id = await addNodeAt(page, "a", 0, 0); + await nodeById(page, id).click(); + + await page.keyboard.press("ControlOrMeta+c"); + const centre = await canvasCentre(page); + await page.mouse.move(centre.x + 200, centre.y + 100); + await page.keyboard.press("ControlOrMeta+v"); + + const nodes = await getNodes(page); + expect(nodes).toHaveLength(2); + expect(nodes.map((n) => n.label)).toEqual(["AND", "AND"]); + }); + + test("cut removes the original and clipboard still pastes", async ({ + page, + }) => { + const id = await addNodeAt(page, "a", 0, 0); + await nodeById(page, id).click(); + + await page.keyboard.press("ControlOrMeta+x"); + expect(await getNodes(page)).toHaveLength(0); + + const centre = await canvasCentre(page); + await page.mouse.move(centre.x + 100, centre.y); + await page.keyboard.press("ControlOrMeta+v"); + + expect(await getNodes(page)).toHaveLength(1); + }); + + test("paste with empty clipboard is a no-op", async ({ page }) => { + await page.keyboard.press("ControlOrMeta+v"); + expect(await getNodes(page)).toHaveLength(0); + }); + + test("paste preserves internal connections between copied nodes", async ({ + page, + }) => { + const a = await addNodeAt(page, "a", -250, 0); + const b = await addNodeAt(page, "a", 250, 0); + + await page.evaluate( + ({ aId, bId }) => window.__editor!.addBooleanConnection(aId, bId), + { + aId: a, + bId: b, + }, + ); + expect(await getConnections(page)).toHaveLength(1); + + await nodeById(page, a).click(); + await nodeById(page, b).click({ modifiers: ["ControlOrMeta"] }); + await page.keyboard.press("ControlOrMeta+c"); + + const centre = await canvasCentre(page); + await page.mouse.move(centre.x, centre.y + 200); + await page.keyboard.press("ControlOrMeta+v"); + + expect(await getNodes(page)).toHaveLength(4); + expect(await getConnections(page)).toHaveLength(2); + }); + + test("paste preserves parent-child relationships", async ({ page }) => { + const { limitId } = await createLimitWithChildren(page, 2); + + + await nodeById(page, limitId).click({ position: { x: 5, y: 5 } }); + + await page.keyboard.press("ControlOrMeta+c"); + const centre = await canvasCentre(page); + await page.mouse.move(centre.x, centre.y + 300); + await page.keyboard.press("ControlOrMeta+v"); + + const after = await getNodes(page); + expect(after).toHaveLength(6); + + const limits = after.filter((n) => n.label === "Limit"); + expect(limits).toHaveLength(2); + + for (const lim of limits) { + const children = after.filter((n) => n.parent === lim.id); + expect(children).toHaveLength(2); + expect(children.every((c) => c.label === "Count")).toBe(true); + } + }); +}); diff --git a/apps/web/e2e/connections.spec.ts b/apps/web/e2e/connections.spec.ts new file mode 100644 index 00000000..c7f6d3df --- /dev/null +++ b/apps/web/e2e/connections.spec.ts @@ -0,0 +1,263 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; +import { + addNodeAt, + getConnections, + gotoEditor, + inSocket, + outSocket, + settle, +} from "./helpers"; + +async function dragSocketToSocket( + page: Page, + source: Locator, + target: Locator, +) { + const s = await source.boundingBox(); + const t = await target.boundingBox(); + if (!s || !t) throw new Error("socket not visible"); + const from = { x: s.x + s.width / 2, y: s.y + s.height / 2 }; + const to = { x: t.x + t.width / 2, y: t.y + t.height / 2 }; + + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + + await page.mouse.move(to.x, to.y, { steps: 12 }); + await page.mouse.up(); +} + +test.describe("Connections", () => { + test.beforeEach(async ({ page }) => { + await gotoEditor(page); + }); + + test("dragging output to input creates a boolean connection", async ({ + page, + }) => { + const a = await addNodeAt(page, "a", -250, 0); + const b = await addNodeAt(page, "a", 250, 0); + + await dragSocketToSocket(page, outSocket(page, a), inSocket(page, b)); + + const connections = await getConnections(page); + expect(connections).toHaveLength(1); + expect(connections[0]!.source).toBe(a); + expect(connections[0]!.target).toBe(b); + }); + + test("dragging output to input creates a rule (limit-item) connection", async ({ + page, + }) => { + const a = await addNodeAt(page, "1", -250, 0); + const b = await addNodeAt(page, "1", 250, 0); + + await dragSocketToSocket(page, outSocket(page, a), inSocket(page, b)); + + const connections = await getConnections(page); + expect(connections).toHaveLength(1); + expect(connections[0]!.source).toBe(a); + expect(connections[0]!.target).toBe(b); + }); + + test("mixed socket types are rejected", async ({ page }) => { + const andNode = await addNodeAt(page, "a", -250, 0); + const count = await addNodeAt(page, "1", 250, 0); + + await dragSocketToSocket( + page, + outSocket(page, andNode), + inSocket(page, count), + ); + + await settle(page); + expect(await getConnections(page)).toHaveLength(0); + }); + + test("dropping a connection on empty canvas creates nothing", async ({ + page, + }) => { + const a = await addNodeAt(page, "a", -250, 0); + + const sourceBox = await outSocket(page, a).boundingBox(); + if (!sourceBox) throw new Error("socket missing"); + await page.mouse.move( + sourceBox.x + sourceBox.width / 2, + sourceBox.y + sourceBox.height / 2, + ); + await page.mouse.down(); + await page.mouse.move(sourceBox.x + 400, sourceBox.y + 300, { steps: 10 }); + await page.mouse.up(); + + await settle(page); + expect(await getConnections(page)).toHaveLength(0); + }); + + test("magnetic snap connects when released near (not on) a socket", async ({ + page, + }) => { + const a = await addNodeAt(page, "a", -250, 0); + const b = await addNodeAt(page, "a", 250, 0); + + const s = await outSocket(page, a).boundingBox(); + const t = await inSocket(page, b).boundingBox(); + if (!s || !t) throw new Error("socket not visible"); + + // Release off the 20px socket but within the magnetic distance — snaps. + await page.mouse.move(s.x + s.width / 2, s.y + s.height / 2); + await page.mouse.down(); + await page.mouse.move(t.x + t.width / 2 - 25, t.y + t.height / 2 + 18, { + steps: 12, + }); + await page.mouse.up(); + + await settle(page); + const connections = await getConnections(page); + expect(connections).toHaveLength(1); + expect(connections[0]!.source).toBe(a); + expect(connections[0]!.target).toBe(b); + }); + + test("pressing Escape cancels a magnetic snap instead of committing it", async ({ + page, + }) => { + const a = await addNodeAt(page, "a", -250, 0); + const b = await addNodeAt(page, "a", 250, 0); + + const s = await outSocket(page, a).boundingBox(); + const t = await inSocket(page, b).boundingBox(); + if (!s || !t) throw new Error("socket not visible"); + + // Drag into magnetic range so the preview is active, then Escape. + await page.mouse.move(s.x + s.width / 2, s.y + s.height / 2); + await page.mouse.down(); + await page.mouse.move(t.x + t.width / 2 - 25, t.y + t.height / 2 + 18, { + steps: 12, + }); + await page.keyboard.press("Escape"); + await page.mouse.up(); + + await settle(page); + expect(await getConnections(page)).toHaveLength(0); + }); + + test("magnetic preview does not resurrect after a connection is removed", async ({ + page, + }) => { + const a = await addNodeAt(page, "a", -250, 0); + const b = await addNodeAt(page, "a", 250, 0); + + // Create, then remove by dragging the input end out to empty space. + await dragSocketToSocket(page, outSocket(page, a), inSocket(page, b)); + expect(await getConnections(page)).toHaveLength(1); + + const t = await inSocket(page, b).boundingBox(); + if (!t) throw new Error("socket not visible"); + await page.mouse.move(t.x + t.width / 2, t.y + t.height / 2); + await page.mouse.down(); + await page.mouse.move(t.x + 400, t.y + 300, { steps: 10 }); + await page.mouse.up(); + + await settle(page); + expect(await getConnections(page)).toHaveLength(0); + + // Hover (no button held) back near the input socket — must NOT show a + // preview or recreate the connection. + await page.mouse.move(t.x + t.width / 2 - 25, t.y + t.height / 2 + 18); + await settle(page); + + await expect(page.getByTestId("magnetic-connection")).toHaveCount(0); + expect(await getConnections(page)).toHaveLength(0); + }); + + test("re-picking a connection end can reconnect it to another node", async ({ + page, + }) => { + const a = await addNodeAt(page, "a", -300, 0); + const b = await addNodeAt(page, "a", 0, 0); + const c = await addNodeAt(page, "a", 300, 0); + + await dragSocketToSocket(page, outSocket(page, a), inSocket(page, b)); + expect(await getConnections(page)).toHaveLength(1); + + // Grab the connection at B's input and drop it on C's input. + await dragSocketToSocket(page, inSocket(page, b), inSocket(page, c)); + await settle(page); + + const connections = await getConnections(page); + expect(connections).toHaveLength(1); + expect(connections[0]!.source).toBe(a); + expect(connections[0]!.target).toBe(c); + }); + + test("magnetic snap works while reconnecting an existing connection", async ({ + page, + }) => { + const a = await addNodeAt(page, "a", -300, 0); + const b = await addNodeAt(page, "a", 0, 0); + const c = await addNodeAt(page, "a", 300, 0); + + await dragSocketToSocket(page, outSocket(page, a), inSocket(page, b)); + expect(await getConnections(page)).toHaveLength(1); + + // Re-pick at B's end, release NEAR (not on) C's input — the magnet should snap. + const from = await inSocket(page, b).boundingBox(); + const to = await inSocket(page, c).boundingBox(); + if (!from || !to) throw new Error("socket not visible"); + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2); + await page.mouse.down(); + await page.mouse.move(to.x + to.width / 2 - 25, to.y + to.height / 2 + 18, { + steps: 12, + }); + await page.mouse.up(); + + await settle(page); + const connections = await getConnections(page); + expect(connections).toHaveLength(1); + expect(connections[0]!.source).toBe(a); + expect(connections[0]!.target).toBe(c); + }); + + test("magnetic snap does not reach beyond its distance", async ({ page }) => { + const a = await addNodeAt(page, "a", -250, 0); + const b = await addNodeAt(page, "a", 250, 0); + + const s = await outSocket(page, a).boundingBox(); + const t = await inSocket(page, b).boundingBox(); + if (!s || !t) throw new Error("socket not visible"); + + // Release far from the input socket — outside the magnetic distance. + await page.mouse.move(s.x + s.width / 2, s.y + s.height / 2); + await page.mouse.down(); + await page.mouse.move(t.x + t.width / 2 - 320, t.y + t.height / 2 - 220, { + steps: 12, + }); + await page.mouse.up(); + + await settle(page); + expect(await getConnections(page)).toHaveLength(0); + }); + + test("toggling a boolean connection flips TRUE to NOT", async ({ page }) => { + const a = await addNodeAt(page, "a", -250, 0); + const b = await addNodeAt(page, "a", 250, 0); + await dragSocketToSocket(page, outSocket(page, a), inSocket(page, b)); + + const beforeOp = await page.evaluate(() => { + const { editor } = window.__editor!; + return ( + editor.getConnections()[0] as unknown as { booleanOperator: string } + ).booleanOperator; + }); + expect(beforeOp).toBe("TRUE"); + + await page.getByRole("button", { name: /^PASS$/ }).click(); + + const afterOp = await page.evaluate(() => { + const { editor } = window.__editor!; + return ( + editor.getConnections()[0] as unknown as { booleanOperator: string } + ).booleanOperator; + }); + expect(afterOp).toBe("NOT"); + }); +}); diff --git a/apps/web/e2e/context-menu.spec.ts b/apps/web/e2e/context-menu.spec.ts new file mode 100644 index 00000000..2d7123a0 --- /dev/null +++ b/apps/web/e2e/context-menu.spec.ts @@ -0,0 +1,70 @@ +import { expect, test } from "@playwright/test"; +import { addNodeAt, canvas, gotoEditor, nodeById } from "./helpers"; + +test.describe("Context menu", () => { + test.beforeEach(async ({ page }) => { + await gotoEditor(page); + }); + + test("right clicking the background opens the root menu", async ({ + page, + }) => { + await canvas(page).click({ button: "right", position: { x: 100, y: 100 } }); + + await expect(page.getByTestId("context-menu")).toBeVisible(); + await expect(page.getByTestId("context-menu-item")).toHaveText([ + "Check", + "Result", + "Check Item", + "Logical", + "Action", + ]); + }); + + test("right clicking a node shows the Delete & Clone items", async ({ + page, + }) => { + const id = await addNodeAt(page, "a", 0, 0); + + await nodeById(page, id).click({ + button: "right", + position: { x: 5, y: 5 }, + }); + await expect(page.getByTestId("context-menu-item")).toHaveText([ + "Delete", + "Clone", + ]); + }); + + const groups = [ + { label: "Check Item", children: ["Position", "Area", "Count"] }, + { label: "Logical", children: ["And", "Or"] }, + { label: "Action", children: ["Warning", "Alert"] }, + ]; + + for (const group of groups) { + test(`hovering "${group.label}" opens its submenu`, async ({ page }) => { + await canvas(page).click({ + button: "right", + position: { x: 100, y: 100 }, + }); + + const items = page.getByTestId("context-menu-item"); + await items.filter({ hasText: new RegExp(`^${group.label}$`) }).hover(); + + for (const child of group.children) { + await expect( + items.filter({ hasText: new RegExp(`^${child}$`) }), + ).toBeVisible(); + } + }); + } + + test("clicking outside closes the menu", async ({ page }) => { + await canvas(page).click({ button: "right", position: { x: 100, y: 100 } }); + await expect(page.getByTestId("context-menu")).toBeVisible(); + + await canvas(page).click({ position: { x: 600, y: 600 } }); + await expect(page.getByTestId("context-menu")).toBeHidden(); + }); +}); diff --git a/apps/web/e2e/creation.spec.ts b/apps/web/e2e/creation.spec.ts new file mode 100644 index 00000000..8f4bceef --- /dev/null +++ b/apps/web/e2e/creation.spec.ts @@ -0,0 +1,65 @@ +import { expect, test } from "@playwright/test"; +import { + canvas, + canvasCentre, + getNodes, + gotoEditor, + pressShortcutAt, +} from "./helpers"; + +test.describe("Node creation", () => { + test.beforeEach(async ({ page }) => { + await gotoEditor(page); + expect(await getNodes(page)).toHaveLength(0); + }); + + test.describe("keyboard shortcuts", () => { + const cases: { key: string; label: string }[] = [ + { key: "a", label: "AND" }, + { key: "o", label: "OR" }, + { key: "l", label: "Limit" }, + { key: "r", label: "Result" }, + { key: "1", label: "Count" }, + { key: "2", label: "Area" }, + { key: "3", label: "Position" }, + ]; + + for (const { key, label } of cases) { + test(`"${key}" adds a ${label} node at the cursor`, async ({ page }) => { + await pressShortcutAt(page, key); + const nodes = await getNodes(page); + expect(nodes).toHaveLength(1); + expect(nodes[0]!.label).toBe(label); + }); + } + }); + + test("new node is auto selected", async ({ page }) => { + await pressShortcutAt(page, "a"); + const [node] = await getNodes(page); + expect(node!.selected).toBe(true); + }); + + test("two shortcut presses create two nodes", async ({ page }) => { + await pressShortcutAt(page, "a"); + const centre = await canvasCentre(page); + await pressShortcutAt(page, "o", { x: centre.x + 200, y: centre.y }); + + const nodes = await getNodes(page); + expect(nodes.map((n) => n.label).sort()).toEqual(["AND", "OR"]); + }); + + test("context menu creates a Check node", async ({ page }) => { + await canvas(page).click({ button: "right", position: { x: 100, y: 100 } }); + + const checkItem = page + .getByTestId("context-menu-item") + .filter({ hasText: /^Check$/ }); + await expect(checkItem).toBeVisible(); + await checkItem.click(); + + const nodes = await getNodes(page); + expect(nodes).toHaveLength(1); + expect(nodes[0]!.label).toBe("Limit"); + }); +}); diff --git a/apps/web/e2e/deletion.spec.ts b/apps/web/e2e/deletion.spec.ts new file mode 100644 index 00000000..f2eaa3ad --- /dev/null +++ b/apps/web/e2e/deletion.spec.ts @@ -0,0 +1,76 @@ +import { expect, test } from "@playwright/test"; +import { + addNodeAt, + createLimitWithChildren, + getConnections, + getNodes, + gotoEditor, + nodeById, +} from "./helpers"; + +test.describe("Deletion", () => { + test.beforeEach(async ({ page }) => { + await gotoEditor(page); + }); + + test("Backspace deletes the selected leaf node", async ({ page }) => { + const id = await addNodeAt(page, "a", 0, 0); + expect((await getNodes(page)).find((n) => n.id === id)).toBeDefined(); + + // when creating a node, it is automatically selected + await page.keyboard.press("Backspace"); + + expect(await getNodes(page)).toHaveLength(0); + }); + + test("Backspace on a LimitNode cascades to its children", async ({ + page, + }) => { + const { limitId } = await createLimitWithChildren(page, 2); + + await nodeById(page, limitId).click({ position: { x: 5, y: 5 } }); + await page.keyboard.press("Backspace"); + + expect(await getNodes(page)).toHaveLength(0); + }); + + test("context menu Delete cascades on a LimitNode", async ({ page }) => { + const { limitId } = await createLimitWithChildren(page, 1); + + await nodeById(page, limitId).click({ + button: "right", + position: { x: 5, y: 5 }, + }); + await page + .getByTestId("context-menu-item") + .filter({ hasText: /^Delete$/ }) + .click(); + + expect(await getNodes(page)).toHaveLength(0); + }); + + test("deleting a middle node also removes both connections", async ({ + page, + }) => { + const a = await addNodeAt(page, "a", -300, 0); + const b = await addNodeAt(page, "a", 0, 0); + const c = await addNodeAt(page, "a", 300, 0); + + await page.evaluate( + async ({ aId, bId, cId }) => { + await window.__editor!.addBooleanConnection(aId, bId); + await window.__editor!.addBooleanConnection(bId, cId); + }, + { aId: a, bId: b, cId: c }, + ); + + expect(await getConnections(page)).toHaveLength(2); + + await nodeById(page, b).click(); + await page.keyboard.press("Backspace"); + + const nodes = await getNodes(page); + expect(nodes.map((n) => n.id).sort()).toEqual([a, c].sort()); + expect(await getConnections(page)).toHaveLength(0); + }); +}); diff --git a/apps/web/e2e/helpers.ts b/apps/web/e2e/helpers.ts new file mode 100644 index 00000000..43a1145a --- /dev/null +++ b/apps/web/e2e/helpers.ts @@ -0,0 +1,239 @@ +import { GRID } from "@/modules/evaluation/graph/editor/constants"; +import type { Locator, Page } from "@playwright/test"; +import { expect } from "@playwright/test"; + +/** + * Loads the editor and waits for the test hook to be installed. + * Always call this at the start of a test. + */ +export async function gotoEditor(page: Page) { + await page.goto("/"); + await page.waitForFunction(() => Boolean(window.__editor)); + await expect(page.getByTestId("editor-canvas")).toBeVisible(); +} + +/** + * Returns the canvas locator. Tests should target gestures (right click, + * keyboard, wheel) against this element - it is the actual rete container. + */ +export function canvas(page: Page): Locator { + return page.getByTestId("editor-canvas"); +} + +export type NodeSnapshot = { + id: string; + label: string; + selected: boolean; + parent: string | null; + position: { x: number; y: number }; +}; + +/** + * Snapshot of every nodes {id, label, selected, parent, position}. + */ +export async function getNodes(page: Page): Promise { + return page.evaluate(() => { + const handle = window.__editor; + if (!handle) throw new Error("test hook not installed"); + return handle.editor.getNodes().map((node) => { + const view = handle.area.nodeViews.get(node.id); + return { + id: node.id, + label: node.label, + selected: Boolean(node.selected), + parent: node.parent ?? null, + position: view + ? { x: view.position.x, y: view.position.y } + : { x: 0, y: 0 }, + }; + }); + }); +} + +export async function getZoom(page: Page) { + return page.evaluate(() => { + const handle = window.__editor; + if (!handle) throw new Error("test hook not installed"); + return handle.area.area.transform.k; + }); +} + +/** Centre of the canvas, in viewport coords. Useful for cursor-anchored shortcuts. */ +export async function canvasCentre(page: Page) { + const box = await canvas(page).boundingBox(); + if (!box) throw new Error("canvas not visible"); + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; +} + +/** + * Moves the mouse over the canvas before triggering a keyboard shortcut so + * setupKeyListeners pointermove tracker has a known position. + */ +export async function pressShortcutAt( + page: Page, + key: string, + position?: { x: number; y: number }, +) { + const target = position ?? (await canvasCentre(page)); + await page.mouse.move(target.x, target.y); + await page.keyboard.press(key); +} + +/** Locator for a specific node by its rete id. */ +export function nodeById(page: Page, id: string): Locator { + return page.locator(`[data-node-id="${id}"]`); +} + +/** Output / input socket locators for a node, by the project-wide testid convention. */ +export function outSocket(page: Page, id: string): Locator { + return page.getByTestId(`socket-output-${id}`); +} + +export function inSocket(page: Page, id: string): Locator { + return page.getByTestId(`socket-input-${id}`); +} + +export type ConnectionSnapshot = { + id: string; + source: string; + target: string; + sourceOutput: string; + targetInput: string; +}; + +export async function getConnections( + page: Page, +): Promise { + return page.evaluate(() => { + const handle = window.__editor; + if (!handle) throw new Error("test hook not installed"); + return handle.editor.getConnections().map((c) => ({ + id: c.id, + source: c.source, + target: c.target, + sourceOutput: String(c.sourceOutput), + targetInput: String(c.targetInput), + })); + }); +} + +/** + * Lets pending editor work settle (node/connection creation is async). Flushes + * two animation frames, so a negative assertion taken afterwards cannot + * false-pass before a wrongly-created node or connection has had time to land. + */ +export async function settle(page: Page) { + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); +} + +/** + * Adds a node via its keyboard shortcut at the given canvas-relative offset + * from the centre. Waits for the node to actually appear (creation is async) + * and returns the newly created node id (diffed against the prior set, since + * the editor does not guarantee node ordering). + */ +export async function addNodeAt( + page: Page, + key: string, + dx: number, + dy: number, +): Promise { + const before = new Set((await getNodes(page)).map((n) => n.id)); + const centre = await canvasCentre(page); + await pressShortcutAt(page, key, { x: centre.x + dx, y: centre.y + dy }); + await page.waitForFunction( + (count) => (window.__editor?.editor.getNodes().length ?? 0) > count, + before.size, + ); + const created = (await getNodes(page)).find((n) => !before.has(n.id)); + if (!created) throw new Error("node was not created"); + return created.id; +} + +/** + * Drags a node by (dx, dy). Grabs near the top-left to avoid the socket hit + * zones along the bottom edge. + */ +export async function dragNode(page: Page, id: string, dx: number, dy: number) { + const box = await nodeById(page, id).boundingBox(); + if (!box) throw new Error(`node ${id} not visible`); + const start = { x: box.x + 30, y: box.y + 20 }; + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + await page.mouse.move(start.x + dx, start.y + dy, { steps: 10 }); + await page.mouse.up(); +} + +/** Hold past the editor's 250ms scope-enter threshold before dragging. */ +export const SCOPE_HOLD_MS = 300; + +/** + * Press-hold-drag gesture used to nest a node into (or out of) a scope: press, + * hold past SCOPE_HOLD_MS to enter scope mode, then drag to the target and drop. + */ +export async function dragWithScopeHold( + page: Page, + from: { x: number; y: number }, + to: { x: number; y: number }, +) { + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + await page.waitForTimeout(SCOPE_HOLD_MS); + await page.mouse.move(to.x, to.y, { steps: 15 }); + await page.mouse.up(); +} + +/** + * Asserts a node moved by approximately (dx, dy). Nodes snap to the grid, so the + * observed delta is allowed to differ from the requested drag by up to one GRID. + */ +export function expectMovedBy( + before: NodeSnapshot, + after: NodeSnapshot, + dx: number, + dy: number, + tolerance: number = GRID, +) { + expect(after.position.x - before.position.x).toBeGreaterThanOrEqual( + dx - tolerance, + ); + expect(after.position.x - before.position.x).toBeLessThanOrEqual( + dx + tolerance, + ); + expect(after.position.y - before.position.y).toBeGreaterThanOrEqual( + dy - tolerance, + ); + expect(after.position.y - before.position.y).toBeLessThanOrEqual( + dy + tolerance, + ); +} + +/** + * Builds a Limit node with `childCount` Count children nested inside it. Returns + * the limit id and the child ids. Parenting is assigned directly on the model + * (the same field the scopes plugin sets) so a test can start from a populated + * limit without performing the drag gesture each time. + */ +export async function createLimitWithChildren(page: Page, childCount: number) { + const limitId = await addNodeAt(page, "l", -200, 0); + const childIds: string[] = []; + for (let i = 0; i < childCount; i++) { + childIds.push(await addNodeAt(page, "1", 100 + i * 100, (i % 2) * 100)); + } + await page.evaluate( + ({ parentId, ids }) => { + const { editor } = window.__editor!; + for (const id of ids) { + const node = editor.getNode(id); + if (node) node.parent = parentId; + } + }, + { parentId: limitId, ids: childIds }, + ); + return { limitId, childIds }; +} diff --git a/apps/web/e2e/history.spec.ts b/apps/web/e2e/history.spec.ts new file mode 100644 index 00000000..9915a9ff --- /dev/null +++ b/apps/web/e2e/history.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from "@playwright/test"; +import { + addNodeAt, + createLimitWithChildren, + getConnections, + getNodes, + gotoEditor, + nodeById, +} from "./helpers"; +import type { EvalLimitItemOperatorEnum } from "@repo/schema"; + +test.describe("Undo & redo", () => { + test.beforeEach(async ({ page }) => { + await gotoEditor(page); + }); + + test("Cmd/Ctrl+Z undoes a node creation", async ({ page }) => { + await addNodeAt(page, "a", 0, 0); + expect(await getNodes(page)).toHaveLength(1); + + await page.keyboard.press("ControlOrMeta+z"); + expect(await getNodes(page)).toHaveLength(0); + }); + + test("Cmd/Ctrl+Shift+Z redoes", async ({ page }) => { + await addNodeAt(page, "a", 0, 0); + await page.keyboard.press("ControlOrMeta+z"); + expect(await getNodes(page)).toHaveLength(0); + + await page.keyboard.press("ControlOrMeta+Shift+z"); + expect(await getNodes(page)).toHaveLength(1); + }); + + test("undo restores a cascade-delete (Limit + children + connections)", async ({ + page, + }) => { + const { limitId, childIds } = await createLimitWithChildren(page, 2); + + await page.evaluate( + ({ aId, bId }) => + window.__editor!.addLimitItemConnection( + aId, + bId, + "AND" as EvalLimitItemOperatorEnum, + ), + { aId: childIds[0], bId: childIds[1] }, + ); + expect(await getConnections(page)).toHaveLength(1); + + await nodeById(page, limitId).click({ position: { x: 5, y: 5 } }); + await page.keyboard.press("Backspace"); + expect(await getNodes(page)).toHaveLength(0); + expect(await getConnections(page)).toHaveLength(0); + + await page.keyboard.press("ControlOrMeta+z"); + const restored = await getNodes(page); + expect(restored).toHaveLength(3); + expect(restored.filter((n) => n.parent === limitId)).toHaveLength(2); + expect(await getConnections(page)).toHaveLength(1); + }); +}); diff --git a/apps/web/e2e/movement.spec.ts b/apps/web/e2e/movement.spec.ts new file mode 100644 index 00000000..5c0ff486 --- /dev/null +++ b/apps/web/e2e/movement.spec.ts @@ -0,0 +1,76 @@ +import { GRID } from "@/modules/evaluation/graph/editor/constants"; +import { expect, test } from "@playwright/test"; +import { + addNodeAt, + createLimitWithChildren, + dragNode, + expectMovedBy, + getNodes, + gotoEditor, + nodeById, +} from "./helpers"; + +test.describe("Node movement", () => { + test.beforeEach(async ({ page }) => { + await gotoEditor(page); + }); + + test("dragging a node moves it by approximately the drag delta", async ({ + page, + }) => { + const id = await addNodeAt(page, "a", 0, 0); + const before = (await getNodes(page)).find((n) => n.id === id)!; + + await dragNode(page, id, 60, 40); + + const after = (await getNodes(page)).find((n) => n.id === id)!; + expectMovedBy(before, after, 60, 40); + }); + + test("node positions remain grid aligned after drag", async ({ page }) => { + const id = await addNodeAt(page, "a", 0, 0); + await dragNode(page, id, 73, 47); + + const after = (await getNodes(page)).find((n) => n.id === id)!; + expect(after.position.x % GRID).toBe(0); + expect(after.position.y % GRID).toBe(0); + }); + + test("dragging a LimitNode moves its children with it", async ({ page }) => { + const { limitId, childIds } = await createLimitWithChildren(page, 2); + + await nodeById(page, limitId).click({ position: { x: 5, y: 5 } }); + const before = await getNodes(page); + expect(childIds).toHaveLength(2); + + await dragNode(page, limitId, 60, 40); + + const after = await getNodes(page); + for (const childId of childIds) { + const b = before.find((n) => n.id === childId)!; + const a = after.find((n) => n.id === childId)!; + expectMovedBy(b, a, 60, 40); + } + }); + + test("multi select drag moves every selected node", async ({ page }) => { + const aId = await addNodeAt(page, "a", -250, 0); + const bId = await addNodeAt(page, "o", 250, 0); + + await nodeById(page, aId).click(); + await nodeById(page, bId).click({ modifiers: ["ControlOrMeta"] }); + + const before = await getNodes(page); + + await page.keyboard.down("ControlOrMeta"); + await dragNode(page, aId, 60, 40); + await page.keyboard.up("ControlOrMeta"); + + const after = await getNodes(page); + for (const id of [aId, bId]) { + const b = before.find((n) => n.id === id)!; + const a = after.find((n) => n.id === id)!; + expectMovedBy(b, a, 60, 40); + } + }); +}); diff --git a/apps/web/e2e/scopes.spec.ts b/apps/web/e2e/scopes.spec.ts new file mode 100644 index 00000000..c27a4fb8 --- /dev/null +++ b/apps/web/e2e/scopes.spec.ts @@ -0,0 +1,87 @@ +import { expect, test } from "@playwright/test"; +import { + addNodeAt, + dragWithScopeHold, + getNodes, + gotoEditor, + nodeById, +} from "./helpers"; + +test.describe("Scopes", () => { + test.beforeEach(async ({ page }) => { + await gotoEditor(page); + }); + + test("LimitNode honours its minimum size when empty", async ({ page }) => { + const id = await addNodeAt(page, "l", 0, 0); + + const { width, height, minWidth, minHeight } = await page.evaluate( + (nodeId) => { + const handle = window.__editor!; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const node = handle.editor.getNode(nodeId) as any; + const min = node.getMinSize(); + return { + width: node.width as number, + height: node.height as number, + minWidth: min.width as number, + minHeight: min.height as number, + }; + }, + id, + ); + + expect(width).toBeGreaterThanOrEqual(minWidth); + expect(height).toBeGreaterThanOrEqual(minHeight); + }); + + test("dragging a rule node onto a Limit makes it a child", async ({ + page, + }) => { + const limitId = await addNodeAt(page, "l", -100, 0); + const countId = await addNodeAt(page, "1", 250, 0); + + const countBox = await nodeById(page, countId).boundingBox(); + const limitBox = await nodeById(page, limitId).boundingBox(); + if (!countBox || !limitBox) throw new Error("node not visible"); + + const from = { x: countBox.x + 30, y: countBox.y + 20 }; + const to = { + x: limitBox.x + limitBox.width / 2, + y: limitBox.y + limitBox.height / 2, + }; + + await dragWithScopeHold(page, from, to); + + const after = (await getNodes(page)).find((n) => n.id === countId)!; + expect(after.parent).toBe(limitId); + }); + + test("dragging a child out of its Limit clears the parent", async ({ + page, + }) => { + const limitId = await addNodeAt(page, "l", -100, 0); + const countId = await addNodeAt(page, "1", 0, 0); + + await page.evaluate( + ({ parentId, childId }) => { + const { editor } = window.__editor!; + const child = editor.getNode(childId)!; + child.parent = parentId; + }, + { parentId: limitId, childId: countId }, + ); + + const childBox = await nodeById(page, countId).boundingBox(); + if (!childBox) throw new Error("child not visible"); + const from = { x: childBox.x + 30, y: childBox.y + 20 }; + + await dragWithScopeHold(page, from, { + x: from.x + 500, + y: from.y + 300, + }); + + const after = (await getNodes(page)).find((n) => n.id === countId)!; + expect(after.parent).toBeNull(); + }); +}); diff --git a/apps/web/e2e/selection.spec.ts b/apps/web/e2e/selection.spec.ts new file mode 100644 index 00000000..5f72909b --- /dev/null +++ b/apps/web/e2e/selection.spec.ts @@ -0,0 +1,100 @@ +import { expect, test } from "@playwright/test"; +import { + addNodeAt, + canvas, + createLimitWithChildren, + getNodes, + gotoEditor, + nodeById, +} from "./helpers"; + +test.describe("Selection", () => { + test.beforeEach(async ({ page }) => { + await gotoEditor(page); + }); + + test("clicking a node selects it & clicking another node replaces selection", async ({ + page, + }) => { + await addNodeAt(page, "a", -200, 0); + await addNodeAt(page, "o", 200, 0); + + const initial = await getNodes(page); + expect(initial).toHaveLength(2); + + const [first, second] = initial as [ + (typeof initial)[number], + (typeof initial)[number], + ]; + + await nodeById(page, first.id).click(); + let nodes = await getNodes(page); + expect(nodes.find((n) => n.id === first.id)?.selected).toBe(true); + expect(nodes.find((n) => n.id === second.id)?.selected).toBe(false); + + await nodeById(page, second.id).click(); + nodes = await getNodes(page); + expect(nodes.find((n) => n.id === first.id)?.selected).toBe(false); + expect(nodes.find((n) => n.id === second.id)?.selected).toBe(true); + }); + + test("clicking background deselects everything", async ({ page }) => { + await addNodeAt(page, "a", 0, 0); + expect((await getNodes(page))[0]!.selected).toBe(true); + + await canvas(page).click({ position: { x: 10, y: 10 } }); + expect((await getNodes(page))[0]!.selected).toBe(false); + }); + + test("Pressing escape key deselects everything", async ({ page }) => { + await addNodeAt(page, "a", 0, 0); + expect((await getNodes(page))[0]!.selected).toBe(true); + + await page.keyboard.press("Escape"); + expect((await getNodes(page))[0]!.selected).toBe(false); + }); + + test("Cmd/Ctrl+A selects every node", async ({ page }) => { + await addNodeAt(page, "a", -200, 0); + await addNodeAt(page, "o", 200, 0); + await addNodeAt(page, "r", 0, 200); + + await page.keyboard.press("Escape"); + expect((await getNodes(page)).every((n) => !n.selected)).toBe(true); + + await page.keyboard.press("ControlOrMeta+a"); + + const nodes = await getNodes(page); + expect(nodes.every((n) => n.selected)).toBe(true); + }); + + test("Ctrl+click accumulates selection", async ({ page }) => { + await addNodeAt(page, "a", -200, 0); + await addNodeAt(page, "o", 200, 0); + const [firstId, secondId] = (await getNodes(page)).map((n) => n.id) as [ + string, + string, + ]; + + await nodeById(page, firstId).click(); + await nodeById(page, secondId).click({ modifiers: ["ControlOrMeta"] }); + + const nodes = await getNodes(page); + expect(nodes.every((n) => n.selected)).toBe(true); + }); + + test("clicking a LimitNode selects all of its children", async ({ page }) => { + const { limitId } = await createLimitWithChildren(page, 2); + + await page.keyboard.press("Escape"); + expect((await getNodes(page)).every((n) => !n.selected)).toBe(true); + + await nodeById(page, limitId).click({ position: { x: 5, y: 5 } }); + + const nodes = await getNodes(page); + expect(nodes.find((n) => n.id === limitId)?.selected).toBe(true); + const children = nodes.filter((n) => n.parent === limitId); + expect(children).toHaveLength(2); + expect(children.every((n) => n.selected)).toBe(true); + }); +}); diff --git a/apps/web/e2e/viewport.spec.ts b/apps/web/e2e/viewport.spec.ts new file mode 100644 index 00000000..69e6f4ae --- /dev/null +++ b/apps/web/e2e/viewport.spec.ts @@ -0,0 +1,67 @@ +import { + ZOOM_MAX, + ZOOM_MIN, +} from "@/modules/evaluation/graph/editor/constants"; +import { expect, test } from "@playwright/test"; +import { canvasCentre, getZoom, gotoEditor } from "./helpers"; + +const PRECISION = 5; + +test.describe("Canvas viewport", () => { + test.beforeEach(async ({ page }) => { + await gotoEditor(page); + }); + + test("wheel down zooms out", async ({ page }) => { + expect(await getZoom(page)).toBe(ZOOM_MAX); + + const centre = await canvasCentre(page); + await page.mouse.move(centre.x, centre.y); + await page.mouse.wheel(0, 200); + + await expect.poll(() => getZoom(page)).toBeLessThan(ZOOM_MAX); + }); + + test("wheel up is clamped at the max", async ({ page }) => { + const centre = await canvasCentre(page); + await page.mouse.move(centre.x, centre.y); + + for (let i = 0; i < 10; i++) { + await page.mouse.wheel(0, -300); + } + + await expect.poll(() => getZoom(page)).toBe(ZOOM_MAX); + }); + + test("wheel down is clamped at the min", async ({ page }) => { + const centre = await canvasCentre(page); + await page.mouse.move(centre.x, centre.y); + + for (let i = 0; i < 30; i++) { + await page.mouse.wheel(0, 300); + } + + await expect.poll(() => getZoom(page)).toBeCloseTo(ZOOM_MIN, PRECISION); + }); + + test("dragging the background pans the canvas", async ({ page }) => { + const before = await page.evaluate(() => ({ + x: window.__editor!.area.area.transform.x, + y: window.__editor!.area.area.transform.y, + })); + + const centre = await canvasCentre(page); + await page.mouse.move(centre.x, centre.y); + await page.mouse.down({ button: "left" }); + await page.mouse.move(centre.x + 200, centre.y + 150, { steps: 10 }); + await page.mouse.up({ button: "left" }); + + const after = await page.evaluate(() => ({ + x: window.__editor!.area.area.transform.x, + y: window.__editor!.area.area.transform.y, + })); + + expect(after.x - before.x).toBeCloseTo(200, 0); + expect(after.y - before.y).toBeCloseTo(150, 0); + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 0738b5c5..37342698 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,7 +7,12 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:unit": "vitest run", + "test:watch": "vitest --watch", + "test:e2e": "playwright test", + "test:all": "vitest run && playwright test" }, "dependencies": { "@base-ui/react": "^1.1.0", @@ -47,9 +52,20 @@ "react-i18next": "^16.5.4", "react-konva": "^19.2.1", "react-redux": "^9.2.0", + "react-resizable-panels": "^4.8.0", "react-router": "^7.13.0", "recharts": "^3.7.0", + "rete": "^2.0.6", + "rete-area-plugin": "^2.1.5", + "rete-auto-arrange-plugin": "^2.0.2", + "rete-connection-plugin": "^2.0.5", + "rete-context-menu-plugin": "^2.0.6", + "rete-history-plugin": "^2.1.1", + "rete-react-plugin": "^2.1.0", + "rete-render-utils": "^2.0.3", + "rete-scopes-plugin": "^2.1.1", "sonner": "^2.0.7", + "styled-components": "^6.3.12", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", "ts-ebml": "^3.0.2", @@ -60,6 +76,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@playwright/test": "^1.60.0", "@repo/eslint-config": "workspace:*", "@repo/typescript-config": "workspace:*", "@types/lodash": "^4.17.23", @@ -76,6 +93,7 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", "vite": "^7.2.4", - "vite-tsconfig-paths": "^6.0.5" + "vite-tsconfig-paths": "^6.0.5", + "vitest": "^4.0.18" } } diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 00000000..4b7fb3ae --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from "@playwright/test"; + +const PORT = Number(process.env.PORT ?? 5174); +const BASE_URL = `http://localhost:${PORT}`; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? "line" : "list", + use: { + baseURL: BASE_URL, + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: { + // Run dev (not preview) so hot-reloaded source matches what unit tests cover. + // VITE_E2E=true is read by the app to expose `window.__editor`. + // + // NOTE: these specs navigate to `/` and wait for `window.__editor` (see e2e/helpers.ts). + // They will not pass until the graph editor Workspace + // (src/modules/evaluation/graph/workspace/Workspace.tsx) is mounted at a route the suite + // visits and the E2E test hook is installed there. That route wiring is a follow-up. + command: `VITE_E2E=true pnpm dev --port ${PORT} --strictPort`, + url: BASE_URL, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, +}); diff --git a/apps/web/src/app/router/index.tsx b/apps/web/src/app/router/index.tsx index 85b5e965..cb2482d0 100644 --- a/apps/web/src/app/router/index.tsx +++ b/apps/web/src/app/router/index.tsx @@ -18,6 +18,7 @@ import { ModelDetailPage, ModelNewPage } from "@/modules/model/components"; import { ProjectsPage } from "@/modules/project"; import { ProjectPage } from "@/modules/project/components/ProjectPage"; import { RunPage } from "@/modules/run"; +import { E2EEditorPage } from "@/modules/evaluation/graph/workspace/E2EEditorPage"; import { createBrowserRouter, Navigate, RouteObject } from "react-router"; const { auth } = appConfig.web.routes; @@ -62,4 +63,8 @@ const authenticatedRoutes: RouteObject = { ], }; -export const router = createBrowserRouter([unguardedPublicRoutes, guestRoutes, authenticatedRoutes]); +// In E2E mode the whole app is replaced by the standalone editor host, so the +// Playwright specs can reach the editor at any path without auth or backend data. +export const router = import.meta.env.VITE_E2E + ? createBrowserRouter([{ path: "*", element: }]) + : createBrowserRouter([unguardedPublicRoutes, guestRoutes, authenticatedRoutes]); diff --git a/apps/web/src/components/icons/AndGateIcon.tsx b/apps/web/src/components/icons/AndGateIcon.tsx new file mode 100644 index 00000000..bb0ffc33 --- /dev/null +++ b/apps/web/src/components/icons/AndGateIcon.tsx @@ -0,0 +1,21 @@ +import type { SVGProps } from "react"; + +type Props = SVGProps & { + color?: string; +}; + +export function AndGateIcon({ color = "currentColor", ...props }: Props) { + return ( + + ); +} diff --git a/apps/web/src/components/icons/OrGateIcon.tsx b/apps/web/src/components/icons/OrGateIcon.tsx new file mode 100644 index 00000000..5d9dc5ca --- /dev/null +++ b/apps/web/src/components/icons/OrGateIcon.tsx @@ -0,0 +1,21 @@ +import type { SVGProps } from "react"; + +type Props = SVGProps & { + color?: string; +}; + +export function OrGateIcon({ color = "currentColor", ...props }: Props) { + return ( + + ); +} diff --git a/apps/web/src/components/icons/index.ts b/apps/web/src/components/icons/index.ts index 87d16594..c84aeb92 100644 --- a/apps/web/src/components/icons/index.ts +++ b/apps/web/src/components/icons/index.ts @@ -23,3 +23,5 @@ export * from "./TableViewIcon"; export * from "./VerticalIcon"; export * from "./XaxisIcon"; export * from "./YaxisIcon"; +export * from "./AndGateIcon"; +export * from "./OrGateIcon"; diff --git a/apps/web/src/global.css b/apps/web/src/global.css index 2eb2a307..bcc8d6ce 100644 --- a/apps/web/src/global.css +++ b/apps/web/src/global.css @@ -209,3 +209,11 @@ @apply font-sans; } } + +/* Marching-ants animation for graph connection paths (evaluation editor). + Defined globally so it does not depend on any one component mounting first. */ +@keyframes dash { + to { + stroke-dashoffset: 0; + } +} diff --git a/apps/web/src/modules/dashboard/components/DeleteLimitDialog/DeleteLimitDialog.tsx b/apps/web/src/modules/dashboard/components/DeleteLimitDialog/DeleteLimitDialog.tsx index a4c8a488..7e23f9a9 100644 --- a/apps/web/src/modules/dashboard/components/DeleteLimitDialog/DeleteLimitDialog.tsx +++ b/apps/web/src/modules/dashboard/components/DeleteLimitDialog/DeleteLimitDialog.tsx @@ -21,9 +21,9 @@ export const DeleteLimitDialog = ({ onCancel, onConfirm, isLoading = false, - title = "Do you really want to delete this limit.", - description = "This action can not be undone. However you can setup a new limit with same parameters.", - confirmLabel = "Delete this limit anyway", + title = "Do you really want to delete this check.", + description = "This action can not be undone. However you can setup a new check with same parameters.", + confirmLabel = "Delete this check anyway", }: DeleteLimitDialogProps) => { return ( !open && !isLoading && onCancel()}> diff --git a/apps/web/src/modules/evaluation/api/evalSelectors.ts b/apps/web/src/modules/evaluation/api/evalSelectors.ts new file mode 100644 index 00000000..45606a73 --- /dev/null +++ b/apps/web/src/modules/evaluation/api/evalSelectors.ts @@ -0,0 +1,40 @@ +import { EvalLimit, EvalLimitDetail } from "@repo/schema"; +import { evaluationApi } from "./evaluationApi"; + +type TestCaseArgs = { + projectId: number; + configId: number; + testCaseId: string; +}; + +// Peek the full-test-case SSOT entry WITHOUT subscribing — `useQueryState` reads the +// cache reactively but never triggers a fetch or keeps the entry alive. This lets the +// table reuse data the graph loaded without itself fanning out N requests on overview +// render (see the SSOT plan). When the entry isn't cached, callers fall back. + +/** + * Limit rows for the table. Returns the SSOT limits (with items, a superset of the + * row shape) when the graph has loaded them, else the caller's already-loaded fallback + * (the overview list's embedded limits). + */ +export function useLimitRows( + args: TestCaseArgs, + fallback: EvalLimit[], +): EvalLimit[] { + const { data } = evaluationApi.endpoints.getEvalTestCaseFull.useQueryState(args); + return data?.limits ?? fallback; +} + +/** + * A single limit WITH its items, read from the SSOT if present (no request). Returns + * undefined when the full entry isn't cached or the limit isn't found — the caller then + * lazily fetches the limit detail. + */ +export function useLimitDetail( + args: TestCaseArgs, + limitId: string | null, +): EvalLimitDetail | undefined { + const { data } = evaluationApi.endpoints.getEvalTestCaseFull.useQueryState(args); + if (!limitId) return undefined; + return data?.limits.find((limit) => limit.id === limitId); +} diff --git a/apps/web/src/modules/evaluation/api/evaluationApi.ts b/apps/web/src/modules/evaluation/api/evaluationApi.ts index 68e5a5c1..037d2775 100644 --- a/apps/web/src/modules/evaluation/api/evaluationApi.ts +++ b/apps/web/src/modules/evaluation/api/evaluationApi.ts @@ -7,12 +7,15 @@ import { EvalTestCase, EvalTestCaseCreateOrUpdate, EvalTestCaseDetail, + EvalTestCaseFull, + EvalTestCaseFullCreateOrUpdate, EvalThreshold, EvalThresholdCreateOrUpdate, EvalThresholdsResponse, evalLimitDetailSchema, evalLimitSchema, evalTestCaseDetailSchema, + evalTestCaseFullSchema, evalTestCaseSchema, evalThresholdsResponseSchema, } from "@repo/schema"; @@ -72,6 +75,22 @@ export const evaluationApi = api.injectEndpoints({ ], }), + // Single source of truth for a test case: the whole thing (meta + logic tree + + // limits WITH their items) in one GET /full call. Both the graph (subscribes) and + // the table (peeks, non-subscribing) read this one cache entry. + getEvalTestCaseFull: builder.query< + EvalTestCaseFull, + { projectId: number; configId: number; testCaseId: string } + >({ + query: ({ projectId, configId, testCaseId }) => + `/eval/${projectId}/config/${configId}/test-case/${testCaseId}/full`, + transformResponse: (response) => evalTestCaseFullSchema.parse(response), + providesTags: (_result, _error, { testCaseId }) => [ + { type: apiCacheTags.eval.testCases, id: testCaseId }, + { type: apiCacheTags.eval.limits, id: testCaseId }, + ], + }), + // Eval Test Case Mutations createEvalTestCase: builder.mutation< EvalTestCaseDetail, @@ -110,6 +129,73 @@ export const evaluationApi = api.injectEndpoints({ ], }), + // Saves an entire test case in one request — name/type/severity/enabled plus the + // full limits (with their items) and logic tree. This is what the graph editor uses + // to persist the serialized flow; the BE diffs limits/items by id. + updateEvalTestCaseFull: builder.mutation< + EvalTestCaseFull, + { + projectId: number; + configId: number; + testCaseId: string; + body: EvalTestCaseFullCreateOrUpdate; + } + >({ + query: ({ projectId, configId, testCaseId, body }) => ({ + url: `/eval/${projectId}/config/${configId}/test-case/${testCaseId}/full`, + method: "PUT", + body, + }), + transformResponse: (response) => evalTestCaseFullSchema.parse(response), + // The PUT /full response is the authoritative full test case (limits WITH items + + // real ids for newly created ones). Patch the caches straight from it so both views + // reflect instantly without waiting on the invalidation refetch. + async onQueryStarted( + { projectId, configId, testCaseId }, + { dispatch, queryFulfilled }, + ) { + try { + const { data } = await queryFulfilled; // EvalTestCaseFull (limits WITH items) + + // SSOT — replace the whole entry with the authoritative response. + dispatch( + evaluationApi.util.updateQueryData( + "getEvalTestCaseFull", + { projectId, configId, testCaseId }, + () => data, + ), + ); + + // Overview list — item-free limits, so strip logicNodes + limitItems. + dispatch( + evaluationApi.util.updateQueryData( + "getEvalTestCases", + { projectId, configId }, + (draft) => { + const index = draft.findIndex((tc) => tc.id === testCaseId); + if (index === -1) return; + const { logicNodes: _logicNodes, limits, ...rest } = data; + draft[index] = { + ...rest, + limits: limits.map( + ({ limitItems: _limitItems, ...limit }) => limit, + ), + }; + }, + ), + ); + } catch { + // Mutation failed — leave caches untouched; nothing was persisted. + } + }, + invalidatesTags: (_result, _error, { configId, testCaseId }) => [ + { type: apiCacheTags.eval.testCases, id: configId }, + { type: apiCacheTags.eval.testCases, id: testCaseId }, + { type: apiCacheTags.eval.limits, id: testCaseId }, + { type: apiCacheTags.eval.thresholds, id: configId }, + ], + }), + // Eval Limit Mutations createEvalLimit: builder.mutation< EvalLimitDetail, @@ -121,6 +207,39 @@ export const evaluationApi = api.injectEndpoints({ body, }), transformResponse: (response) => evalLimitDetailSchema.parse(response), + // Add the new limit to both the SSOT (with items) and the overview list (item-free). + // No-op on the full entry when it isn't cached (table-only session). + async onQueryStarted( + { projectId, configId, testCaseId }, + { dispatch, queryFulfilled }, + ) { + try { + const { data } = await queryFulfilled; + const { limitItems: _items, ...listLimit } = data; + dispatch( + evaluationApi.util.updateQueryData( + "getEvalTestCaseFull", + { projectId, configId, testCaseId }, + (draft) => { + draft.limits.push(data); + }, + ), + ); + dispatch( + evaluationApi.util.updateQueryData( + "getEvalTestCases", + { projectId, configId }, + (draft) => { + draft + .find((tc) => tc.id === testCaseId) + ?.limits.push(listLimit); + }, + ), + ); + } catch { + // Persist failed — invalidation will reconcile. + } + }, invalidatesTags: (_result, _error, { configId, testCaseId }) => [ { type: apiCacheTags.eval.limits, id: testCaseId }, { type: apiCacheTags.eval.testCases, id: configId }, @@ -144,6 +263,39 @@ export const evaluationApi = api.injectEndpoints({ body, }), transformResponse: (response) => evalLimitDetailSchema.parse(response), + // Replace the limit in both the SSOT (with items) and the overview list (item-free). + async onQueryStarted( + { projectId, configId, testCaseId, limitId }, + { dispatch, queryFulfilled }, + ) { + try { + const { data } = await queryFulfilled; + const { limitItems: _items, ...listLimit } = data; + dispatch( + evaluationApi.util.updateQueryData( + "getEvalTestCaseFull", + { projectId, configId, testCaseId }, + (draft) => { + const index = draft.limits.findIndex((l) => l.id === limitId); + if (index !== -1) draft.limits[index] = data; + }, + ), + ); + dispatch( + evaluationApi.util.updateQueryData( + "getEvalTestCases", + { projectId, configId }, + (draft) => { + const limits = draft.find((tc) => tc.id === testCaseId)?.limits; + const index = limits?.findIndex((l) => l.id === limitId) ?? -1; + if (limits && index !== -1) limits[index] = listLimit; + }, + ), + ); + } catch { + // Persist failed — invalidation will reconcile. + } + }, invalidatesTags: (_result, _error, { configId, testCaseId, limitId }) => [ { type: apiCacheTags.eval.limits, id: testCaseId }, { type: apiCacheTags.eval.limits, id: limitId }, @@ -160,6 +312,37 @@ export const evaluationApi = api.injectEndpoints({ url: `/eval/${projectId}/config/${configId}/limit/${testCaseId}/${limitId}`, method: "DELETE", }), + // Remove the limit from both the SSOT and the overview list once the BE confirms. + async onQueryStarted( + { projectId, configId, testCaseId, limitId }, + { dispatch, queryFulfilled }, + ) { + try { + const { data } = await queryFulfilled; + if (!data.deleted) return; + dispatch( + evaluationApi.util.updateQueryData( + "getEvalTestCaseFull", + { projectId, configId, testCaseId }, + (draft) => { + draft.limits = draft.limits.filter((l) => l.id !== limitId); + }, + ), + ); + dispatch( + evaluationApi.util.updateQueryData( + "getEvalTestCases", + { projectId, configId }, + (draft) => { + const tc = draft.find((t) => t.id === testCaseId); + if (tc) tc.limits = tc.limits.filter((l) => l.id !== limitId); + }, + ), + ); + } catch { + // Persist failed — invalidation will reconcile. + } + }, invalidatesTags: (_result, _error, { configId, testCaseId, limitId }) => [ { type: apiCacheTags.eval.limits, id: testCaseId }, { type: apiCacheTags.eval.limits, id: limitId }, @@ -250,6 +433,7 @@ export const { useGetEvalLimitQuery, useGetEvalLimitsQuery, useGetEvalTestCaseQuery, + useGetEvalTestCaseFullQuery, useGetEvalTestCasesQuery, useLazyGetEvalLimitQuery, useLazyGetEvalLimitsQuery, @@ -257,6 +441,7 @@ export const { useLazyGetEvalTestCasesQuery, useCreateEvalTestCaseMutation, useUpdateEvalTestCaseMutation, + useUpdateEvalTestCaseFullMutation, useCreateEvalLimitMutation, useUpdateEvalLimitMutation, useDeleteEvalLimitMutation, diff --git a/apps/web/src/modules/evaluation/components/CreateUpdateLimit/CreateLimitModal.tsx b/apps/web/src/modules/evaluation/components/CreateUpdateLimit/CreateLimitModal.tsx index 94fdc328..b699528f 100644 --- a/apps/web/src/modules/evaluation/components/CreateUpdateLimit/CreateLimitModal.tsx +++ b/apps/web/src/modules/evaluation/components/CreateUpdateLimit/CreateLimitModal.tsx @@ -43,7 +43,7 @@ export const CreateLimitModal = ({ - Add Limit + Add Check diff --git a/apps/web/src/modules/evaluation/components/CreateUpdateLimit/LimitItemRow.tsx b/apps/web/src/modules/evaluation/components/CreateUpdateLimit/LimitItemRow.tsx index 91e9ad5a..c82b667a 100644 --- a/apps/web/src/modules/evaluation/components/CreateUpdateLimit/LimitItemRow.tsx +++ b/apps/web/src/modules/evaluation/components/CreateUpdateLimit/LimitItemRow.tsx @@ -264,7 +264,7 @@ export function LimitItemRow({ variant="ghost" size="icon-sm" className="shrink-0 self-end text-muted-foreground hover:text-destructive" - aria-label="Delete limit" + aria-label="Delete check item" onClick={onDelete} > diff --git a/apps/web/src/modules/evaluation/components/GraphEditor/GraphEditor.tsx b/apps/web/src/modules/evaluation/components/GraphEditor/GraphEditor.tsx new file mode 100644 index 00000000..95a3feb2 --- /dev/null +++ b/apps/web/src/modules/evaluation/components/GraphEditor/GraphEditor.tsx @@ -0,0 +1,380 @@ +import { cn } from "@/lib/utils"; +import type { LabelOption } from "@/modules/evaluation/graph/editor/controls/label"; +import { EditorDebugOverlay } from "@/modules/evaluation/graph/editor/debug/EditorDebugOverlay"; +import { + useGetEvalTestCaseFullQuery, + useUpdateEvalTestCaseFullMutation, +} from "@/modules/evaluation/api/evaluationApi"; +import { deserializeTestCase } from "@/modules/evaluation/graph/editor/deserialization/deserializeTestCase"; +import { serializeTestCase } from "@/modules/evaluation/graph/editor/serialization/serializeTestCase"; +import { toFullCreateOrUpdate } from "@/modules/evaluation/graph/editor/serialization/toFullCreateOrUpdate"; +import { + ZOOM_MAX, + ZOOM_MIN, + ZOOM_STEP, +} from "@/modules/evaluation/graph/editor/constants"; +import { createEditor } from "@/modules/evaluation/graph/editor/setup/createEditor"; +import type { Schemes } from "@/modules/evaluation/graph/editor/types"; +import { installTestHook } from "@/modules/evaluation/graph/workspace/testHook"; +import { useGetProjectLabelsQuery } from "@/modules/project/services/projectApi"; +import { Button } from "@/modules/shadcn/ui/button"; +import { + Keyboard, + LayoutGrid, + Maximize2, + Minimize2, + RefreshCw, + RefreshCwOff, + Save, + ScanEye, + ZoomIn, + ZoomOut, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { NodeEditor } from "rete"; +import { AreaExtensions } from "rete-area-plugin"; +import { useRete } from "rete-react-plugin"; +import { toast } from "sonner"; +import { GraphKeybindsDialog } from "./GraphKeybindsDialog"; + +const MIN_HEIGHT = 240; +const DEFAULT_HEIGHT = 480; +const VIEWPORT_MARGIN = 120; + +const clamp = (value: number, min: number, max: number) => + Math.min(Math.max(value, min), max); + +type Props = { + projectId: number; + configId: number; + testCaseId: string; +}; + +export const GraphEditor = ({ projectId, configId, testCaseId }: Props) => { + const editorRef = useRef> | null>( + null, + ); + const [autoValidationEnabled, setAutoValidationEnabled] = useState(false); + const [editor, setEditor] = useState | null>(null); + const [keybindsOpen, setKeybindsOpen] = useState(false); + const [height, setHeight] = useState(DEFAULT_HEIGHT); + const [maximized, setMaximized] = useState(false); + const dragStart = useRef<{ y: number; height: number } | null>(null); + const saveRef = useRef<() => void>(() => {}); + const { data: projectLabels, isSuccess: labelsLoaded } = useGetProjectLabelsQuery({ projectId }); + const labelsRef = useRef([]); + labelsRef.current = useMemo( + () => + (projectLabels ?? []).map((label) => ({ + id: label.id, + name: label.name, + })), + [projectLabels], + ); + + const handleResizePointerDown = ( + event: React.PointerEvent, + ) => { + dragStart.current = { y: event.clientY, height }; + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const handleResizePointerMove = ( + event: React.PointerEvent, + ) => { + if (!dragStart.current) return; + + const maxHeight = Math.max( + MIN_HEIGHT, + window.innerHeight - VIEWPORT_MARGIN, + ); + const delta = event.clientY - dragStart.current.y; + setHeight(clamp(dragStart.current.height + delta, MIN_HEIGHT, maxHeight)); + }; + + const handleResizePointerUp = (event: React.PointerEvent) => { + dragStart.current = null; + event.currentTarget.releasePointerCapture(event.pointerId); + }; + + const create = useCallback((el: HTMLElement) => { + const instance = createEditor( + el, + (text, type) => { + if (type === "error") { + toast.error(text); + return; + } + + toast.success(text); + }, + { + toggleFullscreen: () => setMaximized((value) => !value), + getLabels: () => labelsRef.current, + save: () => saveRef.current(), + }, + ); + + Promise.resolve(instance).then((result) => { + editorRef.current = result; + setAutoValidationEnabled(result.validation.isLiveValidationEnabled()); + setEditor(result.editor); + installTestHook({ editor: result.editor, area: result.area }); + }); + + return instance; + }, []); + + const [ref] = useRete(create); + + const { data: testCaseData } = useGetEvalTestCaseFullQuery( + { + projectId, + configId, + testCaseId, + }, + { refetchOnMountOrArgChange: true }, + ); + const [updateTestCaseFull, { isLoading: isSaving }] = useUpdateEvalTestCaseFullMutation(); + const [ready, setReady] = useState(false); + const hasLoadedRef = useRef(false); + + useEffect(() => { + const instance = editorRef.current; + if (hasLoadedRef.current) return; + if (!instance || !editor || !testCaseData) return; + if (!labelsLoaded) return; + + hasLoadedRef.current = true; + let cancelled = false; + setReady(false); + + void (async () => { + try { + const payload = toFullCreateOrUpdate(testCaseData); + + await deserializeTestCase( + instance.editor, + instance.area, + payload, + labelsRef.current, + instance.selectableNodes, + ); + if (cancelled) return; + + await instance.arrange.layout(); + } catch (error) { + if (cancelled) return; + toast.error( + error instanceof Error + ? `Failed to load test case: ${error.message}` + : "Failed to load test case.", + ); + } finally { + if (!cancelled) setReady(true); + } + })(); + + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editor, testCaseData, labelsLoaded]); + + const toggleValidation = async () => { + const instance = editorRef.current; + if (!instance) return; + + const isEnabled = instance.validation.isLiveValidationEnabled(); + + if (!isEnabled) { + await instance.validation.runValidation(); + instance.validation.enableLiveValidation(); + setAutoValidationEnabled(true); + return; + } + + instance.validation.disableLiveValidation(); + await instance.validation.clearValidation(); + setAutoValidationEnabled(false); + }; + + const handleSave = async () => { + const instance = editorRef.current; + if (!instance) return; + + if (!testCaseData) { + toast.error("Test case is still loading. Try again in a moment."); + return; + } + + const validationResult = await instance.validation.validateNow(); + + if (!instance.validation.isLiveValidationEnabled()) { + instance.validation.enableLiveValidation(); + setAutoValidationEnabled(true); + } + + if (!validationResult.valid) { + toast.error("Graph contains errors. Fix them before saving."); + return; + } + + const body = serializeTestCase(instance.editor, { + name: testCaseData.name, + }); + body.enabled = testCaseData.enabled; + + try { + await updateTestCaseFull({ + projectId, + configId, + testCaseId, + body, + }).unwrap(); + + toast.success("Test case saved."); + } catch { + toast.error("Failed to save test case."); + } + }; + + saveRef.current = handleSave; + + const handleArrange = async () => { + const instance = editorRef.current; + if (!instance) return; + + await instance.arrange.layout(); + toast.success("Arranged layout."); + }; + + const zoomBy = useCallback((step: number) => { + const instance = editorRef.current; + if (!instance) return; + + const { area } = instance; + const { x, y, k } = area.area.transform; + const next = clamp(k + step, ZOOM_MIN, ZOOM_MAX); + if (next === k) return; + + // Keep the point at the viewport centre fixed. rete applies + // `transform.{x,y} += o{x,y}` (factor 1 here, since `next` is pre-clamped to + // the restrictor bounds), so the offset is measured from the content's current + // translation — subtract transform.{x,y} or the origin drifts with the pan. + const { width, height } = area.container.getBoundingClientRect(); + const ratio = next / k; + void area.area.zoom( + next, + (width / 2 - x) * (1 - ratio), + (height / 2 - y) * (1 - ratio), + ); + }, []); + + const handleFocus = useCallback(() => { + const instance = editorRef.current; + if (!instance) return; + + const nodes = instance.editor.getNodes(); + if (nodes.length === 0) return; + void AreaExtensions.zoomAt(instance.area, nodes); + }, []); + + return ( +
+
+ {editor && } +
+
+
+ + + + +
+ +
+ + + + +
+
+ {!maximized && ( +
+
+
+ )} + +
+ ); +}; diff --git a/apps/web/src/modules/evaluation/components/GraphEditor/GraphKeybindsDialog.tsx b/apps/web/src/modules/evaluation/components/GraphEditor/GraphKeybindsDialog.tsx new file mode 100644 index 00000000..3c266302 --- /dev/null +++ b/apps/web/src/modules/evaluation/components/GraphEditor/GraphKeybindsDialog.tsx @@ -0,0 +1,118 @@ +import { + formatShortcutKeys, + GROUP_ORDER, + SHORTCUTS, + type ShortcutGroup, +} from "@/modules/evaluation/graph/editor/setup/shortcuts"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/modules/shadcn/ui/dialog"; +import { Keyboard } from "lucide-react"; +import type { ReactNode } from "react"; + +type DialogRootProps = Parameters[0]; + +type Props = Pick; + +const isMac = + typeof navigator !== "undefined" && /mac/i.test(navigator.platform); +const mod = isMac ? "⌘" : "Ctrl"; + +// Derived from the shared shortcut list so this dialog can never drift from the +// bindings in setupKeyListeners.ts. +const SECTIONS = GROUP_ORDER.map((group: ShortcutGroup) => ({ + title: group, + shortcuts: SHORTCUTS.filter((shortcut) => shortcut.group === group).map( + (shortcut) => ({ + keys: formatShortcutKeys(shortcut, mod), + label: shortcut.description, + }), + ), +})).filter((section) => section.shortcuts.length > 0); + +// Split the sections into two balanced columns (even indices left, odd right). +const COLUMNS = [ + SECTIONS.filter((_, i) => i % 2 === 0), + SECTIONS.filter((_, i) => i % 2 === 1), +]; + +function Key({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function ShortcutRow({ + shortcut, +}: { + shortcut: { keys: string[]; label: string }; +}) { + return ( +
+ {shortcut.label} +
+ {shortcut.keys.map((key, i) => ( + + {i > 0 && ( + + + )} + {key} + + ))} +
+
+ ); +} + +export function GraphKeybindsDialog({ open, onOpenChange }: Props) { + return ( + + + +
+ + + +
+ + Keyboard shortcuts + + + Speed up building your test-case logic. + +
+
+
+ +
+ {COLUMNS.map((col, ci) => ( +
+ {col.map((section) => ( +
+

+ {section.title} +

+
+ {section.shortcuts.map((shortcut) => ( + + ))} +
+
+ ))} +
+ ))} +
+ +

+ Shortcuts are disabled while typing in inputs. +

+
+
+ ); +} diff --git a/apps/web/src/modules/evaluation/components/TestCasesOverview/LimitEnabledSwitch.tsx b/apps/web/src/modules/evaluation/components/TestCasesOverview/LimitEnabledSwitch.tsx index 1797324d..fcc59843 100644 --- a/apps/web/src/modules/evaluation/components/TestCasesOverview/LimitEnabledSwitch.tsx +++ b/apps/web/src/modules/evaluation/components/TestCasesOverview/LimitEnabledSwitch.tsx @@ -3,6 +3,7 @@ import { Badge } from "@/modules/shadcn/ui/badge"; import { Switch } from "@/modules/shadcn/ui/switch"; import { EvalLimit, EvalLimitDetail } from "@repo/schema"; import { useState } from "react"; +import { useLimitDetail } from "../../api/evalSelectors"; import { evaluationApi, useLazyGetEvalLimitQuery, @@ -49,24 +50,47 @@ export function LimitEnabledSwitch({ const [updateLimit] = useUpdateEvalLimitMutation(); const [pending, setPending] = useState(false); + // Reuse the SSOT detail when the graph already loaded it — skips the detail fetch. + const cachedDetail = useLimitDetail( + { projectId, configId, testCaseId }, + limit.id, + ); + const handleToggle = async (next: boolean) => { setPending(true); - const patchResult = dispatch( + + // Optimistically flip the row in both sources the table reads from (SSOT + list). + // Either patch is a no-op if that entry isn't cached. + const patchFull = dispatch( evaluationApi.util.updateQueryData( - "getEvalLimits", + "getEvalTestCaseFull", { projectId, configId, testCaseId }, (draft) => { - const cached = draft.find((entry) => entry.id === limit.id); + const cached = draft.limits.find((entry) => entry.id === limit.id); + if (cached) cached.enabled = next; + }, + ), + ); + const patchList = dispatch( + evaluationApi.util.updateQueryData( + "getEvalTestCases", + { projectId, configId }, + (draft) => { + const cached = draft + .find((tc) => tc.id === testCaseId) + ?.limits.find((entry) => entry.id === limit.id); if (cached) cached.enabled = next; }, ), ); try { - const detail = await fetchDetail( - { projectId, configId, testCaseId, limitId: limit.id }, - false, - ).unwrap(); + const detail = + cachedDetail ?? + (await fetchDetail( + { projectId, configId, testCaseId, limitId: limit.id }, + false, + ).unwrap()); await updateLimit({ projectId, @@ -76,7 +100,8 @@ export function LimitEnabledSwitch({ body: detailToUpdateBody(detail, next), }).unwrap(); } catch { - patchResult.undo(); + patchFull.undo(); + patchList.undo(); } finally { setPending(false); } @@ -88,7 +113,7 @@ export function LimitEnabledSwitch({ checked={limit.enabled} onCheckedChange={handleToggle} disabled={pending} - aria-label={limit.enabled ? "Disable limit" : "Enable limit"} + aria-label={limit.enabled ? "Disable check" : "Enable check"} /> {limit.enabled ? ( Enabled diff --git a/apps/web/src/modules/evaluation/components/TestCasesOverview/TestCaseSection.tsx b/apps/web/src/modules/evaluation/components/TestCasesOverview/TestCaseSection.tsx index 9759dd16..64e40c55 100644 --- a/apps/web/src/modules/evaluation/components/TestCasesOverview/TestCaseSection.tsx +++ b/apps/web/src/modules/evaluation/components/TestCasesOverview/TestCaseSection.tsx @@ -1,4 +1,6 @@ +import { cn } from "@/lib/utils"; import { DeleteLimitDialog } from "@/modules/dashboard/components/DeleteLimitDialog"; +import { GraphEditor } from "@/modules/evaluation/components/GraphEditor/GraphEditor"; import { Button } from "@/modules/shadcn/ui/button"; import { Card, CardContent, CardHeader } from "@/modules/shadcn/ui/card"; import { Separator } from "@/modules/shadcn/ui/separator"; @@ -6,11 +8,11 @@ import { DataTable } from "@/modules/ui/components/Table"; import { EvalLimit, EvalTestCase } from "@repo/schema"; import { PencilIcon, PlusIcon, Trash2Icon } from "lucide-react"; import { useState } from "react"; +import { useLimitDetail, useLimitRows } from "../../api/evalSelectors"; import { useDeleteEvalLimitMutation, useDeleteEvalTestCaseMutation, useGetEvalLimitQuery, - useGetEvalLimitsQuery, useGetEvalTestCaseQuery, } from "../../api/evaluationApi"; import { CreateLimitModal, UpdateLimitModal } from "../CreateUpdateLimit"; @@ -29,22 +31,34 @@ type DeleteState = | { type: "blocked"; limitId: string } | null; -export function TestCaseSection({ testCase, projectId, configId }: TestCaseSectionProps) { - const { data: limits = [] } = useGetEvalLimitsQuery({ - projectId, - configId, - testCaseId: testCase.id, - }); +type ViewMode = "table" | "graph"; + +export function TestCaseSection({ + testCase, + projectId, + configId, +}: TestCaseSectionProps) { const [isCreateLimitOpen, setIsCreateLimitOpen] = useState(false); const [limitToEditId, setLimitToEditId] = useState(null); const [deleteState, setDeleteState] = useState(null); const [isEditTestCaseOpen, setIsEditTestCaseOpen] = useState(false); const [isDeleteTestCaseOpen, setIsDeleteTestCaseOpen] = useState(false); + const [viewMode, setViewMode] = useState("table"); + + const args = { projectId, configId, testCaseId: testCase.id }; - const { data: limitToEdit } = useGetEvalLimitQuery( - { projectId, configId, testCaseId: testCase.id, limitId: limitToEditId! }, - { skip: !limitToEditId }, + // Rows come from the SSOT (full cache) when the graph has loaded it, otherwise from + // the limits already embedded in the overview list — no per-section request either way. + const limits = useLimitRows(args, testCase.limits); + + // Limit detail (with items) for the edit modal: prefer the SSOT if the graph loaded + // it; otherwise lazily fetch just that limit (table-only session). + const cachedLimit = useLimitDetail(args, limitToEditId); + const { data: fetchedLimit } = useGetEvalLimitQuery( + { ...args, limitId: limitToEditId! }, + { skip: !limitToEditId || Boolean(cachedLimit) }, ); + const limitToEdit = cachedLimit ?? fetchedLimit; const { data: testCaseDetail } = useGetEvalTestCaseQuery( { projectId, configId, testCaseId: testCase.id }, { skip: !isEditTestCaseOpen }, @@ -97,18 +111,44 @@ export function TestCaseSection({ testCase, projectId, configId }: TestCaseSecti return ( - +

{testCase.name}

- - + {viewMode === "table" && ( + <> + + + + )} + + {/* Centered independently of the side groups so it never shifts when the + left/right buttons change between table and graph view. */} +
+ {(["table", "graph"] as const).map((mode) => ( + + ))} +
+
- + + +
+
+ ); +} diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/components/HatchedBox.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/components/HatchedBox.tsx new file mode 100644 index 00000000..28961114 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/components/HatchedBox.tsx @@ -0,0 +1,24 @@ +export const HatchedBox = () => { + return ( +
+
+ + CHILDREN AREA + +
+
+ ); +}; diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/components/IssueTooltip.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/components/IssueTooltip.tsx new file mode 100644 index 00000000..3b3dc4e2 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/components/IssueTooltip.tsx @@ -0,0 +1,51 @@ +import type { + ValidationIssue, + ValidationIssueLevel, +} from "@/modules/evaluation/graph/editor/validation/types"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/modules/shadcn/ui/tooltip"; +import { Info, TriangleAlert } from "lucide-react"; + +type Props = { + issues: ValidationIssue[]; + level: ValidationIssueLevel; +}; + +export function IssueTooltip(props: Props) { + const { issues, level } = props; + const filteredIssues = issues.filter((issue) => issue.level === level); + + if (filteredIssues.length === 0) return <>; + + return ( + + + {level === "error" ? ( + + ) : ( + + )} + + +
    + {filteredIssues.map((issue, index) => ( +
  • +
    {issue.message}
    + + {issue.description?.length ? ( +
      + {issue.description.map((line, lineIndex) => ( +
    • {line}
    • + ))} +
    + ) : null} +
  • + ))} +
+
+
+ ); +} diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/components/Label.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/components/Label.tsx new file mode 100644 index 00000000..53c0b1fa --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/components/Label.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from "react"; + +type Props = { + children: ReactNode; +}; + +export const Label = (props: Props) => { + const { children } = props; + + return ( + {children} + ); +}; diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/components/containers/CompactNodeContainer.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/components/containers/CompactNodeContainer.tsx new file mode 100644 index 00000000..c08f3e00 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/components/containers/CompactNodeContainer.tsx @@ -0,0 +1,61 @@ +import { cn } from "@/lib/utils"; +import { GRID } from "@/modules/evaluation/graph/editor/constants"; +import { IssueTooltip } from "@/modules/evaluation/graph/editor/ui/components/IssueTooltip"; +import type { ValidationIssue } from "@/modules/evaluation/graph/editor/validation/types"; +import type { ReactNode } from "react"; +import { NodeContainer } from "./NodeContainer"; + +type Props = { + nodeId: string; + width?: number; + height?: number; + selected?: boolean; + issues: ValidationIssue[]; + socketHeight: number; + input?: ReactNode; + center?: ReactNode; + output?: ReactNode; + spread?: boolean; +}; + +export const CompactNodeContainer = (props: Props) => { + const { + nodeId, + width, + height, + selected = false, + issues, + socketHeight, + input, + center, + output, + spread = true, + } = props; + + return ( + +
+
+ + +
+
+ {input} + {center} + {output} +
+
+
+ ); +}; diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/components/containers/NodeBody.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/components/containers/NodeBody.tsx new file mode 100644 index 00000000..c5640dd2 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/components/containers/NodeBody.tsx @@ -0,0 +1,16 @@ +import { GRID } from "@/modules/evaluation/graph/editor/constants"; +import type { ReactNode } from "react"; + +type Props = { + controlsHeight: number; + children: ReactNode; +}; + +export const NodeBody = ({ controlsHeight, children }: Props) => ( +
+ {children} +
+); diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/components/containers/NodeContainer.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/components/containers/NodeContainer.tsx new file mode 100644 index 00000000..4755a401 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/components/containers/NodeContainer.tsx @@ -0,0 +1,35 @@ +import { DebugNodeBadge } from "@/modules/evaluation/graph/editor/debug/DebugNodeBadge"; + +type Props = { + children?: React.ReactNode; + selected?: boolean; + width?: number; + height?: number; + nodeId?: string; +}; + +export const NodeContainer = (props: Props) => { + const { children, selected, width, height, nodeId } = props; + + return ( +
+ {nodeId && } + {children} +
+ ); +}; diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/components/containers/NodeHeader.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/components/containers/NodeHeader.tsx new file mode 100644 index 00000000..2c943e44 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/components/containers/NodeHeader.tsx @@ -0,0 +1,36 @@ +import { GRID } from "@/modules/evaluation/graph/editor/constants"; +import { IssueTooltip } from "@/modules/evaluation/graph/editor/ui/components/IssueTooltip"; +import { Label } from "@/modules/evaluation/graph/editor/ui/components/Label"; +import type { ValidationIssue } from "@/modules/evaluation/graph/editor/validation/types"; +import type { ReactNode } from "react"; + +type Props = { + label: ReactNode; + labelHeight: number; + issues: ValidationIssue[]; + headerRight?: ReactNode; +}; + +export const NodeHeader = ({ + label, + labelHeight, + issues, + headerRight, +}: Props) => ( +
+ + +
+
+ {headerRight} +
+ + +
+
+ +
+); diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/components/containers/ParentNodeContainer.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/components/containers/ParentNodeContainer.tsx new file mode 100644 index 00000000..4d1b5958 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/components/containers/ParentNodeContainer.tsx @@ -0,0 +1,55 @@ +import { GRID } from "@/modules/evaluation/graph/editor/constants"; +import type { NodeProps } from "@/modules/evaluation/graph/editor/types"; +import { HatchedBox } from "@/modules/evaluation/graph/editor/ui/components/HatchedBox"; +import type { ReactNode } from "react"; +import { NodeBody } from "./NodeBody"; +import { NodeContainer } from "./NodeContainer"; +import { NodeHeader } from "./NodeHeader"; + +type Props = { + data: NodeProps; + outputSocket?: ReactNode; + headerRight?: ReactNode; + children: ReactNode; +}; + +export const ParentNodeContainer = (props: Props) => { + const { data, outputSocket, headerRight, children } = props; + + const { + id, + label, + height, + width, + selected = false, + controlsHeight, + socketHeight, + labelHeight, + issues, + } = data; + + return ( + +
+ + {children} +
+ + + + + +
+ {outputSocket} +
+
+ ); +}; diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/components/containers/RegularNodeContainer.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/components/containers/RegularNodeContainer.tsx new file mode 100644 index 00000000..dcaceee3 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/components/containers/RegularNodeContainer.tsx @@ -0,0 +1,47 @@ +import { GRID } from "@/modules/evaluation/graph/editor/constants"; +import type { NodeProps } from "@/modules/evaluation/graph/editor/types"; +import type { ReactNode } from "react"; +import { NodeBody } from "./NodeBody"; +import { NodeContainer } from "./NodeContainer"; +import { NodeHeader } from "./NodeHeader"; + +type Props = { + data: NodeProps; + inputSocket?: ReactNode; + outputSocket?: ReactNode; + children: ReactNode; +}; + +export const RegularNodeContainer = (props: Props) => { + const { data, inputSocket, outputSocket, children } = props; + + const { + id, + label, + height, + width, + selected = false, + controlsHeight, + socketHeight, + labelHeight, + issues, + } = data; + + return ( + +
+ + {children} +
+ +
+ {inputSocket} + + {outputSocket} +
+
+ ); +}; diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/components/sockets/InputSocket.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/components/sockets/InputSocket.tsx new file mode 100644 index 00000000..8e4cc491 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/components/sockets/InputSocket.tsx @@ -0,0 +1,30 @@ +import type { Schemes } from "@/modules/evaluation/graph/editor/types"; +import type { ClassicPreset, NodeId } from "rete"; +import { Presets, type RenderEmit } from "rete-react-plugin"; + +const { RefSocket } = Presets.classic; + +type Props = { + emit: RenderEmit; + nodeId: NodeId; + input: ClassicPreset.Input; +}; + +export const InputSocket = (props: Props) => { + const { emit, nodeId, input } = props; + return ( +
+ +
+ ); +}; diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/components/sockets/OutputSocket.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/components/sockets/OutputSocket.tsx new file mode 100644 index 00000000..ad09c72d --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/components/sockets/OutputSocket.tsx @@ -0,0 +1,30 @@ +import type { Schemes } from "@/modules/evaluation/graph/editor/types"; +import type { ClassicPreset, NodeId } from "rete"; +import { Presets, type RenderEmit } from "rete-react-plugin"; + +const { RefSocket } = Presets.classic; + +type Props = { + emit: RenderEmit; + nodeId: NodeId; + output: ClassicPreset.Output; +}; + +export const OutputSocket = (props: Props) => { + const { emit, nodeId, output } = props; + return ( +
+ +
+ ); +}; diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/connections/BooleanConnectionView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/connections/BooleanConnectionView.tsx new file mode 100644 index 00000000..3b7d0163 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/connections/BooleanConnectionView.tsx @@ -0,0 +1,20 @@ +import type { + BooleanConnection, + BooleanOperator, +} from "@/modules/evaluation/graph/editor/connections/booleanConnection"; +import { createToggleConnectionView } from "./ToggleConnectionView"; + +export function createBooleanConnectionView( + refreshConnection: (id: string) => void, +) { + return createToggleConnectionView({ + refreshConnection, + options: ["TRUE", "NOT"], + fallback: "TRUE", + getValue: (connection) => connection.booleanOperator, + setValue: (connection, value) => { + connection.booleanOperator = value; + }, + getLabel: (value) => (value === "TRUE" ? "PASS" : "FLIP"), + }); +} diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/connections/CustomConnectionView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/connections/CustomConnectionView.tsx new file mode 100644 index 00000000..f1ed9e8f --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/connections/CustomConnectionView.tsx @@ -0,0 +1,16 @@ +import { type ClassicScheme, Presets } from "rete-react-plugin"; +import { CONNECTION_PATH_CLASS, CONNECTION_SVG_CLASS } from "./connectionStyles"; + +const { useConnection } = Presets.classic; + +export function CustomConnectionView(_props: { + data: ClassicScheme["Connection"]; +}) { + const { path } = useConnection(); + if (!path) return null; + return ( + + + + ); +} diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/connections/LimitItemConnectionView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/connections/LimitItemConnectionView.tsx new file mode 100644 index 00000000..ec955fe9 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/connections/LimitItemConnectionView.tsx @@ -0,0 +1,20 @@ +import type { LimitItemConnection } from "@/modules/evaluation/graph/editor/connections/limitItemConnection"; +import { EvalLimitItemOperatorEnum } from "@repo/schema"; +import { createToggleConnectionView } from "./ToggleConnectionView"; + +export function createLimitItemConnectionView( + refreshConnection: (id: string) => void, +) { + return createToggleConnectionView< + EvalLimitItemOperatorEnum, + LimitItemConnection + >({ + refreshConnection, + options: [EvalLimitItemOperatorEnum.AND, EvalLimitItemOperatorEnum.OR], + fallback: EvalLimitItemOperatorEnum.AND, + getValue: (connection) => connection.limitItemOperator, + setValue: (connection, value) => { + connection.limitItemOperator = value; + }, + }); +} diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/connections/MagneticConnectionView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/connections/MagneticConnectionView.tsx new file mode 100644 index 00000000..6d385444 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/connections/MagneticConnectionView.tsx @@ -0,0 +1,37 @@ +import { useEffect, useState } from "react"; +import { type ClassicScheme, Presets } from "rete-react-plugin"; +import { + CONNECTION_SVG_CLASS, + MAGNETIC_CONNECTION_PATH_CLASS, +} from "./connectionStyles"; + +const { useConnection } = Presets.classic; + +const VISIBLE_OPACITY = 0.4; + +export function MagneticConnectionView(_props: { + data: ClassicScheme["Connection"]; +}) { + const { path } = useConnection(); + const [shown, setShown] = useState(false); + + useEffect(() => { + const frame = requestAnimationFrame(() => setShown(true)); + return () => cancelAnimationFrame(frame); + }, []); + + if (!path) return null; + + return ( + + + + ); +} diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/connections/ToggleConnectionView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/connections/ToggleConnectionView.tsx new file mode 100644 index 00000000..e81f64b3 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/connections/ToggleConnectionView.tsx @@ -0,0 +1,92 @@ +import { BooleanConnectionToggle } from "@/modules/evaluation/graph/editor/ui/components/BooleanConnectionToggle"; +import { useLayoutEffect, useRef, useState } from "react"; +import type { ClassicScheme } from "rete-react-plugin"; +import { Presets } from "rete-react-plugin"; +import { CONNECTION_PATH_CLASS, CONNECTION_SVG_CLASS } from "./connectionStyles"; + +const { useConnection } = Presets.classic; + +type ToggleOptionPair = readonly [T, T]; + +type ToggleConnectionData = ClassicScheme["Connection"] & { + id: string; + source?: string; + target?: string; +}; + +type Props = { + refreshConnection: (id: string) => void; + options: ToggleOptionPair; + fallback: T; + getValue: (connection: C) => T | undefined; + setValue: (connection: C, value: T) => void; + getLabel?: (value: T) => string; +}; + +export function createToggleConnectionView< + T extends string, + C extends ToggleConnectionData, +>({ + refreshConnection, + options, + fallback, + getValue, + setValue, + getLabel, +}: Props) { + return function ToggleConnectionView(props: { data: C }) { + const { path } = useConnection(); + const pathRef = useRef(null); + const [mid, setMid] = useState<{ x: number; y: number } | null>(null); + const [animationDelay] = useState(() => `-${performance.now() % 1000}ms`); + + useLayoutEffect(() => { + const el = pathRef.current; + + if (!el || !path) { + setMid(null); + return; + } + + try { + const len = el.getTotalLength(); + const p = el.getPointAtLength(len / 2); + setMid({ x: p.x, y: p.y }); + } catch { + setMid(null); + } + }, [path]); + + if (!path) return null; + + const isComplete = Boolean(props.data.source && props.data.target); + const current = getValue(props.data) ?? fallback; + const label = getLabel ? getLabel(current) : current; + const nextValue = current === options[0] ? options[1] : options[0]; + + return ( + <> + + + + + {isComplete && mid && ( + { + setValue(props.data, nextValue); + refreshConnection(props.data.id); + }} + /> + )} + + ); + }; +} diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/connections/connectionStyles.ts b/apps/web/src/modules/evaluation/graph/editor/ui/connections/connectionStyles.ts new file mode 100644 index 00000000..a6ddb0fe --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/connections/connectionStyles.ts @@ -0,0 +1,13 @@ +// Shared Tailwind classes for connection rendering, used by both +// CustomConnectionView (plain connections) and ToggleConnectionView (toggleable +// ones), so the svg canvas and the marching-ants path stay in sync. The +// `dash` keyframes referenced by the animation utility live in global.css. + +export const CONNECTION_SVG_CLASS = + "pointer-events-none absolute z-0 h-[9999px] w-[9999px] overflow-visible"; + +export const CONNECTION_PATH_CLASS = + "pointer-events-auto fill-none stroke-[5] stroke-zinc-400 [stroke-dasharray:10_5] [stroke-dashoffset:45] [animation:dash_1s_linear_infinite]"; + +export const MAGNETIC_CONNECTION_PATH_CLASS = + "pointer-events-none fill-none stroke-[5] stroke-zinc-400 [stroke-dasharray:10_5] [stroke-dashoffset:45] [animation:dash_1s_linear_infinite]"; diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/context-menu/ContextMenuView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/context-menu/ContextMenuView.tsx new file mode 100644 index 00000000..e9da0318 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/context-menu/ContextMenuView.tsx @@ -0,0 +1,63 @@ +import { Presets } from "rete-react-plugin"; +import styled, { css } from "styled-components"; + +export const Menu = styled(Presets.contextMenu.Menu)` + background: var(--color-zinc-50); + color: var(--color-zinc-400); + border: 1px solid var(--color-zinc-200); + border-radius: 0.5rem; + padding: 0.25rem; +`; + +export const Item = styled(Presets.contextMenu.Item).withConfig({ + shouldForwardProp: (prop) => prop !== "hasSubitems", +})<{ hasSubitems?: boolean }>` + background: transparent; + color: var(--color-zinc-400); + border: none; + border-radius: 0.375rem; + position: relative; + + &:hover { + background: var(--color-zinc-100); + border-radius: 0.375rem; + } + + ${(props) => + props.hasSubitems && + css` + &:after { + content: "â–º"; + position: absolute; + opacity: 0.6; + right: 5px; + top: 5px; + } + `} +`; + +export const Common = styled(Presets.contextMenu.Common)` + background: transparent; + color: var(--color-zinc-400); + border: none; + border-radius: 0.375rem; + + &:hover { + background: var(--color-zinc-200); + color: var(--color-zinc-500); + border-radius: 0.375rem; + } +`; + +export const Search = styled(Presets.contextMenu.Search)` + display: none; +`; + +export const Subitems = styled(Presets.contextMenu.Subitems)` + background: var(--color-zinc-50); + color: var(--color-zinc-400); + border: 1px solid var(--color-zinc-200); + border-radius: 0.5rem; + padding: 0.25rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06); +`; diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/controls/EnabledControlView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/controls/EnabledControlView.tsx new file mode 100644 index 00000000..60c6d6cc --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/controls/EnabledControlView.tsx @@ -0,0 +1,21 @@ +import type { EnabledControl } from "@/modules/evaluation/graph/editor/controls/enabled"; +import { Switch } from "@/modules/shadcn/ui/switch"; +import { useSyncExternalStore } from "react"; + +type Props = { + data: EnabledControl; +}; + +export const EnabledControlView = ({ data }: Props) => { + const value = useSyncExternalStore(data.subscribe, data.getSnapshot); + + return ( +
e.stopPropagation()}> + data.setValue(next)} + aria-label={value ? "Disable check" : "Enable check"} + /> +
+ ); +}; diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/controls/IntegerRangeControlView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/controls/IntegerRangeControlView.tsx new file mode 100644 index 00000000..3d6c20f6 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/controls/IntegerRangeControlView.tsx @@ -0,0 +1,108 @@ +import type { IntegerRangeControl } from "@/modules/evaluation/graph/editor/controls/integerRange"; +import { Button } from "@/modules/shadcn/ui/button"; +import { ButtonGroup } from "@/modules/shadcn/ui/button-group"; +import { Input } from "@/modules/shadcn/ui/input"; +import { useEffect, useState, useSyncExternalStore } from "react"; + +type Props = { + data: IntegerRangeControl; +}; + +export const IntegerRangeControlView = ({ data }: Props) => { + const value = useSyncExternalStore(data.subscribe, data.getSnapshot); + + const [limitFromDraft, setLimitFromDraft] = useState( + formatNullableNumber(value.limitFrom), + ); + const [limitToDraft, setLimitToDraft] = useState( + formatNullableNumber(value.limitTo), + ); + + useEffect(() => { + setLimitFromDraft(formatNullableNumber(value.limitFrom)); + setLimitToDraft(formatNullableNumber(value.limitTo)); + }, [value.limitFrom, value.limitTo]); + + const commitLimitFrom = (raw: string) => { + data.setLimitFrom(parseNullableNumber(raw)); + setLimitFromDraft(formatNullableNumber(data.limitFrom)); + }; + + const commitLimitTo = (raw: string) => { + data.setLimitTo(parseNullableNumber(raw)); + setLimitToDraft(formatNullableNumber(data.limitTo)); + }; + + return ( +
event.stopPropagation()} + > + + setLimitFromDraft(event.target.value)} + onBlur={(event) => commitLimitFrom(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + commitLimitFrom(event.currentTarget.value); + } + }} + /> + + + + + + setLimitToDraft(event.target.value)} + onBlur={(event) => commitLimitTo(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + commitLimitTo(event.currentTarget.value); + } + }} + /> + + + +
+ ); +}; + +function formatNullableNumber(value: number | null) { + return value === null ? "" : String(value); +} + +function parseNullableNumber(raw: string) { + if (raw.trim() === "") return null; + + const parsed = Number(raw); + + return Number.isFinite(parsed) ? parsed : null; +} diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/controls/LabelControlView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/controls/LabelControlView.tsx new file mode 100644 index 00000000..8ce1fd36 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/controls/LabelControlView.tsx @@ -0,0 +1,58 @@ +import { cn } from "@/lib/utils"; +import type { LabelControl } from "@/modules/evaluation/graph/editor/controls/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/modules/shadcn/ui/select"; +import { useSyncExternalStore } from "react"; + +type Props = { + data: LabelControl; +}; + +export const LabelControlView = ({ data }: Props) => { + const value = useSyncExternalStore(data.subscribe, data.getSnapshot); + const selected = data.options.find((label) => String(label.id) === value); + + return ( +
e.stopPropagation()}> + +
+ ); +}; diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/controls/NameControlView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/controls/NameControlView.tsx new file mode 100644 index 00000000..b6d5cefd --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/controls/NameControlView.tsx @@ -0,0 +1,42 @@ +import { cn } from "@/lib/utils"; +import type { NameControl } from "@/modules/evaluation/graph/editor/controls/name"; +import { Input } from "@/modules/shadcn/ui/input"; +import { useEffect, useState, useSyncExternalStore } from "react"; + +type Props = { + data: NameControl; +}; + +export const NameControlView = ({ data }: Props) => { + const value = useSyncExternalStore(data.subscribe, data.getSnapshot); + const [draft, setDraft] = useState(value); + + useEffect(() => { + setDraft(value); + }, [value]); + + const commitValue = (raw: string) => { + data.setValue(raw); + }; + + return ( +
e.stopPropagation()}> + setDraft(e.target.value)} + onBlur={(e) => commitValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + commitValue(e.currentTarget.value); + } + }} + /> +
+ ); +}; diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/controls/PercentageRangeControlView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/controls/PercentageRangeControlView.tsx new file mode 100644 index 00000000..cddb7ebd --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/controls/PercentageRangeControlView.tsx @@ -0,0 +1,39 @@ +import type { PercentageRangeControl } from "@/modules/evaluation/graph/editor/controls/percentageRange"; +import { Slider } from "@/modules/shadcn/ui/slider"; +import { useSyncExternalStore } from "react"; + +type Props = { + data: PercentageRangeControl; +}; + +export const PercentageRangeControlView = ({ data }: Props) => { + const value = useSyncExternalStore(data.subscribe, data.getSnapshot); + + return ( +
e.stopPropagation()} + > +
+ From: {value[0]}% + To: {value[1]}% +
+ + { + if (typeof next === "number") return; + + const [first, second] = next; + if (first === undefined || second === undefined) return; + + data.setValue(first <= second ? [first, second] : [second, first]); + }} + /> +
+ ); +}; diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/controls/PositionControlView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/controls/PositionControlView.tsx new file mode 100644 index 00000000..186b069a --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/controls/PositionControlView.tsx @@ -0,0 +1,89 @@ +import { + edgesCompatible, + type PositionControl, +} from "@/modules/evaluation/graph/editor/controls/position"; +import { EvalLimitItemEdgeEnum } from "@repo/schema"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/modules/shadcn/ui/select"; +import { useSyncExternalStore } from "react"; + +type Props = { + data: PositionControl; +}; + +const EDGE_LABELS: Record = { + [EvalLimitItemEdgeEnum.LEFT]: "Left", + [EvalLimitItemEdgeEnum.RIGHT]: "Right", + [EvalLimitItemEdgeEnum.TOP]: "Top", + [EvalLimitItemEdgeEnum.BOTTOM]: "Bottom", + [EvalLimitItemEdgeEnum.CENTER]: "Center", +}; + +const ALL_EDGES: EvalLimitItemEdgeEnum[] = [ + EvalLimitItemEdgeEnum.TOP, + EvalLimitItemEdgeEnum.BOTTOM, + EvalLimitItemEdgeEnum.LEFT, + EvalLimitItemEdgeEnum.RIGHT, + EvalLimitItemEdgeEnum.CENTER, +]; + +export const PositionControlView = ({ data }: Props) => { + const value = useSyncExternalStore(data.subscribe, data.getSnapshot); + + // The parent edge must share the target's axis (or be CENTER), matching the schema. + const parentEdges = ALL_EDGES.filter((edge) => + edgesCompatible(value.targetEdge, edge), + ); + + return ( +
e.stopPropagation()} + > + + + vs + + +
+ ); +}; diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/controls/QuantifierTypeControlView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/controls/QuantifierTypeControlView.tsx new file mode 100644 index 00000000..f93b7529 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/controls/QuantifierTypeControlView.tsx @@ -0,0 +1,39 @@ +import type { QuantifierTypeControl } from "@/modules/evaluation/graph/editor/controls/quantifierType"; +import { EvalLimitItemQuantifierTypeEnum } from "@repo/schema"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/modules/shadcn/ui/select"; +import { useSyncExternalStore } from "react"; + +type Props = { + data: QuantifierTypeControl; +}; + +export const QuantifierTypeControlView = ({ data }: Props) => { + const value = useSyncExternalStore(data.subscribe, data.getSnapshot); + + return ( +
e.stopPropagation()}> + +
+ ); +}; diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/controls/QuantifierUnitsControlView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/controls/QuantifierUnitsControlView.tsx new file mode 100644 index 00000000..944f92fd --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/controls/QuantifierUnitsControlView.tsx @@ -0,0 +1,56 @@ +import type { QuantifierUnitsControl } from "@/modules/evaluation/graph/editor/controls/quantifierUnits"; +import { Button } from "@/modules/shadcn/ui/button"; +import { ButtonGroup } from "@/modules/shadcn/ui/button-group"; +import { Input } from "@/modules/shadcn/ui/input"; +import { useEffect, useState, useSyncExternalStore } from "react"; + +type Props = { + data: QuantifierUnitsControl; +}; + +export const QuantifierUnitsControlView = ({ data }: Props) => { + const snapshot = useSyncExternalStore(data.subscribe, data.getSnapshot); + const [draft, setDraft] = useState(String(snapshot.quantifierValue)); + + useEffect(() => { + setDraft(String(snapshot.quantifierValue)); + }, [snapshot.quantifierValue]); + + const commitValue = (raw: string) => { + const parsed = Number(raw); + data.setValue(Number.isFinite(parsed) ? parsed : 0); + setDraft(String(data.getSnapshot().quantifierValue)); + }; + + return ( + e.stopPropagation()}> + setDraft(e.target.value)} + onBlur={(e) => commitValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + commitValue(e.currentTarget.value); + } + }} + /> + + + + ); +}; diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/nodes/ActionNodeView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/ActionNodeView.tsx new file mode 100644 index 00000000..9dbf36e7 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/ActionNodeView.tsx @@ -0,0 +1,36 @@ +import type { AlertNode } from "@/modules/evaluation/graph/editor/nodes/action/alert"; +import type { WarningNode } from "@/modules/evaluation/graph/editor/nodes/action/warning"; +import type { Schemes } from "@/modules/evaluation/graph/editor/types"; +import { CompactNodeContainer } from "@/modules/evaluation/graph/editor/ui/components/containers/CompactNodeContainer"; +import { InputSocket } from "@/modules/evaluation/graph/editor/ui/components/sockets/InputSocket"; +import type { RenderEmit } from "rete-react-plugin"; +import { Label } from "../components/Label"; + +type Props = { + data: WarningNode | AlertNode; + emit: RenderEmit; +}; + +export const ActionNodeView = (props: Props) => { + const { data, emit } = props; + const { label, height, width, selected = false, issues } = data; + const input = data.inputs.in; + + if (!input) { + throw new Error(`ActionNode is missing expected parts`); + } + + return ( + } + center={} + /> + ); +}; diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/nodes/AreaNodeView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/AreaNodeView.tsx new file mode 100644 index 00000000..55665a87 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/AreaNodeView.tsx @@ -0,0 +1,47 @@ +import type { AreaNode } from "@/modules/evaluation/graph/editor/nodes/limitItem/area"; +import type { Schemes } from "@/modules/evaluation/graph/editor/types"; +import { RegularNodeContainer } from "@/modules/evaluation/graph/editor/ui/components/containers/RegularNodeContainer"; +import { InputSocket } from "@/modules/evaluation/graph/editor/ui/components/sockets/InputSocket"; +import { OutputSocket } from "@/modules/evaluation/graph/editor/ui/components/sockets/OutputSocket"; +import { Presets, type RenderEmit } from "rete-react-plugin"; + +const { RefControl } = Presets.classic; + +type Props = { + data: AreaNode; + emit: RenderEmit; +}; + +export const AreaNodeView = (props: Props) => { + const { data, emit } = props; + const { range, units, quantifier } = data.controls; + const input = data.inputs.in; + const output = data.outputs.out; + + if (!input || !output || !range || !units || !quantifier) { + throw new Error(`AreaNode is missing expected parts`); + } + + return ( + } + outputSocket={ + + } + > +
+ +
+ +
+
+ Units + +
+ + +
+
+ ); +}; diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/nodes/CountNodeView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/CountNodeView.tsx new file mode 100644 index 00000000..4fd9d9ba --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/CountNodeView.tsx @@ -0,0 +1,42 @@ +import type { CountNode } from "@/modules/evaluation/graph/editor/nodes/limitItem/count"; +import type { Schemes } from "@/modules/evaluation/graph/editor/types"; +import { RegularNodeContainer } from "@/modules/evaluation/graph/editor/ui/components/containers/RegularNodeContainer"; +import { InputSocket } from "@/modules/evaluation/graph/editor/ui/components/sockets/InputSocket"; +import { OutputSocket } from "@/modules/evaluation/graph/editor/ui/components/sockets/OutputSocket"; +import { Presets, type RenderEmit } from "rete-react-plugin"; + + +const { RefControl } = Presets.classic; + +type Props = { + data: CountNode; + emit: RenderEmit; +}; + +export const CountNodeView = (props: Props) => { + const { data, emit } = props; + const { count } = data.controls; + const input = data.inputs.in; + const output = data.outputs.out; + + if (!input || !output || !count) { + throw new Error(`CountNode is missing expected parts`); + } + + return ( + } + outputSocket={ + + } + > +
+
+ Count + +
+
+
+ ); +}; diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/nodes/LimitNodeView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/LimitNodeView.tsx new file mode 100644 index 00000000..9071dfff --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/LimitNodeView.tsx @@ -0,0 +1,51 @@ +import type { LimitNode } from "@/modules/evaluation/graph/editor/nodes/limit/limit"; +import type { Schemes } from "@/modules/evaluation/graph/editor/types"; +import { ParentNodeContainer } from "@/modules/evaluation/graph/editor/ui/components/containers/ParentNodeContainer"; +import { OutputSocket } from "@/modules/evaluation/graph/editor/ui/components/sockets/OutputSocket"; +import { Presets, type RenderEmit } from "rete-react-plugin"; + +const { RefControl } = Presets.classic; + +type Props = { + data: LimitNode; + emit: RenderEmit; +}; + +export const LimitNodeView = (props: Props) => { + const { data, emit } = props; + const { name, label, parentLabel, enabled } = data.controls; + const output = data.outputs.out; + + if (!output || !name || !label || !parentLabel || !enabled) { + throw new Error(`CountNode is missing expected parts`); + } + + return ( + + } + headerRight={ + + } + data={data} + > +
+ Name + +
+
+
+ Label + +
+
+ + Parent Label + + +
+
+
+ ); +}; diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/nodes/LogicalNodeView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/LogicalNodeView.tsx new file mode 100644 index 00000000..56dd65d5 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/LogicalNodeView.tsx @@ -0,0 +1,43 @@ +import { AndGateIcon, OrGateIcon } from "@/components/icons"; +import type { AndNode } from "@/modules/evaluation/graph/editor/nodes/logical/and"; +import type { OrNode } from "@/modules/evaluation/graph/editor/nodes/logical/or"; +import type { Schemes } from "@/modules/evaluation/graph/editor/types"; +import { CompactNodeContainer } from "@/modules/evaluation/graph/editor/ui/components/containers/CompactNodeContainer"; +import { InputSocket } from "@/modules/evaluation/graph/editor/ui/components/sockets/InputSocket"; +import { OutputSocket } from "@/modules/evaluation/graph/editor/ui/components/sockets/OutputSocket"; +import type { RenderEmit } from "rete-react-plugin"; + +type Props = { + data: OrNode | AndNode; + emit: RenderEmit; +}; + +export const LogicalNodeView = (props: Props) => { + const { data, emit } = props; + const { label, height, width, selected = false, issues } = data; + const input = data.inputs.in; + const output = data.outputs.out; + + if (!input || !output) { + throw new Error(`LogicalNode is missing expected parts`); + } + + return ( + } + center={ + + {label === "AND" && } + {label === "OR" && } + + } + output={} + /> + ); +}; diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/nodes/PositionNodeView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/PositionNodeView.tsx new file mode 100644 index 00000000..57c41c19 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/PositionNodeView.tsx @@ -0,0 +1,49 @@ +import type { PositionNode } from "@/modules/evaluation/graph/editor/nodes/limitItem/position"; +import type { Schemes } from "@/modules/evaluation/graph/editor/types"; +import { RegularNodeContainer } from "@/modules/evaluation/graph/editor/ui/components/containers/RegularNodeContainer"; +import { InputSocket } from "@/modules/evaluation/graph/editor/ui/components/sockets/InputSocket"; +import { OutputSocket } from "@/modules/evaluation/graph/editor/ui/components/sockets/OutputSocket"; +import { Presets, type RenderEmit } from "rete-react-plugin"; + +const { RefControl } = Presets.classic; + +type Props = { + data: PositionNode; + emit: RenderEmit; +}; + +export const PositionNodeView = (props: Props) => { + const { data, emit } = props; + const { range, units, quantifier, position } = data.controls; + const input = data.inputs.in; + const output = data.outputs.out; + + if (!input || !output || !range || !units || !quantifier || !position) { + throw new Error(`PositionNode is missing expected parts`); + } + + return ( + } + outputSocket={ + + } + > +
+ +
+ + + +
+
+ Units + +
+ + +
+
+ ); +}; diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/nodes/ResultNodeView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/ResultNodeView.tsx new file mode 100644 index 00000000..31b568fd --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/ResultNodeView.tsx @@ -0,0 +1,37 @@ +import type { ResultNode } from "@/modules/evaluation/graph/editor/nodes/result/result"; +import type { Schemes } from "@/modules/evaluation/graph/editor/types"; +import { CompactNodeContainer } from "@/modules/evaluation/graph/editor/ui/components/containers/CompactNodeContainer"; +import { Label } from "@/modules/evaluation/graph/editor/ui/components/Label"; +import { InputSocket } from "@/modules/evaluation/graph/editor/ui/components/sockets/InputSocket"; +import { OutputSocket } from "@/modules/evaluation/graph/editor/ui/components/sockets/OutputSocket"; +import type { RenderEmit } from "rete-react-plugin"; + +type Props = { + data: ResultNode; + emit: RenderEmit; +}; + +export function ResultNodeView(props: Props) { + const { data, emit } = props; + const { label, height, width, selected = false, issues } = data; + const input = data.inputs.in; + const output = data.outputs.out; + + if (!input || !output) { + throw new Error(`ResultNode is missing expected parts`); + } + + return ( + } + center={} + output={} + /> + ); +} diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/sockets/SocketView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/sockets/SocketView.tsx new file mode 100644 index 00000000..e97f529a --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/sockets/SocketView.tsx @@ -0,0 +1,21 @@ +import { AppSocket } from "@/modules/evaluation/graph/editor/sockets/appSocket"; +import { BooleanSocket } from "@/modules/evaluation/graph/editor/sockets/booleanSocket"; +import type { ClassicPreset } from "rete"; + +export function SocketView(props: { data: ClassicPreset.Socket }) { + const isBoolean = props.data instanceof BooleanSocket; + const connected = + props.data instanceof AppSocket ? props.data.connected : false; + + return ( +
+ ); +} diff --git a/apps/web/src/modules/evaluation/graph/editor/utils/__tests__/copyPaste.test.ts b/apps/web/src/modules/evaluation/graph/editor/utils/__tests__/copyPaste.test.ts new file mode 100644 index 00000000..1588c8c3 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/utils/__tests__/copyPaste.test.ts @@ -0,0 +1,354 @@ +import { BooleanConnection } from "@/modules/evaluation/graph/editor/connections/booleanConnection"; +import { LimitItemConnection } from "@/modules/evaluation/graph/editor/connections/limitItemConnection"; +import { LimitNode } from "@/modules/evaluation/graph/editor/nodes/limit/limit"; +import { CountNode } from "@/modules/evaluation/graph/editor/nodes/limitItem/count"; +import { AndNode } from "@/modules/evaluation/graph/editor/nodes/logical/and"; +import { OrNode } from "@/modules/evaluation/graph/editor/nodes/logical/or"; +import { ResultNode } from "@/modules/evaluation/graph/editor/nodes/result/result"; +import type { Schemes } from "@/modules/evaluation/graph/editor/types"; +import { + copyNodes, + pasteNodes, + type Clipboard, +} from "@/modules/evaluation/graph/editor/utils/copyPaste"; +import { EvalLimitItemOperatorEnum } from "@repo/schema"; +import type { AreaPlugin } from "rete-area-plugin"; +import { describe, expect, it, vi } from "vitest"; +import { createTestEditor } from "./testEditor"; + +function mockArea(positions: Record = {}) { + const nodeViews = new Map( + Object.entries(positions).map(([id, pos]) => [id, { position: pos }]), + ); + return { + nodeViews, + translate: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + } as unknown as AreaPlugin; +} + +function mockSelectableNodes() { + return { + select: vi + .fn<(nodeId: string, accumulate: boolean) => Promise>() + .mockResolvedValue(undefined), + unselect: vi + .fn<(nodeId: string) => Promise>() + .mockResolvedValue(undefined), + }; +} + +describe("copyNodes", () => { + it("returns null when no nodes are selected", async () => { + const editor = createTestEditor(); + await editor.addNode(new AndNode()); + expect(copyNodes(editor, mockArea())).toBeNull(); + }); + + it("returns null when the editor is empty", () => { + const editor = createTestEditor(); + expect(copyNodes(editor, mockArea())).toBeNull(); + }); + + it("clones each selected node so the clipboard captures state at copy time", async () => { + const editor = createTestEditor(); + const and = new AndNode(); + await editor.addNode(and); + and.selected = true; + + const result = copyNodes(editor, mockArea()); + + expect(result?.nodes).toHaveLength(1); + expect(result?.nodes.at(0)?.node).not.toBe(and); + }); + + it("reads node positions from area.nodeViews", async () => { + const editor = createTestEditor(); + const and = new AndNode(); + await editor.addNode(and); + and.selected = true; + + const result = copyNodes(editor, mockArea({ [and.id]: { x: 120, y: 80 } })); + + expect(result?.nodes.at(0)?.position).toEqual({ x: 120, y: 80 }); + }); + + it("defaults position to {0,0} when the node has no view entry", async () => { + const editor = createTestEditor(); + const and = new AndNode(); + await editor.addNode(and); + and.selected = true; + + const result = copyNodes(editor, mockArea()); // no position registered + + expect(result?.nodes.at(0)?.position).toEqual({ x: 0, y: 0 }); + }); + + it("computes the center as the average of all node positions", async () => { + const editor = createTestEditor(); + const a = new AndNode(); + const b = new OrNode(); + await editor.addNode(a); + await editor.addNode(b); + a.selected = true; + b.selected = true; + + const result = copyNodes( + editor, + mockArea({ [a.id]: { x: 0, y: 0 }, [b.id]: { x: 100, y: 100 } }), + ); + + expect(result?.center).toEqual({ x: 50, y: 50 }); + }); + + it("only includes connections where both endpoints are selected", async () => { + const editor = createTestEditor(); + const limit = new LimitNode(); + const and = new AndNode(); + const r = new ResultNode(); + await editor.addNode(limit); + await editor.addNode(and); + await editor.addNode(r); + await editor.addConnection(new BooleanConnection(limit, "out", and, "in")); + await editor.addConnection(new BooleanConnection(and, "out", r, "in")); + + limit.selected = true; + and.selected = true; + // r is NOT selected — the and→r connection must be excluded + + const result = copyNodes(editor, mockArea()); + + expect(result?.connections).toHaveLength(1); + expect(result?.connections.at(0)?.kind).toBe("boolean"); + }); + + it("preserves the BooleanOperator on a boolean connection", async () => { + const editor = createTestEditor(); + const a = new LimitNode(); + const b = new ResultNode(); + await editor.addNode(a); + await editor.addNode(b); + await editor.addConnection(new BooleanConnection(a, "out", b, "in", "NOT")); + a.selected = true; + b.selected = true; + + const result = copyNodes(editor, mockArea()); + const conn = result?.connections.at(0); + + expect(conn?.kind).toBe("boolean"); + if (conn?.kind === "boolean") expect(conn.operator).toBe("NOT"); + }); + + it("preserves the limitItemOperator on a limit-item connection", async () => { + const editor = createTestEditor(); + const a = new CountNode(); + const b = new CountNode(); + await editor.addNode(a); + await editor.addNode(b); + await editor.addConnection( + new LimitItemConnection(a, "out", b, "in", EvalLimitItemOperatorEnum.OR), + ); + a.selected = true; + b.selected = true; + + const result = copyNodes(editor, mockArea()); + const conn = result?.connections.at(0); + + expect(conn?.kind).toBe("limitItem"); + if (conn?.kind === "limitItem") expect(conn.operator).toBe("OR"); + }); + + it("records parentIndex for children whose Limit parent is also selected", async () => { + const editor = createTestEditor(); + const limit = new LimitNode(); + const count = new CountNode(); + count.parent = limit.id; + await editor.addNode(limit); + await editor.addNode(count); + limit.selected = true; + count.selected = true; + + const result = copyNodes(editor, mockArea()); + + const limitIdx = result!.nodes.findIndex( + (e) => e.node instanceof LimitNode, + ); + const countEntry = result!.nodes.find((e) => e.node instanceof CountNode); + expect(countEntry?.parentIndex).toBe(limitIdx); + }); + + it("sets parentIndex to null for a child whose Limit parent is not selected", async () => { + const editor = createTestEditor(); + const limit = new LimitNode(); + const count = new CountNode(); + count.parent = limit.id; + await editor.addNode(limit); + await editor.addNode(count); + count.selected = true; // limit is NOT selected + + const result = copyNodes(editor, mockArea()); + + expect(result?.nodes.at(0)?.parentIndex).toBeNull(); + }); +}); + +describe("pasteNodes", () => { + it("adds all clipboard nodes to the editor", async () => { + const editor = createTestEditor(); + const clipboard: Clipboard = { + nodes: [ + { node: new AndNode(), position: { x: 0, y: 0 }, parentIndex: null }, + ], + connections: [], + center: { x: 0, y: 0 }, + }; + + await pasteNodes( + clipboard, + { editor, area: mockArea(), selectableNodes: mockSelectableNodes() }, + { x: 0, y: 0 }, + ); + + expect(editor.getNodes()).toHaveLength(1); + }); + + it("recreates boolean connections between pasted nodes", async () => { + const editor = createTestEditor(); + const clipboard: Clipboard = { + nodes: [ + { node: new LimitNode(), position: { x: 0, y: 0 }, parentIndex: null }, + { + node: new ResultNode(), + position: { x: 100, y: 0 }, + parentIndex: null, + }, + ], + connections: [ + { + kind: "boolean", + sourceIndex: 0, + sourceOutput: "out", + targetIndex: 1, + targetInput: "in", + operator: "TRUE", + }, + ], + center: { x: 50, y: 0 }, + }; + + await pasteNodes( + clipboard, + { editor, area: mockArea(), selectableNodes: mockSelectableNodes() }, + { x: 50, y: 0 }, + ); + + expect(editor.getConnections()).toHaveLength(1); + expect(editor.getConnections().at(0)).toBeInstanceOf(BooleanConnection); + }); + + it("recreates limit-item connections between pasted nodes", async () => { + const editor = createTestEditor(); + const clipboard: Clipboard = { + nodes: [ + { node: new CountNode(), position: { x: 0, y: 0 }, parentIndex: null }, + { node: new CountNode(), position: { x: 50, y: 0 }, parentIndex: null }, + ], + connections: [ + { + kind: "limitItem", + sourceIndex: 0, + sourceOutput: "out", + targetIndex: 1, + targetInput: "in", + operator: EvalLimitItemOperatorEnum.OR, + }, + ], + center: { x: 25, y: 0 }, + }; + + await pasteNodes( + clipboard, + { editor, area: mockArea(), selectableNodes: mockSelectableNodes() }, + { x: 0, y: 0 }, + ); + + const conn = editor.getConnections().at(0); + expect(conn).toBeInstanceOf(LimitItemConnection); + expect((conn as LimitItemConnection).limitItemOperator).toBe("OR"); + }); + + it("selects pasted nodes and deselects previous selection", async () => { + const editor = createTestEditor(); + const clipboard: Clipboard = { + nodes: [ + { node: new AndNode(), position: { x: 0, y: 0 }, parentIndex: null }, + { node: new OrNode(), position: { x: 50, y: 0 }, parentIndex: null }, + ], + connections: [], + center: { x: 25, y: 0 }, + }; + const sel = mockSelectableNodes(); + + await pasteNodes( + clipboard, + { editor, area: mockArea(), selectableNodes: sel }, + { x: 0, y: 0 }, + ); + + expect(sel.select).toHaveBeenCalledTimes(2); + const calls = sel.select.mock.calls; + expect(calls.at(0)?.at(1)).toBe(false); // first node: accumulate=false + expect(calls.at(1)?.at(1)).toBe(true); // second node: accumulate=true + }); + + it("remaps parent references so children point to the new cloned parent", async () => { + const editor = createTestEditor(); + const clipboard: Clipboard = { + nodes: [ + { node: new LimitNode(), position: { x: 0, y: 0 }, parentIndex: null }, // index 0 + { node: new CountNode(), position: { x: 0, y: 0 }, parentIndex: 0 }, // index 1, child of index 0 + ], + connections: [], + center: { x: 0, y: 0 }, + }; + + await pasteNodes( + clipboard, + { editor, area: mockArea(), selectableNodes: mockSelectableNodes() }, + { x: 0, y: 0 }, + ); + + const nodes = editor.getNodes(); + const limit = nodes.find((n) => n instanceof LimitNode); + const count = nodes.find((n) => n instanceof CountNode); + expect(count?.parent).toBe(limit?.id); + }); + + it("translates each node to paste position plus its offset from the clipboard center", async () => { + const editor = createTestEditor(); + const area = mockArea(); + const clipboard: Clipboard = { + nodes: [ + { + node: new AndNode(), + position: { x: 100, y: 200 }, + parentIndex: null, + }, + ], + connections: [], + center: { x: 100, y: 200 }, + }; + + await pasteNodes( + clipboard, + { editor, area, selectableNodes: mockSelectableNodes() }, + { x: 300, y: 400 }, + ); + + // offset from center = (0, 0), so final position = paste position = (300, 400) + expect(area.translate).toHaveBeenCalledWith(expect.any(String), { + x: 300, + y: 400, + }); + }); +}); diff --git a/apps/web/src/modules/evaluation/graph/editor/utils/__tests__/graph.test.ts b/apps/web/src/modules/evaluation/graph/editor/utils/__tests__/graph.test.ts new file mode 100644 index 00000000..8130d705 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/utils/__tests__/graph.test.ts @@ -0,0 +1,123 @@ +import { BooleanConnection } from "@/modules/evaluation/graph/editor/connections/booleanConnection"; +import { AndNode } from "@/modules/evaluation/graph/editor/nodes/logical/and"; +import { OrNode } from "@/modules/evaluation/graph/editor/nodes/logical/or"; +import { ResultNode } from "@/modules/evaluation/graph/editor/nodes/result/result"; +import { + countConnectedComponents, + findConnectedComponents, +} from "@/modules/evaluation/graph/editor/utils/graph"; +import { describe, expect, it } from "vitest"; + +describe("findConnectedComponents", () => { + it("returns no components when nodeIds is empty", () => { + expect(findConnectedComponents([], [])).toEqual([]); + }); + + it("treats every isolated node as its own component", () => { + expect(findConnectedComponents(["a", "b", "c"], [])).toEqual([ + ["a"], + ["b"], + ["c"], + ]); + }); + + it("groups nodes joined by a connection into one component", () => { + const components = findConnectedComponents( + ["a", "b", "c"], + [{ source: "a", target: "b" }], + ); + expect(components).toEqual([["a", "b"], ["c"]]); + }); + + it("treats connections as undirected", () => { + const components = findConnectedComponents( + ["a", "b"], + [{ source: "b", target: "a" }], + ); + expect(components).toEqual([["a", "b"]]); + }); + + it("ignores connections whose endpoints are not in nodeIds", () => { + const components = findConnectedComponents( + ["a", "b"], + [ + { source: "a", target: "missing" }, + { source: "b", target: "also-missing" }, + ], + ); + expect(components).toEqual([["a"], ["b"]]); + }); + + it("discovers chains across multiple hops", () => { + const components = findConnectedComponents( + ["a", "b", "c", "d"], + [ + { source: "a", target: "b" }, + { source: "c", target: "d" }, + { source: "b", target: "c" }, + ], + ); + expect(components).toEqual([["a", "b", "c", "d"]]); + }); + + it("orders nodes within a component by their original index", () => { + const components = findConnectedComponents( + ["c", "a", "b"], + [ + { source: "a", target: "b" }, + { source: "b", target: "c" }, + ], + ); + expect(components).toEqual([["c", "a", "b"]]); + }); + + it("orders components by the oldest node each contains", () => { + const components = findConnectedComponents( + ["a", "b", "c", "d"], + [ + { source: "c", target: "d" }, + { source: "a", target: "b" }, + ], + ); + expect(components).toEqual([ + ["a", "b"], + ["c", "d"], + ]); + }); +}); + +describe("countConnectedComponents", () => { + it("returns 0 for an empty node list", () => { + expect(countConnectedComponents([], [])).toBe(0); + }); + + it("counts each isolated node as its own component", () => { + const a = new AndNode(); + const b = new OrNode(); + const r = new ResultNode(); + expect(countConnectedComponents([a, b, r], [])).toBe(3); + }); + + it("merges nodes joined by a connection into one component", () => { + const a = new AndNode(); + const r = new ResultNode(); + const conn = new BooleanConnection(a, "out", r, "in", "TRUE"); + expect(countConnectedComponents([a, r], [conn])).toBe(1); + }); + + it("ignores connections whose endpoints are missing from the node list", () => { + const a = new AndNode(); + const b = new OrNode(); + const r = new ResultNode(); + const dangling = new BooleanConnection(a, "out", r, "in", "TRUE"); + expect(countConnectedComponents([a, b], [dangling])).toBe(2); + }); + + it("handles a mix of connected and disconnected subgraphs", () => { + const a = new AndNode(); + const b = new OrNode(); + const r = new ResultNode(); + const conn = new BooleanConnection(a, "out", b, "in", "TRUE"); + expect(countConnectedComponents([a, b, r], [conn])).toBe(2); + }); +}); diff --git a/apps/web/src/modules/evaluation/graph/editor/utils/__tests__/guards.test.ts b/apps/web/src/modules/evaluation/graph/editor/utils/__tests__/guards.test.ts new file mode 100644 index 00000000..868e412e --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/utils/__tests__/guards.test.ts @@ -0,0 +1,89 @@ +import { AlertNode } from "@/modules/evaluation/graph/editor/nodes/action/alert"; +import { WarningNode } from "@/modules/evaluation/graph/editor/nodes/action/warning"; +import { LimitNode } from "@/modules/evaluation/graph/editor/nodes/limit/limit"; +import { AreaNode } from "@/modules/evaluation/graph/editor/nodes/limitItem/area"; +import { CountNode } from "@/modules/evaluation/graph/editor/nodes/limitItem/count"; +import { PositionNode } from "@/modules/evaluation/graph/editor/nodes/limitItem/position"; +import { AndNode } from "@/modules/evaluation/graph/editor/nodes/logical/and"; +import { OrNode } from "@/modules/evaluation/graph/editor/nodes/logical/or"; +import { ResultNode } from "@/modules/evaluation/graph/editor/nodes/result/result"; +import { + isActionNode, + isLimitItemNode, + isLimitNode, + isLogicalOperator, + isResultNode, +} from "@/modules/evaluation/graph/editor/utils/guards"; +import { describe, expect, it } from "vitest"; + +describe("isLimitItemNode", () => { + it("accepts Count, Position, and Area nodes", () => { + expect(isLimitItemNode(new CountNode())).toBe(true); + expect(isLimitItemNode(new PositionNode())).toBe(true); + expect(isLimitItemNode(new AreaNode())).toBe(true); + }); + + it("rejects non-limit-item nodes", () => { + expect(isLimitItemNode(new AndNode())).toBe(false); + expect(isLimitItemNode(new OrNode())).toBe(false); + expect(isLimitItemNode(new LimitNode())).toBe(false); + expect(isLimitItemNode(new WarningNode())).toBe(false); + expect(isLimitItemNode(new AlertNode())).toBe(false); + expect(isLimitItemNode(new ResultNode())).toBe(false); + }); +}); + +describe("isLogicalOperator", () => { + it("accepts And and Or nodes", () => { + expect(isLogicalOperator(new AndNode())).toBe(true); + expect(isLogicalOperator(new OrNode())).toBe(true); + }); + + it("rejects non-logical-operator nodes", () => { + expect(isLogicalOperator(new CountNode())).toBe(false); + expect(isLogicalOperator(new LimitNode())).toBe(false); + expect(isLogicalOperator(new WarningNode())).toBe(false); + expect(isLogicalOperator(new AlertNode())).toBe(false); + expect(isLogicalOperator(new ResultNode())).toBe(false); + }); +}); + +describe("isLimitNode", () => { + it("accepts LimitNode", () => { + expect(isLimitNode(new LimitNode())).toBe(true); + }); + + it("rejects non-Limit nodes", () => { + expect(isLimitNode(new CountNode())).toBe(false); + expect(isLimitNode(new AndNode())).toBe(false); + expect(isLimitNode(new ResultNode())).toBe(false); + }); +}); + +describe("isActionNode", () => { + it("accepts Warning and Alert nodes", () => { + expect(isActionNode(new WarningNode())).toBe(true); + expect(isActionNode(new AlertNode())).toBe(true); + }); + + it("rejects non-action nodes", () => { + expect(isActionNode(new CountNode())).toBe(false); + expect(isActionNode(new AndNode())).toBe(false); + expect(isActionNode(new LimitNode())).toBe(false); + expect(isActionNode(new ResultNode())).toBe(false); + }); +}); + +describe("isResultNode", () => { + it("accepts ResultNode", () => { + expect(isResultNode(new ResultNode())).toBe(true); + }); + + it("rejects non-Result nodes", () => { + expect(isResultNode(new CountNode())).toBe(false); + expect(isResultNode(new AndNode())).toBe(false); + expect(isResultNode(new LimitNode())).toBe(false); + expect(isResultNode(new WarningNode())).toBe(false); + expect(isResultNode(new AlertNode())).toBe(false); + }); +}); diff --git a/apps/web/src/modules/evaluation/graph/editor/utils/__tests__/nodeSelection.test.ts b/apps/web/src/modules/evaluation/graph/editor/utils/__tests__/nodeSelection.test.ts new file mode 100644 index 00000000..161085c7 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/utils/__tests__/nodeSelection.test.ts @@ -0,0 +1,132 @@ +import { BooleanConnection } from "@/modules/evaluation/graph/editor/connections/booleanConnection"; +import { LimitNode } from "@/modules/evaluation/graph/editor/nodes/limit/limit"; +import { CountNode } from "@/modules/evaluation/graph/editor/nodes/limitItem/count"; +import { AndNode } from "@/modules/evaluation/graph/editor/nodes/logical/and"; +import { OrNode } from "@/modules/evaluation/graph/editor/nodes/logical/or"; +import { ResultNode } from "@/modules/evaluation/graph/editor/nodes/result/result"; +import { + deleteSelectedNodes, + deselectAllNodes, + selectAllNodes, +} from "@/modules/evaluation/graph/editor/utils/nodeSelection"; +import { describe, expect, it, vi } from "vitest"; +import { createTestEditor } from "./testEditor"; + +function mockSelectableNodes() { + return { + select: vi + .fn<(nodeId: string, accumulate: boolean) => Promise>() + .mockResolvedValue(undefined), + unselect: vi + .fn<(nodeId: string) => Promise>() + .mockResolvedValue(undefined), + }; +} + +describe("selectAllNodes", () => { + it("does nothing when the editor is empty", async () => { + const editor = createTestEditor(); + const sel = mockSelectableNodes(); + await selectAllNodes(sel, editor); + expect(sel.select).not.toHaveBeenCalled(); + }); + + it("calls select for every node with accumulate=false for the first and true for the rest", async () => { + const editor = createTestEditor(); + const a = new AndNode(); + const b = new OrNode(); + const r = new ResultNode(); + await editor.addNode(a); + await editor.addNode(b); + await editor.addNode(r); + + const sel = mockSelectableNodes(); + await selectAllNodes(sel, editor); + + expect(sel.select).toHaveBeenCalledTimes(3); + expect(sel.select).toHaveBeenNthCalledWith(1, a.id, false); + expect(sel.select).toHaveBeenNthCalledWith(2, b.id, true); + expect(sel.select).toHaveBeenNthCalledWith(3, r.id, true); + }); +}); + +describe("deselectAllNodes", () => { + it("does nothing when no nodes are selected", async () => { + const editor = createTestEditor(); + await editor.addNode(new AndNode()); + const sel = mockSelectableNodes(); + await deselectAllNodes(sel, editor); + expect(sel.unselect).not.toHaveBeenCalled(); + }); + + it("calls unselect only for selected nodes", async () => { + const editor = createTestEditor(); + const a = new AndNode(); + const b = new OrNode(); + await editor.addNode(a); + await editor.addNode(b); + a.selected = true; + + const sel = mockSelectableNodes(); + await deselectAllNodes(sel, editor); + + expect(sel.unselect).toHaveBeenCalledTimes(1); + expect(sel.unselect).toHaveBeenCalledWith(a.id); + expect(sel.unselect).not.toHaveBeenCalledWith(b.id); + }); +}); + +describe("deleteSelectedNodes", () => { + it("removes a selected node", async () => { + const editor = createTestEditor(); + const a = new AndNode(); + await editor.addNode(a); + a.selected = true; + await deleteSelectedNodes(editor); + expect(editor.getNode(a.id)).toBeUndefined(); + }); + + it("leaves unselected nodes untouched", async () => { + const editor = createTestEditor(); + const a = new AndNode(); + const b = new OrNode(); + await editor.addNode(a); + await editor.addNode(b); + a.selected = true; + + await deleteSelectedNodes(editor); + + expect(editor.getNode(a.id)).toBeUndefined(); + expect(editor.getNode(b.id)).toBeDefined(); + }); + + it("removes a selected Limit and its children together", async () => { + const editor = createTestEditor(); + const limit = new LimitNode(); + const count = new CountNode(); + count.parent = limit.id; + await editor.addNode(limit); + await editor.addNode(count); + limit.selected = true; + count.selected = true; + + await deleteSelectedNodes(editor); + + expect(editor.getNode(limit.id)).toBeUndefined(); + expect(editor.getNode(count.id)).toBeUndefined(); + }); + + it("also removes connections attached to deleted nodes", async () => { + const editor = createTestEditor(); + const a = new LimitNode(); + const b = new ResultNode(); + await editor.addNode(a); + await editor.addNode(b); + await editor.addConnection(new BooleanConnection(a, "out", b, "in")); + a.selected = true; + + await deleteSelectedNodes(editor); + + expect(editor.getConnections()).toHaveLength(0); + }); +}); diff --git a/apps/web/src/modules/evaluation/graph/editor/utils/__tests__/removeNodes.test.ts b/apps/web/src/modules/evaluation/graph/editor/utils/__tests__/removeNodes.test.ts new file mode 100644 index 00000000..469cf1cf --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/utils/__tests__/removeNodes.test.ts @@ -0,0 +1,115 @@ +import { BooleanConnection } from "@/modules/evaluation/graph/editor/connections/booleanConnection"; +import { LimitItemConnection } from "@/modules/evaluation/graph/editor/connections/limitItemConnection"; +import { LimitNode } from "@/modules/evaluation/graph/editor/nodes/limit/limit"; +import { CountNode } from "@/modules/evaluation/graph/editor/nodes/limitItem/count"; +import { AndNode } from "@/modules/evaluation/graph/editor/nodes/logical/and"; +import { ResultNode } from "@/modules/evaluation/graph/editor/nodes/result/result"; +import { + removeAllNodes, + removeNodeWithDescendants, +} from "@/modules/evaluation/graph/editor/utils/removeNodes"; +import { describe, expect, it } from "vitest"; +import { createTestEditor } from "./testEditor"; + +describe("removeNodeWithDescendants", () => { + it("removes a node that has no connections", async () => { + const editor = createTestEditor(); + const node = new AndNode(); + + await editor.addNode(node); + await removeNodeWithDescendants(editor, node.id); + + expect(editor.getNodes()).toHaveLength(0); + }); + + it("removes both incoming and outgoing connections of the deleted node", async () => { + const editor = createTestEditor(); + const limitA = new LimitNode(); + const limitB = new LimitNode(); + const and = new AndNode(); + const result = new ResultNode(); + + await editor.addNode(limitA); + await editor.addNode(limitB); + await editor.addNode(and); + await editor.addNode(result); + await editor.addConnection(new BooleanConnection(limitA, "out", and, "in")); + await editor.addConnection(new BooleanConnection(limitB, "out", and, "in")); + await editor.addConnection(new BooleanConnection(and, "out", result, "in")); + + await removeNodeWithDescendants(editor, and.id); + + expect( + editor + .getNodes() + .map((n) => n.id) + .sort(), + ).toEqual([limitA.id, limitB.id, result.id].sort()); + expect(editor.getConnections()).toHaveLength(0); + }); + + it("leaves no dangling connection references after deletion", async () => { + const editor = createTestEditor(); + const a = new LimitNode(); + const b = new AndNode(); + const r = new ResultNode(); + + await editor.addNode(a); + await editor.addNode(b); + await editor.addNode(r); + await editor.addConnection(new BooleanConnection(a, "out", b, "in")); + await editor.addConnection(new BooleanConnection(b, "out", r, "in")); + + await removeNodeWithDescendants(editor, b.id); + + for (const conn of editor.getConnections()) { + expect(editor.getNode(conn.source)).toBeDefined(); + expect(editor.getNode(conn.target)).toBeDefined(); + } + }); + + it("cascades into scoped children when removing a Limit node", async () => { + const editor = createTestEditor(); + const limit = new LimitNode(); + const itemA = new CountNode(); + const itemB = new CountNode(); + + itemA.parent = limit.id; + itemB.parent = limit.id; + + await editor.addNode(limit); + await editor.addNode(itemA); + await editor.addNode(itemB); + await editor.addConnection( + new LimitItemConnection(itemA, "out", itemB, "in"), + ); + + await removeNodeWithDescendants(editor, limit.id); + + expect(editor.getNodes()).toHaveLength(0); + expect(editor.getConnections()).toHaveLength(0); + }); +}); + +describe("removeAllNodes", () => { + it("clears all nodes and connections from the editor", async () => { + const editor = createTestEditor(); + const and = new AndNode(); + const result = new ResultNode(); + + await editor.addNode(and); + await editor.addNode(result); + await editor.addConnection(new BooleanConnection(and, "out", result, "in")); + + await removeAllNodes(editor); + + expect(editor.getNodes()).toHaveLength(0); + expect(editor.getConnections()).toHaveLength(0); + }); + + it("does nothing when the editor is already empty", async () => { + const editor = createTestEditor(); + await removeAllNodes(editor); + expect(editor.getNodes()).toHaveLength(0); + }); +}); diff --git a/apps/web/src/modules/evaluation/graph/editor/utils/__tests__/testEditor.ts b/apps/web/src/modules/evaluation/graph/editor/utils/__tests__/testEditor.ts new file mode 100644 index 00000000..9992ea86 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/utils/__tests__/testEditor.ts @@ -0,0 +1,22 @@ +import type { AreaExtra } from "@/modules/evaluation/graph/editor/setup/createEditor"; +import type { Schemes } from "@/modules/evaluation/graph/editor/types"; +import { NodeEditor } from "rete"; +import type { AreaPlugin } from "rete-area-plugin"; + +export function createTestEditor(): NodeEditor { + return new NodeEditor(); +} + +/** + * Minimal AreaPlugin stub for headless tests. The deserializer calls + * area.translate / area.update purely for visual updates; in tests we just + * need them to be awaitable no-ops. + */ +export function createTestArea(): AreaPlugin { + const stub = { + translate: async () => {}, + update: async () => {}, + }; + + return stub as unknown as AreaPlugin; +} diff --git a/apps/web/src/modules/evaluation/graph/editor/utils/copyPaste.ts b/apps/web/src/modules/evaluation/graph/editor/utils/copyPaste.ts new file mode 100644 index 00000000..a1847361 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/utils/copyPaste.ts @@ -0,0 +1,187 @@ +import { + BooleanConnection, + type BooleanOperator, +} from "@/modules/evaluation/graph/editor/connections/booleanConnection"; +import { LimitItemConnection } from "@/modules/evaluation/graph/editor/connections/limitItemConnection"; +import type { + LimitItemProps, + BooleanNodeProps, + Schemes, +} from "@/modules/evaluation/graph/editor/types"; +import type { EvalLimitItemOperatorEnum } from "@repo/schema"; +import type { NodeEditor } from "rete"; +import type { AreaPlugin } from "rete-area-plugin"; +import { deselectAllNodes, type SelectableNodes } from "./nodeSelection"; + +export type ClipboardNodeEntry = { + node: Schemes["Node"]; + position: { x: number; y: number }; + parentIndex: number | null; +}; + +export type ClipboardConnectionEntry = + | { + kind: "boolean"; + sourceIndex: number; + sourceOutput: string; + targetIndex: number; + targetInput: string; + operator: BooleanOperator; + } + | { + kind: "limitItem"; + sourceIndex: number; + sourceOutput: string; + targetIndex: number; + targetInput: string; + operator: EvalLimitItemOperatorEnum; + }; + +export type Clipboard = { + nodes: ClipboardNodeEntry[]; + connections: ClipboardConnectionEntry[]; + center: { x: number; y: number }; +}; + +/** + * Snapshots all selected nodes (cloning their state at copy time) along with + * every connection whose both endpoints are inside the selection. + */ +export function copyNodes( + editor: NodeEditor, + area: AreaPlugin, +): Clipboard | null { + const selectedNodes = editor.getNodes().filter((n) => n.selected); + if (selectedNodes.length === 0) return null; + + const selectedIds = new Set(selectedNodes.map((n) => n.id)); + const indexById = new Map(selectedNodes.map((n, i) => [n.id, i])); + + const nodes: ClipboardNodeEntry[] = selectedNodes.map((node) => ({ + node: node.clone(), + position: area.nodeViews.get(node.id)?.position ?? { x: 0, y: 0 }, + parentIndex: + node.parent != null && indexById.has(node.parent) + ? (indexById.get(node.parent) ?? null) + : null, + })); + + const connections: ClipboardConnectionEntry[] = editor + .getConnections() + .filter((c) => selectedIds.has(c.source) && selectedIds.has(c.target)) + .flatMap((c): ClipboardConnectionEntry[] => { + const sourceIndex = indexById.get(c.source); + const targetIndex = indexById.get(c.target); + if (sourceIndex === undefined || targetIndex === undefined) return []; + + const base = { + sourceIndex, + sourceOutput: String(c.sourceOutput), + targetIndex, + targetInput: String(c.targetInput), + }; + + if (c instanceof BooleanConnection) { + return [ + { ...base, kind: "boolean" as const, operator: c.booleanOperator }, + ]; + } + if (c instanceof LimitItemConnection) { + return [ + { + ...base, + kind: "limitItem" as const, + operator: c.limitItemOperator, + }, + ]; + } + return []; + }); + + const center = { + x: nodes.reduce((sum, n) => sum + n.position.x, 0) / nodes.length, + y: nodes.reduce((sum, n) => sum + n.position.y, 0) / nodes.length, + }; + + return { nodes, connections, center }; +} + +/** + * Clones the clipboard entries (fresh IDs each paste), positions the chunk + * around the given canvas position, restores internal connections, and selects + * the newly created nodes. + */ +export async function pasteNodes( + clipboard: Clipboard, + props: { + editor: NodeEditor; + area: AreaPlugin; + selectableNodes: SelectableNodes; + }, + position: { x: number; y: number }, +) { + const { editor, area, selectableNodes } = props; + + const newNodes = clipboard.nodes.map((entry) => entry.node.clone()); + + // Remap parent references from clipboard IDs to the newly cloned IDs. + for (const [i, newNode] of newNodes.entries()) { + const parentIndex = clipboard.nodes.at(i)?.parentIndex; + if (parentIndex == null) continue; + newNode.parent = newNodes.at(parentIndex)?.id; + } + + // The scopes plugin requires a parent node to exist before its children are added. + const roots = newNodes.filter((n) => !n.parent); + const children = newNodes.filter((n) => !!n.parent); + for (const node of [...roots, ...children]) { + await editor.addNode(node); + } + + // Place each node at the paste position plus its original offset from the center. + for (const [i, node] of newNodes.entries()) { + const entry = clipboard.nodes.at(i); + if (!entry) continue; + await area.translate(node.id, { + x: position.x + (entry.position.x - clipboard.center.x), + y: position.y + (entry.position.y - clipboard.center.y), + }); + } + + for (const connEntry of clipboard.connections) { + const source = newNodes.at(connEntry.sourceIndex); + const target = newNodes.at(connEntry.targetIndex); + if (!source || !target) continue; + + if (connEntry.kind === "boolean") { + await editor.addConnection( + new BooleanConnection( + source as BooleanNodeProps, + connEntry.sourceOutput, + target as BooleanNodeProps, + connEntry.targetInput, + connEntry.operator, + ), + ); + } else { + await editor.addConnection( + new LimitItemConnection( + source as LimitItemProps, + connEntry.sourceOutput, + target as LimitItemProps, + connEntry.targetInput, + connEntry.operator, + ), + ); + } + } + + await deselectAllNodes(selectableNodes, editor); + for (const [i, node] of newNodes.entries()) { + await selectableNodes.select(node.id, i > 0); + } + + for (const node of newNodes) { + await area.update("node", node.id); + } +} diff --git a/apps/web/src/modules/evaluation/graph/editor/utils/graph.ts b/apps/web/src/modules/evaluation/graph/editor/utils/graph.ts new file mode 100644 index 00000000..842755d2 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/utils/graph.ts @@ -0,0 +1,88 @@ +import type { + ConnProps, + NodeProps, +} from "@/modules/evaluation/graph/editor/types"; + +export function findConnectedComponents( + nodeIds: string[], + connections: Array<{ source: string; target: string }>, +) { + const allowedIds = new Set(nodeIds); + const adjacency = new Map>(); + + const originalIndexByNodeId = new Map(); + + nodeIds.forEach((nodeId, index) => { + originalIndexByNodeId.set(nodeId, index); + adjacency.set(nodeId, new Set()); + }); + + for (const connection of connections) { + if (!allowedIds.has(connection.source)) continue; + if (!allowedIds.has(connection.target)) continue; + + adjacency.get(connection.source)?.add(connection.target); + adjacency.get(connection.target)?.add(connection.source); + } + + const components: string[][] = []; + const visited = new Set(); + + for (const nodeId of nodeIds) { + if (visited.has(nodeId)) continue; + + const component: string[] = []; + const stack = [nodeId]; + + visited.add(nodeId); + + while (stack.length > 0) { + const current = stack.pop(); + if (current === undefined) continue; + + component.push(current); + + for (const next of adjacency.get(current) ?? []) { + if (visited.has(next)) continue; + + visited.add(next); + stack.push(next); + } + } + + component.sort((a, b) => { + const indexA = originalIndexByNodeId.get(a) ?? Number.MAX_SAFE_INTEGER; + const indexB = originalIndexByNodeId.get(b) ?? Number.MAX_SAFE_INTEGER; + return indexA - indexB; + }); + + components.push(component); + } + + return components.sort((a, b) => { + const oldestIndexA = getOldestNodeIndex(a, originalIndexByNodeId); + const oldestIndexB = getOldestNodeIndex(b, originalIndexByNodeId); + return oldestIndexA - oldestIndexB; + }); +} + +function getOldestNodeIndex( + component: string[], + originalIndexByNodeId: Map, +) { + return component.reduce((oldestIndex, nodeId) => { + const index = originalIndexByNodeId.get(nodeId) ?? Number.MAX_SAFE_INTEGER; + + return Math.min(oldestIndex, index); + }, Number.MAX_SAFE_INTEGER); +} + +export function countConnectedComponents( + nodes: NodeProps[], + connections: ConnProps[], +) { + return findConnectedComponents( + nodes.map((node) => node.id), + connections, + ).length; +} diff --git a/apps/web/src/modules/evaluation/graph/editor/utils/guards.ts b/apps/web/src/modules/evaluation/graph/editor/utils/guards.ts new file mode 100644 index 00000000..a8da813c --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/utils/guards.ts @@ -0,0 +1,37 @@ +import { AlertNode } from "@/modules/evaluation/graph/editor/nodes/action/alert"; +import { WarningNode } from "@/modules/evaluation/graph/editor/nodes/action/warning"; +import { LimitNode } from "@/modules/evaluation/graph/editor/nodes/limit/limit"; +import { AreaNode } from "@/modules/evaluation/graph/editor/nodes/limitItem/area"; +import { CountNode } from "@/modules/evaluation/graph/editor/nodes/limitItem/count"; +import { PositionNode } from "@/modules/evaluation/graph/editor/nodes/limitItem/position"; +import { AndNode } from "@/modules/evaluation/graph/editor/nodes/logical/and"; +import { OrNode } from "@/modules/evaluation/graph/editor/nodes/logical/or"; +import { ResultNode } from "@/modules/evaluation/graph/editor/nodes/result/result"; +import type { + LimitItemProps, + NodeProps, +} from "@/modules/evaluation/graph/editor/types"; + +export function isLimitItemNode(node: NodeProps): node is LimitItemProps { + return ( + node instanceof CountNode || + node instanceof PositionNode || + node instanceof AreaNode + ); +} + +export function isLogicalOperator(node: NodeProps): node is AndNode | OrNode { + return node instanceof AndNode || node instanceof OrNode; +} + +export function isLimitNode(node: NodeProps): node is LimitNode { + return node instanceof LimitNode; +} + +export function isActionNode(node: NodeProps): node is WarningNode | AlertNode { + return node instanceof WarningNode || node instanceof AlertNode; +} + +export function isResultNode(node: NodeProps): node is ResultNode { + return node instanceof ResultNode; +} diff --git a/apps/web/src/modules/evaluation/graph/editor/utils/nodeSelection.ts b/apps/web/src/modules/evaluation/graph/editor/utils/nodeSelection.ts new file mode 100644 index 00000000..fbb11ebd --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/utils/nodeSelection.ts @@ -0,0 +1,66 @@ +import type { AreaExtra } from "@/modules/evaluation/graph/editor/setup/createEditor"; +import type { Schemes } from "@/modules/evaluation/graph/editor/types"; +import type { NodeEditor } from "rete"; +import type { AreaPlugin } from "rete-area-plugin"; +import type { HistoryPlugin } from "rete-history-plugin"; +import { removeNodeWithDescendants } from "./removeNodes"; + +export type SelectableNodes = { + select: (nodeId: string, accumulate: boolean) => Promise; + unselect: (nodeId: string) => Promise; +}; + +export async function selectAllNodes( + selectableNodes: SelectableNodes, + editor: NodeEditor, +) { + const nodes = editor.getNodes(); + for (const [index, node] of nodes.entries()) { + await selectableNodes.select(node.id, index > 0); + } +} + +export async function deselectAllNodes( + selectableNodes: SelectableNodes, + editor: NodeEditor, +) { + const selectedNodes = editor.getNodes().filter((node) => node.selected); + for (const node of selectedNodes) { + await selectableNodes.unselect(node.id); + } +} + +/** + * Removes all selected nodes, treating each top-level selected node as the root + * of its subtree. Children of a selected parent are removed via cascade and are + * not processed separately. + */ +export async function deleteSelectedNodes( + editor: NodeEditor, + area?: AreaPlugin, + history?: HistoryPlugin, +) { + const selectedNodes = editor.getNodes().filter((node) => node.selected); + const selectedNodeIds = new Set(selectedNodes.map((node) => node.id)); + + const topLevelSelectedNodes = selectedNodes.filter( + (node) => !node.parent || !selectedNodeIds.has(node.parent), + ); + + if (topLevelSelectedNodes.length === 0) return; + + const options = area && history ? { area, history } : undefined; + + // The history plugin chains undo across actions within 200ms of each other. + // Without explicit boundaries, undoing the cascade deletes also undoes any + // drag/create action that happened to land in that window, restoring nodes + // to their pre-drag positions instead of where the user deleted them. + // separate() marks the latest action as a hard stop for the chain. + history?.separate(); + + for (const node of topLevelSelectedNodes) { + await removeNodeWithDescendants(editor, node.id, options); + } + + history?.separate(); +} diff --git a/apps/web/src/modules/evaluation/graph/editor/utils/removeNodes.ts b/apps/web/src/modules/evaluation/graph/editor/utils/removeNodes.ts new file mode 100644 index 00000000..3f62d377 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/utils/removeNodes.ts @@ -0,0 +1,201 @@ +import type { AreaExtra } from "@/modules/evaluation/graph/editor/setup/createEditor"; +import type { Schemes } from "@/modules/evaluation/graph/editor/types"; +import type { NodeEditor } from "rete"; +import type { AreaPlugin } from "rete-area-plugin"; +import type { HistoryPlugin } from "rete-history-plugin"; + +type Position = { x: number; y: number }; + +type NodeSnapshot = { + node: Schemes["Node"]; + position: Position; + width: number; + height: number; + depth: number; +}; + +/** + * Atomic history action for a cascade delete. The default classic preset + * records each child removal plus the parent translations the scopes plugin + * triggers as separate entries, and replaying them in reverse leaves the + * parent the wrong size, shifts children via translateChildren side effects, + * and can drop connections. Snapshotting upfront and restoring in one shot + * sidesteps all of that. + */ +class CascadeRemoveAction { + constructor( + private editor: NodeEditor, + private area: AreaPlugin, + private snapshots: NodeSnapshot[], + private connections: Schemes["Connection"][], + ) {} + + async undo() { + const ascending = [...this.snapshots].sort((a, b) => a.depth - b.depth); + + for (const snap of ascending) { + snap.node.width = snap.width; + snap.node.height = snap.height; + await this.editor.addNode(snap.node); + await this.area.translate(snap.node.id, snap.position); + } + + for (const snap of ascending) { + if (snap.node.width !== snap.width || snap.node.height !== snap.height) { + snap.node.width = snap.width; + snap.node.height = snap.height; + await this.area.resize(snap.node.id, snap.width, snap.height); + } + } + + for (const connection of this.connections) { + await this.editor.addConnection(connection); + } + } + + async redo() { + for (const connection of this.connections) { + await this.editor.removeConnection(connection.id); + } + + const descending = [...this.snapshots].sort((a, b) => b.depth - a.depth); + for (const snap of descending) { + await this.editor.removeNode(snap.node.id); + } + } +} + +type CascadeOptions = { + area: AreaPlugin; + history: HistoryPlugin; +}; + +/** + * Disable history recording while running `fn`. The classic preset's pipes + * still fire, but History.add() short-circuits when `active` is true. Used to + * keep the cascade delete from polluting the history with the per-node remove + * actions and the scopes plugin's resize-driven drag actions. + */ +async function withSuspendedHistory( + history: HistoryPlugin, + fn: () => Promise, +): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const inner = (history as any).history as { active: boolean }; + const previous = inner.active; + inner.active = true; + try { + return await fn(); + } finally { + inner.active = previous; + } +} + +export async function removeNodeWithDescendants( + editor: NodeEditor, + nodeId: string, + options?: CascadeOptions, +) { + const nodeIdsToRemove = collectDescendantNodeIds(editor, nodeId); + + const connectionsToRemove = editor.getConnections().filter((connection) => { + return ( + nodeIdsToRemove.has(connection.source) || + nodeIdsToRemove.has(connection.target) + ); + }); + + const nodesInRemovalOrder = [...nodeIdsToRemove] + .map((id) => editor.getNode(id)) + .filter((node): node is Schemes["Node"] => Boolean(node)) + .sort((a, b) => { + return getNodeDepth(editor, b.id) - getNodeDepth(editor, a.id); + }); + + if (!options) { + for (const connection of connectionsToRemove) { + await editor.removeConnection(connection.id); + } + for (const node of nodesInRemovalOrder) { + await editor.removeNode(node.id); + } + return; + } + + const { area, history } = options; + + const snapshots: NodeSnapshot[] = []; + for (const node of nodesInRemovalOrder) { + const view = area.nodeViews.get(node.id); + snapshots.push({ + node, + position: view ? { x: view.position.x, y: view.position.y } : { x: 0, y: 0 }, + width: node.width, + height: node.height, + depth: getNodeDepth(editor, node.id), + }); + } + + await withSuspendedHistory(history, async () => { + for (const connection of connectionsToRemove) { + await editor.removeConnection(connection.id); + } + for (const node of nodesInRemovalOrder) { + await editor.removeNode(node.id); + } + }); + + history.add( + new CascadeRemoveAction(editor, area, snapshots, connectionsToRemove), + ); +} + +export async function removeAllNodes(editor: NodeEditor) { + for (const connection of editor.getConnections()) { + await editor.removeConnection(connection.id); + } + + const nodes = editor + .getNodes() + .sort((a, b) => getNodeDepth(editor, b.id) - getNodeDepth(editor, a.id)); + + for (const node of nodes) { + await editor.removeNode(node.id); + } +} + +function collectDescendantNodeIds( + editor: NodeEditor, + rootNodeId: string, +) { + const result = new Set(); + const stack = [rootNodeId]; + + while (stack.length > 0) { + const currentId = stack.pop(); + + if (!currentId || result.has(currentId)) continue; + + result.add(currentId); + + for (const node of editor.getNodes()) { + if (node.parent === currentId) { + stack.push(node.id); + } + } + } + + return result; +} + +function getNodeDepth(editor: NodeEditor, nodeId: string) { + let depth = 0; + let current = editor.getNode(nodeId); + + while (current?.parent) { + depth += 1; + current = editor.getNode(current.parent); + } + + return depth; +} diff --git a/apps/web/src/modules/evaluation/graph/editor/utils/scopes.ts b/apps/web/src/modules/evaluation/graph/editor/utils/scopes.ts new file mode 100644 index 00000000..ba90fd6c --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/utils/scopes.ts @@ -0,0 +1,18 @@ +import { LimitNode } from "@/modules/evaluation/graph/editor/nodes/limit/limit"; +import type { NodeProps } from "@/modules/evaluation/graph/editor/types"; + +export function canHaveChildren(node: NodeProps | undefined): boolean { + return !!node && node.allowedChildGroups.length > 0; +} + +export function canBeChildOf( + child: NodeProps | undefined, + parent: NodeProps | undefined, +): boolean { + if (!child || !parent) return false; + return parent.allowedChildGroups.includes(child.nodeGroup); +} + +export function isScopeNode(node: NodeProps | undefined): node is LimitNode { + return node instanceof LimitNode; +} diff --git a/apps/web/src/modules/evaluation/graph/editor/validation/issues.ts b/apps/web/src/modules/evaluation/graph/editor/validation/issues.ts new file mode 100644 index 00000000..8fa38312 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/issues.ts @@ -0,0 +1,27 @@ +import type { + ControlIssue, + ControlIssuesByNode, + ValidationIssue, +} from "./types"; + +export function pushIssue( + nodeIssues: Map, + nodeId: string, + issue: ValidationIssue, +) { + const issues = nodeIssues.get(nodeId) ?? []; + issues.push(issue); + nodeIssues.set(nodeId, issues); +} + +export function pushControlIssue( + controlIssues: ControlIssuesByNode, + nodeId: string, + controlKey: string, + issue: ControlIssue, +) { + const nodeIssues = controlIssues.get(nodeId) ?? {}; + const issues = nodeIssues[controlKey] ?? []; + nodeIssues[controlKey] = [...issues, issue]; + controlIssues.set(nodeId, nodeIssues); +} diff --git a/apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/helpers.ts b/apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/helpers.ts new file mode 100644 index 00000000..43d73c2e --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/helpers.ts @@ -0,0 +1,31 @@ +import type { + ConnProps, + NodeProps, + Schemes, +} from "@/modules/evaluation/graph/editor/types"; +import type { ValidationContext } from "@/modules/evaluation/graph/editor/validation/types"; +import { ValidationGraph } from "@/modules/evaluation/graph/editor/validation/validationGraph"; +import { NodeEditor } from "rete"; + +export function makeContext( + nodes: NodeProps[], + connections: ConnProps[] = [], +): ValidationContext { + const editor = { + getNodes: () => nodes, + getConnections: () => connections, + }; + + const graph = new ValidationGraph(editor as unknown as NodeEditor); + + return { + graph, + nodeIssues: new Map(), + controlIssues: new Map(), + graphIssues: [], + }; +} + +export function conn(source: string, target: string): ConnProps { + return { id: `${source}->${target}`, source, target } as unknown as ConnProps; +} diff --git a/apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/validateActions.test.ts b/apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/validateActions.test.ts new file mode 100644 index 00000000..8eae30f9 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/validateActions.test.ts @@ -0,0 +1,49 @@ +import { AlertNode } from "@/modules/evaluation/graph/editor/nodes/action/alert"; +import { WarningNode } from "@/modules/evaluation/graph/editor/nodes/action/warning"; +import { LimitNode } from "@/modules/evaluation/graph/editor/nodes/limit/limit"; +import { AndNode } from "@/modules/evaluation/graph/editor/nodes/logical/and"; +import { ResultNode } from "@/modules/evaluation/graph/editor/nodes/result/result"; +import { findActionsWithInvalidSource } from "@/modules/evaluation/graph/editor/validation/rules/validateActions"; +import { describe, expect, it } from "vitest"; +import { conn, makeContext } from "./helpers"; + +describe("findActionsWithInvalidSource", () => { + it("reports no issues when there are no action nodes", () => { + const ctx = makeContext([new LimitNode(), new ResultNode()]); + findActionsWithInvalidSource(ctx); + expect(ctx.nodeIssues.size).toBe(0); + }); + + it("reports no issues for an action with no incoming connections", () => { + const action = new WarningNode(); + const ctx = makeContext([action]); + findActionsWithInvalidSource(ctx); + expect(ctx.nodeIssues.size).toBe(0); + }); + + it("reports no issues when an action is sourced from a LimitNode", () => { + const limit = new LimitNode(); + const action = new WarningNode(); + const ctx = makeContext([limit, action], [conn(limit.id, action.id)]); + findActionsWithInvalidSource(ctx); + expect(ctx.nodeIssues.size).toBe(0); + }); + + it("reports no issues when an action is sourced from the ResultNode", () => { + const result = new ResultNode(); + const action = new AlertNode(); + const ctx = makeContext([result, action], [conn(result.id, action.id)]); + findActionsWithInvalidSource(ctx); + expect(ctx.nodeIssues.size).toBe(0); + }); + + it("reports an error when an action is sourced from a logical node", () => { + const and = new AndNode(); + const action = new WarningNode(); + const ctx = makeContext([and, action], [conn(and.id, action.id)]); + findActionsWithInvalidSource(ctx); + const issues = ctx.nodeIssues.get(action.id) ?? []; + expect(issues).toHaveLength(1); + expect(issues.at(0)?.level).toBe("error"); + }); +}); diff --git a/apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/validateGlobal.test.ts b/apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/validateGlobal.test.ts new file mode 100644 index 00000000..319e8ae4 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/validateGlobal.test.ts @@ -0,0 +1,92 @@ +import { LimitNode } from "@/modules/evaluation/graph/editor/nodes/limit/limit"; +import { CountNode } from "@/modules/evaluation/graph/editor/nodes/limitItem/count"; +import { AndNode } from "@/modules/evaluation/graph/editor/nodes/logical/and"; +import { ResultNode } from "@/modules/evaluation/graph/editor/nodes/result/result"; +import { + findNodesOutsideResultIsland, + findBranchesNotLeadingToResult, +} from "@/modules/evaluation/graph/editor/validation/rules/validateGlobal"; +import { describe, expect, it } from "vitest"; +import { conn, makeContext } from "./helpers"; + +describe("findNodesOutsideResultIsland", () => { + it("reports no issues for an empty graph", () => { + const ctx = makeContext([]); + findNodesOutsideResultIsland(ctx); + expect(ctx.nodeIssues.size).toBe(0); + }); + + it("reports no issues when the only node is the ResultNode", () => { + const result = new ResultNode(); + const ctx = makeContext([result]); + findNodesOutsideResultIsland(ctx); + expect(ctx.nodeIssues.size).toBe(0); + }); + + it("reports no issues when a LimitNode is connected to the ResultNode", () => { + const limit = new LimitNode(); + const result = new ResultNode(); + const ctx = makeContext([limit, result], [conn(limit.id, result.id)]); + findNodesOutsideResultIsland(ctx); + expect(ctx.nodeIssues.size).toBe(0); + }); + + it("reports an error for a node isolated from any ResultNode island", () => { + const limit = new LimitNode(); + const result = new ResultNode(); + const ctx = makeContext([limit, result]); + findNodesOutsideResultIsland(ctx); + const issues = ctx.nodeIssues.get(limit.id) ?? []; + expect(issues).toHaveLength(1); + expect(issues.at(0)?.level).toBe("error"); + expect(ctx.nodeIssues.get(result.id)).toBeUndefined(); + }); + + it("excludes LimitItem nodes from the disconnected-node check", () => { + const count = new CountNode(); + const ctx = makeContext([count]); + findNodesOutsideResultIsland(ctx); + expect(ctx.nodeIssues.size).toBe(0); + }); +}); + +describe("findBranchesNotLeadingToResult", () => { + it("reports no issues when a branching node has only one graph output", () => { + const limit = new LimitNode(); + const result = new ResultNode(); + const ctx = makeContext([limit, result], [conn(limit.id, result.id)]); + findBranchesNotLeadingToResult(ctx); + expect(ctx.nodeIssues.size).toBe(0); + }); + + it("reports no issues when every branch from a branching node leads to a ResultNode", () => { + const limit = new LimitNode(); + const and = new AndNode(); + const result = new ResultNode(); + const ctx = makeContext( + [limit, and, result], + [ + conn(limit.id, result.id), + conn(limit.id, and.id), + conn(and.id, result.id), + ], + ); + findBranchesNotLeadingToResult(ctx); + expect(ctx.nodeIssues.size).toBe(0); + }); + + it("reports an error on a branch target that does not lead to a ResultNode", () => { + const limit = new LimitNode(); + const and = new AndNode(); + const result = new ResultNode(); + const ctx = makeContext( + [limit, and, result], + [conn(limit.id, result.id), conn(limit.id, and.id)], + ); + findBranchesNotLeadingToResult(ctx); + const issues = ctx.nodeIssues.get(and.id) ?? []; + expect(issues).toHaveLength(1); + expect(issues.at(0)?.level).toBe("error"); + expect(ctx.nodeIssues.get(result.id)).toBeUndefined(); + }); +}); diff --git a/apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/validateLimitItems.test.ts b/apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/validateLimitItems.test.ts new file mode 100644 index 00000000..15c6d80b --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/validateLimitItems.test.ts @@ -0,0 +1,71 @@ +import { LimitNode } from "@/modules/evaluation/graph/editor/nodes/limit/limit"; +import { CountNode } from "@/modules/evaluation/graph/editor/nodes/limitItem/count"; +import { + findCrossScopeConnections, + findOrphanLimitItems, +} from "@/modules/evaluation/graph/editor/validation/rules/validateLimitItems"; +import { describe, expect, it } from "vitest"; +import { conn, makeContext } from "./helpers"; + +describe("findOrphanLimitItems", () => { + it("reports an error for a root-level LimitItem with no parent", () => { + const count = new CountNode(); + const ctx = makeContext([count]); + findOrphanLimitItems(ctx); + const issues = ctx.nodeIssues.get(count.id) ?? []; + expect(issues).toHaveLength(1); + expect(issues.at(0)?.level).toBe("error"); + }); + + it("reports no issues for a LimitItem nested inside a LimitNode", () => { + const limit = new LimitNode(); + const count = new CountNode(); + count.parent = limit.id; + const ctx = makeContext([limit, count]); + findOrphanLimitItems(ctx); + expect(ctx.nodeIssues.size).toBe(0); + }); + + it("ignores non-LimitItem nodes without a parent", () => { + const limit = new LimitNode(); + const ctx = makeContext([limit]); + findOrphanLimitItems(ctx); + expect(ctx.nodeIssues.size).toBe(0); + }); +}); + +describe("findCrossScopeConnections", () => { + it("reports no issues for a connection between two root-level nodes", () => { + const a = new CountNode(); + const b = new CountNode(); + const ctx = makeContext([a, b], [conn(a.id, b.id)]); + findCrossScopeConnections(ctx); + expect(ctx.nodeIssues.size).toBe(0); + }); + + it("reports no issues for a connection between nodes with the same parent", () => { + const limit = new LimitNode(); + const a = new CountNode(); + const b = new CountNode(); + a.parent = limit.id; + b.parent = limit.id; + const ctx = makeContext([limit, a, b], [conn(a.id, b.id)]); + findCrossScopeConnections(ctx); + expect(ctx.nodeIssues.size).toBe(0); + }); + + it("reports an error on the target when connected nodes have different parents", () => { + const limitA = new LimitNode(); + const limitB = new LimitNode(); + const a = new CountNode(); + const b = new CountNode(); + a.parent = limitA.id; + b.parent = limitB.id; + const ctx = makeContext([limitA, limitB, a, b], [conn(a.id, b.id)]); + findCrossScopeConnections(ctx); + const issues = ctx.nodeIssues.get(b.id) ?? []; + expect(issues).toHaveLength(1); + expect(issues.at(0)?.level).toBe("error"); + expect(ctx.nodeIssues.get(a.id)).toBeUndefined(); + }); +}); diff --git a/apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/validateLimits.test.ts b/apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/validateLimits.test.ts new file mode 100644 index 00000000..1a9a51d3 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/validateLimits.test.ts @@ -0,0 +1,100 @@ +import { LimitNode } from "@/modules/evaluation/graph/editor/nodes/limit/limit"; +import { CountNode } from "@/modules/evaluation/graph/editor/nodes/limitItem/count"; +import { + findEmptyLimits, + findLimitsWithMissingRequiredInputs, + findLimitsWithMultipleIslands, +} from "@/modules/evaluation/graph/editor/validation/rules/validateLimits"; +import { describe, expect, it } from "vitest"; +import { conn, makeContext } from "./helpers"; + +describe("findEmptyLimits", () => { + it("warns when a LimitNode has no children", () => { + const limit = new LimitNode(); + const ctx = makeContext([limit]); + findEmptyLimits(ctx); + const issues = ctx.nodeIssues.get(limit.id) ?? []; + expect(issues).toHaveLength(1); + expect(issues.at(0)?.level).toBe("warning"); + }); + + it("reports no issues when a LimitNode has at least one child", () => { + const limit = new LimitNode(); + const child = new CountNode(); + child.parent = limit.id; + const ctx = makeContext([limit, child]); + findEmptyLimits(ctx); + expect(ctx.nodeIssues.size).toBe(0); + }); +}); + +describe("findLimitsWithMultipleIslands", () => { + it("reports no issues for a LimitNode with a single child", () => { + const limit = new LimitNode(); + const child = new CountNode(); + child.parent = limit.id; + const ctx = makeContext([limit, child]); + findLimitsWithMultipleIslands(ctx); + expect(ctx.nodeIssues.size).toBe(0); + }); + + it("reports no issues when all children are connected into one component", () => { + const limit = new LimitNode(); + const a = new CountNode(); + const b = new CountNode(); + a.parent = limit.id; + b.parent = limit.id; + const ctx = makeContext([limit, a, b], [conn(a.id, b.id)]); + findLimitsWithMultipleIslands(ctx); + expect(ctx.nodeIssues.size).toBe(0); + }); + + it("reports an error when children form disconnected islands", () => { + const limit = new LimitNode(); + const a = new CountNode(); + const b = new CountNode(); + a.parent = limit.id; + b.parent = limit.id; + const ctx = makeContext([limit, a, b]); + findLimitsWithMultipleIslands(ctx); + const issues = ctx.nodeIssues.get(limit.id) ?? []; + expect(issues).toHaveLength(1); + expect(issues.at(0)?.level).toBe("error"); + }); +}); + +describe("findLimitsWithMissingRequiredInputs", () => { + it("reports errors on both controls when name and label are absent", () => { + const limit = new LimitNode(); + const ctx = makeContext([limit]); + findLimitsWithMissingRequiredInputs(ctx); + const issues = ctx.controlIssues.get(limit.id); + expect(issues?.name).toHaveLength(1); + expect(issues?.label).toHaveLength(1); + }); + + it("reports an error only on the name control when the label is valid", () => { + const limit = new LimitNode({ label: 1 }); + const ctx = makeContext([limit]); + findLimitsWithMissingRequiredInputs(ctx); + const issues = ctx.controlIssues.get(limit.id); + expect(issues?.name).toHaveLength(1); + expect(issues?.label).toBeUndefined(); + }); + + it("reports an error only on the label control when the name is valid", () => { + const limit = new LimitNode({ name: "test" }); + const ctx = makeContext([limit]); + findLimitsWithMissingRequiredInputs(ctx); + const issues = ctx.controlIssues.get(limit.id); + expect(issues?.name).toBeUndefined(); + expect(issues?.label).toHaveLength(1); + }); + + it("reports no issues when both name and label are valid", () => { + const limit = new LimitNode({ name: "test", label: 1 }); + const ctx = makeContext([limit]); + findLimitsWithMissingRequiredInputs(ctx); + expect(ctx.controlIssues.size).toBe(0); + }); +}); diff --git a/apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/validateLogical.test.ts b/apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/validateLogical.test.ts new file mode 100644 index 00000000..ab274e7d --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/validateLogical.test.ts @@ -0,0 +1,46 @@ +import { LimitNode } from "@/modules/evaluation/graph/editor/nodes/limit/limit"; +import { AndNode } from "@/modules/evaluation/graph/editor/nodes/logical/and"; +import { OrNode } from "@/modules/evaluation/graph/editor/nodes/logical/or"; +import { findUselessLogicalNodes } from "@/modules/evaluation/graph/editor/validation/rules/validateLogical"; +import { describe, expect, it } from "vitest"; +import { conn, makeContext } from "./helpers"; + +describe("findUselessLogicalNodes", () => { + it("reports an error for a logical node with zero inputs", () => { + const and = new AndNode(); + const ctx = makeContext([and]); + findUselessLogicalNodes(ctx); + const issues = ctx.nodeIssues.get(and.id) ?? []; + expect(issues).toHaveLength(1); + expect(issues.at(0)?.level).toBe("error"); + }); + + it("warns when a logical node has exactly one input", () => { + const limit = new LimitNode(); + const and = new AndNode(); + const ctx = makeContext([limit, and], [conn(limit.id, and.id)]); + findUselessLogicalNodes(ctx); + const issues = ctx.nodeIssues.get(and.id) ?? []; + expect(issues).toHaveLength(1); + expect(issues.at(0)?.level).toBe("warning"); + }); + + it("reports no warning for a logical node with two or more inputs", () => { + const limitA = new LimitNode(); + const limitB = new LimitNode(); + const or = new OrNode(); + const ctx = makeContext( + [limitA, limitB, or], + [conn(limitA.id, or.id), conn(limitB.id, or.id)], + ); + findUselessLogicalNodes(ctx); + expect(ctx.nodeIssues.size).toBe(0); + }); + + it("ignores non-logical nodes", () => { + const limit = new LimitNode(); + const ctx = makeContext([limit]); + findUselessLogicalNodes(ctx); + expect(ctx.nodeIssues.size).toBe(0); + }); +}); diff --git a/apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/validateResult.test.ts b/apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/validateResult.test.ts new file mode 100644 index 00000000..06112255 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/validateResult.test.ts @@ -0,0 +1,35 @@ +import { LimitNode } from "@/modules/evaluation/graph/editor/nodes/limit/limit"; +import { ResultNode } from "@/modules/evaluation/graph/editor/nodes/result/result"; +import { findInvalidResultNodeCount } from "@/modules/evaluation/graph/editor/validation/rules/validateResult"; +import { describe, expect, it } from "vitest"; +import { makeContext } from "./helpers"; + +describe("findInvalidResultNodeCount", () => { + it("reports a graph-level error when the graph has no result nodes", () => { + const ctx = makeContext([new LimitNode()]); + findInvalidResultNodeCount(ctx); + expect(ctx.nodeIssues.size).toBe(0); + expect(ctx.graphIssues).toHaveLength(1); + expect(ctx.graphIssues.at(0)?.level).toBe("error"); + }); + + it("reports no issues when the graph has exactly one result node", () => { + const result = new ResultNode(); + const ctx = makeContext([result]); + findInvalidResultNodeCount(ctx); + expect(ctx.nodeIssues.size).toBe(0); + }); + + it("reports an error on every result node when there are multiple", () => { + const a = new ResultNode(); + const b = new ResultNode(); + const ctx = makeContext([a, b]); + findInvalidResultNodeCount(ctx); + const issuesA = ctx.nodeIssues.get(a.id) ?? []; + const issuesB = ctx.nodeIssues.get(b.id) ?? []; + expect(issuesA).toHaveLength(1); + expect(issuesA.at(0)?.level).toBe("error"); + expect(issuesB).toHaveLength(1); + expect(issuesB.at(0)?.level).toBe("error"); + }); +}); diff --git a/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateActions.ts b/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateActions.ts new file mode 100644 index 00000000..b646ca40 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateActions.ts @@ -0,0 +1,39 @@ +import { pushIssue } from "@/modules/evaluation/graph/editor/validation/issues"; +import { + isActionNode, + isLimitNode, + isResultNode, +} from "@/modules/evaluation/graph/editor/utils/guards"; +import { + type ValidationContext, +} from "@/modules/evaluation/graph/editor/validation/types"; + +/** + * Finds action nodes connected from unsupported sources. + * + * Action nodes can only be connected directly from a Limit node or from the + * Result node. + */ +export function findActionsWithInvalidSource(context: ValidationContext) { + const { graph, nodeIssues } = context; + for (const node of graph.nodes) { + if (!isActionNode(node)) continue; + const incomingConnections = graph.getIncoming(node.id); + + for (const connection of incomingConnections) { + const sourceNode = graph.getNode(connection.source); + if (!sourceNode) continue; + if (isLimitNode(sourceNode) || isResultNode(sourceNode)) continue; + + pushIssue(nodeIssues, node.id, { + level: "error", + message: "Invalid action placement", + description: [ + "The previous node does not support an action.", + "Actions can only be connected directly to a Check node or to the Result node.", + ], + }); + break; + } + } +} diff --git a/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateGlobal.ts b/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateGlobal.ts new file mode 100644 index 00000000..f0da0391 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateGlobal.ts @@ -0,0 +1,165 @@ +import { pushIssue } from "@/modules/evaluation/graph/editor/validation/issues"; +import type { NodeProps } from "@/modules/evaluation/graph/editor/types"; +import { findConnectedComponents } from "@/modules/evaluation/graph/editor/utils/graph"; +import { + isActionNode, + isLimitItemNode, + isLimitNode, + isLogicalOperator, + isResultNode, +} from "@/modules/evaluation/graph/editor/utils/guards"; +import { + type ValidationContext, +} from "@/modules/evaluation/graph/editor/validation/types"; + +/** + * Finds graph nodes that are not connected to any Result node island. + * + * Every graph node except LimitItem nodes must belong to a connected component + * that contains at least one Result node. + * + * LimitItem nodes are ignored because they are scoped inside Limit nodes and + * validated separately as children of their parent Limit. + */ +export function findNodesOutsideResultIsland(context: ValidationContext) { + const { graph, nodeIssues } = context; + + const graphNodes = graph.nodes.filter((node) => !isLimitItemNode(node)); + + if (graphNodes.length === 0) return; + + const nodeIdsWithResult = getResultIslandNodeIds(context); + + for (const node of graphNodes) { + if (nodeIdsWithResult.has(node.id)) continue; + + pushIssue(nodeIssues, node.id, { + level: "error", + message: "Disconnected node", + description: [ + "This node is not connected to a Result node.", + "All graph nodes except Check items must be connected to an island that contains a Result node.", + ], + }); + } +} + +/** + * Finds graph branches that do not lead to a Result node. + * + * This only applies inside islands that contain a Result node. Islands without + * a Result node are ignored here because they are already reported by the + * disconnected-node validation. + * + * When a Limit or logical node has multiple non-action outputs, every branch + * must eventually lead to a Result node. Branch targets that do not lead to a + * Result node are marked as invalid. + */ +export function findBranchesNotLeadingToResult(context: ValidationContext) { + const { graph, nodeIssues } = context; + + const resultIslandNodeIds = getResultIslandNodeIds(context); + const reportedNodeIds = new Set(); + + for (const node of graph.nodes) { + if (!resultIslandNodeIds.has(node.id)) continue; + if (!canProduceGraphOutput(node)) continue; + + const graphOutputConnections = getGraphOutputConnections(context, node.id); + + if (graphOutputConnections.length <= 1) continue; + + for (const connection of graphOutputConnections) { + if (reportedNodeIds.has(connection.target)) continue; + if (doesPathLeadToResult(context, connection.target)) continue; + + reportedNodeIds.add(connection.target); + + pushIssue(nodeIssues, connection.target, { + level: "error", + message: "Branch does not lead to Result", + description: [ + "This branch is created from a node with multiple graph outputs.", + "Every branch in a Result island must eventually flow into a Result node.", + ], + }); + } + } +} + +function canProduceGraphOutput(node: NodeProps) { + return isLimitNode(node) || isLogicalOperator(node); +} + +function getGraphOutputConnections(context: ValidationContext, nodeId: string) { + const { graph } = context; + + return graph.getOutgoing(nodeId).filter((connection) => { + const targetNode = graph.getNode(connection.target); + + if (!targetNode) return false; + if (isActionNode(targetNode)) return false; + if (isLimitItemNode(targetNode)) return false; + + return true; + }); +} + +function getResultIslandNodeIds(context: ValidationContext) { + const { graph } = context; + + const graphNodes = graph.nodes.filter((node) => !isLimitItemNode(node)); + const nodeIds = graphNodes.map((node) => node.id); + const islands = findConnectedComponents(nodeIds, graph.connections); + + const resultIslandNodeIds = new Set(); + + for (const island of islands) { + const islandHasResult = island.some((nodeId) => { + const node = graph.getNode(nodeId); + return node && isResultNode(node); + }); + + if (!islandHasResult) continue; + + for (const nodeId of island) { + resultIslandNodeIds.add(nodeId); + } + } + + return resultIslandNodeIds; +} + +function doesPathLeadToResult(context: ValidationContext, startNodeId: string) { + const { graph } = context; + + const visited = new Set(); + const stack = [startNodeId]; + + while (stack.length > 0) { + const currentNodeId = stack.pop(); + + if (!currentNodeId) continue; + if (visited.has(currentNodeId)) continue; + + visited.add(currentNodeId); + + const currentNode = graph.getNode(currentNodeId); + + if (!currentNode) continue; + if (isResultNode(currentNode)) return true; + + const outgoingConnections = getGraphOutputConnections( + context, + currentNodeId, + ); + + for (const connection of outgoingConnections) { + if (visited.has(connection.target)) continue; + + stack.push(connection.target); + } + } + + return false; +} diff --git a/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateLimitItems.ts b/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateLimitItems.ts new file mode 100644 index 00000000..9803645c --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateLimitItems.ts @@ -0,0 +1,53 @@ +import { pushIssue } from "@/modules/evaluation/graph/editor/validation/issues"; +import { isLimitItemNode } from "@/modules/evaluation/graph/editor/utils/guards"; +import { + type ValidationContext, +} from "@/modules/evaluation/graph/editor/validation/types"; + +/** + * Finds LimitItem nodes that are placed on the root level. + * + * LimitItems must always be children of a Limit node. + */ +export function findOrphanLimitItems(context: ValidationContext) { + const { graph, nodeIssues } = context; + + for (const node of graph.nodes) { + if (!isLimitItemNode(node)) continue; + if (node.parent) continue; + + pushIssue(nodeIssues, node.id, { + level: "error", + message: "Orphan Check item", + description: [ + "Check item nodes must be placed inside a check node", + "Long press to enter the scope mode and move this node inside a check node.", + ], + }); + } +} + +/** + * Finds connections that cross parent scope boundaries. + * + * Connected nodes must either share the same parent or both be root-level nodes. + */ +export function findCrossScopeConnections(context: ValidationContext) { + const { graph, nodeIssues } = context; + + for (const connection of graph.connections) { + const sourceNode = graph.getNode(connection.source); + const targetNode = graph.getNode(connection.target); + + if (!sourceNode || !targetNode) continue; + if (graph.haveSameScope(sourceNode, targetNode)) continue; + + pushIssue(nodeIssues, targetNode.id, { + level: "error", + message: "Out of bounds connection", + description: [ + "All connected Check items must be placed inside the same parent check node.", + ], + }); + } +} diff --git a/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateLimits.ts b/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateLimits.ts new file mode 100644 index 00000000..c5d8f173 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateLimits.ts @@ -0,0 +1,84 @@ +import { pushIssue, pushControlIssue } from "@/modules/evaluation/graph/editor/validation/issues"; +import { countConnectedComponents } from "@/modules/evaluation/graph/editor/utils/graph"; +import { isLimitNode } from "@/modules/evaluation/graph/editor/utils/guards"; +import { + type ValidationContext, +} from "@/modules/evaluation/graph/editor/validation/types"; + +/** + * Finds Limit nodes that do not contain any child nodes. + * + * Empty Limit nodes have no rules to evaluate and therefore have no effect. + */ +export function findEmptyLimits(context: ValidationContext) { + const { graph, nodeIssues } = context; + + for (const node of graph.nodes) { + if (!isLimitNode(node)) continue; + if (graph.getChildren(node.id).length > 0) continue; + + pushIssue(nodeIssues, node.id, { + level: "warning", + message: "Useless Check", + description: [ + "A check node with no children has no effect and can be removed.", + ], + }); + } +} + +/** + * Finds Limit nodes whose children form more than one disconnected island. + * + * All nodes inside a Limit node must be connected into a single child graph. + */ +export function findLimitsWithMultipleIslands(context: ValidationContext) { + const { graph, nodeIssues } = context; + + for (const limitNode of graph.nodes) { + if (!isLimitNode(limitNode)) continue; + + const children = graph.getChildren(limitNode.id); + if (children.length <= 1) continue; + + const islandCount = countConnectedComponents(children, graph.connections); + if (islandCount <= 1) continue; + + pushIssue(nodeIssues, limitNode.id, { + level: "error", + message: "Disconnected children", + description: [ + "All Check items inside a Check must form a single connected component.", + "Connect all child nodes together or split them into separate Check nodes.", + ], + }); + } +} + +/** + * Finds missing required inputs inside Limit nodes. + * + * These issues are attached to specific controls so the UI can highlight the + * invalid input without adding extra content to the node. + */ +export function findLimitsWithMissingRequiredInputs( + context: ValidationContext, +) { + const { graph, controlIssues } = context; + + for (const node of graph.nodes) { + if (!isLimitNode(node)) continue; + + if (!node.controls.name.isValid()) { + pushControlIssue(controlIssues, node.id, "name", { + level: "error", + }); + } + + if (!node.controls.label.isValid()) { + pushControlIssue(controlIssues, node.id, "label", { + level: "error", + }); + } + } +} diff --git a/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateLogical.ts b/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateLogical.ts new file mode 100644 index 00000000..91ccb351 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateLogical.ts @@ -0,0 +1,43 @@ +import { pushIssue } from "@/modules/evaluation/graph/editor/validation/issues"; +import { isLogicalOperator } from "@/modules/evaluation/graph/editor/utils/guards"; +import { + type ValidationContext, +} from "@/modules/evaluation/graph/editor/validation/types"; + +/** + * Finds logical nodes that cannot form a meaningful expression: zero inputs + * (unevaluable, reported as an error) or exactly one input (a no-op that does + * not change the result, reported as a warning). + */ +export function findUselessLogicalNodes(context: ValidationContext) { + const { graph, nodeIssues } = context; + + for (const node of graph.nodes) { + if (!isLogicalOperator(node)) continue; + + const inputCount = graph.getIncoming(node.id).length; + + if (inputCount === 0) { + pushIssue(nodeIssues, node.id, { + level: "error", + message: "Operator has no inputs", + description: [ + "A logical operator needs at least two inputs to combine.", + "Connect inputs to this operator or remove it.", + ], + }); + continue; + } + + if (inputCount !== 1) continue; + + pushIssue(nodeIssues, node.id, { + level: "warning", + message: "Useless operator", + description: [ + "Logical operators with only one input do not change the result and can be removed.", + "Either connect the input directly to the output and remove this node, or add more inputs to create a meaningful logical expression.", + ], + }); + } +} diff --git a/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateResult.ts b/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateResult.ts new file mode 100644 index 00000000..f30dc48f --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateResult.ts @@ -0,0 +1,35 @@ +import { pushIssue } from "@/modules/evaluation/graph/editor/validation/issues"; +import { isResultNode } from "@/modules/evaluation/graph/editor/utils/guards"; +import { + type ValidationContext, +} from "@/modules/evaluation/graph/editor/validation/types"; + +/** + * Finds graphs that do not contain exactly one Result node. + * + * The Result node is the required terminal node of the graph and acts as the + * anchor for the main graph island. + */ +export function findInvalidResultNodeCount(context: ValidationContext) { + const { graph, nodeIssues, graphIssues } = context; + const resultNodes = graph.nodes.filter(isResultNode); + + if (resultNodes.length === 1) return; + + if (resultNodes.length === 0) { + graphIssues.push({ + level: "error", + message: "Missing Result node", + description: ["The graph must contain exactly one Result node."], + }); + return; + } + + for (const node of resultNodes) { + pushIssue(nodeIssues, node.id, { + level: "error", + message: "Multiple Result nodes", + description: ["The graph can contain only one Result node."], + }); + } +} diff --git a/apps/web/src/modules/evaluation/graph/editor/validation/types.ts b/apps/web/src/modules/evaluation/graph/editor/validation/types.ts new file mode 100644 index 00000000..bd50ea18 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/types.ts @@ -0,0 +1,30 @@ +import type { ValidationGraph } from "./validationGraph"; + +export type ValidationIssueLevel = "error" | "warning"; + +export type ValidationIssue = { + message: string; + description?: string[]; + level: ValidationIssueLevel; +}; + +export type ControlIssue = { + level: ValidationIssueLevel; +}; + +export type ControlIssues = Record; +export type ControlIssuesByNode = Map; + +export type ValidationResult = { + valid: boolean; + nodeIssues: Map; + controlIssues: ControlIssuesByNode; + graphIssues: ValidationIssue[]; +}; + +export type ValidationContext = { + readonly graph: ValidationGraph; + readonly nodeIssues: Map; + readonly controlIssues: ControlIssuesByNode; + readonly graphIssues: ValidationIssue[]; +}; diff --git a/apps/web/src/modules/evaluation/graph/editor/validation/validateGraph.ts b/apps/web/src/modules/evaluation/graph/editor/validation/validateGraph.ts new file mode 100644 index 00000000..297a4506 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/validateGraph.ts @@ -0,0 +1,65 @@ +import type { Schemes } from "@/modules/evaluation/graph/editor/types"; +import type { NodeEditor } from "rete"; +import { findActionsWithInvalidSource } from "./rules/validateActions"; +import { + findNodesOutsideResultIsland, + findBranchesNotLeadingToResult, +} from "./rules/validateGlobal"; +import { + findCrossScopeConnections, + findOrphanLimitItems, +} from "./rules/validateLimitItems"; +import { + findEmptyLimits, + findLimitsWithMissingRequiredInputs, + findLimitsWithMultipleIslands, +} from "./rules/validateLimits"; +import { findUselessLogicalNodes } from "./rules/validateLogical"; +import { findInvalidResultNodeCount } from "./rules/validateResult"; +import type { ControlIssues, ValidationIssue, ValidationResult } from "./types"; +import { ValidationGraph } from "./validationGraph"; + +export function validateGraph(editor: NodeEditor): ValidationResult { + const graph = new ValidationGraph(editor); + const nodeIssues = new Map(); + const controlIssues = new Map(); + const graphIssues: ValidationIssue[] = []; + + const context = { graph, nodeIssues, controlIssues, graphIssues }; + + findEmptyLimits(context); + findLimitsWithMultipleIslands(context); + + findOrphanLimitItems(context); + findCrossScopeConnections(context); + findLimitsWithMissingRequiredInputs(context); + + findUselessLogicalNodes(context); + + findActionsWithInvalidSource(context); + + findInvalidResultNodeCount(context); + + findNodesOutsideResultIsland(context); + findBranchesNotLeadingToResult(context); + + const hasNodeErrors = Array.from(nodeIssues.values()).some((issues) => + issues.some((issue) => issue.level === "error"), + ); + + const hasControlErrors = Array.from(controlIssues.values()).some( + (controlIssueMap) => + Object.values(controlIssueMap).some((issues) => + issues.some((issue) => issue.level === "error"), + ), + ); + + const hasGraphErrors = graphIssues.some((issue) => issue.level === "error"); + + return { + valid: !hasNodeErrors && !hasControlErrors && !hasGraphErrors, + nodeIssues, + controlIssues, + graphIssues, + }; +} diff --git a/apps/web/src/modules/evaluation/graph/editor/validation/validationGraph.ts b/apps/web/src/modules/evaluation/graph/editor/validation/validationGraph.ts new file mode 100644 index 00000000..3dc791c8 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/validationGraph.ts @@ -0,0 +1,61 @@ +import type { + ConnProps, + NodeProps, + Schemes, +} from "@/modules/evaluation/graph/editor/types"; +import type { NodeEditor } from "rete"; + +export class ValidationGraph { + readonly nodes: NodeProps[]; + readonly connections: ConnProps[]; + + private nodeById = new Map(); + private childrenByParent = new Map(); + private incomingByNode = new Map(); + private outgoingByNode = new Map(); + + constructor(editor: NodeEditor) { + this.nodes = editor.getNodes(); + this.connections = editor.getConnections(); + + for (const node of this.nodes) { + this.nodeById.set(node.id, node); + + if (node.parent) { + const children = this.childrenByParent.get(node.parent) ?? []; + children.push(node); + this.childrenByParent.set(node.parent, children); + } + } + + for (const connection of this.connections) { + const incoming = this.incomingByNode.get(connection.target) ?? []; + incoming.push(connection); + this.incomingByNode.set(connection.target, incoming); + + const outgoing = this.outgoingByNode.get(connection.source) ?? []; + outgoing.push(connection); + this.outgoingByNode.set(connection.source, outgoing); + } + } + + getNode(id: string) { + return this.nodeById.get(id); + } + + getChildren(parentId: string) { + return this.childrenByParent.get(parentId) ?? []; + } + + getIncoming(nodeId: string) { + return this.incomingByNode.get(nodeId) ?? []; + } + + getOutgoing(nodeId: string) { + return this.outgoingByNode.get(nodeId) ?? []; + } + + haveSameScope(a: NodeProps, b: NodeProps) { + return a.parent === b.parent; + } +} diff --git a/apps/web/src/modules/evaluation/graph/workspace/E2EEditorPage.tsx b/apps/web/src/modules/evaluation/graph/workspace/E2EEditorPage.tsx new file mode 100644 index 00000000..8b756f3b --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/workspace/E2EEditorPage.tsx @@ -0,0 +1,39 @@ +import { createEditor } from "@/modules/evaluation/graph/editor/setup/createEditor"; +import { focusContainer } from "@/modules/evaluation/graph/editor/setup/focusManager"; +import { installTestHook } from "@/modules/evaluation/graph/workspace/testHook"; +import { useCallback, useEffect, useRef } from "react"; +import { useRete } from "rete-react-plugin"; + +/** + * Standalone editor host used only in E2E mode (VITE_E2E). It mounts the graph + * editor with no project data, backend calls, or auth, exposes the live editor + * on window.__editor through the test hook, and renders the canvas the Playwright + * specs drive. It is wired into the router only when VITE_E2E is set and is never + * reachable in dev or production builds. + */ +export const E2EEditorPage = () => { + const disposeHookRef = useRef<(() => void) | undefined>(undefined); + + const create = useCallback((el: HTMLElement) => { + const instance = createEditor(el, () => {}); + Promise.resolve(instance).then((result) => { + disposeHookRef.current = installTestHook({ + editor: result.editor, + area: result.area, + }); + // Single editor in E2E: focus it on mount so keyboard shortcuts work + // without first clicking the canvas (the specs press shortcuts directly). + focusContainer(el); + }); + return instance; + }, []); + + const [ref] = useRete(create); + useEffect(() => () => disposeHookRef.current?.(), []); + + return ( +
+
+
+ ); +}; diff --git a/apps/web/src/modules/evaluation/graph/workspace/testHook.ts b/apps/web/src/modules/evaluation/graph/workspace/testHook.ts new file mode 100644 index 00000000..ffc8dd3c --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/workspace/testHook.ts @@ -0,0 +1,80 @@ +import { + BooleanConnection, + type BooleanOperator, +} from "@/modules/evaluation/graph/editor/connections/booleanConnection"; +import { LimitItemConnection } from "@/modules/evaluation/graph/editor/connections/limitItemConnection"; +import type { AreaExtra } from "@/modules/evaluation/graph/editor/setup/createEditor"; +import type { + LimitItemProps, + BooleanNodeProps, + Schemes, +} from "@/modules/evaluation/graph/editor/types"; +import type { EvalLimitItemOperatorEnum } from "@repo/schema"; +import type { NodeEditor } from "rete"; +import type { AreaPlugin } from "rete-area-plugin"; + +export type TestHookHandle = { + editor: NodeEditor; + area: AreaPlugin; + addBooleanConnection: ( + sourceId: string, + targetId: string, + operator?: BooleanOperator, + ) => Promise; + addLimitItemConnection: ( + sourceId: string, + targetId: string, + operator: EvalLimitItemOperatorEnum, + ) => Promise; +}; + +declare global { + interface Window { + __editor?: TestHookHandle; + } +} + +/** + * Exposes the live editor + area on `window.__editor` so Playwright tests can + * make state-level assertions while still driving the UI through real gestures. + * + * Gated on the VITE_E2E env flag - set only by playwright.config.ts. + * Normal dev and production builds receive an undefined `__editor`. + */ +export function installTestHook(handle: { + editor: NodeEditor; + area: AreaPlugin; +}): (() => void) | undefined { + if (!import.meta.env.VITE_E2E) return; + const { editor } = handle; + + const requireNode = (id: string) => { + const node = editor.getNode(id); + if (!node) throw new Error(`Test hook: node "${id}" not found.`); + return node; + }; + + const hook: TestHookHandle = { + ...handle, + addBooleanConnection: async (sourceId, targetId, operator = "TRUE") => { + const source = requireNode(sourceId) as BooleanNodeProps; + const target = requireNode(targetId) as BooleanNodeProps; + await editor.addConnection( + new BooleanConnection(source, "out", target, "in", operator), + ); + }, + addLimitItemConnection: async (sourceId, targetId, operator) => { + const source = requireNode(sourceId) as LimitItemProps; + const target = requireNode(targetId) as LimitItemProps; + await editor.addConnection( + new LimitItemConnection(source, "out", target, "in", operator), + ); + }, + }; + + window.__editor = hook; + + return () => { + if (window.__editor === hook) delete window.__editor; + }; +} diff --git a/apps/web/src/modules/evaluation/types/createLimitForm.schema.ts b/apps/web/src/modules/evaluation/types/createLimitForm.schema.ts index c6e919b7..5475682a 100644 --- a/apps/web/src/modules/evaluation/types/createLimitForm.schema.ts +++ b/apps/web/src/modules/evaluation/types/createLimitForm.schema.ts @@ -34,7 +34,7 @@ export const createLimitFormSchema = z.object({ targetParentLabelId: z.number().nullable(), limitItems: z .array(limitItemFormSchema) - .min(1, "At least one limit item is required"), + .min(1, "At least one check item is required"), }); export type CreateLimitFormSchema = z.infer; diff --git a/apps/web/src/modules/shadcn/ui/button-group.tsx b/apps/web/src/modules/shadcn/ui/button-group.tsx new file mode 100644 index 00000000..4de3eeb3 --- /dev/null +++ b/apps/web/src/modules/shadcn/ui/button-group.tsx @@ -0,0 +1,87 @@ +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; +import { Separator } from "@/modules/shadcn/ui/separator"; + +const buttonGroupVariants = cva( + "flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1", + { + variants: { + orientation: { + horizontal: + "*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-md! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0", + vertical: + "flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-md! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0", + }, + }, + defaultVariants: { + orientation: "horizontal", + }, + }, +); + +function ButtonGroup({ + className, + orientation, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function ButtonGroupText({ + className, + render, + ...props +}: useRender.ComponentProps<"div">) { + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">( + { + className: cn( + "flex items-center gap-2 rounded-md border bg-muted px-2.5 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4", + className, + ), + }, + props, + ), + render, + state: { + slot: "button-group-text", + }, + }); +} + +function ButtonGroupSeparator({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + ButtonGroup, + ButtonGroupSeparator, + ButtonGroupText, + buttonGroupVariants, +}; diff --git a/apps/web/src/modules/shadcn/ui/toggle-group.tsx b/apps/web/src/modules/shadcn/ui/toggle-group.tsx new file mode 100644 index 00000000..25dfd290 --- /dev/null +++ b/apps/web/src/modules/shadcn/ui/toggle-group.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"; +import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"; +import { type VariantProps } from "class-variance-authority"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; +import { toggleVariants } from "@/modules/shadcn/ui/toggle"; + +const ToggleGroupContext = React.createContext< + VariantProps & { + spacing?: number; + orientation?: "horizontal" | "vertical"; + } +>({ + size: "default", + variant: "default", + spacing: 0, + orientation: "horizontal", +}); + +function ToggleGroup({ + className, + variant, + size, + spacing = 0, + orientation = "horizontal", + children, + ...props +}: ToggleGroupPrimitive.Props & + VariantProps & { + spacing?: number; + orientation?: "horizontal" | "vertical"; + }) { + return ( + + + {children} + + + ); +} + +function ToggleGroupItem({ + className, + children, + variant = "default", + size = "default", + ...props +}: TogglePrimitive.Props & VariantProps) { + const context = React.useContext(ToggleGroupContext); + + return ( + + {children} + + ); +} + +export { ToggleGroup, ToggleGroupItem }; diff --git a/apps/web/src/modules/shadcn/ui/toggle.tsx b/apps/web/src/modules/shadcn/ui/toggle.tsx new file mode 100644 index 00000000..4a963ba0 --- /dev/null +++ b/apps/web/src/modules/shadcn/ui/toggle.tsx @@ -0,0 +1,43 @@ +import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const toggleVariants = cva( + "group/toggle inline-flex items-center justify-center gap-1 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-white hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-white dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-transparent", + outline: "border border-input bg-zinc-50 shadow-xs hover:bg-white", + }, + size: { + default: + "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + sm: "h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5", + lg: "h-10 min-w-10 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +function Toggle({ + className, + variant = "default", + size = "default", + ...props +}: TogglePrimitive.Props & VariantProps) { + return ( + + ); +} + +export { Toggle, toggleVariants }; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index cf2763c6..27532cbf 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "@repo/typescript-config/vite.json", - "include": ["src", "src/vite-env.d.ts"], + "include": ["src", "src/vite-env.d.ts", "e2e"], "compilerOptions": { "jsx": "react-jsx", "paths": { diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 00000000..403c26ca --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,10 @@ +import tsconfigPaths from "vite-tsconfig-paths"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +}); diff --git a/package.json b/package.json index 72849f76..6a0eab82 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "description": "", "author": "", - "license": "UNLICENSED", + "license": "MIT", "scripts": { "dev": "turbo run dev", "build": "turbo run build", diff --git a/packages/schema/src/eval/eval.schema.ts b/packages/schema/src/eval/eval.schema.ts index 494b6254..341a9a91 100644 --- a/packages/schema/src/eval/eval.schema.ts +++ b/packages/schema/src/eval/eval.schema.ts @@ -155,7 +155,9 @@ export const evalTestCaseDetailSchema = evalTestCaseSchema.extend({ logicNodes: evalLogicNodeSchema.array(), // [] by default }); -/* Eval test case full -> detail with limit items inlined on each limit. (node-view) */ +/* Eval test case full -> detail with limit items inlined on each limit. + This is the shape the graph editor needs (limits WITH their items + logicNodes) + and the single source of truth shared with the table view. Returned by GET .../full. */ export const evalTestCaseFullSchema = evalTestCaseDetailSchema.extend({ limits: z.array(evalLimitDetailSchema), }); diff --git a/packages/schema/src/eval/eval.types.ts b/packages/schema/src/eval/eval.types.ts index f873572d..bbc3df07 100644 --- a/packages/schema/src/eval/eval.types.ts +++ b/packages/schema/src/eval/eval.types.ts @@ -7,13 +7,19 @@ import { evalLogicNodeSchema, evalTestCaseCreateOrUpdateSchema, evalTestCaseDetailSchema, + evalTestCaseFullCreateOrUpdateSchema, evalTestCaseFullSchema, - evalTestCaseSchema,evalTestCaseThresholdSchema,evalThresholdCreateOrUpdateSchema, evalThresholdSchema, evalThresholdsResponseSchema + evalTestCaseSchema, + evalTestCaseThresholdSchema, + evalThresholdCreateOrUpdateSchema, + evalThresholdSchema, + evalThresholdsResponseSchema, } from "./eval.schema"; - export type EvalTestCaseThreshold = z.infer; -export type EvalThresholdCreateOrUpdate = z.infer; +export type EvalThresholdCreateOrUpdate = z.infer< + typeof evalThresholdCreateOrUpdateSchema +>; export type EvalThreshold = z.infer; export type EvalLogicNode = z.infer; export type EvalLimitItem = z.infer; @@ -28,4 +34,9 @@ export type EvalTestCaseCreateOrUpdate = z.infer< export type EvalLimitCreateOrUpdate = z.infer< typeof evalLimitCreateOrUpdateSchema >; -export type EvalThresholdsResponse = z.infer; +export type EvalTestCaseFullCreateOrUpdate = z.infer< + typeof evalTestCaseFullCreateOrUpdateSchema +>; +export type EvalThresholdsResponse = z.infer< + typeof evalThresholdsResponseSchema +>; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc134e7b..1dffb6c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -327,15 +327,48 @@ importers: react-redux: specifier: ^9.2.0 version: 9.2.0(@types/react@19.2.8)(react@19.2.3)(redux@5.0.1) + react-resizable-panels: + specifier: ^4.8.0 + version: 4.11.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react-router: specifier: ^7.13.0 version: 7.13.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) recharts: specifier: ^3.7.0 version: 3.7.0(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react-is@18.3.1)(react@19.2.3)(redux@5.0.1) + rete: + specifier: ^2.0.6 + version: 2.0.6 + rete-area-plugin: + specifier: ^2.1.5 + version: 2.1.5(rete@2.0.6) + rete-auto-arrange-plugin: + specifier: ^2.0.2 + version: 2.0.2(elkjs@0.8.2)(rete-area-plugin@2.1.5(rete@2.0.6))(rete@2.0.6)(web-worker@1.5.0) + rete-connection-plugin: + specifier: ^2.0.5 + version: 2.0.5(rete-area-plugin@2.1.5(rete@2.0.6))(rete@2.0.6) + rete-context-menu-plugin: + specifier: ^2.0.6 + version: 2.0.6(rete-area-plugin@2.1.5(rete@2.0.6))(rete@2.0.6) + rete-history-plugin: + specifier: ^2.1.1 + version: 2.1.1(rete-area-plugin@2.1.5(rete@2.0.6))(rete@2.0.6) + rete-react-plugin: + specifier: ^2.1.0 + version: 2.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rete-area-plugin@2.1.5(rete@2.0.6))(rete-render-utils@2.0.3(rete-area-plugin@2.1.5(rete@2.0.6))(rete@2.0.6))(rete@2.0.6)(styled-components@6.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) + rete-render-utils: + specifier: ^2.0.3 + version: 2.0.3(rete-area-plugin@2.1.5(rete@2.0.6))(rete@2.0.6) + rete-scopes-plugin: + specifier: ^2.1.1 + version: 2.1.1(rete-area-plugin@2.1.5(rete@2.0.6))(rete@2.0.6) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + styled-components: + specifier: ^6.3.12 + version: 6.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) tailwind-merge: specifier: ^3.5.0 version: 3.5.0 @@ -361,6 +394,9 @@ importers: '@eslint/js': specifier: ^9.39.1 version: 9.39.2 + '@playwright/test': + specifier: ^1.60.0 + version: 1.61.0 '@repo/eslint-config': specifier: workspace:* version: link:../../packages/eslint-config @@ -412,6 +448,9 @@ importers: vite-tsconfig-paths: specifier: ^6.0.5 version: 6.0.5(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)) + vitest: + specifier: ^4.0.18 + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.10.7)(msw@2.12.10(@types/node@24.10.7)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)) packages/database: dependencies: @@ -496,7 +535,7 @@ importers: version: 29.7.0(@types/node@22.19.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.19.5)(typescript@5.9.3)) next: specifier: 16.0.10 - version: 16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.3) + version: 16.0.10(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.3) typescript: specifier: ~5.9.3 version: 5.9.3 @@ -967,6 +1006,12 @@ packages: '@emnapi/runtime@1.8.1': resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + '@emotion/is-prop-valid@1.4.0': + resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==} + + '@emotion/memoize@0.9.0': + resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -2341,6 +2386,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.61.0': + resolution: {integrity: sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==} + engines: {node: '>=18'} + hasBin: true + '@prisma/instrumentation@7.6.0': resolution: {integrity: sha512-ZPW2gRiwpPzEfgeZgaekhqXrbW+Y2RJKHVqUmlhZhKzRNCcvR6DykzylDrynpArKKRQtLxoZy36fK7U0p3pdgQ==} peerDependencies: @@ -2890,6 +2940,9 @@ packages: '@types/caseless@0.12.5': resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -2928,6 +2981,9 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -3159,6 +3215,35 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -3369,6 +3454,10 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types@0.16.1: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} @@ -3531,6 +3620,10 @@ packages: caniuse-lite@1.0.30001763: resolution: {integrity: sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -4078,6 +4171,9 @@ packages: electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + elkjs@0.8.2: + resolution: {integrity: sha512-L6uRgvZTH+4OF5NE/MBbzQx/WYpru1xCBE9respNj6qznEewGUIfhzmm7horWWxbNO2M0WckQypGctR8lH79xQ==} + emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} engines: {node: '>=12'} @@ -4132,6 +4228,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -4266,6 +4365,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -4305,6 +4407,10 @@ packages: resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} engines: {node: '>= 0.8.0'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + expect@29.7.0: resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -4468,6 +4574,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -5714,6 +5825,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -5854,6 +5969,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pause@0.0.1: resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} @@ -5918,6 +6036,16 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + playwright-core@1.61.0: + resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.0: + resolution: {integrity: sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==} + engines: {node: '>=18'} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -6102,6 +6230,12 @@ packages: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} + react-resizable-panels@4.11.2: + resolution: {integrity: sha512-+kfFbDZ8mygc7g0vxOcDzCVGuwiIUOnILqPoUHo6/uP+Mmyx6HzZU+kj1aOPDlktXuobYbr6BtQekvJwHRX4Eg==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + react-router@7.13.0: resolution: {integrity: sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==} engines: {node: '>=20.0.0'} @@ -6210,6 +6344,62 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} + rete-area-plugin@2.1.5: + resolution: {integrity: sha512-iquEvwkQlcsO4cmgM3Z37TG0AWaE536dfA+lCJAze5YJzVx4RBaViUCqdB4dUA/utSytpBCkiDC4D3ztM9akGQ==} + peerDependencies: + rete: ^2.0.0 + + rete-auto-arrange-plugin@2.0.2: + resolution: {integrity: sha512-VLTnVb9tqHfQKkFMZzpYgYxDWcIa+nXHRC+Po0k+gUh2zJqegkx8bFSA1n6VkMv+wPYa9EnWsTMfUDD3k1QEzg==} + peerDependencies: + elkjs: ^0.8.2 + rete: ^2.0.1 + rete-area-plugin: ^2.0.0 + web-worker: ^1.2.0 + + rete-connection-plugin@2.0.5: + resolution: {integrity: sha512-KFtlOyEJRc0y9STVgo2T+t+j9u5fxiTxbyzPbMCm0uqncb3b8d2ABDIzvWoNo5zQAh2Oz/OvlUovupbzrGzpSg==} + peerDependencies: + rete: ^2.0.1 + rete-area-plugin: ^2.0.0 + + rete-context-menu-plugin@2.0.6: + resolution: {integrity: sha512-OmzdYEDvaJux4W7pTZPJ9oxO+YMNA4cw3/262ia564+0HXazrcHjyg3m6jXswfleeOS8SAaV+e+X3caTLUyEgw==} + peerDependencies: + rete: ^2.0.1 + rete-area-plugin: ^2.0.0 + + rete-history-plugin@2.1.1: + resolution: {integrity: sha512-Kb2FF51qwl9axSVzusZDJ7ojksg0qPBY3CIhAusHxTLYzO5Di1Nu3cAzHUOHik6kcb3eZI+GYdfqaj/E8xggxQ==} + peerDependencies: + rete: ^2.0.1 + rete-area-plugin: ^2.0.0 + + rete-react-plugin@2.1.0: + resolution: {integrity: sha512-FXYhdb4FFL7S7Iie398nfyLTRRan+OT6Gdo5nZnF/3nq8R1xan6zKuWmpovUXjxEgm6pkVhY9BjCEV8XlAXWAg==} + peerDependencies: + react: '^16.8.6 || ^17 || ^18 || ^19 ' + react-dom: ^16.8.6 || ^17 || ^18 || ^19 + rete: ^2.0.1 + rete-area-plugin: ^2.0.0 + rete-render-utils: ^2.0.0 + styled-components: ^5.3.6 || ^6 + + rete-render-utils@2.0.3: + resolution: {integrity: sha512-Oz4W2PNayHocRvlzadb5BCNf+tDzJ8RhTwB3ucBPCdCLKZ974wWDiTSCRfA287L2hmHVzRfBdyAwC03K9eP+4g==} + peerDependencies: + rete: ^2.0.0 + rete-area-plugin: ^2.0.0 + + rete-scopes-plugin@2.1.1: + resolution: {integrity: sha512-7wZng5apPHxu9e9cWz3TR/U4hvr6+B4XPTVzCchPLhzCHaPLUg+TAM8l4CClqP22wIhdYJUMDFpmZBe9y6y0yQ==} + peerDependencies: + rete: ^2.0.1 + rete-area-plugin: ^2.0.0 + + rete@2.0.6: + resolution: {integrity: sha512-kPmlKCGFES2VWtY7Y7SCB8ZeXRMsgX5deza9cu4OwmfM/ZUimd461kC3hRyccoyVxE4POlHUx0gg2jcGfusHFg==} + retry-request@7.0.2: resolution: {integrity: sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==} engines: {node: '>=14'} @@ -6362,6 +6552,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -6418,10 +6611,16 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stdin-discarder@0.2.2: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} @@ -6526,6 +6725,22 @@ packages: style-mod@4.1.3: resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + styled-components@6.4.2: + resolution: {integrity: sha512-xZBhBJsMtGqb+aKcwKgaT+BtuFums9VynX2JRvXJGTx5UfZzN12rk5r4nVdhXYvRw+hE7yiYxVrOqJZaK2+Txg==} + engines: {node: '>= 16'} + peerDependencies: + css-to-react-native: '>= 3.2.0' + react: '>= 16.8.0' + react-dom: '>= 16.8.0' + react-native: '>= 0.68.0' + peerDependenciesMeta: + css-to-react-native: + optional: true + react-dom: + optional: true + react-native: + optional: true + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -6539,6 +6754,9 @@ packages: babel-plugin-macros: optional: true + stylis@4.3.6: + resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} + superagent@10.3.0: resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} engines: {node: '>=14.18.0'} @@ -6631,10 +6849,21 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tldts-core@7.0.25: resolution: {integrity: sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==} @@ -6991,6 +7220,47 @@ packages: yaml: optional: true + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} @@ -7012,6 +7282,9 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + web-worker@1.5.0: + resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -7062,6 +7335,11 @@ packages: engines: {node: ^16.13.0 || >=18.0.0} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -7807,6 +8085,12 @@ snapshots: tslib: 2.8.1 optional: true + '@emotion/is-prop-valid@1.4.0': + dependencies: + '@emotion/memoize': 0.9.0 + + '@emotion/memoize@0.9.0': {} + '@esbuild/aix-ppc64@0.25.12': optional: true @@ -9176,6 +9460,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.61.0': + dependencies: + playwright: 1.61.0 + '@prisma/instrumentation@7.6.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -9689,6 +9977,11 @@ snapshots: '@types/caseless@0.12.5': {} + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/connect@3.4.38': dependencies: '@types/node': 22.19.5 @@ -9723,6 +10016,8 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/deep-eql@4.0.2': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -10036,6 +10331,48 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@4.1.9': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.9(msw@2.12.10(@types/node@24.10.7)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.10(@types/node@24.10.7)(typescript@5.9.3) + vite: 7.3.1(@types/node@24.10.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0) + + '@vitest/pretty-format@4.1.9': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.9': + dependencies: + '@vitest/utils': 4.1.9 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.9': {} + + '@vitest/utils@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -10280,6 +10617,8 @@ snapshots: asap@2.0.6: {} + assertion-error@2.0.1: {} + ast-types@0.16.1: dependencies: tslib: 2.8.1 @@ -10492,6 +10831,8 @@ snapshots: caniuse-lite@1.0.30001763: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -10895,6 +11236,8 @@ snapshots: electron-to-chromium@1.5.267: {} + elkjs@0.8.2: {} + emittery@0.13.1: {} emoji-regex@10.6.0: {} @@ -11007,6 +11350,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-module-lexer@2.1.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -11224,6 +11569,10 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} etag@1.8.1: {} @@ -11269,6 +11618,8 @@ snapshots: exit@0.1.2: {} + expect-type@1.3.0: {} + expect@29.7.0: dependencies: '@jest/expect-utils': 29.7.0 @@ -11490,6 +11841,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -12802,7 +13156,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.3): + next@16.0.10(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.3): dependencies: '@next/env': 16.0.10 '@swc/helpers': 0.5.15 @@ -12810,7 +13164,7 @@ snapshots: postcss: 8.4.31 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(@babel/core@7.28.5)(babel-plugin-macros@3.1.0)(react@19.2.3) + styled-jsx: 5.1.6(babel-plugin-macros@3.1.0)(react@19.2.3) optionalDependencies: '@next/swc-darwin-arm64': 16.0.10 '@next/swc-darwin-x64': 16.0.10 @@ -12821,6 +13175,7 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.0.10 '@next/swc-win32-x64-msvc': 16.0.10 '@opentelemetry/api': 1.9.1 + '@playwright/test': 1.61.0 sass: 1.97.3 sharp: 0.34.5 transitivePeerDependencies: @@ -12907,6 +13262,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + obug@2.1.3: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -13065,6 +13422,8 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + pause@0.0.1: {} pg-cloudflare@1.2.7: @@ -13118,6 +13477,14 @@ snapshots: dependencies: find-up: 4.1.0 + playwright-core@1.61.0: {} + + playwright@1.61.0: + dependencies: + playwright-core: 1.61.0 + optionalDependencies: + fsevents: 2.3.2 + pluralize@8.0.0: {} possible-typed-array-names@1.1.0: {} @@ -13286,6 +13653,11 @@ snapshots: react-refresh@0.18.0: {} + react-resizable-panels@4.11.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-router@7.13.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: cookie: 1.1.1 @@ -13415,6 +13787,64 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + rete-area-plugin@2.1.5(rete@2.0.6): + dependencies: + '@babel/runtime': 7.28.4 + rete: 2.0.6 + + rete-auto-arrange-plugin@2.0.2(elkjs@0.8.2)(rete-area-plugin@2.1.5(rete@2.0.6))(rete@2.0.6)(web-worker@1.5.0): + dependencies: + '@babel/runtime': 7.28.4 + elkjs: 0.8.2 + rete: 2.0.6 + rete-area-plugin: 2.1.5(rete@2.0.6) + web-worker: 1.5.0 + + rete-connection-plugin@2.0.5(rete-area-plugin@2.1.5(rete@2.0.6))(rete@2.0.6): + dependencies: + '@babel/runtime': 7.28.4 + rete: 2.0.6 + rete-area-plugin: 2.1.5(rete@2.0.6) + + rete-context-menu-plugin@2.0.6(rete-area-plugin@2.1.5(rete@2.0.6))(rete@2.0.6): + dependencies: + '@babel/runtime': 7.28.4 + rete: 2.0.6 + rete-area-plugin: 2.1.5(rete@2.0.6) + + rete-history-plugin@2.1.1(rete-area-plugin@2.1.5(rete@2.0.6))(rete@2.0.6): + dependencies: + '@babel/runtime': 7.28.4 + rete: 2.0.6 + rete-area-plugin: 2.1.5(rete@2.0.6) + + rete-react-plugin@2.1.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rete-area-plugin@2.1.5(rete@2.0.6))(rete-render-utils@2.0.3(rete-area-plugin@2.1.5(rete@2.0.6))(rete@2.0.6))(rete@2.0.6)(styled-components@6.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)): + dependencies: + '@babel/runtime': 7.28.4 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + rete: 2.0.6 + rete-area-plugin: 2.1.5(rete@2.0.6) + rete-render-utils: 2.0.3(rete-area-plugin@2.1.5(rete@2.0.6))(rete@2.0.6) + styled-components: 6.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + usehooks-ts: 3.1.1(react@19.2.3) + + rete-render-utils@2.0.3(rete-area-plugin@2.1.5(rete@2.0.6))(rete@2.0.6): + dependencies: + '@babel/runtime': 7.28.4 + rete: 2.0.6 + rete-area-plugin: 2.1.5(rete@2.0.6) + + rete-scopes-plugin@2.1.1(rete-area-plugin@2.1.5(rete@2.0.6))(rete@2.0.6): + dependencies: + '@babel/runtime': 7.28.4 + rete: 2.0.6 + rete-area-plugin: 2.1.5(rete@2.0.6) + + rete@2.0.6: + dependencies: + '@babel/runtime': 7.28.4 + retry-request@7.0.2: dependencies: '@types/request': 2.48.13 @@ -13711,6 +14141,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -13752,8 +14184,12 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.1.0: {} + stdin-discarder@0.2.2: {} stop-iteration-iterator@1.1.0: @@ -13876,14 +14312,24 @@ snapshots: style-mod@4.1.3: {} - styled-jsx@5.1.6(@babel/core@7.28.5)(babel-plugin-macros@3.1.0)(react@19.2.3): + styled-components@6.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@emotion/is-prop-valid': 1.4.0 + csstype: 3.2.3 + react: 19.2.3 + stylis: 4.3.6 + optionalDependencies: + react-dom: 19.2.3(react@19.2.3) + + styled-jsx@5.1.6(babel-plugin-macros@3.1.0)(react@19.2.3): dependencies: client-only: 0.0.1 react: 19.2.3 optionalDependencies: - '@babel/core': 7.28.5 babel-plugin-macros: 3.1.0 + stylis@4.3.6: {} + superagent@10.3.0: dependencies: component-emitter: 1.3.1 @@ -14008,11 +14454,17 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.1.0: {} + tldts-core@7.0.25: {} tldts@7.0.25: @@ -14350,6 +14802,34 @@ snapshots: terser: 5.44.1 tsx: 4.21.0 + vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.10.7)(msw@2.12.10(@types/node@24.10.7)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(msw@2.12.10(@types/node@24.10.7)(typescript@5.9.3))(vite@7.3.1(@types/node@24.10.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.3.1(@types/node@24.10.7)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 24.10.7 + transitivePeerDependencies: + - msw + void-elements@3.1.0: {} w3c-keyname@2.2.8: {} @@ -14369,6 +14849,8 @@ snapshots: web-streams-polyfill@3.3.3: {} + web-worker@1.5.0: {} + webidl-conversions@3.0.1: {} webpack-node-externals@3.0.0: {} @@ -14461,6 +14943,11 @@ snapshots: dependencies: isexe: 3.1.5 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wordwrap@1.0.0: {}