From 5d051031dfe4fee9dc1bc7c3621b63b23a9dab25 Mon Sep 17 00:00:00 2001 From: andrej-sokolov Date: Wed, 17 Jun 2026 15:21:22 +0200 Subject: [PATCH 01/20] feat: add a test-case view selector --- .../TestCasesOverview/TestCaseSection.tsx | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/apps/web/src/modules/evaluation/components/TestCasesOverview/TestCaseSection.tsx b/apps/web/src/modules/evaluation/components/TestCasesOverview/TestCaseSection.tsx index ef9d0387..f0f50843 100644 --- a/apps/web/src/modules/evaluation/components/TestCasesOverview/TestCaseSection.tsx +++ b/apps/web/src/modules/evaluation/components/TestCasesOverview/TestCaseSection.tsx @@ -1,3 +1,4 @@ +import { cn } from "@/lib/utils"; import { DeleteLimitDialog } from "@/modules/dashboard/components/DeleteLimitDialog"; import { Button } from "@/modules/shadcn/ui/button"; import { Card, CardContent, CardHeader } from "@/modules/shadcn/ui/card"; @@ -29,6 +30,8 @@ type DeleteState = | { type: "blocked"; limitId: string } | null; +type ViewMode = "table" | "graph"; + export function TestCaseSection({ testCase, projectId, configId }: TestCaseSectionProps) { const { data: limits = [] } = useGetEvalLimitsQuery({ projectId, @@ -40,6 +43,7 @@ export function TestCaseSection({ testCase, projectId, configId }: TestCaseSecti const [deleteState, setDeleteState] = useState(null); const [isEditTestCaseOpen, setIsEditTestCaseOpen] = useState(false); const [isDeleteTestCaseOpen, setIsDeleteTestCaseOpen] = useState(false); + const [viewMode, setViewMode] = useState("table"); const { data: limitToEdit } = useGetEvalLimitQuery( { projectId, configId, testCaseId: testCase.id, limitId: limitToEditId! }, @@ -109,7 +113,25 @@ export function TestCaseSection({ testCase, projectId, configId }: TestCaseSecti Add limit -
+
+ {(["table", "graph"] as const).map((mode) => ( + + ))} +
+
- + {viewMode === "table" ? ( + + ) : ( +
+ Graph view coming soon +
+ )}
{isCreateLimitOpen && ( From 4cb6adf8a0e27a621b7081e7bb4c053b04d2b80c Mon Sep 17 00:00:00 2001 From: andrej-sokolov Date: Wed, 17 Jun 2026 18:56:32 +0200 Subject: [PATCH 02/20] feat: repo merge --- apps/web/e2e/clipboard.spec.ts | 112 ++++ apps/web/e2e/connections.spec.ts | 121 +++++ apps/web/e2e/context-menu.spec.ts | 70 +++ apps/web/e2e/creation.spec.ts | 66 +++ apps/web/e2e/deletion.spec.ts | 99 ++++ apps/web/e2e/helpers.ts | 128 +++++ apps/web/e2e/history.spec.ts | 72 +++ apps/web/e2e/movement.spec.ts | 120 +++++ apps/web/e2e/scopes.spec.ts | 103 ++++ apps/web/e2e/selection.spec.ts | 129 +++++ apps/web/e2e/viewport.spec.ts | 63 +++ apps/web/package.json | 20 +- apps/web/playwright.config.ts | 35 ++ .../components/GraphEditor/GraphEditor.tsx | 336 ++++++++++++ .../GraphEditor/GraphKeybindsDialog.tsx | 91 ++++ .../TestCasesOverview/TestCaseSection.tsx | 79 +-- .../editor/connections/booleanConnection.ts | 22 + .../editor/connections/limitItemConnection.ts | 21 + .../evaluation/graph/editor/constants.ts | 1 + .../controls/__tests__/position.test.ts | 53 ++ .../editor/controls/core/observableControl.ts | 20 + .../controls/core/validatableControl.ts | 22 + .../graph/editor/controls/integerRange.ts | 96 ++++ .../evaluation/graph/editor/controls/label.ts | 60 +++ .../evaluation/graph/editor/controls/name.ts | 34 ++ .../graph/editor/controls/percentageRange.ts | 36 ++ .../graph/editor/controls/position.ts | 66 +++ .../graph/editor/controls/quantifierType.ts | 33 ++ .../graph/editor/controls/quantifierUnits.ts | 77 +++ .../graph/editor/debug/DebugNodeBadge.tsx | 21 + .../graph/editor/debug/EditorDebugOverlay.tsx | 52 ++ .../graph/editor/debug/isDebugEnabled.ts | 5 + .../__tests__/deserializeLimitItems.test.ts | 113 ++++ .../__tests__/deserializeLimits.test.ts | 193 +++++++ .../__tests__/deserializeLogicNodes.test.ts | 339 ++++++++++++ .../deserialization/deserializeLimitItems.ts | 40 ++ .../deserialization/deserializeLimits.ts | 125 +++++ .../deserialization/deserializeLogicNodes.ts | 237 +++++++++ .../deserialization/deserializeTestCase.ts | 69 +++ .../graph/editor/deserialization/types.ts | 28 + .../graph/editor/nodes/action/actionBase.ts | 43 ++ .../graph/editor/nodes/action/alert.ts | 15 + .../graph/editor/nodes/action/warning.ts | 15 + .../evaluation/graph/editor/nodes/appNode.ts | 69 +++ .../graph/editor/nodes/limit/limit.ts | 100 ++++ .../graph/editor/nodes/limitItem/area.ts | 95 ++++ .../graph/editor/nodes/limitItem/count.ts | 65 +++ .../editor/nodes/limitItem/limitItemBase.ts | 37 ++ .../graph/editor/nodes/limitItem/position.ts | 110 ++++ .../graph/editor/nodes/logical/and.ts | 15 + .../graph/editor/nodes/logical/logicalBase.ts | 37 ++ .../graph/editor/nodes/logical/or.ts | 15 + .../graph/editor/nodes/result/result.ts | 36 ++ .../graph/editor/presets/customScopePreset.ts | 265 ++++++++++ .../__tests__/schemaAdapters.test.ts | 97 ++++ .../__tests__/serializeLimitItems.test.ts | 97 ++++ .../__tests__/serializeLimits.test.ts | 141 +++++ .../__tests__/serializeLogicNodes.test.ts | 302 +++++++++++ .../serialization/__tests__/utils.test.ts | 81 +++ .../editor/serialization/backendTypes.ts | 85 +++ .../editor/serialization/schemaAdapters.ts | 159 ++++++ .../serialization/serializeLimitItems.ts | 43 ++ .../editor/serialization/serializeLimits.ts | 73 +++ .../serialization/serializeLogicNodes.ts | 286 ++++++++++ .../editor/serialization/serializeTestCase.ts | 34 ++ .../graph/editor/serialization/utils.ts | 33 ++ .../editor/setup/__tests__/shortcuts.test.ts | 81 +++ .../graph/editor/setup/createEditor.ts | 119 +++++ .../graph/editor/setup/focusManager.ts | 26 + .../graph/editor/setup/setupArrange.ts | 152 ++++++ .../graph/editor/setup/setupBackground.ts | 27 + .../graph/editor/setup/setupConnections.ts | 386 ++++++++++++++ .../graph/editor/setup/setupContextMenu.ts | 107 ++++ .../graph/editor/setup/setupKeyListeners.ts | 205 ++++++++ .../graph/editor/setup/setupRender.ts | 185 +++++++ .../graph/editor/setup/setupScopes.ts | 58 ++ .../graph/editor/setup/setupSelector.ts | 77 +++ .../graph/editor/setup/setupValidation.ts | 216 ++++++++ .../graph/editor/setup/shortcuts.ts | 208 ++++++++ .../graph/editor/sockets/appSocket.ts | 14 + .../graph/editor/sockets/booleanSocket.ts | 12 + .../graph/editor/sockets/ruleSocket.ts | 12 + .../modules/evaluation/graph/editor/types.ts | 41 ++ .../graph/editor/ui/ContextMenuView.ts | 66 +++ .../editor/ui/components/ConnectionToggle.tsx | 38 ++ .../graph/editor/ui/components/HatchedBox.tsx | 24 + .../editor/ui/components/IssesTooltip.tsx | 52 ++ .../graph/editor/ui/components/Label.tsx | 13 + .../components/containers/NodeContainer.tsx | 35 ++ .../containers/ParentNodeContainer.tsx | 72 +++ .../containers/RegularNodeContainer.tsx | 69 +++ .../ui/components/icons/AndGateIcon.tsx | 21 + .../editor/ui/components/icons/OrGateIcon.tsx | 21 + .../ui/components/sockets/InputSocket.tsx | 30 ++ .../ui/components/sockets/OutputSocket.tsx | 30 ++ .../ui/connections/BooleanConnectionView.tsx | 19 + .../ui/connections/CustomConnectionView.tsx | 42 ++ .../connections/LimitItemConnectionView.tsx | 19 + .../ui/connections/ToggleConnectionView.tsx | 94 ++++ .../ui/controls/IntegerRangeControlView.tsx | 107 ++++ .../editor/ui/controls/LabelControlView.tsx | 51 ++ .../editor/ui/controls/NameControlView.tsx | 42 ++ .../ui/controls/PositionControlView.tsx | 83 +++ .../ui/controls/QuantifierControlView.tsx | 40 ++ .../controls/QuantifierValueControlView.tsx | 57 ++ .../editor/ui/controls/RangeControlView.tsx | 40 ++ .../graph/editor/ui/nodes/ActionNodeView.tsx | 49 ++ .../graph/editor/ui/nodes/AreaNodeView.tsx | 49 ++ .../graph/editor/ui/nodes/CountNodeView.tsx | 43 ++ .../graph/editor/ui/nodes/LimitNodeView.tsx | 48 ++ .../graph/editor/ui/nodes/LogicalNode.tsx | 56 ++ .../editor/ui/nodes/PositionNodeView.tsx | 51 ++ .../graph/editor/ui/nodes/ResultNodeView.tsx | 48 ++ .../graph/editor/ui/sockets/SocketView.tsx | 21 + .../editor/utils/__tests__/copyPaste.test.ts | 353 +++++++++++++ .../editor/utils/__tests__/graph.test.ts | 123 +++++ .../editor/utils/__tests__/guards.test.ts | 89 ++++ .../utils/__tests__/nodeSelection.test.ts | 132 +++++ .../utils/__tests__/removeNodes.test.ts | 115 ++++ .../editor/utils/__tests__/testEditor.ts | 22 + .../graph/editor/utils/copyPaste.ts | 190 +++++++ .../evaluation/graph/editor/utils/graph.ts | 132 +++++ .../evaluation/graph/editor/utils/guards.ts | 37 ++ .../graph/editor/utils/nodeSelection.ts | 66 +++ .../graph/editor/utils/removeNodes.ts | 209 ++++++++ .../evaluation/graph/editor/utils/scopes.ts | 18 + .../validation/rules/__tests__/helpers.ts | 30 ++ .../rules/__tests__/validateActions.test.ts | 49 ++ .../rules/__tests__/validateGlobal.test.ts | 92 ++++ .../__tests__/validateLimitItems.test.ts | 71 +++ .../rules/__tests__/validateLimits.test.ts | 100 ++++ .../rules/__tests__/validateLogical.test.ts | 45 ++ .../rules/__tests__/validateResult.test.ts | 34 ++ .../validation/rules/validateActions.ts | 39 ++ .../editor/validation/rules/validateGlobal.ts | 188 +++++++ .../validation/rules/validateLimitItems.ts | 54 ++ .../editor/validation/rules/validateLimits.ts | 85 +++ .../validation/rules/validateLogical.ts | 31 ++ .../editor/validation/rules/validateResult.ts | 26 + .../graph/editor/validation/types.ts | 51 ++ .../graph/editor/validation/validateGraph.ts | 61 +++ .../editor/validation/validationGraph.ts | 76 +++ .../evaluation/graph/workspace/testHook.ts | 73 +++ .../src/modules/shadcn/ui/button-group.tsx | 87 +++ .../src/modules/shadcn/ui/toggle-group.tsx | 89 ++++ apps/web/src/modules/shadcn/ui/toggle.tsx | 43 ++ apps/web/vitest.config.ts | 10 + packages/schema/src/eval/eval.types.ts | 19 +- pnpm-lock.yaml | 497 +++++++++++++++++- 149 files changed, 12404 insertions(+), 43 deletions(-) create mode 100644 apps/web/e2e/clipboard.spec.ts create mode 100644 apps/web/e2e/connections.spec.ts create mode 100644 apps/web/e2e/context-menu.spec.ts create mode 100644 apps/web/e2e/creation.spec.ts create mode 100644 apps/web/e2e/deletion.spec.ts create mode 100644 apps/web/e2e/helpers.ts create mode 100644 apps/web/e2e/history.spec.ts create mode 100644 apps/web/e2e/movement.spec.ts create mode 100644 apps/web/e2e/scopes.spec.ts create mode 100644 apps/web/e2e/selection.spec.ts create mode 100644 apps/web/e2e/viewport.spec.ts create mode 100644 apps/web/playwright.config.ts create mode 100644 apps/web/src/modules/evaluation/components/GraphEditor/GraphEditor.tsx create mode 100644 apps/web/src/modules/evaluation/components/GraphEditor/GraphKeybindsDialog.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/connections/booleanConnection.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/connections/limitItemConnection.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/constants.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/controls/__tests__/position.test.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/controls/core/observableControl.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/controls/core/validatableControl.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/controls/integerRange.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/controls/label.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/controls/name.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/controls/percentageRange.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/controls/position.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/controls/quantifierType.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/controls/quantifierUnits.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/debug/DebugNodeBadge.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/debug/EditorDebugOverlay.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/debug/isDebugEnabled.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/deserialization/__tests__/deserializeLimitItems.test.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/deserialization/__tests__/deserializeLimits.test.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/deserialization/__tests__/deserializeLogicNodes.test.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/deserialization/deserializeLimitItems.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/deserialization/deserializeLimits.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/deserialization/deserializeLogicNodes.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/deserialization/deserializeTestCase.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/deserialization/types.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/nodes/action/actionBase.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/nodes/action/alert.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/nodes/action/warning.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/nodes/appNode.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/nodes/limit/limit.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/nodes/limitItem/area.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/nodes/limitItem/count.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/nodes/limitItem/limitItemBase.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/nodes/limitItem/position.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/nodes/logical/and.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/nodes/logical/logicalBase.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/nodes/logical/or.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/nodes/result/result.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/presets/customScopePreset.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/serialization/__tests__/schemaAdapters.test.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/serialization/__tests__/serializeLimitItems.test.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/serialization/__tests__/serializeLimits.test.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/serialization/__tests__/serializeLogicNodes.test.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/serialization/__tests__/utils.test.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/serialization/backendTypes.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/serialization/schemaAdapters.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/serialization/serializeLimitItems.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/serialization/serializeLimits.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/serialization/serializeLogicNodes.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/serialization/serializeTestCase.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/serialization/utils.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/setup/__tests__/shortcuts.test.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/setup/createEditor.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/setup/focusManager.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/setup/setupArrange.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/setup/setupBackground.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/setup/setupConnections.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/setup/setupContextMenu.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/setup/setupKeyListeners.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/setup/setupRender.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/setup/setupScopes.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/setup/setupSelector.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/setup/setupValidation.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/setup/shortcuts.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/sockets/appSocket.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/sockets/booleanSocket.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/sockets/ruleSocket.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/types.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/ContextMenuView.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/components/ConnectionToggle.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/components/HatchedBox.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/components/IssesTooltip.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/components/Label.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/components/containers/NodeContainer.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/components/containers/ParentNodeContainer.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/components/containers/RegularNodeContainer.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/components/icons/AndGateIcon.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/components/icons/OrGateIcon.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/components/sockets/InputSocket.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/components/sockets/OutputSocket.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/connections/BooleanConnectionView.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/connections/CustomConnectionView.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/connections/LimitItemConnectionView.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/connections/ToggleConnectionView.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/controls/IntegerRangeControlView.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/controls/LabelControlView.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/controls/NameControlView.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/controls/PositionControlView.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/controls/QuantifierControlView.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/controls/QuantifierValueControlView.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/controls/RangeControlView.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/nodes/ActionNodeView.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/nodes/AreaNodeView.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/nodes/CountNodeView.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/nodes/LimitNodeView.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/nodes/LogicalNode.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/nodes/PositionNodeView.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/nodes/ResultNodeView.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/sockets/SocketView.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/utils/__tests__/copyPaste.test.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/utils/__tests__/graph.test.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/utils/__tests__/guards.test.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/utils/__tests__/nodeSelection.test.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/utils/__tests__/removeNodes.test.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/utils/__tests__/testEditor.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/utils/copyPaste.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/utils/graph.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/utils/guards.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/utils/nodeSelection.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/utils/removeNodes.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/utils/scopes.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/helpers.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/validateActions.test.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/validateGlobal.test.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/validateLimitItems.test.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/validateLimits.test.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/validateLogical.test.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/validateResult.test.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/validation/rules/validateActions.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/validation/rules/validateGlobal.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/validation/rules/validateLimitItems.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/validation/rules/validateLimits.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/validation/rules/validateLogical.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/validation/rules/validateResult.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/validation/types.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/validation/validateGraph.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/validation/validationGraph.ts create mode 100644 apps/web/src/modules/evaluation/graph/workspace/testHook.ts create mode 100644 apps/web/src/modules/shadcn/ui/button-group.tsx create mode 100644 apps/web/src/modules/shadcn/ui/toggle-group.tsx create mode 100644 apps/web/src/modules/shadcn/ui/toggle.tsx create mode 100644 apps/web/vitest.config.ts diff --git a/apps/web/e2e/clipboard.spec.ts b/apps/web/e2e/clipboard.spec.ts new file mode 100644 index 00000000..121e3c3d --- /dev/null +++ b/apps/web/e2e/clipboard.spec.ts @@ -0,0 +1,112 @@ +import { expect, test } from "@playwright/test"; +import { + addNodeAt, + canvasCentre, + getConnections, + getNodes, + gotoEditor, + nodeById, + pressShortcutAt, +} 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, "q", 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, "q", 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, "q", -250, 0); + const b = await addNodeAt(page, "q", 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 centre = await canvasCentre(page); + await pressShortcutAt(page, "l", { x: centre.x - 200, y: centre.y }); + await pressShortcutAt(page, "1", { x: centre.x + 100, y: centre.y }); + await pressShortcutAt(page, "1", { x: centre.x + 200, y: centre.y + 100 }); + const limit = (await getNodes(page)).find((n) => n.label === "Limit")!; + // FIX(duplication): this "create Limit + Count children, then assign n.parent via page.evaluate" fixture block is copy-pasted in deletion.spec.ts (x2), history.spec.ts, movement.spec.ts and selection.spec.ts — fix: add a createLimitWithChildren(page, childCount) helper to helpers.ts (ideally backed by a setParent method on the window.__editor test hook); why: six near-identical copies will drift, and direct n.parent mutation bypassing the scopes plugin is a fragile detail that should live in one place. + await page.evaluate((parentId) => { + const { editor } = window.__editor!; + for (const n of editor.getNodes()) { + if (n.id !== parentId && n.label === "Count") n.parent = parentId; + } + }, limit.id); + + // selects both children as parent selection selects its children. + await nodeById(page, limit.id).click({ position: { x: 5, y: 5 } }); + + await page.keyboard.press("ControlOrMeta+c"); + 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..d07c8c6a --- /dev/null +++ b/apps/web/e2e/connections.spec.ts @@ -0,0 +1,121 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; +import { addNodeAt, getConnections, gotoEditor } 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(); + + // The connection plugin redraws on pointermove; multiple steps make it + // pick up the trail and detect the drop target reliably. + await page.mouse.move(to.x, to.y, { steps: 12 }); + await page.mouse.up(); +} + +// FIX(consistency): raw attribute selector for a data-testid — fix: use page.getByTestId(`socket-output-${id}`) like helpers.ts/gotoEditor does; why: getByTestId is the project-wide convention and respects a configured testIdAttribute. +function outSocket(page: Page, id: string) { + return page.locator(`[data-testid="socket-output-${id}"]`); +} + +function inSocket(page: Page, id: string) { + return page.locator(`[data-testid="socket-input-${id}"]`); +} + +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, "q", -250, 0); + const b = await addNodeAt(page, "q", 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, "q", -250, 0); + const count = await addNodeAt(page, "1", 250, 0); + + await dragSocketToSocket( + page, + outSocket(page, andNode), + inSocket(page, count), + ); + + // FIX(flakiness): negative assertion taken immediately after mouse.up — connection creation is async, so this can false-pass before a (wrongly created) connection lands; same pattern in the empty-canvas test below — fix: await expect.poll(() => getConnections(page), ...) over a short window, or first prove the timing with a positive control in the same flow; why: a regression that starts accepting mixed sockets would likely still go green here. + expect(await getConnections(page)).toHaveLength(0); + }); + + test("dropping a connection on empty canvas creates nothing", async ({ + page, + }) => { + const a = await addNodeAt(page, "q", -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(); + + expect(await getConnections(page)).toHaveLength(0); + }); + + test("toggling a boolean connection flips TRUE to NOT", async ({ page }) => { + const a = await addNodeAt(page, "q", -250, 0); + const b = await addNodeAt(page, "q", 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: /^TRUE$/ }).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..669b536b --- /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([ + "Limit", + "Result", + "Limit Item", + "Logical", + "Action", + ]); + }); + + test("right clicking a node shows the Delete & Clone items", async ({ + page, + }) => { + const id = await addNodeAt(page, "q", 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: "Limit 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..4ec7da80 --- /dev/null +++ b/apps/web/e2e/creation.spec.ts @@ -0,0 +1,66 @@ +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: "q", label: "AND" }, + { key: "w", 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, "q"); + const [node] = await getNodes(page); + expect(node!.selected).toBe(true); + }); + + test("two shortcut presses create two nodes", async ({ page }) => { + await pressShortcutAt(page, "q"); + // FIX(naming): `first` holds the canvas centre point, not the first node — fix: rename to `centre` as the other specs do; why: the name suggests a node snapshot and misreads on review. + const first = await canvasCentre(page); + await pressShortcutAt(page, "w", { x: first.x + 200, y: first.y }); + + const nodes = await getNodes(page); + expect(nodes.map((n) => n.label).sort()).toEqual(["AND", "OR"]); + }); + + test("context menu creates a Limit node", async ({ page }) => { + await canvas(page).click({ button: "right", position: { x: 100, y: 100 } }); + + const limitItem = page + .getByTestId("context-menu-item") + .filter({ hasText: /^Limit$/ }); + await expect(limitItem).toBeVisible(); + await limitItem.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..341acd11 --- /dev/null +++ b/apps/web/e2e/deletion.spec.ts @@ -0,0 +1,99 @@ +import { expect, test } from "@playwright/test"; +import { + addNodeAt, + canvasCentre, + getConnections, + getNodes, + gotoEditor, + nodeById, + pressShortcutAt, +} 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, "q", 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 centre = await canvasCentre(page); + await pressShortcutAt(page, "l", { x: centre.x - 200, y: centre.y }); + await pressShortcutAt(page, "1", { x: centre.x + 100, y: centre.y }); + await pressShortcutAt(page, "1", { x: centre.x + 200, y: centre.y + 100 }); + + const limit = (await getNodes(page)).find((n) => n.label === "Limit")!; + + // FIX(duplication): "Limit + Count children + parent assignment via page.evaluate" fixture is duplicated twice in this file and again in clipboard/history/movement/selection specs — fix: extract createLimitWithChildren(page, childCount) into helpers.ts; why: six copies of a non-trivial fixture will drift independently when the parenting mechanism changes. + await page.evaluate((parentId) => { + const { editor } = window.__editor!; + for (const n of editor.getNodes()) { + if (n.id !== parentId && n.label === "Count") n.parent = parentId; + } + }, limit.id); + + await nodeById(page, limit.id).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 centre = await canvasCentre(page); + await pressShortcutAt(page, "l", { x: centre.x - 200, y: centre.y }); + await pressShortcutAt(page, "1", { x: centre.x + 100, y: centre.y }); + const limit = (await getNodes(page)).find((n) => n.label === "Limit")!; + await page.evaluate((parentId) => { + const { editor } = window.__editor!; + for (const n of editor.getNodes()) { + if (n.id !== parentId && n.label === "Count") n.parent = parentId; + } + }, limit.id); + + await nodeById(page, limit.id).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, "q", -300, 0); + const b = await addNodeAt(page, "q", 0, 0); + const c = await addNodeAt(page, "q", 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..45ffd0ab --- /dev/null +++ b/apps/web/e2e/helpers.ts @@ -0,0 +1,128 @@ +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}"]`); +} + +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), + })); + }); +} + +/** + * Adds a node via its keyboard shortcut at the given canvas-relative offset + * from the centre. Returns the newly created node id. + */ +export async function addNodeAt( + page: Page, + key: string, + dx: number, + dy: number, +): Promise { + const centre = await canvasCentre(page); + await pressShortcutAt(page, key, { x: centre.x + dx, y: centre.y + dy }); + // FIX(flakiness): getNodes() runs immediately after the keypress, but node creation in the app is async (editor.addNode returns a promise) — fix: capture the node count before the keypress and page.waitForFunction until window.__editor.editor.getNodes().length increases; why: a race here makes every spec that builds fixtures through addNodeAt intermittently fail. + const nodes = await getNodes(page); + // FIX(flakiness): nodes.at(-1) assumes editor.getNodes() returns nodes in creation order — fix: diff the set of ids before/after the keypress and return the new id; why: the rete editor API does not guarantee ordering, so a different insertion strategy would silently return the wrong node. + const latest = nodes.at(-1); + if (!latest) throw new Error("node was not created"); + return latest.id; +} diff --git a/apps/web/e2e/history.spec.ts b/apps/web/e2e/history.spec.ts new file mode 100644 index 00000000..10f6e2b2 --- /dev/null +++ b/apps/web/e2e/history.spec.ts @@ -0,0 +1,72 @@ +import { expect, test } from "@playwright/test"; +import { + addNodeAt, + canvasCentre, + getConnections, + getNodes, + gotoEditor, + nodeById, + pressShortcutAt, +} from "./helpers"; + +test.describe("Undo & redo", () => { + test.beforeEach(async ({ page }) => { + await gotoEditor(page); + }); + + test("Cmd/Ctrl+Z undoes a node creation", async ({ page }) => { + await addNodeAt(page, "q", 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, "q", 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 centre = await canvasCentre(page); + await pressShortcutAt(page, "l", { x: centre.x - 200, y: centre.y }); + await pressShortcutAt(page, "1", { x: centre.x + 100, y: centre.y }); + await pressShortcutAt(page, "1", { x: centre.x + 200, y: centre.y + 100 }); + + const limit = (await getNodes(page)).find((n) => n.label === "Limit")!; + // FIX(duplication): same "Limit + Count children + parent assignment" fixture copy-pasted from clipboard/deletion/movement/selection specs — fix: extract createLimitWithChildren(page, childCount) into helpers.ts; why: shared fixtures belong in helpers so one change updates all specs. + await page.evaluate((parentId) => { + const { editor } = window.__editor!; + for (const n of editor.getNodes()) { + if (n.id !== parentId && n.label === "Count") n.parent = parentId; + } + }, limit.id); + + const children = (await getNodes(page)) + .filter((n) => n.parent === limit.id) + .map((n) => n.id) as [string, string]; + await page.evaluate( + ({ aId, bId }) => + window.__editor!.addLimitItemConnection(aId, bId, "AND"), + { aId: children[0], bId: children[1] }, + ); + expect(await getConnections(page)).toHaveLength(1); + + await nodeById(page, limit.id).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 === limit.id)).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..20b7f6a5 --- /dev/null +++ b/apps/web/e2e/movement.spec.ts @@ -0,0 +1,120 @@ +import { GRID } from "@/modules/evaluation/graph/editor/constants"; +import { expect, test } from "@playwright/test"; +import { + addNodeAt, + canvasCentre, + getNodes, + gotoEditor, + nodeById, + pressShortcutAt, +} from "./helpers"; + +// FIX(structure): generic node-drag gesture defined locally (with an inline import('@playwright/test').Page type instead of a top-level type import like connections.spec.ts uses) — fix: move dragNode into helpers.ts; why: scopes.spec.ts re-implements the same press-move-release sequence by hand, and gesture details (grab offset to dodge socket hit zones) should live in one place. +async function dragNode( + page: import("@playwright/test").Page, + id: string, + dx: number, + dy: number, +) { + const handle = nodeById(page, id); + const box = await handle.boundingBox(); + if (!box) throw new Error(`node ${id} not visible`); + // Grab near the top-left to avoid socket hit-zones, which are placed along the bottom edge. + 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(); +} + +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, "q", 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)!; + // FIX(duplication): this four-line "delta within ±GRID of the drag distance" tolerance block is repeated three times in this file (here, child-follow test, multi-select test) — fix: extract an expectMovedBy(before, after, dx, dy, tolerance) assertion helper; why: twelve hand-written bounds invite copy-paste mistakes and obscure the intent (snap tolerance = GRID). + expect(after.position.x - before.position.x).toBeGreaterThanOrEqual(40); + expect(after.position.x - before.position.x).toBeLessThanOrEqual(80); + expect(after.position.y - before.position.y).toBeGreaterThanOrEqual(20); + expect(after.position.y - before.position.y).toBeLessThanOrEqual(60); + }); + + test("node positions remain grid aligned after drag", async ({ page }) => { + const id = await addNodeAt(page, "q", 0, 0); + await dragNode(page, id, 73, 47); // GRID size is 20, intentionally not alligned + + 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 centre = await canvasCentre(page); + await pressShortcutAt(page, "l", { x: centre.x - 200, y: centre.y }); + await pressShortcutAt(page, "1", { x: centre.x + 100, y: centre.y }); + await pressShortcutAt(page, "1", { x: centre.x + 200, y: centre.y + 100 }); + + const limit = (await getNodes(page)).find((n) => n.label === "Limit")!; + + // FIX(duplication): same "Limit + Count children + parent assignment" fixture copy-pasted from clipboard/deletion/history/selection specs — fix: extract createLimitWithChildren(page, childCount) into helpers.ts; why: shared fixtures belong in helpers so one change updates all specs. + await page.evaluate((parentId) => { + const { editor } = window.__editor!; + for (const n of editor.getNodes()) { + if (n.id !== parentId && n.label === "Count") n.parent = parentId; + } + }, limit.id); + + await nodeById(page, limit.id).click({ position: { x: 5, y: 5 } }); + const before = await getNodes(page); + const childIds = before + .filter((n) => n.parent === limit.id) + .map((n) => n.id); + expect(childIds).toHaveLength(2); + + await dragNode(page, limit.id, 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)!; + expect(a.position.x - b.position.x).toBeGreaterThanOrEqual(40); + expect(a.position.x - b.position.x).toBeLessThanOrEqual(80); + expect(a.position.y - b.position.y).toBeGreaterThanOrEqual(20); + expect(a.position.y - b.position.y).toBeLessThanOrEqual(60); + } + }); + + test("multi select drag moves every selected node", async ({ page }) => { + const aId = await addNodeAt(page, "q", -250, 0); + const bId = await addNodeAt(page, "w", 250, 0); + + await nodeById(page, aId).click(); + await nodeById(page, bId).click({ modifiers: ["ControlOrMeta"] }); + + const before = await getNodes(page); + + // FIX(consistency): hardcodes the 'Control' key while every other multi-select interaction in the suite uses 'ControlOrMeta' (it works only because rete's accumulateOnCtrl accepts both Control and Meta) — fix: use page.keyboard.down('ControlOrMeta'); why: mixed modifier conventions make the suite behave differently per platform and confuse future edits. + await page.keyboard.down("Control"); + await dragNode(page, aId, 60, 40); + await page.keyboard.up("Control"); + + 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)!; + expect(a.position.x - b.position.x).toBeGreaterThanOrEqual(40); + expect(a.position.x - b.position.x).toBeLessThanOrEqual(80); + expect(a.position.y - b.position.y).toBeGreaterThanOrEqual(20); + expect(a.position.y - b.position.y).toBeLessThanOrEqual(60); + } + }); +}); diff --git a/apps/web/e2e/scopes.spec.ts b/apps/web/e2e/scopes.spec.ts new file mode 100644 index 00000000..6b8ba62d --- /dev/null +++ b/apps/web/e2e/scopes.spec.ts @@ -0,0 +1,103 @@ +import { expect, test } from "@playwright/test"; +import { + addNodeAt, + canvasCentre, + getNodes, + gotoEditor, + nodeById, + pressShortcutAt, +} 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 page.mouse.move(from.x, from.y); + // To enter the scope mode, the user must press and hold a node for 250ms. + // To assign the node to a parent, the user must release the node over the parent node. + // FIX(duplication): this press-hold-drag sequence with the magic 300ms wait is duplicated verbatim in the "dragging a child out" test below — fix: extract a dragWithScopeHold(page, from, to) helper in helpers.ts with a named SCOPE_HOLD_MS constant tied to the app's 250ms threshold; why: when the hold threshold changes, two hand-rolled copies (and the bare 300) must be hunted down instead of one constant. + await page.mouse.down(); + await page.waitForTimeout(300); + await page.mouse.move(to.x, to.y, { steps: 15 }); + await page.mouse.up(); + + 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); + // FIX(consistency): re-implements addNodeAt by hand (pressShortcutAt + canvasCentre twice + re-deriving the id via a label lookup) right after using addNodeAt for the limit on the previous line — fix: const countId = await addNodeAt(page, '1', 0, 0); why: the roundabout version is three times the code and would pick the wrong node if a second Count ever existed. + await pressShortcutAt(page, "1", { + x: (await canvasCentre(page)).x, + y: (await canvasCentre(page)).y, + }); + const countId = (await getNodes(page)).find((n) => n.label === "Count")!.id; + + 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 page.mouse.move(from.x, from.y); + // To enter the scope mode, the user must press and hold a node for 250ms. + // To clear the parent, the user must release the node over a different location. + await page.mouse.down(); + await page.waitForTimeout(300); + await page.mouse.move(from.x + 500, from.y + 300, { steps: 15 }); + await page.mouse.up(); + + 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..b6328c07 --- /dev/null +++ b/apps/web/e2e/selection.spec.ts @@ -0,0 +1,129 @@ +import { expect, test } from "@playwright/test"; +import { + canvas, + canvasCentre, + getNodes, + gotoEditor, + nodeById, + pressShortcutAt, +} from "./helpers"; + +// FIX(duplication): local addNodeAt shadows the helpers.ts export of the same name but silently drops the returned node id — fix: delete this and import addNodeAt from './helpers' (ignore the return value where unused); why: two functions with identical names and diverging behaviour across the suite is a copy-paste trap, and it is why this file re-fetches ids via getNodes() ordering below. +async function addNodeAt( + page: import("@playwright/test").Page, + key: string, + dx: number, + dy: number, +) { + const centre = await canvasCentre(page); + await pressShortcutAt(page, key, { x: centre.x + dx, y: centre.y + dy }); +} + +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, "q", -200, 0); + await addNodeAt(page, "w", 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, "q", 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, "q", 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, "q", -200, 0); + await addNodeAt(page, "w", 200, 0); + await addNodeAt(page, "r", 0, 200); + + // deselect all, by default the last created node would be selected. + await page.keyboard.press("Escape"); + expect((await getNodes(page)).every((n) => !n.selected)).toBe(true); + + // FIX(dead-code): this background click is unexplained — Escape above already deselected everything and keyboard focus is already on the page, and per the "clicking background deselects" test the click is a no-op here — fix: remove it, or add a comment stating the focus quirk it works around; why: unexplained ritual steps get cargo-culted into new tests. + await canvas(page).click({ position: { x: 5, y: 5 } }); + 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, "q", -200, 0); + await addNodeAt(page, "w", 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 centre = await canvasCentre(page); + await pressShortcutAt(page, "l", { x: centre.x - 200, y: centre.y }); + await pressShortcutAt(page, "1", { x: centre.x + 200, y: centre.y }); + await pressShortcutAt(page, "1", { x: centre.x + 200, y: centre.y + 150 }); + + const limit = (await getNodes(page)).find((n) => n.label === "Limit")!; + // FIX(dead-code): expect(limit).toBeDefined() is redundant — the `!` non-null assertion on the line above already claims it exists, so the check either never fires or is contradicted by the assertion operator — fix: drop the expect (or drop the `!` and keep a real guard); why: checks that cannot fail give false confidence. + expect(limit).toBeDefined(); + + // FIX(duplication): same "Limit + Count children + parent assignment" fixture copy-pasted from clipboard/deletion/history/movement specs — fix: extract createLimitWithChildren(page, childCount) into helpers.ts; why: shared fixtures belong in helpers so one change updates all specs. + await page.evaluate((parentId) => { + const { editor } = window.__editor!; + for (const n of editor.getNodes()) { + if (n.id !== parentId && n.label === "Count") n.parent = parentId; + } + }, limit.id); + + await page.keyboard.press("Escape"); + expect((await getNodes(page)).every((n) => !n.selected)).toBe(true); + + await nodeById(page, limit.id).click({ position: { x: 5, y: 5 } }); + + const nodes = await getNodes(page); + expect(nodes.find((n) => n.id === limit.id)?.selected).toBe(true); + const children = nodes.filter((n) => n.parent === limit.id); + 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..8f60eeaa --- /dev/null +++ b/apps/web/e2e/viewport.spec.ts @@ -0,0 +1,63 @@ +import { expect, test } from "@playwright/test"; +import { canvasCentre, getZoom, gotoEditor } from "./helpers"; + +test.describe("Canvas viewport", () => { + test.beforeEach(async ({ page }) => { + await gotoEditor(page); + }); + + test("wheel down zooms out", async ({ page }) => { + expect(await getZoom(page)).toBe(1); + + const centre = await canvasCentre(page); + await page.mouse.move(centre.x, centre.y); + await page.mouse.wheel(0, 200); + + // FIX(flakiness): mouse.wheel does not wait for the wheel event to be processed (per Playwright docs), so reading the zoom immediately can race the area plugin's handler — same pattern in both clamp tests below — fix: use await expect.poll(() => getZoom(page)).toBeLessThan(1); why: a one-frame delay in event dispatch turns these into intermittent failures on slow CI. + const zoomed = await getZoom(page); + expect(zoomed).toBeLessThan(1); + }); + + test("wheel up is clamped at the max (1.0)", 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); + } + + expect(await getZoom(page)).toBe(1); + }); + + test("wheel down is clamped at the min (0.4)", 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); + } + + expect(await getZoom(page)).toBeCloseTo(0.4, 5); + }); + + 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..abf1ea7a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,7 +7,10 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest --watch", + "test:e2e": "playwright test" }, "dependencies": { "@base-ui/react": "^1.1.0", @@ -47,9 +50,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 +74,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 +91,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..da039dd4 --- /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/modules/evaluation/components/GraphEditor/GraphEditor.tsx b/apps/web/src/modules/evaluation/components/GraphEditor/GraphEditor.tsx new file mode 100644 index 00000000..ccfd5849 --- /dev/null +++ b/apps/web/src/modules/evaluation/components/GraphEditor/GraphEditor.tsx @@ -0,0 +1,336 @@ +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 { deserializeTestCase } from "@/modules/evaluation/graph/editor/deserialization/deserializeTestCase"; +import type { EvalTestCaseCreateOrUpdatePayload } from "@/modules/evaluation/graph/editor/serialization/backendTypes"; +import { serializeTestCase } from "@/modules/evaluation/graph/editor/serialization/serializeTestCase"; +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, + RotateCcw, + Save, +} from "lucide-react"; +import { useCallback, useMemo, useRef, useState } from "react"; +import type { NodeEditor } from "rete"; +import { useRete } from "rete-react-plugin"; +import { toast } from "sonner"; +import { GraphKeybindsDialog } from "./GraphKeybindsDialog"; + +const MIN_HEIGHT = 240; +const DEFAULT_HEIGHT = 480; +// Leave room for surrounding chrome when dragging toward the bottom of the viewport. +const VIEWPORT_MARGIN = 120; + +const clamp = (value: number, min: number, max: number) => + Math.min(Math.max(value, min), max); + +type Props = { + projectId: number; +}; + +export const GraphEditor = ({ projectId }: 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); + + // Project labels feed the Limit-node selectors. They're read lazily via a ref so + // node-creation sites (context menu, shortcut, deserialize) always see the latest + // set — the editor mounts immediately and doesn't need to wait for the query. + const { data: projectLabels } = 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, + }, + ); + + // FIX(react): this .then has no guard against the instance having been superseded or destroyed — under StrictMode useRete creates the editor twice, so the callback also fires for the discarded first instance, and nothing ever clears editorRef / setEditor / window.__editor on unmount — fix: track the latest created instance (or a disposed flag set by destroy) and skip stale resolutions, and clear the ref/state/test hook in a cleanup; why: toolbar handlers and Playwright hooks can end up operating on a destroyed editor. + 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 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); + }; + + // FIX(naming): handleSave does not save anything — it serializes, console.logs the payload and toasts "serialized successfully" — fix: rename to handleSerialize (or wire the real persistence call) and drop the console.log from the production path; why: a Save button that only logs misleads users and reviewers about what state is persisted. + const handleSave = async () => { + const instance = editorRef.current; + if (!instance) 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 payload = serializeTestCase(instance.editor, { + id: "", + name: "", + }); + + console.log(JSON.stringify(payload, null, 2)); + + toast.success("Graph serialized successfully."); + }; + + const handleArrange = async () => { + const instance = editorRef.current; + if (!instance) return; + + await instance.arrange.layout(); + toast.success("Arranged layout."); + }; + + // FIX(duplication): the validateNow + enableLiveValidation + error-toast + serializeTestCase block below is copy-pasted from handleSave — fix: extract a shared validateAndSerialize() helper that returns the payload or null; why: the copies differ only in toast text and every validation-flow fix must now be applied twice. + const handleRoundTripTestCase = async () => { + const instance = editorRef.current; + if (!instance) 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 round-trip testing."); + return; + } + const serialized = serializeTestCase(instance.editor, { + id: "", + name: "", + }); + const payload = withAssignedIds(serialized); + console.log("Round-trip payload:", JSON.stringify(payload, null, 2)); + await instance.validation.clearValidation(); + await deserializeTestCase( + instance.editor, + instance.area, + payload, + labelsRef.current, + instance.selectableNodes, + ); + await instance.arrange.layout(); + + await instance.validation.validateNow(); + toast.success("Serialized and loaded back into editor."); + }; + + return ( +
+
+ {editor && } +
+ + + + + + +
+ {!maximized && ( +
+
+
+ )} + +
+ ); +}; + +function createTemporaryId(prefix: string) { + return `${prefix}-${crypto.randomUUID()}`; +} + +function withAssignedIds( + payload: EvalTestCaseCreateOrUpdatePayload, +): EvalTestCaseCreateOrUpdatePayload { + return { + ...payload, + id: payload.id || createTemporaryId("test-case"), + + limits: payload.limits.map((limit) => ({ + ...limit, + id: limit.id || createTemporaryId("limit"), + + limitItems: limit.limitItems.map((item) => ({ + ...item, + id: item.id || createTemporaryId("limit-item"), + })), + })), + + // FIX(duplication): this map body is an exact inline copy of assignLogicNodeIds below — fix: replace with `logicNodes: assignLogicNodeIds(payload.logicNodes)`; why: the helper already handles the top level, and the duplicate GROUP/OPERATOR branches will silently diverge. + logicNodes: payload.logicNodes.map((node) => { + if (node.type === "GROUP") { + return { + ...node, + id: node.id || createTemporaryId("logic-group"), + children: assignLogicNodeIds(node.children), + }; + } + + if (node.type === "OPERATOR") { + return { + ...node, + id: node.id || createTemporaryId("logic-operator"), + }; + } + + return node; + }), + }; +} + +function assignLogicNodeIds( + nodes: EvalTestCaseCreateOrUpdatePayload["logicNodes"], +): EvalTestCaseCreateOrUpdatePayload["logicNodes"] { + return nodes.map((node) => { + if (node.type === "GROUP") { + return { + ...node, + id: node.id || createTemporaryId("logic-group"), + children: assignLogicNodeIds(node.children), + }; + } + + if (node.type === "OPERATOR") { + return { + ...node, + id: node.id || createTemporaryId("logic-operator"), + }; + } + + return node; + }); +} 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..719616d5 --- /dev/null +++ b/apps/web/src/modules/evaluation/components/GraphEditor/GraphKeybindsDialog.tsx @@ -0,0 +1,91 @@ +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 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); + +function Kbd({ 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 + + Shortcuts are ignored while typing in an input. + + +
+ {SECTIONS.map((section) => ( +
+

+ {section.title} +

+
+ {section.shortcuts.map((shortcut) => ( + + ))} +
+
+ ))} +
+
+
+ ); +} diff --git a/apps/web/src/modules/evaluation/components/TestCasesOverview/TestCaseSection.tsx b/apps/web/src/modules/evaluation/components/TestCasesOverview/TestCaseSection.tsx index f0f50843..80f876b4 100644 --- a/apps/web/src/modules/evaluation/components/TestCasesOverview/TestCaseSection.tsx +++ b/apps/web/src/modules/evaluation/components/TestCasesOverview/TestCaseSection.tsx @@ -1,5 +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"; @@ -32,7 +33,11 @@ type DeleteState = type ViewMode = "table" | "graph"; -export function TestCaseSection({ testCase, projectId, configId }: TestCaseSectionProps) { +export function TestCaseSection({ + testCase, + projectId, + configId, +}: TestCaseSectionProps) { const { data: limits = [] } = useGetEvalLimitsQuery({ projectId, configId, @@ -101,19 +106,26 @@ 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) => (
-
- - - + +
+ {viewMode === "table" && ( + <> + + + + + )} +
+ ); +} 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/IssesTooltip.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/components/IssesTooltip.tsx new file mode 100644 index 00000000..49b9535b --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/components/IssesTooltip.tsx @@ -0,0 +1,52 @@ +// FIX(naming): filename "IssesTooltip.tsx" is a typo and does not match the exported component IssueTooltip — fix: rename the file to IssueTooltip.tsx and update the 6 import sites; why: the misspelled path is copy-pasted into every consumer, hurts discoverability (searching "IssueTooltip" misses the file), and breaks the file/export naming convention. +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..e76507cd --- /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/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/ParentNodeContainer.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/components/containers/ParentNodeContainer.tsx new file mode 100644 index 00000000..fa271e6c --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/components/containers/ParentNodeContainer.tsx @@ -0,0 +1,72 @@ +import { GRID } from "@/modules/evaluation/graph/editor/constants"; +import type { LimitNode } from "@/modules/evaluation/graph/editor/nodes/limit/limit"; +import { HatchedBox } from "@/modules/evaluation/graph/editor/ui/components/HatchedBox"; +import { IssueTooltip } from "@/modules/evaluation/graph/editor/ui/components/IssesTooltip"; +import { Label } from "@/modules/evaluation/graph/editor/ui/components/Label"; +import type { ReactNode } from "react"; +import { NodeContainer } from "./NodeContainer"; + +type Props = { + // FIX(structure): a generic layout container is coupled to the concrete LimitNode class while its sibling RegularNodeContainer accepts the generic NodeProps — fix: accept NodeProps (the only fields used are shared layout props) like RegularNodeContainer does; why: the dependency on a specific node class inverts the layering (shared component -> feature node) and blocks reuse for any future parent-style node. + data: LimitNode; + outputSocket?: ReactNode; + children: ReactNode; +}; + +export const ParentNodeContainer = (props: Props) => { + const { data, outputSocket, children } = props; + + const { + id, + label, + height, + width, + selected = false, + controlsHeight, + socketHeight, + labelHeight, + issues, + } = data; + + return ( + + {/* FIX(duplication): this entire header block (label row + warning/error IssueTooltips + controls section) is copy-pasted verbatim from RegularNodeContainer — fix: extract a shared NodeHeader/NodeBody component used by both containers; why: the two copies have already started drifting in whitespace and any future header change (e.g. a new tooltip level) must be made twice. */} +
+
+ + +
+
+ + +
+ +
+
+ {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..9bcda091 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/components/containers/RegularNodeContainer.tsx @@ -0,0 +1,69 @@ +import { GRID } from "@/modules/evaluation/graph/editor/constants"; +import type { NodeProps } from "@/modules/evaluation/graph/editor/types"; +import { IssueTooltip } from "@/modules/evaluation/graph/editor/ui/components/IssesTooltip"; +import { Label } from "@/modules/evaluation/graph/editor/ui/components/Label"; +import type { ReactNode } from "react"; +import { NodeContainer } from "./NodeContainer"; + +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/icons/AndGateIcon.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/components/icons/AndGateIcon.tsx new file mode 100644 index 00000000..bb0ffc33 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/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/modules/evaluation/graph/editor/ui/components/icons/OrGateIcon.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/components/icons/OrGateIcon.tsx new file mode 100644 index 00000000..5d9dc5ca --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/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/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..0cc4e240 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/connections/BooleanConnectionView.tsx @@ -0,0 +1,19 @@ +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; + }, + }); +} 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..ea15aa43 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/connections/CustomConnectionView.tsx @@ -0,0 +1,42 @@ +import { type ClassicScheme, Presets } from "rete-react-plugin"; +import styled from "styled-components"; + +const { useConnection } = Presets.classic; + +// FIX(consistency): this file styles the identical svg/path connection markup with styled-components while sibling ToggleConnectionView.tsx uses Tailwind utility classes for the same styles (same 9999px canvas, stroke, dasharray, animation) — fix: pick one approach (Tailwind, to match the rest of src/editor/ui) and share the path styling between the two views; why: the duplicated styles in two styling systems will drift (ToggleConnectionView already adds an animation-delay this one lacks) and doubles maintenance for any stroke/animation tweak. +const Svg = styled.svg` + overflow: visible !important; + position: absolute; + pointer-events: none; + width: 9999px; + height: 9999px; +`; + +// FIX(structure): the `@keyframes dash` declared inside this styled-component is emitted globally (unhashed) and is the ONLY definition of `dash` in the app, yet ToggleConnectionView's Tailwind class [animation:dash_1s_linear_infinite] silently depends on it — fix: move the dash keyframes into the global stylesheet (or Tailwind theme) instead of a component-scoped style block; why: the keyframes are only injected once a CustomConnectionView has mounted, so toggle connections on a canvas with no plain connection get no animation (hidden load-order coupling). +const Path = styled.path` + fill: none; + stroke-width: 5px; + stroke: var(--color-zinc-400); + pointer-events: auto; + stroke-dasharray: 10 5; + animation: dash 1s linear infinite; + stroke-dashoffset: 45; + @keyframes dash { + to { + stroke-dashoffset: 0; + } + } +`; + +// FIX(config): `npm run lint` fails on this line at HEAD — the `_` prefix signals an intentionally-unused param but the eslint config has no `argsIgnorePattern` — fix: add `'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }]` to eslint.config.js (or drop the param; rete's customize API doesn't require declaring it); why: a red lint baseline trains everyone to ignore lint failures. +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..03095e16 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/connections/LimitItemConnectionView.tsx @@ -0,0 +1,19 @@ +import type { LimitItemConnection } from "@/modules/evaluation/graph/editor/connections/limitItemConnection"; +import type { EvalLimitItemOperator } from "@/modules/evaluation/graph/editor/serialization/backendTypes"; +import { createToggleConnectionView } from "./ToggleConnectionView"; + +export function createLimitItemConnectionView( + refreshConnection: (id: string) => void, +) { + return createToggleConnectionView( + { + refreshConnection, + options: ["AND", "OR"], + fallback: "AND", + getValue: (connection) => connection.limitItemOperator, + setValue: (connection, value) => { + connection.limitItemOperator = value; + }, + }, + ); +} 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..0c2912e1 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/connections/ToggleConnectionView.tsx @@ -0,0 +1,94 @@ +import { BooleanConnectionToggle } from "@/modules/evaluation/graph/editor/ui/components/ConnectionToggle"; +import { useLayoutEffect, useRef, useState } from "react"; +import type { ClassicScheme } from "rete-react-plugin"; +import { Presets } from "rete-react-plugin"; + +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) { + // FIX(perf): every call to this factory returns a brand-new component identity, and setupRender.ts invokes createBoolean/LimitItemConnectionView inside the per-connection `customize.connection` callback — fix: create each configured view once at module/setup scope (or memoize by options) and return the cached component; why: a new component type per render decision makes React unmount/remount the connection subtree on every area update, losing the `mid` state and restarting the dash animation in a hot canvas path. + return function ToggleConnectionView(props: { data: C }) { + const { path } = useConnection(); + const pathRef = useRef(null); + const [mid, setMid] = useState<{ x: number; y: number } | null>(null); + // FIX(react): animationDelay is recomputed from performance.now() on every render, so each re-render (e.g. the setMid layout effect, drags, toggles) writes a new animation-delay and makes the dash animation jump phase; it is also a render-time side-input that breaks render purity — fix: capture it once per mount, e.g. const [animationDelay] = useState(() => `-${performance.now() % 1000}ms`); why: connections re-render constantly while dragging nodes, producing visible animation stutter. + const animationDelay = `-${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 ( + <> + + {/* FIX(bug): the [animation:dash_1s_linear_infinite] utility references the `dash` keyframes that are only defined inside CustomConnectionView's styled-components block, which is injected only after a CustomConnectionView mounts — fix: define `@keyframes dash` in global CSS so this view does not depend on a sibling component mounting first; why: a graph containing only toggle connections renders them without the marching-ants animation. */} + + + + {isComplete && mid && ( + { + setValue(props.data, nextValue); + refreshConnection(props.data.id); + }} + /> + )} + + ); + }; +} 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..e0055031 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/controls/IntegerRangeControlView.tsx @@ -0,0 +1,107 @@ +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]); + + // FIX(bug): when the committed input normalizes to the value already stored, IntegerRangeControl.setValue early-returns without emitting, so the resync effect never fires and the input keeps showing the stale raw text (e.g. value is 6, user types "5.7" -> control rounds to 6 -> no emit -> field still shows "5.7"; same for "-3" when value is 0) — fix: after commit, re-read the snapshot and setLimitFromDraft/setLimitToDraft unconditionally (or resync in onBlur); why: the UI displays a number that differs from the actual committed model value. + const commitLimitFrom = (raw: string) => { + data.setLimitFrom(parseNullableNumber(raw)); + }; + + const commitLimitTo = (raw: string) => { + data.setLimitTo(parseNullableNumber(raw)); + }; + + 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..43b34926 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/controls/LabelControlView.tsx @@ -0,0 +1,51 @@ +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..c23148be --- /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/PositionControlView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/controls/PositionControlView.tsx new file mode 100644 index 00000000..454e8935 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/controls/PositionControlView.tsx @@ -0,0 +1,83 @@ +import { + edgesCompatible, + type PositionControl, +} from "@/modules/evaluation/graph/editor/controls/position"; +import type { EvalLimitItemEdge } from "@/modules/evaluation/graph/editor/serialization/backendTypes"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/modules/shadcn/ui/select"; +import { useSyncExternalStore } from "react"; + +type Props = { + data: PositionControl; +}; + +const EDGE_LABELS: Record = { + LEFT: "Left", + RIGHT: "Right", + TOP: "Top", + BOTTOM: "Bottom", + CENTER: "Center", +}; + +const ALL_EDGES: EvalLimitItemEdge[] = [ + "TOP", + "BOTTOM", + "LEFT", + "RIGHT", + "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/QuantifierControlView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/controls/QuantifierControlView.tsx new file mode 100644 index 00000000..54b585ee --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/controls/QuantifierControlView.tsx @@ -0,0 +1,40 @@ +// FIX(naming): file is named QuantifierControlView.tsx but exports QuantifierTypeControlView for QuantifierTypeControl — fix: rename the file to QuantifierTypeControlView.tsx so file and export match; why: the folder already has QuantifierValueControlView.tsx exporting QuantifierUnitsControlView, and two near-identically named files that both mismatch their exports make the quantifier controls easy to confuse. +import type { QuantifierTypeControl } from "@/modules/evaluation/graph/editor/controls/quantifierType"; +import type { EvalLimitItemQuantifierType } from "@/modules/evaluation/graph/editor/serialization/backendTypes"; +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/QuantifierValueControlView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/controls/QuantifierValueControlView.tsx new file mode 100644 index 00000000..b232e358 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/controls/QuantifierValueControlView.tsx @@ -0,0 +1,57 @@ +// FIX(naming): file is named QuantifierValueControlView.tsx but exports QuantifierUnitsControlView for QuantifierUnitsControl — fix: rename the file to QuantifierUnitsControlView.tsx (or rename the component) so file and export match like the sibling control views; why: searching for the component by name misses the file and the mismatch invites wrong imports. +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]); + + // FIX(bug): QuantifierUnitsControl.setValue clamps via normalizeUnitsValue and early-returns when the clamped value equals the stored one, so the resync effect never fires and the stale raw text stays in the input (e.g. value is 100 in PERCENT mode, user types "150" -> clamps to 100 -> no emit -> field still shows "150") — fix: resync the draft from data.getSnapshot() after committing (or on blur) regardless of whether the store emitted; why: the displayed quantity silently diverges from the committed model value. + const commitValue = (raw: string) => { + const parsed = Number(raw); + data.setValue(Number.isFinite(parsed) ? parsed : 0); + }; + + 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/RangeControlView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/controls/RangeControlView.tsx new file mode 100644 index 00000000..4c43c3c6 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/controls/RangeControlView.tsx @@ -0,0 +1,40 @@ +// FIX(naming): file is named RangeControlView.tsx but exports PercentageRangeControlView for PercentageRangeControl — fix: rename the file to PercentageRangeControlView.tsx so file and export match; why: the sibling IntegerRangeControlView.tsx follows the file==export convention, and the generic "RangeControlView" name makes it ambiguous which of the two range controls this file renders. +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/nodes/ActionNodeView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/ActionNodeView.tsx new file mode 100644 index 00000000..09d41452 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/ActionNodeView.tsx @@ -0,0 +1,49 @@ +import { GRID } from "@/modules/evaluation/graph/editor/constants"; +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 { NodeContainer } from "@/modules/evaluation/graph/editor/ui/components/containers/NodeContainer"; +import { IssueTooltip } from "@/modules/evaluation/graph/editor/ui/components/IssesTooltip"; +import { InputSocket } from "@/modules/evaluation/graph/editor/ui/components/sockets/InputSocket"; +import type { RenderEmit } from "rete-react-plugin"; + +type Props = { + data: WarningNode | AlertNode; + emit: RenderEmit; +}; + +export const ActionNodeView = (props: Props) => { + const { data, emit } = props; + const input = data.inputs.in; + + const { label, height, width, selected = false, issues } = data; + + // FIX(error-handling): error message says "CoundNode" (copy-paste typo from CountNodeView) but this is the action (Warning/Alert) node view — fix: throw new Error(`${label} node is missing expected parts`) like ResultNodeView does; why: a wrong node name in the error sends whoever debugs a broken graph to the wrong file. + if (!input) { + throw new Error(`CoundNode is missing expected parts`); + } + + return ( + + {/* FIX(duplication): this compact-node body (issue-tooltip row + socket row sized by socketHeight * GRID) is copy-pasted across ActionNodeView, LogicalNodeView and ResultNodeView — fix: extract a shared CompactNodeContainer (sibling to RegularNodeContainer/ParentNodeContainer) taking input/output/center slots; why: three drifting copies of the same layout already differ only in whitespace and the center element, and layout fixes must be applied three times. */} +
+
+ + +
+
+ + {label} +
+
+
+ ); +}; 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..8efa6a04 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/AreaNodeView.tsx @@ -0,0 +1,49 @@ +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 input = data.inputs.in; + const output = data.outputs.out; + const { range, units, quantifier } = data.controls; + + // FIX(error-handling): error message says "CountNode" but this is AreaNodeView (copy-paste from CountNodeView) — fix: "AreaNode is missing expected parts"; why: the misleading node name in the thrown error points debugging at the wrong node type. + if (!input || !output || !range || !units || !quantifier) { + throw new Error(`CountNode 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..701701b1 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/CountNodeView.tsx @@ -0,0 +1,43 @@ +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 input = data.inputs.in; + const output = data.outputs.out; + const { count } = data.controls; + + // FIX(naming): typo "CoundNode" in the error message (and this exact typo has been copy-pasted into ActionNodeView and LogicalNode) — fix: "CountNode is missing expected parts"; why: misspelled error text is ungreppable against the real node name when debugging. + if (!input || !output || !count) { + throw new Error(`CoundNode 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..0c566c09 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/LimitNodeView.tsx @@ -0,0 +1,48 @@ +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 output = data.outputs.out; + if (!output) throw new Error(`LimitNode is missing an output socket`); + + // FIX(consistency): the output socket is guarded above, but the name/label/parentLabel controls are passed to RefControl unchecked, while every sibling view (AreaNodeView, CountNodeView, PositionNodeView) validates its controls and throws — fix: include the three controls in the missing-parts guard (if (!output || !name || !label || !parentLabel) throw ...); why: a deserialized LimitNode with a missing control would render silently broken here instead of failing loudly like the other node views. + const { name, label, parentLabel } = data.controls; + + return ( + + } + data={data} + > +
+ Name + +
+
+
+ Label + +
+
+ + Parent Label + + +
+
+
+ ); +}; diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/nodes/LogicalNode.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/LogicalNode.tsx new file mode 100644 index 00000000..bc64e848 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/LogicalNode.tsx @@ -0,0 +1,56 @@ +// FIX(naming): file is named LogicalNode.tsx but exports LogicalNodeView, breaking the *NodeView.tsx convention every sibling in this folder follows (ActionNodeView.tsx, CountNodeView.tsx, ...) and colliding conceptually with the node-model files under src/editor/nodes/logical/ — fix: rename the file to LogicalNodeView.tsx; why: the inconsistent name makes the view look like a data-model class and breaks path-based search habits. +import { GRID } from "@/modules/evaluation/graph/editor/constants"; +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 { NodeContainer } from "@/modules/evaluation/graph/editor/ui/components/containers/NodeContainer"; +import { AndGateIcon } from "@/modules/evaluation/graph/editor/ui/components/icons/AndGateIcon"; +import { OrGateIcon } from "@/modules/evaluation/graph/editor/ui/components/icons/OrGateIcon"; +import { IssueTooltip } from "@/modules/evaluation/graph/editor/ui/components/IssesTooltip"; +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; + + // FIX(error-handling): error message says "CoundNode" (copy-pasted typo from CountNodeView) but this is the AND/OR logical node view — fix: throw new Error(`${label} node is missing expected parts`); why: a wrong node name in the error misdirects debugging of a broken graph. + if (!input || !output) { + throw new Error(`CoundNode is missing expected parts`); + } + + return ( + +
+
+ + +
+
+ + + {label === "AND" && } + {label === "OR" && } + + +
+
+
+ ); +}; 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..11e96475 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/PositionNodeView.tsx @@ -0,0 +1,51 @@ +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 input = data.inputs.in; + const output = data.outputs.out; + const { range, units, quantifier, position } = data.controls; + + // FIX(error-handling): error message says "CountNode" but this is PositionNodeView (copy-paste from CountNodeView) — fix: "PositionNode is missing expected parts"; why: the misleading node name in the thrown error points debugging at the wrong node type. + if (!input || !output || !range || !units || !quantifier || !position) { + throw new Error(`CountNode 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..081b2567 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/ResultNodeView.tsx @@ -0,0 +1,48 @@ +import { GRID } from "@/modules/evaluation/graph/editor/constants"; +import type { ResultNode } from "@/modules/evaluation/graph/editor/nodes/result/result"; +import type { Schemes } from "@/modules/evaluation/graph/editor/types"; +import { NodeContainer } from "@/modules/evaluation/graph/editor/ui/components/containers/NodeContainer"; +import { IssueTooltip } from "@/modules/evaluation/graph/editor/ui/components/IssesTooltip"; +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(`${label} node is missing expected parts`); + + return ( + +
+
+ + +
+
+ + + +
+
+
+ ); +} 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..dd6d1dff --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/utils/__tests__/copyPaste.test.ts @@ -0,0 +1,353 @@ +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 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", "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: "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..0078bb64 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/utils/copyPaste.ts @@ -0,0 +1,190 @@ +import { + BooleanConnection, + type BooleanOperator, +} from "@/modules/evaluation/graph/editor/connections/booleanConnection"; +import { LimitItemConnection } from "@/modules/evaluation/graph/editor/connections/limitItemConnection"; +import type { EvalLimitItemOperator } from "@/modules/evaluation/graph/editor/serialization/backendTypes"; +import type { + LimitItemProps, + LogicalProps, + Schemes, +} from "@/modules/evaluation/graph/editor/types"; +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 }; + // Index into the clipboard nodes array of this node's parent, or null if root-level. + 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: EvalLimitItemOperator; + }; + +export type Clipboard = { + nodes: ClipboardNodeEntry[]; + connections: ClipboardConnectionEntry[]; + center: { x: number; y: number }; +}; + +// E is generic so this module does not need to import AreaExtra from the setup layer. + +/** + * 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 LogicalProps, + connEntry.sourceOutput, + target as LogicalProps, + 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..0dfbb4f9 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/utils/graph.ts @@ -0,0 +1,132 @@ +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); + + // FIX(perf): sorting neighbours before pushing onto the DFS stack has no effect on the result — component membership is order-independent and each component is re-sorted by original index below anyway — fix: iterate adjacency.get(current) directly; why: O(E log E) of wasted work and it implies traversal order matters when it does not. + const neighbours = [...(adjacency.get(current) ?? [])].sort((a, b) => { + const indexA = originalIndexByNodeId.get(a) ?? Number.MAX_SAFE_INTEGER; + const indexB = originalIndexByNodeId.get(b) ?? Number.MAX_SAFE_INTEGER; + return indexA - indexB; + }); + + for (const next of neighbours) { + 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); +} + +// FIX(duplication): re-implements the adjacency build + undirected traversal of findConnectedComponents above — fix: return findConnectedComponents(nodes.map((n) => n.id), connections).length (or extract one shared traversal); why: two hand-written copies of the same algorithm drift independently and double the test/maintenance surface. +export function countConnectedComponents( + nodes: NodeProps[], + connections: ConnProps[], +) { + if (nodes.length === 0) return 0; + + const nodeIds = new Set(nodes.map((node) => node.id)); + const adjacency = new Map>(); + + for (const node of nodes) { + adjacency.set(node.id, new Set()); + } + + for (const connection of connections) { + if (!nodeIds.has(connection.source)) continue; + if (!nodeIds.has(connection.target)) continue; + + adjacency.get(connection.source)?.add(connection.target); + adjacency.get(connection.target)?.add(connection.source); + } + + let count = 0; + const visited = new Set(); + + for (const node of nodes) { + if (visited.has(node.id)) continue; + + count += 1; + const stack = [node.id]; + visited.add(node.id); + + while (stack.length > 0) { + const current = stack.pop(); + if (!current) continue; + + for (const next of adjacency.get(current) ?? []) { + if (visited.has(next)) continue; + visited.add(next); + stack.push(next); + } + } + } + + return count; +} 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..6f2c01a4 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/utils/removeNodes.ts @@ -0,0 +1,209 @@ +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() { + // Parents before children so the scopes plugin's nodecreate validator + // finds the parent already in the editor. + 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); + } + + // Each child translate triggers the scopes plugin to resize/translate its + // parent. After the last child is restored the parent's bounding box + // matches the original, but force-set width/height to be exact in case + // padding/min-size clamping rounded differently. + 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); + // FIX(bug): a node without a view is silently dropped from the snapshot but is still removed below, so undo restores the cascade minus this node while its connections are re-added pointing at a missing endpoint — fix: snapshot it with a fallback position (e.g. { x: 0, y: 0 }) instead of skipping; why: a partial undo silently corrupts the graph. + if (!view) continue; + snapshots.push({ + node, + position: { x: view.position.x, y: view.position.y }, + 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/rules/__tests__/helpers.ts b/apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/helpers.ts new file mode 100644 index 00000000..3f2f67ee --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/helpers.ts @@ -0,0 +1,30 @@ +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(), + }; +} + +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..f1edb09a --- /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, + findNodesWithMultipleGraphOutputs, +} 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("findNodesWithMultipleGraphOutputs", () => { + 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)]); + findNodesWithMultipleGraphOutputs(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), + ], + ); + findNodesWithMultipleGraphOutputs(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)], + ); + findNodesWithMultipleGraphOutputs(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..6a487816 --- /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 { + findLimitItemCrossScopeConnections, + 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("findLimitItemCrossScopeConnections", () => { + 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)]); + findLimitItemCrossScopeConnections(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)]); + findLimitItemCrossScopeConnections(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)]); + findLimitItemCrossScopeConnections(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..8aa4f7c0 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/validateLogical.test.ts @@ -0,0 +1,45 @@ +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", () => { + // FIX(testing): this test pins the zero-input gap as correct behavior — a zero-input operator is even less meaningful than a one-input one, and no other rule catches it when it sits in the Result island — fix: if the rule is extended to report length === 0 (see FIX in validateLogical.ts), assert an issue here instead; why: the test certifies a validation gap as intended. + it("reports no warning for a logical node with zero inputs", () => { + const and = new AndNode(); + const ctx = makeContext([and]); + findUselessLogicalNodes(ctx); + expect(ctx.nodeIssues.size).toBe(0); + }); + + 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..ae3c3324 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/validateResult.test.ts @@ -0,0 +1,34 @@ +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", () => { + // FIX(testing): this test pins the silent-pass for zero Result nodes as correct, contradicting the rule's doc comment ('exactly one Result node') — fix: once the rule reports the missing-Result case, this test must assert an issue instead; why: it currently certifies a validation gap as intended behavior. + it("reports no issues when the graph has no result nodes", () => { + const ctx = makeContext([new LimitNode()]); + findInvalidResultNodeCount(ctx); + expect(ctx.nodeIssues.size).toBe(0); + }); + + 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..42942b2a --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateActions.ts @@ -0,0 +1,39 @@ +import { + isActionNode, + isLimitNode, + isResultNode, +} from "@/modules/evaluation/graph/editor/utils/guards"; +import { + pushIssue, + 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; + + // FIX(bug): an identical issue is pushed once per invalid incoming connection, so an action with N bad sources shows N duplicate 'Invalid action placement' entries — fix: break after the first invalid source (or collect and push once); why: duplicated identical messages clutter the node's issue list in the UI. + 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 Limit node or to the Result node.", + ], + }); + } + } +} 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..b2791478 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateGlobal.ts @@ -0,0 +1,188 @@ +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 { + pushIssue, + 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; + + // FIX(duplication): this whole island-with-Result computation duplicates getResultIslandNodeIds() defined below, and runs findConnectedComponents a second time on every validation pass — fix: replace this block with `const nodeIdsWithResult = getResultIslandNodeIds(context);`; why: two copies of the same traversal will diverge over time and double the work on every editor change. + const nodeIds = graphNodes.map((node) => node.id); + const nodeIdsWithResult = new Set(); + + const islands = findConnectedComponents(nodeIds, graph.connections); + + 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) { + nodeIdsWithResult.add(nodeId); + } + } + + 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 Limit 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. + */ +// FIX(naming): the name says it finds 'nodes with multiple graph outputs', but having multiple outputs is only the trigger — what it actually reports are branch *targets* that never reach a Result node — fix: rename to something like findBranchesNotLeadingToResult; why: the issue is attached to connection targets, so readers grepping by the reported message will not connect it to this name. +export function findNodesWithMultipleGraphOutputs(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 connect to a Result node.", + ], + }); + } + } +} + +function canProduceGraphOutput(node: NodeProps) { + // FIX(dead-code): the three early returns are unreachable in effect — the last line already returns true only for Limit/Logical nodes, and a node can never be both Limit/Logical and LimitItem/Action/Result (guards are disjoint instanceof checks) — fix: reduce the body to `return isLimitNode(node) || isLogicalOperator(node);`; why: redundant guards suggest node categories can overlap and obscure the actual rule. + if (isLimitItemNode(node)) return false; + if (isActionNode(node)) return false; + if (isResultNode(node)) return false; + + 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..fe46498b --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateLimitItems.ts @@ -0,0 +1,54 @@ +import { isLimitItemNode } from "@/modules/evaluation/graph/editor/utils/guards"; +import { + pushIssue, + 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 Limit item", + description: [ + "Limit item nodes must be placed inside a limit node", + "Long press to enter the scope mode and move this node inside a limit node.", + ], + }); + } +} + +/** + * Finds connections that cross parent scope boundaries. + * + * Connected nodes must either share the same parent or both be root-level nodes. + */ +// FIX(naming): the name (and host file) says 'LimitItem' but the rule iterates ALL connections and fires for any cross-scope pair — there is no isLimitItemNode check — fix: either rename to findCrossScopeConnections and move it next to the other graph-wide rules in validateGlobal.ts, or actually restrict it to limit-item connections; why: the name misleads readers about the rule's scope and about where graph-wide checks live. +export function findLimitItemCrossScopeConnections(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 (sourceNode.parent === targetNode.parent) continue; + + pushIssue(nodeIssues, targetNode.id, { + level: "error", + message: "Out of bounds connection", + description: [ + "All connected Limit items must be placed inside the same parent limit 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..23271e28 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateLimits.ts @@ -0,0 +1,85 @@ +import { countConnectedComponents } from "@/modules/evaluation/graph/editor/utils/graph"; +import { isLimitNode } from "@/modules/evaluation/graph/editor/utils/guards"; +import { + pushControlIssue, + pushIssue, + 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 Limit", + description: [ + "A limit 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 Limit items inside a Limit must form a single connected component.", + "Connect all child nodes together or split them into separate Limit 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..1988e1e5 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateLogical.ts @@ -0,0 +1,31 @@ +import { isLogicalOperator } from "@/modules/evaluation/graph/editor/utils/guards"; +import { + pushIssue, + type ValidationContext, +} from "@/modules/evaluation/graph/editor/validation/types"; + +/** + * Finds logical nodes with exactly one input. + * + * A logical operator with only one input does not change the result and has no + * meaningful logical effect. + */ +export function findUselessLogicalNodes(context: ValidationContext) { + const { graph, nodeIssues } = context; + + for (const node of graph.nodes) { + if (!isLogicalOperator(node)) continue; + // FIX(bug): a logical operator with zero inputs silently passes — wired into the Result island (e.g. AND -> Result) it is flagged by no rule at all, yet it cannot evaluate to anything meaningful — fix: also report length === 0 (arguably as an error, since the node is not just useless but unevaluable); why: malformed graphs validate as clean. + if (graph.getIncoming(node.id).length != 1) continue; + + pushIssue(nodeIssues, node.id, { + level: "warning", + // FIX(consistency): 'Operator useless' inverts the word order of the sibling rule's title 'Useless Limit' (validateLimits.ts) — fix: use 'Useless operator'; why: these titles are user-facing and should follow one convention. + message: "Operator useless", + 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..21a630b0 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateResult.ts @@ -0,0 +1,26 @@ +import { isResultNode } from "@/modules/evaluation/graph/editor/utils/guards"; +import { + pushIssue, + 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 } = context; + const resultNodes = graph.nodes.filter(isResultNode); + // FIX(bug): the doc comment promises 'exactly one' Result node, but the zero-Result case silently passes — the loop below has nothing to iterate, so a graph with no Result node (e.g. an empty graph) returns valid: true from validateGraph — fix: handle resultNodes.length === 0 explicitly (requires a graph-level issue slot or forcing valid=false in the result); why: validation green-lights graphs missing their documented required terminal node. + if (resultNodes.length === 1) 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..f0068ff0 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/types.ts @@ -0,0 +1,51 @@ +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; +}; + +export type ValidationContext = { + readonly graph: ValidationGraph; + readonly nodeIssues: Map; + readonly controlIssues: ControlIssuesByNode; +}; + +// FIX(structure): pushIssue/pushControlIssue are runtime helpers exported from a file named types.ts — fix: move them into their own module (e.g. issues.ts) and keep types.ts erasable; why: every rule imports runtime code from 'types', which misleads readers and prevents type-only imports of this module. +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/validateGraph.ts b/apps/web/src/modules/evaluation/graph/editor/validation/validateGraph.ts new file mode 100644 index 00000000..2f10ea75 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/validateGraph.ts @@ -0,0 +1,61 @@ +import type { Schemes } from "@/modules/evaluation/graph/editor/types"; +import type { NodeEditor } from "rete"; +import { findActionsWithInvalidSource } from "./rules/validateActions"; +import { + findNodesOutsideResultIsland, + findNodesWithMultipleGraphOutputs, +} from "./rules/validateGlobal"; +import { + findLimitItemCrossScopeConnections, + 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 context = { graph, nodeIssues, controlIssues }; + + findEmptyLimits(context); + findLimitsWithMultipleIslands(context); + + findOrphanLimitItems(context); + findLimitItemCrossScopeConnections(context); + findLimitsWithMissingRequiredInputs(context); + + findUselessLogicalNodes(context); + + findActionsWithInvalidSource(context); + + findInvalidResultNodeCount(context); + + findNodesOutsideResultIsland(context); + findNodesWithMultipleGraphOutputs(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"), + ), + ); + + return { + valid: !hasNodeErrors && !hasControlErrors, + nodeIssues, + controlIssues, + }; +} 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..8457f619 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/validation/validationGraph.ts @@ -0,0 +1,76 @@ +// FIX(naming): file name 'validationGraph.ts' is one keystroke away from its sibling 'validateGraph.ts' and both live in the same folder — fix: rename this file after its export (ValidationGraph.ts) or something distinct like graphIndex.ts; why: the near-identical names make imports, code navigation, and reviews error-prone (this review was explicitly asked whether one is a stale copy of the other). +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) ?? []; + } + + // FIX(dead-code): getConnectionsOf and hasAnyConnection are not referenced anywhere in src — fix: delete them (or add the rules/tests that were meant to use them); why: unused API on a core class implies behavior that does not exist and still has to be maintained. + getConnectionsOf(nodeId: string) { + return [...this.getIncoming(nodeId), ...this.getOutgoing(nodeId)]; + } + + hasAnyConnection(nodeId: string) { + return this.incomingByNode.has(nodeId) || this.outgoingByNode.has(nodeId); + } + + // FIX(duplication): haveSameScope and areBothRoot are never called, while findLimitItemCrossScopeConnections re-implements exactly this check inline (sourceNode.parent === targetNode.parent) — fix: use haveSameScope in that rule or delete both helpers; why: two copies of the scope rule can silently diverge. + haveSameScope(a: NodeProps, b: NodeProps) { + return a.parent === b.parent; + } + + areBothRoot(a: NodeProps, b: NodeProps) { + return !a.parent && !b.parent; + } +} 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..f942aaae --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/workspace/testHook.ts @@ -0,0 +1,73 @@ +import { + BooleanConnection, + type BooleanOperator, +} from "@/modules/evaluation/graph/editor/connections/booleanConnection"; +import { LimitItemConnection } from "@/modules/evaluation/graph/editor/connections/limitItemConnection"; +import type { EvalLimitItemOperator } from "@/modules/evaluation/graph/editor/serialization/backendTypes"; +import type { AreaExtra } from "@/modules/evaluation/graph/editor/setup/createEditor"; +import type { + LimitItemProps, + LogicalProps, + Schemes, +} from "@/modules/evaluation/graph/editor/types"; +import type { NodeEditor } from "rete"; +import type { AreaPlugin } from "rete-area-plugin"; + +export type TestHookHandle = { + editor: NodeEditor; + area: AreaPlugin; + /** + * Programmatically create a connection - bypasses UI drag for tests that + * need a pre-built graph as a fixture. + */ + addBooleanConnection: ( + sourceId: string, + targetId: string, + operator?: BooleanOperator, + ) => Promise; + addLimitItemConnection: ( + sourceId: string, + targetId: string, + operator: EvalLimitItemOperator, + ) => 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; +}) { + if (!import.meta.env.VITE_E2E) return; + const { editor } = handle; + // FIX(structure): the hook is installed on window but never uninstalled — fix: return a cleanup that deletes window.__editor and invoke it from the editor's destroy path in Workspace; why: after unmount (or the StrictMode double-mount) the global can reference a destroyed editor, making e2e assertions read stale state. + window.__editor = { + ...handle, + addBooleanConnection: async (sourceId, targetId, operator = "TRUE") => { + // FIX(error-handling): getNode may return undefined and the cast hides it (same in addLimitItemConnection below), so a wrong fixture id fails deep inside the connection constructor with a cryptic error — fix: throw new Error(`node ${sourceId} not found`) when the lookup fails; why: e2e failures should point at the bad id, not at rete internals. + const source = editor.getNode(sourceId) as LogicalProps; + const target = editor.getNode(targetId) as LogicalProps; + await editor.addConnection( + new BooleanConnection(source, "out", target, "in", operator), + ); + }, + addLimitItemConnection: async (sourceId, targetId, operator) => { + const source = editor.getNode(sourceId) as LimitItemProps; + const target = editor.getNode(targetId) as LimitItemProps; + await editor.addConnection( + new LimitItemConnection(source, "out", target, "in", operator), + ); + }, + }; +} 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/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/packages/schema/src/eval/eval.types.ts b/packages/schema/src/eval/eval.types.ts index 9363f811..bc1baea1 100644 --- a/packages/schema/src/eval/eval.types.ts +++ b/packages/schema/src/eval/eval.types.ts @@ -7,12 +7,18 @@ import { evalLogicNodeSchema, evalTestCaseCreateOrUpdateSchema, evalTestCaseDetailSchema, - evalTestCaseSchema,evalTestCaseThresholdSchema,evalThresholdCreateOrUpdateSchema, evalThresholdSchema, evalThresholdsResponseSchema + evalTestCaseFullCreateOrUpdateSchema, + 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; @@ -26,4 +32,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: {} From 73f3fb199948b9c7979f09d5606cf81b7f0966fa Mon Sep 17 00:00:00 2001 From: andrej-sokolov Date: Thu, 18 Jun 2026 14:34:21 +0200 Subject: [PATCH 03/20] refactor: test-case source of truth --- .../modules/evaluation/api/evalSelectors.ts | 40 +++ .../modules/evaluation/api/evaluationApi.ts | 232 ++++++++++++++++++ .../components/GraphEditor/GraphEditor.tsx | 105 +++++++- .../TestCasesOverview/LimitEnabledSwitch.tsx | 41 +++- .../TestCasesOverview/TestCaseSection.tsx | 29 ++- .../__tests__/deserializeLogicNodes.test.ts | 115 ++++++++- .../deserialization/deserializeLogicNodes.ts | 127 +++++++--- .../__tests__/serializeLogicNodes.test.ts | 19 +- .../editor/serialization/schemaAdapters.ts | 40 +++ .../serialization/serializeLogicNodes.ts | 31 ++- .../graph/editor/setup/setupRender.ts | 10 + packages/schema/src/eval/eval.schema.ts | 8 + packages/schema/src/eval/eval.types.ts | 2 + 13 files changed, 709 insertions(+), 90 deletions(-) create mode 100644 apps/web/src/modules/evaluation/api/evalSelectors.ts 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..4c1d143f 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,55 @@ export const evaluationApi = api.injectEndpoints({ ], }), + // Single source of truth for a test case: the whole thing (meta + logic tree + + // limits WITH their items). Both the graph (subscribes) and the table (peeks, + // non-subscribing) read this one cache entry. + // + // The test-case detail endpoint omits per-limit `limitItems`, so as a stopgap this + // composes the test-case detail with each limit's detail (which carries its items) + // into one flat `EvalTestCaseFull` via N+1 client-side requests. + // + // FIXME(backend): replace this whole composite query with a single direct call to + // GET /eval/{projectId}/config/{configId}/test-case/{testCaseId}/full once that + // endpoint returns limits WITH their limitItems. Then this becomes a plain `query:` + // + `transformResponse: evalTestCaseFullSchema.parse` — the flat shape is unchanged. + getEvalTestCaseFull: builder.query< + EvalTestCaseFull, + { projectId: number; configId: number; testCaseId: string } + >({ + async queryFn( + { projectId, configId, testCaseId }, + _api, + _extraOptions, + baseQuery, + ) { + const base = `/eval/${projectId}/config/${configId}`; + + const detailResult = await baseQuery(`${base}/test-case/${testCaseId}`); + if (detailResult.error) return { error: detailResult.error }; + const detail = evalTestCaseDetailSchema.parse(detailResult.data); + + const limitResults = await Promise.all( + detail.limits.map((limit) => + baseQuery(`${base}/limit/${testCaseId}/${limit.id}`), + ), + ); + + const failed = limitResults.find((result) => result.error); + if (failed?.error) return { error: failed.error }; + + // limitResults follows detail.limits order, so the assembled limits stay ordered. + const limits = limitResults.map((result) => result.data); + const full = evalTestCaseFullSchema.parse({ ...detail, limits }); + + return { data: full }; + }, + 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 +162,87 @@ 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< + EvalTestCaseDetail, + { + 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) => evalTestCaseDetailSchema.parse(response), + // The table view reads limits/test-cases through different cached queries than the + // graph. The /full response already carries the updated limits (with severity), so + // patch those caches straight from it — the table reflects instantly instead of + // waiting on the invalidation refetch round-trip. The invalidation below still runs + // as a background reconcile (and covers thresholds + the graph's own query). + async onQueryStarted( + { projectId, configId, testCaseId }, + { dispatch, queryFulfilled }, + ) { + try { + const { data } = await queryFulfilled; // EvalTestCaseDetail (limits w/o items) + + // Overview list — reflect meta + limit changes in the table instantly. + dispatch( + evaluationApi.util.updateQueryData( + "getEvalTestCases", + { projectId, configId }, + (draft) => { + const index = draft.findIndex((tc) => tc.id === testCaseId); + if (index !== -1) { + const { logicNodes: _logicNodes, ...testCase } = data; + draft[index] = testCase; + } + }, + ), + ); + + // SSOT — patch meta + the limit list. The detail response carries no + // limitItems, so preserve the existing items for surviving limits; the + // invalidation below refetches the full entry to reconcile items. + // (Simplifiable once BE PUT /full returns limits with items.) + dispatch( + evaluationApi.util.updateQueryData( + "getEvalTestCaseFull", + { projectId, configId, testCaseId }, + (draft) => { + const existingItems = new Map( + draft.limits.map((limit) => [limit.id, limit.limitItems]), + ); + draft.name = data.name; + draft.type = data.type; + draft.severity = data.severity; + draft.enabled = data.enabled; + draft.logicNodes = data.logicNodes; + draft.limits = data.limits.map((limit) => ({ + ...limit, + limitItems: existingItems.get(limit.id) ?? [], + })); + }, + ), + ); + } 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 +254,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 +310,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 +359,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 +480,7 @@ export const { useGetEvalLimitQuery, useGetEvalLimitsQuery, useGetEvalTestCaseQuery, + useGetEvalTestCaseFullQuery, useGetEvalTestCasesQuery, useLazyGetEvalLimitQuery, useLazyGetEvalLimitsQuery, @@ -257,6 +488,7 @@ export const { useLazyGetEvalTestCasesQuery, useCreateEvalTestCaseMutation, useUpdateEvalTestCaseMutation, + useUpdateEvalTestCaseFullMutation, useCreateEvalLimitMutation, useUpdateEvalLimitMutation, useDeleteEvalLimitMutation, diff --git a/apps/web/src/modules/evaluation/components/GraphEditor/GraphEditor.tsx b/apps/web/src/modules/evaluation/components/GraphEditor/GraphEditor.tsx index ccfd5849..f144f4ba 100644 --- a/apps/web/src/modules/evaluation/components/GraphEditor/GraphEditor.tsx +++ b/apps/web/src/modules/evaluation/components/GraphEditor/GraphEditor.tsx @@ -1,8 +1,16 @@ 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 type { EvalTestCaseCreateOrUpdatePayload } from "@/modules/evaluation/graph/editor/serialization/backendTypes"; +import { + fromFullTestCase, + toSchemaTestCase, +} from "@/modules/evaluation/graph/editor/serialization/schemaAdapters"; import { serializeTestCase } from "@/modules/evaluation/graph/editor/serialization/serializeTestCase"; import { createEditor } from "@/modules/evaluation/graph/editor/setup/createEditor"; import type { Schemes } from "@/modules/evaluation/graph/editor/types"; @@ -19,7 +27,7 @@ import { RotateCcw, Save, } from "lucide-react"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { NodeEditor } from "rete"; import { useRete } from "rete-react-plugin"; import { toast } from "sonner"; @@ -35,9 +43,11 @@ const clamp = (value: number, min: number, max: number) => type Props = { projectId: number; + configId: number; + testCaseId: string; }; -export const GraphEditor = ({ projectId }: Props) => { +export const GraphEditor = ({ projectId, configId, testCaseId }: Props) => { const editorRef = useRef> | null>( null, ); @@ -117,6 +127,59 @@ export const GraphEditor = ({ projectId }: Props) => { const [ref] = useRete(create); + // Loads the saved test case into the editor once both the editor instance and the + // fetched data are ready. `editor` (state) flips non-null in the same tick the full + // instance lands in editorRef, so it's a safe readiness trigger. projectLabels is a + // dep so a late labels response re-runs the load and the limit nodes resolve names. + // GraphEditor mounts fresh each time the graph view opens, so refetch on mount to + // pick up anything the table view changed while the graph was hidden. + const { data: testCaseData } = useGetEvalTestCaseFullQuery( + { + projectId, + configId, + testCaseId, + }, + { refetchOnMountOrArgChange: true }, + ); + const [updateTestCaseFull, { isLoading: isSaving }] = + useUpdateEvalTestCaseFullMutation(); + + useEffect(() => { + const instance = editorRef.current; + if (!instance || !editor || !testCaseData) return; + + let cancelled = false; + + void (async () => { + try { + const payload = fromFullTestCase(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.", + ); + } + })(); + + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editor, testCaseData, projectLabels]); + const toggleValidation = async () => { const instance = editorRef.current; if (!instance) return; @@ -135,11 +198,15 @@ export const GraphEditor = ({ projectId }: Props) => { setAutoValidationEnabled(false); }; - // FIX(naming): handleSave does not save anything — it serializes, console.logs the payload and toasts "serialized successfully" — fix: rename to handleSerialize (or wire the real persistence call) and drop the console.log from the production path; why: a Save button that only logs misleads users and reviewers about what state is persisted. 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()) { @@ -152,14 +219,29 @@ export const GraphEditor = ({ projectId }: Props) => { return; } - const payload = serializeTestCase(instance.editor, { - id: "", - name: "", + // The graph owns only the flow (limits, items, logic, severity, type). name and + // enabled are owned by the table-view UI, so carry them over from the loaded data. + const serialized = serializeTestCase(instance.editor, { + id: testCaseId, + name: testCaseData.name, }); - console.log(JSON.stringify(payload, null, 2)); + const body = toSchemaTestCase({ + ...serialized, + enabled: testCaseData.enabled, + }); - toast.success("Graph serialized successfully."); + try { + await updateTestCaseFull({ + projectId, + configId, + testCaseId, + body, + }).unwrap(); + toast.success("Test case saved."); + } catch { + toast.error("Failed to save test case."); + } }; const handleArrange = async () => { @@ -239,7 +321,12 @@ export const GraphEditor = ({ projectId }: Props) => { > {autoValidationEnabled ? : } -
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 index 8efa6a04..55665a87 100644 --- a/apps/web/src/modules/evaluation/graph/editor/ui/nodes/AreaNodeView.tsx +++ b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/AreaNodeView.tsx @@ -14,14 +14,12 @@ type Props = { 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; - const { range, units, quantifier } = data.controls; - // FIX(error-handling): error message says "CountNode" but this is AreaNodeView (copy-paste from CountNodeView) — fix: "AreaNode is missing expected parts"; why: the misleading node name in the thrown error points debugging at the wrong node type. if (!input || !output || !range || !units || !quantifier) { - throw new Error(`CountNode is missing expected parts`); + throw new Error(`AreaNode is missing expected parts`); } return ( 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 index 701701b1..eea982af 100644 --- a/apps/web/src/modules/evaluation/graph/editor/ui/nodes/CountNodeView.tsx +++ b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/CountNodeView.tsx @@ -14,14 +14,12 @@ type Props = { export const CountNodeView = (props: Props) => { const { data, emit } = props; - + const { count } = data.controls; const input = data.inputs.in; const output = data.outputs.out; - const { count } = data.controls; - // FIX(naming): typo "CoundNode" in the error message (and this exact typo has been copy-pasted into ActionNodeView and LogicalNode) — fix: "CountNode is missing expected parts"; why: misspelled error text is ungreppable against the real node name when debugging. if (!input || !output || !count) { - throw new Error(`CoundNode is missing expected parts`); + throw new Error(`CountNode is missing expected parts`); } return ( 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 index 943dab87..9071dfff 100644 --- a/apps/web/src/modules/evaluation/graph/editor/ui/nodes/LimitNodeView.tsx +++ b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/LimitNodeView.tsx @@ -13,12 +13,12 @@ type Props = { export const LimitNodeView = (props: Props) => { const { data, emit } = props; - + const { name, label, parentLabel, enabled } = data.controls; const output = data.outputs.out; - if (!output) throw new Error(`LimitNode is missing an output socket`); - // FIX(consistency): the output socket is guarded above, but the name/label/parentLabel controls are passed to RefControl unchecked, while every sibling view (AreaNodeView, CountNodeView, PositionNodeView) validates its controls and throws — fix: include the three controls in the missing-parts guard (if (!output || !name || !label || !parentLabel) throw ...); why: a deserialized LimitNode with a missing control would render silently broken here instead of failing loudly like the other node views. - const { name, label, parentLabel, enabled } = data.controls; + if (!output || !name || !label || !parentLabel || !enabled) { + throw new Error(`CountNode is missing expected parts`); + } return ( { const input = data.inputs.in; const output = data.outputs.out; - // FIX(error-handling): error message says "CoundNode" (copy-pasted typo from CountNodeView) but this is the AND/OR logical node view — fix: throw new Error(`${label} node is missing expected parts`); why: a wrong node name in the error misdirects debugging of a broken graph. if (!input || !output) { - throw new Error(`CoundNode is missing expected parts`); + throw new Error(`LogicalNode is missing expected parts`); } return ( 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 index 11e96475..57c41c19 100644 --- a/apps/web/src/modules/evaluation/graph/editor/ui/nodes/PositionNodeView.tsx +++ b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/PositionNodeView.tsx @@ -14,14 +14,12 @@ type Props = { 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; - const { range, units, quantifier, position } = data.controls; - // FIX(error-handling): error message says "CountNode" but this is PositionNodeView (copy-paste from CountNodeView) — fix: "PositionNode is missing expected parts"; why: the misleading node name in the thrown error points debugging at the wrong node type. if (!input || !output || !range || !units || !quantifier || !position) { - throw new Error(`CountNode is missing expected parts`); + throw new Error(`PositionNode is missing expected parts`); } return ( 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 index 081b2567..f23182ab 100644 --- a/apps/web/src/modules/evaluation/graph/editor/ui/nodes/ResultNodeView.tsx +++ b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/ResultNodeView.tsx @@ -19,9 +19,10 @@ export function ResultNodeView(props: Props) { const input = data.inputs.in; const output = data.outputs.out; - if (!input || !output) - throw new Error(`${label} node is missing expected parts`); - + if (!input || !output) { + throw new Error(`ResultNode is missing expected parts`); + } + return ( Date: Tue, 23 Jun 2026 15:53:40 +0200 Subject: [PATCH 08/20] fixes: components --- .../graph/editor/setup/setupRender.ts | 2 +- .../{IssesTooltip.tsx => IssueTooltip.tsx} | 1 - .../containers/CompactNodeContainer.tsx | 61 +++++++++++++++++++ .../ui/components/containers/NodeBody.tsx | 16 +++++ .../ui/components/containers/NodeHeader.tsx | 36 +++++++++++ .../containers/ParentNodeContainer.tsx | 50 +++++---------- .../containers/RegularNodeContainer.tsx | 32 ++-------- .../graph/editor/ui/nodes/ActionNodeView.tsx | 32 +++------- .../graph/editor/ui/nodes/LogicalNodeView.tsx | 42 +++++-------- .../graph/editor/ui/nodes/ResultNodeView.tsx | 34 ++++------- 10 files changed, 170 insertions(+), 136 deletions(-) rename apps/web/src/modules/evaluation/graph/editor/ui/components/{IssesTooltip.tsx => IssueTooltip.tsx} (79%) create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/components/containers/CompactNodeContainer.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/components/containers/NodeBody.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/components/containers/NodeHeader.tsx diff --git a/apps/web/src/modules/evaluation/graph/editor/setup/setupRender.ts b/apps/web/src/modules/evaluation/graph/editor/setup/setupRender.ts index eb5b7c23..2d66d126 100644 --- a/apps/web/src/modules/evaluation/graph/editor/setup/setupRender.ts +++ b/apps/web/src/modules/evaluation/graph/editor/setup/setupRender.ts @@ -34,7 +34,7 @@ import { ActionNodeView } from "@/modules/evaluation/graph/editor/ui/nodes/Actio import { AreaNodeView } from "@/modules/evaluation/graph/editor/ui/nodes/AreaNodeView"; import { CountNodeView } from "@/modules/evaluation/graph/editor/ui/nodes/CountNodeView"; import { LimitNodeView } from "@/modules/evaluation/graph/editor/ui/nodes/LimitNodeView"; -import { LogicalNodeView } from "@/modules/evaluation/graph/editor/ui/nodes/LogicalNode"; +import { LogicalNodeView } from "@/modules/evaluation/graph/editor/ui/nodes/LogicalNodeView"; import { PositionNodeView } from "@/modules/evaluation/graph/editor/ui/nodes/PositionNodeView"; import { ResultNodeView } from "@/modules/evaluation/graph/editor/ui/nodes/ResultNodeView"; import { SocketView } from "@/modules/evaluation/graph/editor/ui/sockets/SocketView"; diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/components/IssesTooltip.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/components/IssueTooltip.tsx similarity index 79% rename from apps/web/src/modules/evaluation/graph/editor/ui/components/IssesTooltip.tsx rename to apps/web/src/modules/evaluation/graph/editor/ui/components/IssueTooltip.tsx index 49b9535b..3b3dc4e2 100644 --- a/apps/web/src/modules/evaluation/graph/editor/ui/components/IssesTooltip.tsx +++ b/apps/web/src/modules/evaluation/graph/editor/ui/components/IssueTooltip.tsx @@ -1,4 +1,3 @@ -// FIX(naming): filename "IssesTooltip.tsx" is a typo and does not match the exported component IssueTooltip — fix: rename the file to IssueTooltip.tsx and update the 6 import sites; why: the misspelled path is copy-pasted into every consumer, hurts discoverability (searching "IssueTooltip" misses the file), and breaks the file/export naming convention. import type { ValidationIssue, ValidationIssueLevel, 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/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 index 9a981990..4d1b5958 100644 --- 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 @@ -1,16 +1,14 @@ import { GRID } from "@/modules/evaluation/graph/editor/constants"; -import type { LimitNode } from "@/modules/evaluation/graph/editor/nodes/limit/limit"; +import type { NodeProps } from "@/modules/evaluation/graph/editor/types"; import { HatchedBox } from "@/modules/evaluation/graph/editor/ui/components/HatchedBox"; -import { IssueTooltip } from "@/modules/evaluation/graph/editor/ui/components/IssesTooltip"; -import { Label } from "@/modules/evaluation/graph/editor/ui/components/Label"; import type { ReactNode } from "react"; +import { NodeBody } from "./NodeBody"; import { NodeContainer } from "./NodeContainer"; +import { NodeHeader } from "./NodeHeader"; type Props = { - // FIX(structure): a generic layout container is coupled to the concrete LimitNode class while its sibling RegularNodeContainer accepts the generic NodeProps — fix: accept NodeProps (the only fields used are shared layout props) like RegularNodeContainer does; why: the dependency on a specific node class inverts the layering (shared component -> feature node) and blocks reuse for any future parent-style node. - data: LimitNode; + data: NodeProps; outputSocket?: ReactNode; - /** Rendered on the right of the title row (e.g. the enable switch). */ headerRight?: ReactNode; children: ReactNode; }; @@ -31,38 +29,18 @@ export const ParentNodeContainer = (props: Props) => { } = data; return ( - - {/* FIX(duplication): this entire header block (label row + warning/error IssueTooltips + controls section) is copy-pasted verbatim from RegularNodeContainer — fix: extract a shared NodeHeader/NodeBody component used by both containers; why: the two copies have already started drifting in whitespace and any future header change (e.g. a new tooltip level) must be made twice. */} +
-
- - -
-
- {headerRight} -
- - -
-
- -
-
- {children} -
+ + {children}
- + + 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 index 9bcda091..dcaceee3 100644 --- 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 @@ -1,9 +1,9 @@ import { GRID } from "@/modules/evaluation/graph/editor/constants"; import type { NodeProps } from "@/modules/evaluation/graph/editor/types"; -import { IssueTooltip } from "@/modules/evaluation/graph/editor/ui/components/IssesTooltip"; -import { Label } from "@/modules/evaluation/graph/editor/ui/components/Label"; import type { ReactNode } from "react"; +import { NodeBody } from "./NodeBody"; import { NodeContainer } from "./NodeContainer"; +import { NodeHeader } from "./NodeHeader"; type Props = { data: NodeProps; @@ -28,32 +28,10 @@ export const RegularNodeContainer = (props: Props) => { } = data; return ( - +
-
- - -
-
- - -
- -
-
- {children} -
+ + {children}
{ } return ( - - {/* FIX(duplication): this compact-node body (issue-tooltip row + socket row sized by socketHeight * GRID) is copy-pasted across ActionNodeView, LogicalNodeView and ResultNodeView — fix: extract a shared CompactNodeContainer (sibling to RegularNodeContainer/ParentNodeContainer) taking input/output/center slots; why: three drifting copies of the same layout already differ only in whitespace and the center element, and layout fixes must be applied three times. */} -
-
- - -
-
- - -
-
-
+ issues={issues} + socketHeight={data.socketHeight} + spread={false} + input={} + center={} + /> ); }; 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 index 8be48311..56dd65d5 100644 --- a/apps/web/src/modules/evaluation/graph/editor/ui/nodes/LogicalNodeView.tsx +++ b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/LogicalNodeView.tsx @@ -1,10 +1,8 @@ -import { GRID } from "@/modules/evaluation/graph/editor/constants"; +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 { NodeContainer } from "@/modules/evaluation/graph/editor/ui/components/containers/NodeContainer"; -import { AndGateIcon, OrGateIcon } from "@/components/icons"; -import { IssueTooltip } from "@/modules/evaluation/graph/editor/ui/components/IssesTooltip"; +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"; @@ -25,29 +23,21 @@ export const LogicalNodeView = (props: Props) => { } return ( - -
-
- - -
-
- - - {label === "AND" && } - {label === "OR" && } - - -
-
-
+ issues={issues} + socketHeight={data.socketHeight} + input={} + center={ + + {label === "AND" && } + {label === "OR" && } + + } + output={} + /> ); }; 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 index f23182ab..31b568fd 100644 --- a/apps/web/src/modules/evaluation/graph/editor/ui/nodes/ResultNodeView.tsx +++ b/apps/web/src/modules/evaluation/graph/editor/ui/nodes/ResultNodeView.tsx @@ -1,8 +1,6 @@ -import { GRID } from "@/modules/evaluation/graph/editor/constants"; import type { ResultNode } from "@/modules/evaluation/graph/editor/nodes/result/result"; import type { Schemes } from "@/modules/evaluation/graph/editor/types"; -import { NodeContainer } from "@/modules/evaluation/graph/editor/ui/components/containers/NodeContainer"; -import { IssueTooltip } from "@/modules/evaluation/graph/editor/ui/components/IssesTooltip"; +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"; @@ -22,28 +20,18 @@ export function ResultNodeView(props: Props) { if (!input || !output) { throw new Error(`ResultNode is missing expected parts`); } - + return ( - -
-
- - -
-
- - - -
-
-
+ issues={issues} + socketHeight={data.socketHeight} + input={} + center={} + output={} + /> ); } From 9855b212530fd080a3167c3f00c103a61fb43c3f Mon Sep 17 00:00:00 2001 From: andrej-sokolov Date: Tue, 23 Jun 2026 19:12:05 +0200 Subject: [PATCH 09/20] fix: connections --- apps/web/src/global.css | 8 ++++ .../graph/editor/setup/setupRender.ts | 18 ++++++--- .../ui/components/BooleanConnectionToggle.tsx | 36 ++++++++++++++++++ .../editor/ui/components/ConnectionToggle.tsx | 38 ------------------- .../ui/connections/BooleanConnectionView.tsx | 1 + .../ui/connections/CustomConnectionView.tsx | 34 ++--------------- .../ui/connections/ToggleConnectionView.tsx | 12 +++--- .../editor/ui/connections/connectionStyles.ts | 10 +++++ .../editor/ui/controls/EnabledControlView.tsx | 1 - 9 files changed, 76 insertions(+), 82 deletions(-) create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/components/BooleanConnectionToggle.tsx delete mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/components/ConnectionToggle.tsx create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/connections/connectionStyles.ts 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/evaluation/graph/editor/setup/setupRender.ts b/apps/web/src/modules/evaluation/graph/editor/setup/setupRender.ts index 2d66d126..23fc5f84 100644 --- a/apps/web/src/modules/evaluation/graph/editor/setup/setupRender.ts +++ b/apps/web/src/modules/evaluation/graph/editor/setup/setupRender.ts @@ -66,6 +66,16 @@ export function setupRender(props: Props): ReactPlugin { const render = new ReactPlugin({ createRoot }); + // Build each configured connection view once. The customize.connection callback + // below runs on every render; creating a fresh view there would give it a new + // component identity each time, remounting the connection (losing the toggle's + // position and restarting the dash animation). + const refreshConnection = (id: string) => { + void area.update("connection", id); + }; + const limitItemConnectionView = createLimitItemConnectionView(refreshConnection); + const booleanConnectionView = createBooleanConnectionView(refreshConnection); + render.addPreset( Presets.classic.setup({ customize: { @@ -122,9 +132,7 @@ export function setupRender(props: Props): ReactPlugin { sourceSocket instanceof RuleSocket && targetSocket instanceof RuleSocket ) { - return createLimitItemConnectionView((id) => { - void area.update("connection", id); - }); + return limitItemConnectionView; } if ( @@ -144,9 +152,7 @@ export function setupRender(props: Props): ReactPlugin { return CustomConnectionView; } - return createBooleanConnectionView((id) => { - void area.update("connection", id); - }); + return booleanConnectionView; } return CustomConnectionView; diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/components/BooleanConnectionToggle.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/components/BooleanConnectionToggle.tsx new file mode 100644 index 00000000..8dd4ed6f --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/components/BooleanConnectionToggle.tsx @@ -0,0 +1,36 @@ +import { Button } from "@/modules/shadcn/ui/button"; + +type Props = { + x: number; + y: number; + label: string; + onToggle: () => void; +}; + +export function BooleanConnectionToggle(props: Props) { + const { x, y, label, onToggle } = props; + + return ( +
+ +
+ ); +} diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/components/ConnectionToggle.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/components/ConnectionToggle.tsx deleted file mode 100644 index 949673ed..00000000 --- a/apps/web/src/modules/evaluation/graph/editor/ui/components/ConnectionToggle.tsx +++ /dev/null @@ -1,38 +0,0 @@ -// FIX(naming): file is named ConnectionToggle.tsx but exports BooleanConnectionToggle, and the component is not boolean-specific — it renders any two-state label toggle (also used for AND/OR limit-item connections via ToggleConnectionView) — fix: rename the export to ConnectionToggle to match the filename and its generic usage; why: the "Boolean" prefix misleads readers about where this component applies and breaks file/export symmetry used elsewhere in this folder. -import { Button } from "@/modules/shadcn/ui/button"; - -type Props = { - x: number; - y: number; - label: string; - onToggle: () => void; -}; - -export function BooleanConnectionToggle(props: Props) { - const { x, y, label, onToggle } = props; - - // FIX(duplication): the centering offsets (x - 24, y - 12) hardcode half of the h-6/w-12 dimensions that are themselves duplicated in both the wrapper div and the Button className — fix: define the size once (e.g. const W = 48, H = 24, or -translate-x-1/2 -translate-y-1/2 instead of manual offsets) and derive everything from it; why: resizing the toggle requires editing four places that must stay in sync or the button drifts off the connection midpoint. - 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 index 0cc4e240..3b7d0163 100644 --- a/apps/web/src/modules/evaluation/graph/editor/ui/connections/BooleanConnectionView.tsx +++ b/apps/web/src/modules/evaluation/graph/editor/ui/connections/BooleanConnectionView.tsx @@ -15,5 +15,6 @@ export function createBooleanConnectionView( 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 index ea15aa43..f1ed9e8f 100644 --- a/apps/web/src/modules/evaluation/graph/editor/ui/connections/CustomConnectionView.tsx +++ b/apps/web/src/modules/evaluation/graph/editor/ui/connections/CustomConnectionView.tsx @@ -1,42 +1,16 @@ import { type ClassicScheme, Presets } from "rete-react-plugin"; -import styled from "styled-components"; +import { CONNECTION_PATH_CLASS, CONNECTION_SVG_CLASS } from "./connectionStyles"; const { useConnection } = Presets.classic; -// FIX(consistency): this file styles the identical svg/path connection markup with styled-components while sibling ToggleConnectionView.tsx uses Tailwind utility classes for the same styles (same 9999px canvas, stroke, dasharray, animation) — fix: pick one approach (Tailwind, to match the rest of src/editor/ui) and share the path styling between the two views; why: the duplicated styles in two styling systems will drift (ToggleConnectionView already adds an animation-delay this one lacks) and doubles maintenance for any stroke/animation tweak. -const Svg = styled.svg` - overflow: visible !important; - position: absolute; - pointer-events: none; - width: 9999px; - height: 9999px; -`; - -// FIX(structure): the `@keyframes dash` declared inside this styled-component is emitted globally (unhashed) and is the ONLY definition of `dash` in the app, yet ToggleConnectionView's Tailwind class [animation:dash_1s_linear_infinite] silently depends on it — fix: move the dash keyframes into the global stylesheet (or Tailwind theme) instead of a component-scoped style block; why: the keyframes are only injected once a CustomConnectionView has mounted, so toggle connections on a canvas with no plain connection get no animation (hidden load-order coupling). -const Path = styled.path` - fill: none; - stroke-width: 5px; - stroke: var(--color-zinc-400); - pointer-events: auto; - stroke-dasharray: 10 5; - animation: dash 1s linear infinite; - stroke-dashoffset: 45; - @keyframes dash { - to { - stroke-dashoffset: 0; - } - } -`; - -// FIX(config): `npm run lint` fails on this line at HEAD — the `_` prefix signals an intentionally-unused param but the eslint config has no `argsIgnorePattern` — fix: add `'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }]` to eslint.config.js (or drop the param; rete's customize API doesn't require declaring it); why: a red lint baseline trains everyone to ignore lint failures. 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/ToggleConnectionView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/connections/ToggleConnectionView.tsx index 0c2912e1..e81f64b3 100644 --- a/apps/web/src/modules/evaluation/graph/editor/ui/connections/ToggleConnectionView.tsx +++ b/apps/web/src/modules/evaluation/graph/editor/ui/connections/ToggleConnectionView.tsx @@ -1,7 +1,8 @@ -import { BooleanConnectionToggle } from "@/modules/evaluation/graph/editor/ui/components/ConnectionToggle"; +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; @@ -33,13 +34,11 @@ export function createToggleConnectionView< setValue, getLabel, }: Props) { - // FIX(perf): every call to this factory returns a brand-new component identity, and setupRender.ts invokes createBoolean/LimitItemConnectionView inside the per-connection `customize.connection` callback — fix: create each configured view once at module/setup scope (or memoize by options) and return the cached component; why: a new component type per render decision makes React unmount/remount the connection subtree on every area update, losing the `mid` state and restarting the dash animation in a hot canvas path. return function ToggleConnectionView(props: { data: C }) { const { path } = useConnection(); const pathRef = useRef(null); const [mid, setMid] = useState<{ x: number; y: number } | null>(null); - // FIX(react): animationDelay is recomputed from performance.now() on every render, so each re-render (e.g. the setMid layout effect, drags, toggles) writes a new animation-delay and makes the dash animation jump phase; it is also a render-time side-input that breaks render purity — fix: capture it once per mount, e.g. const [animationDelay] = useState(() => `-${performance.now() % 1000}ms`); why: connections re-render constantly while dragging nodes, producing visible animation stutter. - const animationDelay = `-${performance.now() % 1000}ms`; + const [animationDelay] = useState(() => `-${performance.now() % 1000}ms`); useLayoutEffect(() => { const el = pathRef.current; @@ -67,13 +66,12 @@ export function createToggleConnectionView< return ( <> - - {/* FIX(bug): the [animation:dash_1s_linear_infinite] utility references the `dash` keyframes that are only defined inside CustomConnectionView's styled-components block, which is injected only after a CustomConnectionView mounts — fix: define `@keyframes dash` in global CSS so this view does not depend on a sibling component mounting first; why: a graph containing only toggle connections renders them without the marching-ants animation. */} + 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..5895d064 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/ui/connections/connectionStyles.ts @@ -0,0 +1,10 @@ +// 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]"; 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 index 597f8a0b..bb297164 100644 --- a/apps/web/src/modules/evaluation/graph/editor/ui/controls/EnabledControlView.tsx +++ b/apps/web/src/modules/evaluation/graph/editor/ui/controls/EnabledControlView.tsx @@ -10,7 +10,6 @@ export const EnabledControlView = ({ data }: Props) => { const value = useSyncExternalStore(data.subscribe, data.getSnapshot); return ( - // Stop propagation so toggling doesn't start a node drag / selection.
e.stopPropagation()}> Date: Tue, 23 Jun 2026 20:45:29 +0200 Subject: [PATCH 10/20] fix: test integration --- apps/web/e2e/clipboard.spec.ts | 30 ++--- apps/web/e2e/connections.spec.ts | 37 +++--- apps/web/e2e/context-menu.spec.ts | 2 +- apps/web/e2e/creation.spec.ts | 13 +- apps/web/e2e/deletion.spec.ts | 41 ++---- apps/web/e2e/helpers.ts | 125 +++++++++++++++++- apps/web/e2e/history.spec.ts | 37 ++---- apps/web/e2e/movement.spec.ts | 76 +++-------- apps/web/e2e/scopes.spec.ts | 30 +---- apps/web/e2e/selection.spec.ts | 57 ++------ apps/web/e2e/viewport.spec.ts | 8 +- apps/web/package.json | 4 +- apps/web/playwright.config.ts | 2 +- apps/web/src/app/router/index.tsx | 7 +- .../ui/controls/IntegerRangeControlView.tsx | 3 +- .../graph/workspace/E2EEditorPage.tsx | 33 +++++ apps/web/tsconfig.json | 2 +- 17 files changed, 260 insertions(+), 247 deletions(-) create mode 100644 apps/web/src/modules/evaluation/graph/workspace/E2EEditorPage.tsx diff --git a/apps/web/e2e/clipboard.spec.ts b/apps/web/e2e/clipboard.spec.ts index 121e3c3d..91a5d053 100644 --- a/apps/web/e2e/clipboard.spec.ts +++ b/apps/web/e2e/clipboard.spec.ts @@ -2,11 +2,11 @@ import { expect, test } from "@playwright/test"; import { addNodeAt, canvasCentre, + createLimitWithChildren, getConnections, getNodes, gotoEditor, nodeById, - pressShortcutAt, } from "./helpers"; test.describe("Copy, paste & cut", () => { @@ -15,7 +15,7 @@ test.describe("Copy, paste & cut", () => { }); test("copy + paste duplicates the selected node", async ({ page }) => { - const id = await addNodeAt(page, "q", 0, 0); + const id = await addNodeAt(page, "a", 0, 0); await nodeById(page, id).click(); await page.keyboard.press("ControlOrMeta+c"); @@ -31,7 +31,7 @@ test.describe("Copy, paste & cut", () => { test("cut removes the original and clipboard still pastes", async ({ page, }) => { - const id = await addNodeAt(page, "q", 0, 0); + const id = await addNodeAt(page, "a", 0, 0); await nodeById(page, id).click(); await page.keyboard.press("ControlOrMeta+x"); @@ -52,8 +52,8 @@ test.describe("Copy, paste & cut", () => { test("paste preserves internal connections between copied nodes", async ({ page, }) => { - const a = await addNodeAt(page, "q", -250, 0); - const b = await addNodeAt(page, "q", 250, 0); + 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), @@ -77,23 +77,13 @@ test.describe("Copy, paste & cut", () => { }); test("paste preserves parent-child relationships", async ({ page }) => { - const centre = await canvasCentre(page); - await pressShortcutAt(page, "l", { x: centre.x - 200, y: centre.y }); - await pressShortcutAt(page, "1", { x: centre.x + 100, y: centre.y }); - await pressShortcutAt(page, "1", { x: centre.x + 200, y: centre.y + 100 }); - const limit = (await getNodes(page)).find((n) => n.label === "Limit")!; - // FIX(duplication): this "create Limit + Count children, then assign n.parent via page.evaluate" fixture block is copy-pasted in deletion.spec.ts (x2), history.spec.ts, movement.spec.ts and selection.spec.ts — fix: add a createLimitWithChildren(page, childCount) helper to helpers.ts (ideally backed by a setParent method on the window.__editor test hook); why: six near-identical copies will drift, and direct n.parent mutation bypassing the scopes plugin is a fragile detail that should live in one place. - await page.evaluate((parentId) => { - const { editor } = window.__editor!; - for (const n of editor.getNodes()) { - if (n.id !== parentId && n.label === "Count") n.parent = parentId; - } - }, limit.id); - - // selects both children as parent selection selects its children. - await nodeById(page, limit.id).click({ position: { x: 5, y: 5 } }); + 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"); diff --git a/apps/web/e2e/connections.spec.ts b/apps/web/e2e/connections.spec.ts index d07c8c6a..ce9eacc5 100644 --- a/apps/web/e2e/connections.spec.ts +++ b/apps/web/e2e/connections.spec.ts @@ -1,5 +1,12 @@ import { expect, test, type Locator, type Page } from "@playwright/test"; -import { addNodeAt, getConnections, gotoEditor } from "./helpers"; +import { + addNodeAt, + getConnections, + gotoEditor, + inSocket, + outSocket, + settle, +} from "./helpers"; async function dragSocketToSocket( page: Page, @@ -15,21 +22,10 @@ async function dragSocketToSocket( await page.mouse.move(from.x, from.y); await page.mouse.down(); - // The connection plugin redraws on pointermove; multiple steps make it - // pick up the trail and detect the drop target reliably. await page.mouse.move(to.x, to.y, { steps: 12 }); await page.mouse.up(); } -// FIX(consistency): raw attribute selector for a data-testid — fix: use page.getByTestId(`socket-output-${id}`) like helpers.ts/gotoEditor does; why: getByTestId is the project-wide convention and respects a configured testIdAttribute. -function outSocket(page: Page, id: string) { - return page.locator(`[data-testid="socket-output-${id}"]`); -} - -function inSocket(page: Page, id: string) { - return page.locator(`[data-testid="socket-input-${id}"]`); -} - test.describe("Connections", () => { test.beforeEach(async ({ page }) => { await gotoEditor(page); @@ -38,8 +34,8 @@ test.describe("Connections", () => { test("dragging output to input creates a boolean connection", async ({ page, }) => { - const a = await addNodeAt(page, "q", -250, 0); - const b = await addNodeAt(page, "q", 250, 0); + 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)); @@ -64,7 +60,7 @@ test.describe("Connections", () => { }); test("mixed socket types are rejected", async ({ page }) => { - const andNode = await addNodeAt(page, "q", -250, 0); + const andNode = await addNodeAt(page, "a", -250, 0); const count = await addNodeAt(page, "1", 250, 0); await dragSocketToSocket( @@ -73,14 +69,14 @@ test.describe("Connections", () => { inSocket(page, count), ); - // FIX(flakiness): negative assertion taken immediately after mouse.up — connection creation is async, so this can false-pass before a (wrongly created) connection lands; same pattern in the empty-canvas test below — fix: await expect.poll(() => getConnections(page), ...) over a short window, or first prove the timing with a positive control in the same flow; why: a regression that starts accepting mixed sockets would likely still go green here. + 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, "q", -250, 0); + const a = await addNodeAt(page, "a", -250, 0); const sourceBox = await outSocket(page, a).boundingBox(); if (!sourceBox) throw new Error("socket missing"); @@ -92,12 +88,13 @@ test.describe("Connections", () => { 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("toggling a boolean connection flips TRUE to NOT", async ({ page }) => { - const a = await addNodeAt(page, "q", -250, 0); - const b = await addNodeAt(page, "q", 250, 0); + 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(() => { @@ -108,7 +105,7 @@ test.describe("Connections", () => { }); expect(beforeOp).toBe("TRUE"); - await page.getByRole("button", { name: /^TRUE$/ }).click(); + await page.getByRole("button", { name: /^PASS$/ }).click(); const afterOp = await page.evaluate(() => { const { editor } = window.__editor!; diff --git a/apps/web/e2e/context-menu.spec.ts b/apps/web/e2e/context-menu.spec.ts index 669b536b..68f5af0e 100644 --- a/apps/web/e2e/context-menu.spec.ts +++ b/apps/web/e2e/context-menu.spec.ts @@ -24,7 +24,7 @@ test.describe("Context menu", () => { test("right clicking a node shows the Delete & Clone items", async ({ page, }) => { - const id = await addNodeAt(page, "q", 0, 0); + const id = await addNodeAt(page, "a", 0, 0); await nodeById(page, id).click({ button: "right", diff --git a/apps/web/e2e/creation.spec.ts b/apps/web/e2e/creation.spec.ts index 4ec7da80..3b5dd94a 100644 --- a/apps/web/e2e/creation.spec.ts +++ b/apps/web/e2e/creation.spec.ts @@ -15,8 +15,8 @@ test.describe("Node creation", () => { test.describe("keyboard shortcuts", () => { const cases: { key: string; label: string }[] = [ - { key: "q", label: "AND" }, - { key: "w", label: "OR" }, + { key: "a", label: "AND" }, + { key: "o", label: "OR" }, { key: "l", label: "Limit" }, { key: "r", label: "Result" }, { key: "1", label: "Count" }, @@ -35,16 +35,15 @@ test.describe("Node creation", () => { }); test("new node is auto selected", async ({ page }) => { - await pressShortcutAt(page, "q"); + 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, "q"); - // FIX(naming): `first` holds the canvas centre point, not the first node — fix: rename to `centre` as the other specs do; why: the name suggests a node snapshot and misreads on review. - const first = await canvasCentre(page); - await pressShortcutAt(page, "w", { x: first.x + 200, y: first.y }); + 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"]); diff --git a/apps/web/e2e/deletion.spec.ts b/apps/web/e2e/deletion.spec.ts index 341acd11..f2eaa3ad 100644 --- a/apps/web/e2e/deletion.spec.ts +++ b/apps/web/e2e/deletion.spec.ts @@ -1,12 +1,11 @@ import { expect, test } from "@playwright/test"; import { addNodeAt, - canvasCentre, + createLimitWithChildren, getConnections, getNodes, gotoEditor, nodeById, - pressShortcutAt, } from "./helpers"; test.describe("Deletion", () => { @@ -15,7 +14,7 @@ test.describe("Deletion", () => { }); test("Backspace deletes the selected leaf node", async ({ page }) => { - const id = await addNodeAt(page, "q", 0, 0); + 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 @@ -27,40 +26,18 @@ test.describe("Deletion", () => { test("Backspace on a LimitNode cascades to its children", async ({ page, }) => { - const centre = await canvasCentre(page); - await pressShortcutAt(page, "l", { x: centre.x - 200, y: centre.y }); - await pressShortcutAt(page, "1", { x: centre.x + 100, y: centre.y }); - await pressShortcutAt(page, "1", { x: centre.x + 200, y: centre.y + 100 }); + const { limitId } = await createLimitWithChildren(page, 2); - const limit = (await getNodes(page)).find((n) => n.label === "Limit")!; - - // FIX(duplication): "Limit + Count children + parent assignment via page.evaluate" fixture is duplicated twice in this file and again in clipboard/history/movement/selection specs — fix: extract createLimitWithChildren(page, childCount) into helpers.ts; why: six copies of a non-trivial fixture will drift independently when the parenting mechanism changes. - await page.evaluate((parentId) => { - const { editor } = window.__editor!; - for (const n of editor.getNodes()) { - if (n.id !== parentId && n.label === "Count") n.parent = parentId; - } - }, limit.id); - - await nodeById(page, limit.id).click({ position: { x: 5, y: 5 } }); + 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 centre = await canvasCentre(page); - await pressShortcutAt(page, "l", { x: centre.x - 200, y: centre.y }); - await pressShortcutAt(page, "1", { x: centre.x + 100, y: centre.y }); - const limit = (await getNodes(page)).find((n) => n.label === "Limit")!; - await page.evaluate((parentId) => { - const { editor } = window.__editor!; - for (const n of editor.getNodes()) { - if (n.id !== parentId && n.label === "Count") n.parent = parentId; - } - }, limit.id); + const { limitId } = await createLimitWithChildren(page, 1); - await nodeById(page, limit.id).click({ + await nodeById(page, limitId).click({ button: "right", position: { x: 5, y: 5 }, }); @@ -75,9 +52,9 @@ test.describe("Deletion", () => { test("deleting a middle node also removes both connections", async ({ page, }) => { - const a = await addNodeAt(page, "q", -300, 0); - const b = await addNodeAt(page, "q", 0, 0); - const c = await addNodeAt(page, "q", 300, 0); + 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 }) => { diff --git a/apps/web/e2e/helpers.ts b/apps/web/e2e/helpers.ts index 45ffd0ab..43a1145a 100644 --- a/apps/web/e2e/helpers.ts +++ b/apps/web/e2e/helpers.ts @@ -1,3 +1,4 @@ +import { GRID } from "@/modules/evaluation/graph/editor/constants"; import type { Locator, Page } from "@playwright/test"; import { expect } from "@playwright/test"; @@ -83,6 +84,15 @@ 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; @@ -107,9 +117,25 @@ export async function getConnections( }); } +/** + * 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. Returns the newly created node id. + * 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, @@ -117,12 +143,97 @@ export async function addNodeAt( 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 }); - // FIX(flakiness): getNodes() runs immediately after the keypress, but node creation in the app is async (editor.addNode returns a promise) — fix: capture the node count before the keypress and page.waitForFunction until window.__editor.editor.getNodes().length increases; why: a race here makes every spec that builds fixtures through addNodeAt intermittently fail. - const nodes = await getNodes(page); - // FIX(flakiness): nodes.at(-1) assumes editor.getNodes() returns nodes in creation order — fix: diff the set of ids before/after the keypress and return the new id; why: the rete editor API does not guarantee ordering, so a different insertion strategy would silently return the wrong node. - const latest = nodes.at(-1); - if (!latest) throw new Error("node was not created"); - return latest.id; + 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 index 10f6e2b2..9915a9ff 100644 --- a/apps/web/e2e/history.spec.ts +++ b/apps/web/e2e/history.spec.ts @@ -1,13 +1,13 @@ import { expect, test } from "@playwright/test"; import { addNodeAt, - canvasCentre, + createLimitWithChildren, getConnections, getNodes, gotoEditor, nodeById, - pressShortcutAt, } from "./helpers"; +import type { EvalLimitItemOperatorEnum } from "@repo/schema"; test.describe("Undo & redo", () => { test.beforeEach(async ({ page }) => { @@ -15,7 +15,7 @@ test.describe("Undo & redo", () => { }); test("Cmd/Ctrl+Z undoes a node creation", async ({ page }) => { - await addNodeAt(page, "q", 0, 0); + await addNodeAt(page, "a", 0, 0); expect(await getNodes(page)).toHaveLength(1); await page.keyboard.press("ControlOrMeta+z"); @@ -23,7 +23,7 @@ test.describe("Undo & redo", () => { }); test("Cmd/Ctrl+Shift+Z redoes", async ({ page }) => { - await addNodeAt(page, "q", 0, 0); + await addNodeAt(page, "a", 0, 0); await page.keyboard.press("ControlOrMeta+z"); expect(await getNodes(page)).toHaveLength(0); @@ -34,31 +34,20 @@ test.describe("Undo & redo", () => { test("undo restores a cascade-delete (Limit + children + connections)", async ({ page, }) => { - const centre = await canvasCentre(page); - await pressShortcutAt(page, "l", { x: centre.x - 200, y: centre.y }); - await pressShortcutAt(page, "1", { x: centre.x + 100, y: centre.y }); - await pressShortcutAt(page, "1", { x: centre.x + 200, y: centre.y + 100 }); + const { limitId, childIds } = await createLimitWithChildren(page, 2); - const limit = (await getNodes(page)).find((n) => n.label === "Limit")!; - // FIX(duplication): same "Limit + Count children + parent assignment" fixture copy-pasted from clipboard/deletion/movement/selection specs — fix: extract createLimitWithChildren(page, childCount) into helpers.ts; why: shared fixtures belong in helpers so one change updates all specs. - await page.evaluate((parentId) => { - const { editor } = window.__editor!; - for (const n of editor.getNodes()) { - if (n.id !== parentId && n.label === "Count") n.parent = parentId; - } - }, limit.id); - - const children = (await getNodes(page)) - .filter((n) => n.parent === limit.id) - .map((n) => n.id) as [string, string]; await page.evaluate( ({ aId, bId }) => - window.__editor!.addLimitItemConnection(aId, bId, "AND"), - { aId: children[0], bId: children[1] }, + window.__editor!.addLimitItemConnection( + aId, + bId, + "AND" as EvalLimitItemOperatorEnum, + ), + { aId: childIds[0], bId: childIds[1] }, ); expect(await getConnections(page)).toHaveLength(1); - await nodeById(page, limit.id).click({ position: { x: 5, y: 5 } }); + 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); @@ -66,7 +55,7 @@ test.describe("Undo & redo", () => { await page.keyboard.press("ControlOrMeta+z"); const restored = await getNodes(page); expect(restored).toHaveLength(3); - expect(restored.filter((n) => n.parent === limit.id)).toHaveLength(2); + 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 index 20b7f6a5..5c0ff486 100644 --- a/apps/web/e2e/movement.spec.ts +++ b/apps/web/e2e/movement.spec.ts @@ -2,31 +2,14 @@ import { GRID } from "@/modules/evaluation/graph/editor/constants"; import { expect, test } from "@playwright/test"; import { addNodeAt, - canvasCentre, + createLimitWithChildren, + dragNode, + expectMovedBy, getNodes, gotoEditor, nodeById, - pressShortcutAt, } from "./helpers"; -// FIX(structure): generic node-drag gesture defined locally (with an inline import('@playwright/test').Page type instead of a top-level type import like connections.spec.ts uses) — fix: move dragNode into helpers.ts; why: scopes.spec.ts re-implements the same press-move-release sequence by hand, and gesture details (grab offset to dodge socket hit zones) should live in one place. -async function dragNode( - page: import("@playwright/test").Page, - id: string, - dx: number, - dy: number, -) { - const handle = nodeById(page, id); - const box = await handle.boundingBox(); - if (!box) throw new Error(`node ${id} not visible`); - // Grab near the top-left to avoid socket hit-zones, which are placed along the bottom edge. - 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(); -} - test.describe("Node movement", () => { test.beforeEach(async ({ page }) => { await gotoEditor(page); @@ -35,22 +18,18 @@ test.describe("Node movement", () => { test("dragging a node moves it by approximately the drag delta", async ({ page, }) => { - const id = await addNodeAt(page, "q", 0, 0); + 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)!; - // FIX(duplication): this four-line "delta within ±GRID of the drag distance" tolerance block is repeated three times in this file (here, child-follow test, multi-select test) — fix: extract an expectMovedBy(before, after, dx, dy, tolerance) assertion helper; why: twelve hand-written bounds invite copy-paste mistakes and obscure the intent (snap tolerance = GRID). - expect(after.position.x - before.position.x).toBeGreaterThanOrEqual(40); - expect(after.position.x - before.position.x).toBeLessThanOrEqual(80); - expect(after.position.y - before.position.y).toBeGreaterThanOrEqual(20); - expect(after.position.y - before.position.y).toBeLessThanOrEqual(60); + expectMovedBy(before, after, 60, 40); }); test("node positions remain grid aligned after drag", async ({ page }) => { - const id = await addNodeAt(page, "q", 0, 0); - await dragNode(page, id, 73, 47); // GRID size is 20, intentionally not alligned + 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); @@ -58,63 +37,40 @@ test.describe("Node movement", () => { }); test("dragging a LimitNode moves its children with it", async ({ page }) => { - const centre = await canvasCentre(page); - await pressShortcutAt(page, "l", { x: centre.x - 200, y: centre.y }); - await pressShortcutAt(page, "1", { x: centre.x + 100, y: centre.y }); - await pressShortcutAt(page, "1", { x: centre.x + 200, y: centre.y + 100 }); - - const limit = (await getNodes(page)).find((n) => n.label === "Limit")!; - - // FIX(duplication): same "Limit + Count children + parent assignment" fixture copy-pasted from clipboard/deletion/history/selection specs — fix: extract createLimitWithChildren(page, childCount) into helpers.ts; why: shared fixtures belong in helpers so one change updates all specs. - await page.evaluate((parentId) => { - const { editor } = window.__editor!; - for (const n of editor.getNodes()) { - if (n.id !== parentId && n.label === "Count") n.parent = parentId; - } - }, limit.id); + const { limitId, childIds } = await createLimitWithChildren(page, 2); - await nodeById(page, limit.id).click({ position: { x: 5, y: 5 } }); + await nodeById(page, limitId).click({ position: { x: 5, y: 5 } }); const before = await getNodes(page); - const childIds = before - .filter((n) => n.parent === limit.id) - .map((n) => n.id); expect(childIds).toHaveLength(2); - await dragNode(page, limit.id, 60, 40); + 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)!; - expect(a.position.x - b.position.x).toBeGreaterThanOrEqual(40); - expect(a.position.x - b.position.x).toBeLessThanOrEqual(80); - expect(a.position.y - b.position.y).toBeGreaterThanOrEqual(20); - expect(a.position.y - b.position.y).toBeLessThanOrEqual(60); + expectMovedBy(b, a, 60, 40); } }); test("multi select drag moves every selected node", async ({ page }) => { - const aId = await addNodeAt(page, "q", -250, 0); - const bId = await addNodeAt(page, "w", 250, 0); + 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); - // FIX(consistency): hardcodes the 'Control' key while every other multi-select interaction in the suite uses 'ControlOrMeta' (it works only because rete's accumulateOnCtrl accepts both Control and Meta) — fix: use page.keyboard.down('ControlOrMeta'); why: mixed modifier conventions make the suite behave differently per platform and confuse future edits. - await page.keyboard.down("Control"); + await page.keyboard.down("ControlOrMeta"); await dragNode(page, aId, 60, 40); - await page.keyboard.up("Control"); + 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)!; - expect(a.position.x - b.position.x).toBeGreaterThanOrEqual(40); - expect(a.position.x - b.position.x).toBeLessThanOrEqual(80); - expect(a.position.y - b.position.y).toBeGreaterThanOrEqual(20); - expect(a.position.y - b.position.y).toBeLessThanOrEqual(60); + expectMovedBy(b, a, 60, 40); } }); }); diff --git a/apps/web/e2e/scopes.spec.ts b/apps/web/e2e/scopes.spec.ts index 6b8ba62d..c27a4fb8 100644 --- a/apps/web/e2e/scopes.spec.ts +++ b/apps/web/e2e/scopes.spec.ts @@ -1,11 +1,10 @@ import { expect, test } from "@playwright/test"; import { addNodeAt, - canvasCentre, + dragWithScopeHold, getNodes, gotoEditor, nodeById, - pressShortcutAt, } from "./helpers"; test.describe("Scopes", () => { @@ -52,14 +51,7 @@ test.describe("Scopes", () => { y: limitBox.y + limitBox.height / 2, }; - await page.mouse.move(from.x, from.y); - // To enter the scope mode, the user must press and hold a node for 250ms. - // To assign the node to a parent, the user must release the node over the parent node. - // FIX(duplication): this press-hold-drag sequence with the magic 300ms wait is duplicated verbatim in the "dragging a child out" test below — fix: extract a dragWithScopeHold(page, from, to) helper in helpers.ts with a named SCOPE_HOLD_MS constant tied to the app's 250ms threshold; why: when the hold threshold changes, two hand-rolled copies (and the bare 300) must be hunted down instead of one constant. - await page.mouse.down(); - await page.waitForTimeout(300); - await page.mouse.move(to.x, to.y, { steps: 15 }); - await page.mouse.up(); + await dragWithScopeHold(page, from, to); const after = (await getNodes(page)).find((n) => n.id === countId)!; expect(after.parent).toBe(limitId); @@ -69,12 +61,7 @@ test.describe("Scopes", () => { page, }) => { const limitId = await addNodeAt(page, "l", -100, 0); - // FIX(consistency): re-implements addNodeAt by hand (pressShortcutAt + canvasCentre twice + re-deriving the id via a label lookup) right after using addNodeAt for the limit on the previous line — fix: const countId = await addNodeAt(page, '1', 0, 0); why: the roundabout version is three times the code and would pick the wrong node if a second Count ever existed. - await pressShortcutAt(page, "1", { - x: (await canvasCentre(page)).x, - y: (await canvasCentre(page)).y, - }); - const countId = (await getNodes(page)).find((n) => n.label === "Count")!.id; + const countId = await addNodeAt(page, "1", 0, 0); await page.evaluate( ({ parentId, childId }) => { @@ -89,13 +76,10 @@ test.describe("Scopes", () => { if (!childBox) throw new Error("child not visible"); const from = { x: childBox.x + 30, y: childBox.y + 20 }; - await page.mouse.move(from.x, from.y); - // To enter the scope mode, the user must press and hold a node for 250ms. - // To clear the parent, the user must release the node over a different location. - await page.mouse.down(); - await page.waitForTimeout(300); - await page.mouse.move(from.x + 500, from.y + 300, { steps: 15 }); - await page.mouse.up(); + 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 index b6328c07..5f72909b 100644 --- a/apps/web/e2e/selection.spec.ts +++ b/apps/web/e2e/selection.spec.ts @@ -1,24 +1,13 @@ import { expect, test } from "@playwright/test"; import { + addNodeAt, canvas, - canvasCentre, + createLimitWithChildren, getNodes, gotoEditor, nodeById, - pressShortcutAt, } from "./helpers"; -// FIX(duplication): local addNodeAt shadows the helpers.ts export of the same name but silently drops the returned node id — fix: delete this and import addNodeAt from './helpers' (ignore the return value where unused); why: two functions with identical names and diverging behaviour across the suite is a copy-paste trap, and it is why this file re-fetches ids via getNodes() ordering below. -async function addNodeAt( - page: import("@playwright/test").Page, - key: string, - dx: number, - dy: number, -) { - const centre = await canvasCentre(page); - await pressShortcutAt(page, key, { x: centre.x + dx, y: centre.y + dy }); -} - test.describe("Selection", () => { test.beforeEach(async ({ page }) => { await gotoEditor(page); @@ -27,8 +16,8 @@ test.describe("Selection", () => { test("clicking a node selects it & clicking another node replaces selection", async ({ page, }) => { - await addNodeAt(page, "q", -200, 0); - await addNodeAt(page, "w", 200, 0); + await addNodeAt(page, "a", -200, 0); + await addNodeAt(page, "o", 200, 0); const initial = await getNodes(page); expect(initial).toHaveLength(2); @@ -50,7 +39,7 @@ test.describe("Selection", () => { }); test("clicking background deselects everything", async ({ page }) => { - await addNodeAt(page, "q", 0, 0); + await addNodeAt(page, "a", 0, 0); expect((await getNodes(page))[0]!.selected).toBe(true); await canvas(page).click({ position: { x: 10, y: 10 } }); @@ -58,7 +47,7 @@ test.describe("Selection", () => { }); test("Pressing escape key deselects everything", async ({ page }) => { - await addNodeAt(page, "q", 0, 0); + await addNodeAt(page, "a", 0, 0); expect((await getNodes(page))[0]!.selected).toBe(true); await page.keyboard.press("Escape"); @@ -66,16 +55,13 @@ test.describe("Selection", () => { }); test("Cmd/Ctrl+A selects every node", async ({ page }) => { - await addNodeAt(page, "q", -200, 0); - await addNodeAt(page, "w", 200, 0); + await addNodeAt(page, "a", -200, 0); + await addNodeAt(page, "o", 200, 0); await addNodeAt(page, "r", 0, 200); - // deselect all, by default the last created node would be selected. await page.keyboard.press("Escape"); expect((await getNodes(page)).every((n) => !n.selected)).toBe(true); - // FIX(dead-code): this background click is unexplained — Escape above already deselected everything and keyboard focus is already on the page, and per the "clicking background deselects" test the click is a no-op here — fix: remove it, or add a comment stating the focus quirk it works around; why: unexplained ritual steps get cargo-culted into new tests. - await canvas(page).click({ position: { x: 5, y: 5 } }); await page.keyboard.press("ControlOrMeta+a"); const nodes = await getNodes(page); @@ -83,8 +69,8 @@ test.describe("Selection", () => { }); test("Ctrl+click accumulates selection", async ({ page }) => { - await addNodeAt(page, "q", -200, 0); - await addNodeAt(page, "w", 200, 0); + 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, @@ -98,31 +84,16 @@ test.describe("Selection", () => { }); test("clicking a LimitNode selects all of its children", async ({ page }) => { - const centre = await canvasCentre(page); - await pressShortcutAt(page, "l", { x: centre.x - 200, y: centre.y }); - await pressShortcutAt(page, "1", { x: centre.x + 200, y: centre.y }); - await pressShortcutAt(page, "1", { x: centre.x + 200, y: centre.y + 150 }); - - const limit = (await getNodes(page)).find((n) => n.label === "Limit")!; - // FIX(dead-code): expect(limit).toBeDefined() is redundant — the `!` non-null assertion on the line above already claims it exists, so the check either never fires or is contradicted by the assertion operator — fix: drop the expect (or drop the `!` and keep a real guard); why: checks that cannot fail give false confidence. - expect(limit).toBeDefined(); - - // FIX(duplication): same "Limit + Count children + parent assignment" fixture copy-pasted from clipboard/deletion/history/movement specs — fix: extract createLimitWithChildren(page, childCount) into helpers.ts; why: shared fixtures belong in helpers so one change updates all specs. - await page.evaluate((parentId) => { - const { editor } = window.__editor!; - for (const n of editor.getNodes()) { - if (n.id !== parentId && n.label === "Count") n.parent = parentId; - } - }, limit.id); + const { limitId } = await createLimitWithChildren(page, 2); await page.keyboard.press("Escape"); expect((await getNodes(page)).every((n) => !n.selected)).toBe(true); - await nodeById(page, limit.id).click({ position: { x: 5, y: 5 } }); + await nodeById(page, limitId).click({ position: { x: 5, y: 5 } }); const nodes = await getNodes(page); - expect(nodes.find((n) => n.id === limit.id)?.selected).toBe(true); - const children = nodes.filter((n) => n.parent === limit.id); + 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 index 8f60eeaa..a798560b 100644 --- a/apps/web/e2e/viewport.spec.ts +++ b/apps/web/e2e/viewport.spec.ts @@ -13,9 +13,7 @@ test.describe("Canvas viewport", () => { await page.mouse.move(centre.x, centre.y); await page.mouse.wheel(0, 200); - // FIX(flakiness): mouse.wheel does not wait for the wheel event to be processed (per Playwright docs), so reading the zoom immediately can race the area plugin's handler — same pattern in both clamp tests below — fix: use await expect.poll(() => getZoom(page)).toBeLessThan(1); why: a one-frame delay in event dispatch turns these into intermittent failures on slow CI. - const zoomed = await getZoom(page); - expect(zoomed).toBeLessThan(1); + await expect.poll(() => getZoom(page)).toBeLessThan(1); }); test("wheel up is clamped at the max (1.0)", async ({ page }) => { @@ -26,7 +24,7 @@ test.describe("Canvas viewport", () => { await page.mouse.wheel(0, -300); } - expect(await getZoom(page)).toBe(1); + await expect.poll(() => getZoom(page)).toBe(1); }); test("wheel down is clamped at the min (0.4)", async ({ page }) => { @@ -37,7 +35,7 @@ test.describe("Canvas viewport", () => { await page.mouse.wheel(0, 300); } - expect(await getZoom(page)).toBeCloseTo(0.4, 5); + await expect.poll(() => getZoom(page)).toBeCloseTo(0.4, 5); }); test("dragging the background pans the canvas", async ({ page }) => { diff --git a/apps/web/package.json b/apps/web/package.json index abf1ea7a..37342698 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,8 +9,10 @@ "lint": "eslint .", "preview": "vite preview", "test": "vitest run", + "test:unit": "vitest run", "test:watch": "vitest --watch", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "test:all": "vitest run && playwright test" }, "dependencies": { "@base-ui/react": "^1.1.0", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index da039dd4..4b7fb3ae 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -27,7 +27,7 @@ export default defineConfig({ // 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`, + 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 2be0a236..0b252ba9 100644 --- a/apps/web/src/app/router/index.tsx +++ b/apps/web/src/app/router/index.tsx @@ -16,6 +16,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; @@ -69,4 +70,8 @@ const authenticatedRoutes: RouteObject = { ], }; -export const router = createBrowserRouter([publicRoutes, 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([publicRoutes, authenticatedRoutes]); 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 index 01ed3a22..3d6c20f6 100644 --- a/apps/web/src/modules/evaluation/graph/editor/ui/controls/IntegerRangeControlView.tsx +++ b/apps/web/src/modules/evaluation/graph/editor/ui/controls/IntegerRangeControlView.tsx @@ -23,13 +23,14 @@ export const IntegerRangeControlView = ({ data }: Props) => { setLimitToDraft(formatNullableNumber(value.limitTo)); }, [value.limitFrom, value.limitTo]); - // FIX(bug): when the committed input normalizes to the value already stored, IntegerRangeControl.setValue early-returns without emitting, so the resync effect never fires and the input keeps showing the stale raw text (e.g. value is 6, user types "5.7" -> control rounds to 6 -> no emit -> field still shows "5.7"; same for "-3" when value is 0) — fix: after commit, re-read the snapshot and setLimitFromDraft/setLimitToDraft unconditionally (or resync in onBlur); why: the UI displays a number that differs from the actual committed model value. 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 ( 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..ea3bbe40 --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/workspace/E2EEditorPage.tsx @@ -0,0 +1,33 @@ +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 } 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 create = useCallback((el: HTMLElement) => { + const instance = createEditor(el, () => {}); + Promise.resolve(instance).then((result) => { + 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); + + return ( +
+
+
+ ); +}; 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": { From 15ecec0635d8cd8f7346f8d675ee49ddf8b19c09 Mon Sep 17 00:00:00 2001 From: andrej-sokolov Date: Tue, 23 Jun 2026 21:00:52 +0200 Subject: [PATCH 11/20] fix: naming --- .../editor/connections/booleanConnection.ts | 10 +++++----- .../deserialization/deserializeLogicNodes.ts | 8 ++------ .../serialization/serializeLogicNodes.ts | 4 +++- .../graph/editor/setup/setupConnections.ts | 6 +++--- .../graph/editor/setup/setupRender.ts | 18 +++++------------- .../modules/evaluation/graph/editor/types.ts | 9 ++------- ...View.tsx => PercentageRangeControlView.tsx} | 1 - ...lView.tsx => QuantifierTypeControlView.tsx} | 1 - ...View.tsx => QuantifierUnitsControlView.tsx} | 1 - .../evaluation/graph/editor/utils/copyPaste.ts | 9 +++------ .../validation/rules/__tests__/helpers.ts | 2 +- .../rules/__tests__/validateGlobal.test.ts | 10 +++++----- .../rules/__tests__/validateLimitItems.test.ts | 10 +++++----- .../editor/validation/rules/validateGlobal.ts | 3 +-- .../validation/rules/validateLimitItems.ts | 3 +-- .../graph/editor/validation/types.ts | 2 +- .../graph/editor/validation/validateGraph.ts | 10 +++++----- .../graph/editor/validation/validationGraph.ts | 3 +-- .../evaluation/graph/workspace/testHook.ts | 6 +++--- 19 files changed, 46 insertions(+), 70 deletions(-) rename apps/web/src/modules/evaluation/graph/editor/ui/controls/{RangeControlView.tsx => PercentageRangeControlView.tsx} (74%) rename apps/web/src/modules/evaluation/graph/editor/ui/controls/{QuantifierControlView.tsx => QuantifierTypeControlView.tsx} (73%) rename apps/web/src/modules/evaluation/graph/editor/ui/controls/{QuantifierValueControlView.tsx => QuantifierUnitsControlView.tsx} (86%) diff --git a/apps/web/src/modules/evaluation/graph/editor/connections/booleanConnection.ts b/apps/web/src/modules/evaluation/graph/editor/connections/booleanConnection.ts index 6ed7a779..15269e54 100644 --- a/apps/web/src/modules/evaluation/graph/editor/connections/booleanConnection.ts +++ b/apps/web/src/modules/evaluation/graph/editor/connections/booleanConnection.ts @@ -1,18 +1,18 @@ -import type { LogicalProps } from "@/modules/evaluation/graph/editor/types"; +import type { BooleanNodeProps } from "@/modules/evaluation/graph/editor/types"; import { ClassicPreset } from "rete"; export type BooleanOperator = "TRUE" | "NOT"; export class BooleanConnection extends ClassicPreset.Connection< - LogicalProps, - LogicalProps + BooleanNodeProps, + BooleanNodeProps > { booleanOperator: BooleanOperator; constructor( - source: LogicalProps, + source: BooleanNodeProps, sourceOutput: string, - target: LogicalProps, + target: BooleanNodeProps, targetInput: string, booleanOperator: BooleanOperator = "TRUE", ) { diff --git a/apps/web/src/modules/evaluation/graph/editor/deserialization/deserializeLogicNodes.ts b/apps/web/src/modules/evaluation/graph/editor/deserialization/deserializeLogicNodes.ts index 18d39643..35a50aa6 100644 --- a/apps/web/src/modules/evaluation/graph/editor/deserialization/deserializeLogicNodes.ts +++ b/apps/web/src/modules/evaluation/graph/editor/deserialization/deserializeLogicNodes.ts @@ -1,4 +1,3 @@ -// FIX(naming): "LogicNodes" here vs "Logical" everywhere else (nodes/logical/, validateLogical.ts, LogicalNodeBase) — fix: rename to deserializeLogicalNodes.ts (and the serialization twin), or add a note that the name intentionally mirrors the backend payload field `logicNodes`; why: the Logic/Logical split makes cross-module grep and navigation unreliable. import { BooleanConnection } from "@/modules/evaluation/graph/editor/connections/booleanConnection"; import { AlertNode } from "@/modules/evaluation/graph/editor/nodes/action/alert"; import { WarningNode } from "@/modules/evaluation/graph/editor/nodes/action/warning"; @@ -7,7 +6,7 @@ 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 { - LogicalProps, + BooleanNodeProps, Schemes, } from "@/modules/evaluation/graph/editor/types"; import { @@ -161,7 +160,6 @@ function parseOperand(node: EvalLogicNode): ExpressionNode { } if (node.type === EvalLogicNodeTypeEnum.GROUP) { - // Zod's recursive `get children()` widens the element type; it is an EvalLogicNode[]. const expression = parseLogicExpression( node.children as EvalLogicNode[], ); @@ -214,8 +212,6 @@ function collapseExpressionParts( if (!operand || typeof operand === "string") throw new Error("Invalid logic expression: operand expected."); - // Coalesce a run of the same operator into the existing n-ary node; otherwise - // nest the accumulated expression as the left child of the new operator. accumulator = accumulator.kind === "operator" && accumulator.operator === operator ? { @@ -236,7 +232,7 @@ function collapseExpressionParts( async function createLogicGraphFromExpression( context: BuildContext, expression: ExpressionNode, -): Promise { +): Promise { if (expression.kind === "limit") { const limitNode = context.limitNodesById.get(expression.limitId); if (!limitNode) diff --git a/apps/web/src/modules/evaluation/graph/editor/serialization/serializeLogicNodes.ts b/apps/web/src/modules/evaluation/graph/editor/serialization/serializeLogicNodes.ts index b0569d68..6c50fe39 100644 --- a/apps/web/src/modules/evaluation/graph/editor/serialization/serializeLogicNodes.ts +++ b/apps/web/src/modules/evaluation/graph/editor/serialization/serializeLogicNodes.ts @@ -1,4 +1,6 @@ -// FIX(naming): "LogicNodes" here vs "Logical" everywhere else (nodes/logical/, validateLogical.ts, LogicalNodeBase) — fix: rename to serializeLogicalNodes.ts (and the deserialization twin), or add a note that the name intentionally mirrors the backend payload field `logicNodes`; why: the Logic/Logical split makes cross-module grep and navigation unreliable. +// "LogicNodes" (not "Logical") deliberately mirrors the persisted payload field +// `logicNodes` -- the flat boolean expression -- not the AND/OR "logical" node +// classes under nodes/logical/. import type { NodeProps, Schemes, diff --git a/apps/web/src/modules/evaluation/graph/editor/setup/setupConnections.ts b/apps/web/src/modules/evaluation/graph/editor/setup/setupConnections.ts index 3711aaaf..92c1b08d 100644 --- a/apps/web/src/modules/evaluation/graph/editor/setup/setupConnections.ts +++ b/apps/web/src/modules/evaluation/graph/editor/setup/setupConnections.ts @@ -7,7 +7,7 @@ import { BooleanSocket } from "@/modules/evaluation/graph/editor/sockets/boolean import { RuleSocket } from "@/modules/evaluation/graph/editor/sockets/ruleSocket"; import type { LimitItemProps, - LogicalProps, + BooleanNodeProps, Schemes, } from "@/modules/evaluation/graph/editor/types"; import type { NodeEditor } from "rete"; @@ -221,9 +221,9 @@ export function setupConnection( if (sourceOutput !== "out" || targetInput !== "in") return null; return new BooleanConnection( - sourceNode as LogicalProps, + sourceNode as BooleanNodeProps, "out", - targetNode as LogicalProps, + targetNode as BooleanNodeProps, "in", ); } diff --git a/apps/web/src/modules/evaluation/graph/editor/setup/setupRender.ts b/apps/web/src/modules/evaluation/graph/editor/setup/setupRender.ts index 23fc5f84..3088cc92 100644 --- a/apps/web/src/modules/evaluation/graph/editor/setup/setupRender.ts +++ b/apps/web/src/modules/evaluation/graph/editor/setup/setupRender.ts @@ -26,10 +26,10 @@ import { IntegerRangeControlView } from "@/modules/evaluation/graph/editor/ui/co import { LabelControlView } from "@/modules/evaluation/graph/editor/ui/controls/LabelControlView"; import { NameControlView } from "@/modules/evaluation/graph/editor/ui/controls/NameControlView"; import { PositionControlView } from "@/modules/evaluation/graph/editor/ui/controls/PositionControlView"; -import { QuantifierTypeControlView } from "@/modules/evaluation/graph/editor/ui/controls/QuantifierControlView"; -import { QuantifierUnitsControlView } from "@/modules/evaluation/graph/editor/ui/controls/QuantifierValueControlView"; +import { QuantifierTypeControlView } from "@/modules/evaluation/graph/editor/ui/controls/QuantifierTypeControlView"; +import { QuantifierUnitsControlView } from "@/modules/evaluation/graph/editor/ui/controls/QuantifierUnitsControlView"; import { EnabledControlView } from "@/modules/evaluation/graph/editor/ui/controls/EnabledControlView"; -import { PercentageRangeControlView } from "@/modules/evaluation/graph/editor/ui/controls/RangeControlView"; +import { PercentageRangeControlView } from "@/modules/evaluation/graph/editor/ui/controls/PercentageRangeControlView"; import { ActionNodeView } from "@/modules/evaluation/graph/editor/ui/nodes/ActionNodeView"; import { AreaNodeView } from "@/modules/evaluation/graph/editor/ui/nodes/AreaNodeView"; import { CountNodeView } from "@/modules/evaluation/graph/editor/ui/nodes/CountNodeView"; @@ -65,11 +65,6 @@ export function setupRender(props: Props): ReactPlugin { const { editor, area } = props; const render = new ReactPlugin({ createRoot }); - - // Build each configured connection view once. The customize.connection callback - // below runs on every render; creating a fresh view there would give it a new - // component identity each time, remounting the connection (losing the toggle's - // position and restarting the dash animation). const refreshConnection = (id: string) => { void area.update("connection", id); }; @@ -139,12 +134,9 @@ export function setupRender(props: Props): ReactPlugin { sourceSocket instanceof BooleanSocket && targetSocket instanceof BooleanSocket ) { - if (targetNode instanceof ActionNodeBase) + if (targetNode instanceof ActionNodeBase){ return CustomConnectionView; - - // An And/Or node feeding the Result is a plain edge: the whole result - // can't be negated (no DEFECT, no group-NOT in the table view). Only a - // single Limit -> Result may carry NOT, so it keeps the toggle. + } if ( targetNode instanceof ResultNode && (sourceNode instanceof AndNode || sourceNode instanceof OrNode) diff --git a/apps/web/src/modules/evaluation/graph/editor/types.ts b/apps/web/src/modules/evaluation/graph/editor/types.ts index 2494b7dc..378117c2 100644 --- a/apps/web/src/modules/evaluation/graph/editor/types.ts +++ b/apps/web/src/modules/evaluation/graph/editor/types.ts @@ -18,19 +18,14 @@ import type { ResultNode } from "./nodes/result/result"; export type NodeGroup = "rule" | "logical" | "limit" | "action" | "result"; export type LimitItemProps = CountNode | PositionNode | AreaNode; -// FIX(naming): LogicalProps actually means "any node connectable via BooleanConnection" and -// includes Limit, Warning, Alert and Result nodes, while elsewhere "logical" means only AND/OR -// (nodeGroup 'logical', isLogicalOperator guard, nodes/logical/ folder) — fix: rename to -// something like BooleanNodeProps/BooleanGraphProps; why: the same word meaning two different -// node sets invites wrong instanceof assumptions at the BooleanConnection casts. -export type LogicalProps = +export type BooleanNodeProps = | AndNode | OrNode | LimitNode | WarningNode | AlertNode | ResultNode; -export type NodeProps = LimitItemProps | LogicalProps; +export type NodeProps = LimitItemProps | BooleanNodeProps; export type ConnProps = LimitItemConnection | BooleanConnection; diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/controls/RangeControlView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/controls/PercentageRangeControlView.tsx similarity index 74% rename from apps/web/src/modules/evaluation/graph/editor/ui/controls/RangeControlView.tsx rename to apps/web/src/modules/evaluation/graph/editor/ui/controls/PercentageRangeControlView.tsx index 97cf2293..cddb7ebd 100644 --- a/apps/web/src/modules/evaluation/graph/editor/ui/controls/RangeControlView.tsx +++ b/apps/web/src/modules/evaluation/graph/editor/ui/controls/PercentageRangeControlView.tsx @@ -1,4 +1,3 @@ -// FIX(naming): file is named RangeControlView.tsx but exports PercentageRangeControlView for PercentageRangeControl — fix: rename the file to PercentageRangeControlView.tsx so file and export match; why: the sibling IntegerRangeControlView.tsx follows the file==export convention, and the generic "RangeControlView" name makes it ambiguous which of the two range controls this file renders. import type { PercentageRangeControl } from "@/modules/evaluation/graph/editor/controls/percentageRange"; import { Slider } from "@/modules/shadcn/ui/slider"; import { useSyncExternalStore } from "react"; diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/controls/QuantifierControlView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/controls/QuantifierTypeControlView.tsx similarity index 73% rename from apps/web/src/modules/evaluation/graph/editor/ui/controls/QuantifierControlView.tsx rename to apps/web/src/modules/evaluation/graph/editor/ui/controls/QuantifierTypeControlView.tsx index 6f5affb0..f93b7529 100644 --- a/apps/web/src/modules/evaluation/graph/editor/ui/controls/QuantifierControlView.tsx +++ b/apps/web/src/modules/evaluation/graph/editor/ui/controls/QuantifierTypeControlView.tsx @@ -1,4 +1,3 @@ -// FIX(naming): file is named QuantifierControlView.tsx but exports QuantifierTypeControlView for QuantifierTypeControl — fix: rename the file to QuantifierTypeControlView.tsx so file and export match; why: the folder already has QuantifierValueControlView.tsx exporting QuantifierUnitsControlView, and two near-identically named files that both mismatch their exports make the quantifier controls easy to confuse. import type { QuantifierTypeControl } from "@/modules/evaluation/graph/editor/controls/quantifierType"; import { EvalLimitItemQuantifierTypeEnum } from "@repo/schema"; import { diff --git a/apps/web/src/modules/evaluation/graph/editor/ui/controls/QuantifierValueControlView.tsx b/apps/web/src/modules/evaluation/graph/editor/ui/controls/QuantifierUnitsControlView.tsx similarity index 86% rename from apps/web/src/modules/evaluation/graph/editor/ui/controls/QuantifierValueControlView.tsx rename to apps/web/src/modules/evaluation/graph/editor/ui/controls/QuantifierUnitsControlView.tsx index 87fb9821..bee81b95 100644 --- a/apps/web/src/modules/evaluation/graph/editor/ui/controls/QuantifierValueControlView.tsx +++ b/apps/web/src/modules/evaluation/graph/editor/ui/controls/QuantifierUnitsControlView.tsx @@ -1,4 +1,3 @@ -// FIX(naming): file is named QuantifierValueControlView.tsx but exports QuantifierUnitsControlView for QuantifierUnitsControl — fix: rename the file to QuantifierUnitsControlView.tsx (or rename the component) so file and export match like the sibling control views; why: searching for the component by name misses the file and the mismatch invites wrong imports. 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"; diff --git a/apps/web/src/modules/evaluation/graph/editor/utils/copyPaste.ts b/apps/web/src/modules/evaluation/graph/editor/utils/copyPaste.ts index 1901a8c0..a1847361 100644 --- a/apps/web/src/modules/evaluation/graph/editor/utils/copyPaste.ts +++ b/apps/web/src/modules/evaluation/graph/editor/utils/copyPaste.ts @@ -5,7 +5,7 @@ import { import { LimitItemConnection } from "@/modules/evaluation/graph/editor/connections/limitItemConnection"; import type { LimitItemProps, - LogicalProps, + BooleanNodeProps, Schemes, } from "@/modules/evaluation/graph/editor/types"; import type { EvalLimitItemOperatorEnum } from "@repo/schema"; @@ -16,7 +16,6 @@ import { deselectAllNodes, type SelectableNodes } from "./nodeSelection"; export type ClipboardNodeEntry = { node: Schemes["Node"]; position: { x: number; y: number }; - // Index into the clipboard nodes array of this node's parent, or null if root-level. parentIndex: number | null; }; @@ -44,8 +43,6 @@ export type Clipboard = { center: { x: number; y: number }; }; -// E is generic so this module does not need to import AreaExtra from the setup layer. - /** * Snapshots all selected nodes (cloning their state at copy time) along with * every connection whose both endpoints are inside the selection. @@ -159,9 +156,9 @@ export async function pasteNodes( if (connEntry.kind === "boolean") { await editor.addConnection( new BooleanConnection( - source as LogicalProps, + source as BooleanNodeProps, connEntry.sourceOutput, - target as LogicalProps, + target as BooleanNodeProps, connEntry.targetInput, connEntry.operator, ), 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 index 3f2f67ee..85439b29 100644 --- 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 @@ -4,7 +4,7 @@ import type { 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 { ValidationGraph } from "@/modules/evaluation/graph/editor/validation/ValidationGraph"; import { NodeEditor } from "rete"; export function makeContext( 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 index f1edb09a..319e8ae4 100644 --- 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 @@ -4,7 +4,7 @@ import { AndNode } from "@/modules/evaluation/graph/editor/nodes/logical/and"; import { ResultNode } from "@/modules/evaluation/graph/editor/nodes/result/result"; import { findNodesOutsideResultIsland, - findNodesWithMultipleGraphOutputs, + findBranchesNotLeadingToResult, } from "@/modules/evaluation/graph/editor/validation/rules/validateGlobal"; import { describe, expect, it } from "vitest"; import { conn, makeContext } from "./helpers"; @@ -50,12 +50,12 @@ describe("findNodesOutsideResultIsland", () => { }); }); -describe("findNodesWithMultipleGraphOutputs", () => { +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)]); - findNodesWithMultipleGraphOutputs(ctx); + findBranchesNotLeadingToResult(ctx); expect(ctx.nodeIssues.size).toBe(0); }); @@ -71,7 +71,7 @@ describe("findNodesWithMultipleGraphOutputs", () => { conn(and.id, result.id), ], ); - findNodesWithMultipleGraphOutputs(ctx); + findBranchesNotLeadingToResult(ctx); expect(ctx.nodeIssues.size).toBe(0); }); @@ -83,7 +83,7 @@ describe("findNodesWithMultipleGraphOutputs", () => { [limit, and, result], [conn(limit.id, result.id), conn(limit.id, and.id)], ); - findNodesWithMultipleGraphOutputs(ctx); + findBranchesNotLeadingToResult(ctx); const issues = ctx.nodeIssues.get(and.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__/validateLimitItems.test.ts b/apps/web/src/modules/evaluation/graph/editor/validation/rules/__tests__/validateLimitItems.test.ts index 6a487816..15c6d80b 100644 --- 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 @@ -1,7 +1,7 @@ import { LimitNode } from "@/modules/evaluation/graph/editor/nodes/limit/limit"; import { CountNode } from "@/modules/evaluation/graph/editor/nodes/limitItem/count"; import { - findLimitItemCrossScopeConnections, + findCrossScopeConnections, findOrphanLimitItems, } from "@/modules/evaluation/graph/editor/validation/rules/validateLimitItems"; import { describe, expect, it } from "vitest"; @@ -34,12 +34,12 @@ describe("findOrphanLimitItems", () => { }); }); -describe("findLimitItemCrossScopeConnections", () => { +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)]); - findLimitItemCrossScopeConnections(ctx); + findCrossScopeConnections(ctx); expect(ctx.nodeIssues.size).toBe(0); }); @@ -50,7 +50,7 @@ describe("findLimitItemCrossScopeConnections", () => { a.parent = limit.id; b.parent = limit.id; const ctx = makeContext([limit, a, b], [conn(a.id, b.id)]); - findLimitItemCrossScopeConnections(ctx); + findCrossScopeConnections(ctx); expect(ctx.nodeIssues.size).toBe(0); }); @@ -62,7 +62,7 @@ describe("findLimitItemCrossScopeConnections", () => { a.parent = limitA.id; b.parent = limitB.id; const ctx = makeContext([limitA, limitB, a, b], [conn(a.id, b.id)]); - findLimitItemCrossScopeConnections(ctx); + findCrossScopeConnections(ctx); const issues = ctx.nodeIssues.get(b.id) ?? []; expect(issues).toHaveLength(1); expect(issues.at(0)?.level).toBe("error"); 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 index b2791478..72d27e87 100644 --- a/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateGlobal.ts +++ b/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateGlobal.ts @@ -72,8 +72,7 @@ export function findNodesOutsideResultIsland(context: ValidationContext) { * must eventually lead to a Result node. Branch targets that do not lead to a * Result node are marked as invalid. */ -// FIX(naming): the name says it finds 'nodes with multiple graph outputs', but having multiple outputs is only the trigger — what it actually reports are branch *targets* that never reach a Result node — fix: rename to something like findBranchesNotLeadingToResult; why: the issue is attached to connection targets, so readers grepping by the reported message will not connect it to this name. -export function findNodesWithMultipleGraphOutputs(context: ValidationContext) { +export function findBranchesNotLeadingToResult(context: ValidationContext) { const { graph, nodeIssues } = context; const resultIslandNodeIds = getResultIslandNodeIds(context); 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 index fe46498b..06f2ef72 100644 --- a/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateLimitItems.ts +++ b/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateLimitItems.ts @@ -32,8 +32,7 @@ export function findOrphanLimitItems(context: ValidationContext) { * * Connected nodes must either share the same parent or both be root-level nodes. */ -// FIX(naming): the name (and host file) says 'LimitItem' but the rule iterates ALL connections and fires for any cross-scope pair — there is no isLimitItemNode check — fix: either rename to findCrossScopeConnections and move it next to the other graph-wide rules in validateGlobal.ts, or actually restrict it to limit-item connections; why: the name misleads readers about the rule's scope and about where graph-wide checks live. -export function findLimitItemCrossScopeConnections(context: ValidationContext) { +export function findCrossScopeConnections(context: ValidationContext) { const { graph, nodeIssues } = context; for (const connection of graph.connections) { diff --git a/apps/web/src/modules/evaluation/graph/editor/validation/types.ts b/apps/web/src/modules/evaluation/graph/editor/validation/types.ts index f0068ff0..ea674654 100644 --- a/apps/web/src/modules/evaluation/graph/editor/validation/types.ts +++ b/apps/web/src/modules/evaluation/graph/editor/validation/types.ts @@ -1,4 +1,4 @@ -import type { ValidationGraph } from "./validationGraph"; +import type { ValidationGraph } from "./ValidationGraph"; export type ValidationIssueLevel = "error" | "warning"; diff --git a/apps/web/src/modules/evaluation/graph/editor/validation/validateGraph.ts b/apps/web/src/modules/evaluation/graph/editor/validation/validateGraph.ts index 2f10ea75..d057caca 100644 --- a/apps/web/src/modules/evaluation/graph/editor/validation/validateGraph.ts +++ b/apps/web/src/modules/evaluation/graph/editor/validation/validateGraph.ts @@ -3,10 +3,10 @@ import type { NodeEditor } from "rete"; import { findActionsWithInvalidSource } from "./rules/validateActions"; import { findNodesOutsideResultIsland, - findNodesWithMultipleGraphOutputs, + findBranchesNotLeadingToResult, } from "./rules/validateGlobal"; import { - findLimitItemCrossScopeConnections, + findCrossScopeConnections, findOrphanLimitItems, } from "./rules/validateLimitItems"; import { @@ -17,7 +17,7 @@ import { import { findUselessLogicalNodes } from "./rules/validateLogical"; import { findInvalidResultNodeCount } from "./rules/validateResult"; import type { ControlIssues, ValidationIssue, ValidationResult } from "./types"; -import { ValidationGraph } from "./validationGraph"; +import { ValidationGraph } from "./ValidationGraph"; export function validateGraph(editor: NodeEditor): ValidationResult { const graph = new ValidationGraph(editor); @@ -30,7 +30,7 @@ export function validateGraph(editor: NodeEditor): ValidationResult { findLimitsWithMultipleIslands(context); findOrphanLimitItems(context); - findLimitItemCrossScopeConnections(context); + findCrossScopeConnections(context); findLimitsWithMissingRequiredInputs(context); findUselessLogicalNodes(context); @@ -40,7 +40,7 @@ export function validateGraph(editor: NodeEditor): ValidationResult { findInvalidResultNodeCount(context); findNodesOutsideResultIsland(context); - findNodesWithMultipleGraphOutputs(context); + findBranchesNotLeadingToResult(context); const hasNodeErrors = Array.from(nodeIssues.values()).some((issues) => issues.some((issue) => issue.level === "error"), diff --git a/apps/web/src/modules/evaluation/graph/editor/validation/validationGraph.ts b/apps/web/src/modules/evaluation/graph/editor/validation/validationGraph.ts index 8457f619..c99cfe9f 100644 --- a/apps/web/src/modules/evaluation/graph/editor/validation/validationGraph.ts +++ b/apps/web/src/modules/evaluation/graph/editor/validation/validationGraph.ts @@ -1,4 +1,3 @@ -// FIX(naming): file name 'validationGraph.ts' is one keystroke away from its sibling 'validateGraph.ts' and both live in the same folder — fix: rename this file after its export (ValidationGraph.ts) or something distinct like graphIndex.ts; why: the near-identical names make imports, code navigation, and reviews error-prone (this review was explicitly asked whether one is a stale copy of the other). import type { ConnProps, NodeProps, @@ -65,7 +64,7 @@ export class ValidationGraph { return this.incomingByNode.has(nodeId) || this.outgoingByNode.has(nodeId); } - // FIX(duplication): haveSameScope and areBothRoot are never called, while findLimitItemCrossScopeConnections re-implements exactly this check inline (sourceNode.parent === targetNode.parent) — fix: use haveSameScope in that rule or delete both helpers; why: two copies of the scope rule can silently diverge. + // FIX(duplication): haveSameScope and areBothRoot are never called, while findCrossScopeConnections re-implements exactly this check inline (sourceNode.parent === targetNode.parent) — fix: use haveSameScope in that rule or delete both helpers; why: two copies of the scope rule can silently diverge. haveSameScope(a: NodeProps, b: NodeProps) { return a.parent === b.parent; } diff --git a/apps/web/src/modules/evaluation/graph/workspace/testHook.ts b/apps/web/src/modules/evaluation/graph/workspace/testHook.ts index 038af20a..5b4dc716 100644 --- a/apps/web/src/modules/evaluation/graph/workspace/testHook.ts +++ b/apps/web/src/modules/evaluation/graph/workspace/testHook.ts @@ -6,7 +6,7 @@ import { LimitItemConnection } from "@/modules/evaluation/graph/editor/connectio import type { AreaExtra } from "@/modules/evaluation/graph/editor/setup/createEditor"; import type { LimitItemProps, - LogicalProps, + BooleanNodeProps, Schemes, } from "@/modules/evaluation/graph/editor/types"; import type { EvalLimitItemOperatorEnum } from "@repo/schema"; @@ -56,8 +56,8 @@ export function installTestHook(handle: { ...handle, addBooleanConnection: async (sourceId, targetId, operator = "TRUE") => { // FIX(error-handling): getNode may return undefined and the cast hides it (same in addLimitItemConnection below), so a wrong fixture id fails deep inside the connection constructor with a cryptic error — fix: throw new Error(`node ${sourceId} not found`) when the lookup fails; why: e2e failures should point at the bad id, not at rete internals. - const source = editor.getNode(sourceId) as LogicalProps; - const target = editor.getNode(targetId) as LogicalProps; + const source = editor.getNode(sourceId) as BooleanNodeProps; + const target = editor.getNode(targetId) as BooleanNodeProps; await editor.addConnection( new BooleanConnection(source, "out", target, "in", operator), ); From 7e48e7a108b52ed8de837567a158be1979d8a845 Mon Sep 17 00:00:00 2001 From: andrej-sokolov Date: Tue, 23 Jun 2026 21:52:44 +0200 Subject: [PATCH 12/20] fix: simple --- .../components/GraphEditor/GraphEditor.tsx | 5 +- .../graph/editor/debug/EditorDebugOverlay.tsx | 19 ++++--- .../deserialization/addActionForSeverity.ts | 30 +++++++++++ .../deserialization/deserializeLimits.ts | 35 +----------- .../deserialization/deserializeLogicNodes.ts | 49 ++--------------- .../graph/editor/serialization/utils.ts | 17 +++--- .../graph/editor/setup/createEditor.ts | 2 +- .../graph/editor/setup/setupConnections.ts | 14 ++--- .../graph/editor/setup/setupContextMenu.ts | 2 +- .../graph/editor/setup/setupKeyListeners.ts | 9 +--- .../graph/editor/setup/setupScopes.ts | 4 -- .../graph/editor/setup/setupValidation.ts | 6 ++- .../graph/editor/sockets/appSocket.ts | 9 +--- .../modules/evaluation/graph/editor/types.ts | 6 +-- .../ContextMenuView.tsx} | 3 -- .../controls/QuantifierUnitsControlView.tsx | 2 +- .../evaluation/graph/editor/utils/graph.ts | 54 ++----------------- .../graph/editor/utils/removeNodes.ts | 12 +---- .../graph/editor/validation/issues.ts | 27 ++++++++++ .../validation/rules/validateActions.ts | 4 +- .../editor/validation/rules/validateGlobal.ts | 26 +-------- .../validation/rules/validateLimitItems.ts | 4 +- .../editor/validation/rules/validateLimits.ts | 3 +- .../validation/rules/validateLogical.ts | 5 +- .../editor/validation/rules/validateResult.ts | 2 +- .../graph/editor/validation/types.ts | 23 -------- .../editor/validation/validationGraph.ts | 14 ----- .../graph/workspace/E2EEditorPage.tsx | 10 +++- .../evaluation/graph/workspace/testHook.ts | 31 ++++++----- 29 files changed, 149 insertions(+), 278 deletions(-) create mode 100644 apps/web/src/modules/evaluation/graph/editor/deserialization/addActionForSeverity.ts rename apps/web/src/modules/evaluation/graph/editor/ui/{ContextMenuView.ts => context-menu/ContextMenuView.tsx} (66%) create mode 100644 apps/web/src/modules/evaluation/graph/editor/validation/issues.ts diff --git a/apps/web/src/modules/evaluation/components/GraphEditor/GraphEditor.tsx b/apps/web/src/modules/evaluation/components/GraphEditor/GraphEditor.tsx index 9b29c5c4..bf43a763 100644 --- a/apps/web/src/modules/evaluation/components/GraphEditor/GraphEditor.tsx +++ b/apps/web/src/modules/evaluation/components/GraphEditor/GraphEditor.tsx @@ -309,8 +309,9 @@ export const GraphEditor = ({ projectId, configId, testCaseId }: Props) => { ready ? "opacity-0" : "opacity-100", )} /> -
+
-
+
+
+ + + + +
+
); 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 index 42031d4e..b646ca40 100644 --- a/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateActions.ts +++ b/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateActions.ts @@ -30,7 +30,7 @@ export function findActionsWithInvalidSource(context: ValidationContext) { message: "Invalid action placement", description: [ "The previous node does not support an action.", - "Actions can only be connected directly to a Limit node or to the Result node.", + "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 index 6f345d69..f0da0391 100644 --- a/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateGlobal.ts +++ b/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateGlobal.ts @@ -38,7 +38,7 @@ export function findNodesOutsideResultIsland(context: ValidationContext) { message: "Disconnected node", description: [ "This node is not connected to a Result node.", - "All graph nodes except Limit items must be connected to an island that contains a Result node.", + "All graph nodes except Check items must be connected to an island that contains a Result node.", ], }); } 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 index 37701428..9803645c 100644 --- a/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateLimitItems.ts +++ b/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateLimitItems.ts @@ -18,10 +18,10 @@ export function findOrphanLimitItems(context: ValidationContext) { pushIssue(nodeIssues, node.id, { level: "error", - message: "Orphan Limit item", + message: "Orphan Check item", description: [ - "Limit item nodes must be placed inside a limit node", - "Long press to enter the scope mode and move this node inside a limit node.", + "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.", ], }); } @@ -46,7 +46,7 @@ export function findCrossScopeConnections(context: ValidationContext) { level: "error", message: "Out of bounds connection", description: [ - "All connected Limit items must be placed inside the same parent limit node.", + "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 index 2c9f2a94..c5d8f173 100644 --- a/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateLimits.ts +++ b/apps/web/src/modules/evaluation/graph/editor/validation/rules/validateLimits.ts @@ -19,9 +19,9 @@ export function findEmptyLimits(context: ValidationContext) { pushIssue(nodeIssues, node.id, { level: "warning", - message: "Useless Limit", + message: "Useless Check", description: [ - "A limit node with no children has no effect and can be removed.", + "A check node with no children has no effect and can be removed.", ], }); } @@ -48,8 +48,8 @@ export function findLimitsWithMultipleIslands(context: ValidationContext) { level: "error", message: "Disconnected children", description: [ - "All Limit items inside a Limit must form a single connected component.", - "Connect all child nodes together or split them into separate Limit nodes.", + "All Check items inside a Check must form a single connected component.", + "Connect all child nodes together or split them into separate Check nodes.", ], }); } 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; From 7c1f8d08d3a1443368956cc115079fc7a9201885 Mon Sep 17 00:00:00 2001 From: andrej-sokolov Date: Fri, 26 Jun 2026 12:23:37 +0200 Subject: [PATCH 18/20] chore: update API use --- .../modules/evaluation/api/evaluationApi.ts | 101 +++++------------- .../components/GraphEditor/GraphEditor.tsx | 16 +-- 2 files changed, 29 insertions(+), 88 deletions(-) diff --git a/apps/web/src/modules/evaluation/api/evaluationApi.ts b/apps/web/src/modules/evaluation/api/evaluationApi.ts index 4c1d143f..037d2775 100644 --- a/apps/web/src/modules/evaluation/api/evaluationApi.ts +++ b/apps/web/src/modules/evaluation/api/evaluationApi.ts @@ -76,48 +76,15 @@ export const evaluationApi = api.injectEndpoints({ }), // Single source of truth for a test case: the whole thing (meta + logic tree + - // limits WITH their items). Both the graph (subscribes) and the table (peeks, - // non-subscribing) read this one cache entry. - // - // The test-case detail endpoint omits per-limit `limitItems`, so as a stopgap this - // composes the test-case detail with each limit's detail (which carries its items) - // into one flat `EvalTestCaseFull` via N+1 client-side requests. - // - // FIXME(backend): replace this whole composite query with a single direct call to - // GET /eval/{projectId}/config/{configId}/test-case/{testCaseId}/full once that - // endpoint returns limits WITH their limitItems. Then this becomes a plain `query:` - // + `transformResponse: evalTestCaseFullSchema.parse` — the flat shape is unchanged. + // 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 } >({ - async queryFn( - { projectId, configId, testCaseId }, - _api, - _extraOptions, - baseQuery, - ) { - const base = `/eval/${projectId}/config/${configId}`; - - const detailResult = await baseQuery(`${base}/test-case/${testCaseId}`); - if (detailResult.error) return { error: detailResult.error }; - const detail = evalTestCaseDetailSchema.parse(detailResult.data); - - const limitResults = await Promise.all( - detail.limits.map((limit) => - baseQuery(`${base}/limit/${testCaseId}/${limit.id}`), - ), - ); - - const failed = limitResults.find((result) => result.error); - if (failed?.error) return { error: failed.error }; - - // limitResults follows detail.limits order, so the assembled limits stay ordered. - const limits = limitResults.map((result) => result.data); - const full = evalTestCaseFullSchema.parse({ ...detail, limits }); - - return { data: full }; - }, + 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 }, @@ -166,7 +133,7 @@ export const evaluationApi = api.injectEndpoints({ // 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< - EvalTestCaseDetail, + EvalTestCaseFull, { projectId: number; configId: number; @@ -179,55 +146,41 @@ export const evaluationApi = api.injectEndpoints({ method: "PUT", body, }), - transformResponse: (response) => evalTestCaseDetailSchema.parse(response), - // The table view reads limits/test-cases through different cached queries than the - // graph. The /full response already carries the updated limits (with severity), so - // patch those caches straight from it — the table reflects instantly instead of - // waiting on the invalidation refetch round-trip. The invalidation below still runs - // as a background reconcile (and covers thresholds + the graph's own query). + 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; // EvalTestCaseDetail (limits w/o items) + const { data } = await queryFulfilled; // EvalTestCaseFull (limits WITH items) - // Overview list — reflect meta + limit changes in the table instantly. + // SSOT — replace the whole entry with the authoritative response. dispatch( evaluationApi.util.updateQueryData( - "getEvalTestCases", - { projectId, configId }, - (draft) => { - const index = draft.findIndex((tc) => tc.id === testCaseId); - if (index !== -1) { - const { logicNodes: _logicNodes, ...testCase } = data; - draft[index] = testCase; - } - }, + "getEvalTestCaseFull", + { projectId, configId, testCaseId }, + () => data, ), ); - // SSOT — patch meta + the limit list. The detail response carries no - // limitItems, so preserve the existing items for surviving limits; the - // invalidation below refetches the full entry to reconcile items. - // (Simplifiable once BE PUT /full returns limits with items.) + // Overview list — item-free limits, so strip logicNodes + limitItems. dispatch( evaluationApi.util.updateQueryData( - "getEvalTestCaseFull", - { projectId, configId, testCaseId }, + "getEvalTestCases", + { projectId, configId }, (draft) => { - const existingItems = new Map( - draft.limits.map((limit) => [limit.id, limit.limitItems]), - ); - draft.name = data.name; - draft.type = data.type; - draft.severity = data.severity; - draft.enabled = data.enabled; - draft.logicNodes = data.logicNodes; - draft.limits = data.limits.map((limit) => ({ - ...limit, - limitItems: existingItems.get(limit.id) ?? [], - })); + 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, + ), + }; }, ), ); diff --git a/apps/web/src/modules/evaluation/components/GraphEditor/GraphEditor.tsx b/apps/web/src/modules/evaluation/components/GraphEditor/GraphEditor.tsx index eca5ae3c..247ec410 100644 --- a/apps/web/src/modules/evaluation/components/GraphEditor/GraphEditor.tsx +++ b/apps/web/src/modules/evaluation/components/GraphEditor/GraphEditor.tsx @@ -226,26 +226,14 @@ export const GraphEditor = ({ projectId, configId, testCaseId }: Props) => { body.enabled = testCaseData.enabled; try { - const saved = await updateTestCaseFull({ + await updateTestCaseFull({ projectId, configId, testCaseId, body, }).unwrap(); - const diverged = (body.limits ?? []).some((sent) => { - if (!sent.id) return false; - const persisted = saved.limits.find((limit) => limit.id === sent.id); - return persisted ? persisted.enabled !== sent.enabled : false; - }); - - if (diverged) { - toast.warning( - "Saved, but some changes didn't persist on the server.", - ); - } else { - toast.success("Test case saved."); - } + toast.success("Test case saved."); } catch { toast.error("Failed to save test case."); } From 114989d36d66d4fdf0ef6858aa3038569529734f Mon Sep 17 00:00:00 2001 From: andrej-sokolov Date: Fri, 26 Jun 2026 12:37:21 +0200 Subject: [PATCH 19/20] fix: zoom target --- .../components/GraphEditor/GraphEditor.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/web/src/modules/evaluation/components/GraphEditor/GraphEditor.tsx b/apps/web/src/modules/evaluation/components/GraphEditor/GraphEditor.tsx index 247ec410..95a3feb2 100644 --- a/apps/web/src/modules/evaluation/components/GraphEditor/GraphEditor.tsx +++ b/apps/web/src/modules/evaluation/components/GraphEditor/GraphEditor.tsx @@ -254,13 +254,21 @@ export const GraphEditor = ({ projectId, configId, testCaseId }: Props) => { if (!instance) return; const { area } = instance; - const { k } = area.area.transform; + 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 delta = next / k - 1; - void area.area.zoom(next, (-width / 2) * delta, (-height / 2) * delta); + const ratio = next / k; + void area.area.zoom( + next, + (width / 2 - x) * (1 - ratio), + (height / 2 - y) * (1 - ratio), + ); }, []); const handleFocus = useCallback(() => { From 83fd86745293547e6bc327138b65be5e52621c2b Mon Sep 17 00:00:00 2001 From: andrej-sokolov Date: Fri, 26 Jun 2026 15:04:32 +0200 Subject: [PATCH 20/20] feat: magnetic connections --- apps/web/e2e/connections.spec.ts | 145 ++++++++++ apps/web/e2e/context-menu.spec.ts | 6 +- apps/web/e2e/creation.spec.ts | 10 +- apps/web/e2e/viewport.spec.ts | 82 +----- .../evaluation/graph/editor/constants.ts | 3 + .../graph/editor/setup/connectionRules.ts | 128 +++++++++ .../graph/editor/setup/createEditor.ts | 4 + .../graph/editor/setup/setupConnections.ts | 113 ++------ .../editor/setup/setupMagneticConnection.ts | 255 ++++++++++++++++++ .../graph/editor/setup/setupRender.ts | 5 + .../ui/connections/MagneticConnectionView.tsx | 37 +++ .../editor/ui/connections/connectionStyles.ts | 3 + 12 files changed, 608 insertions(+), 183 deletions(-) create mode 100644 apps/web/src/modules/evaluation/graph/editor/setup/connectionRules.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/setup/setupMagneticConnection.ts create mode 100644 apps/web/src/modules/evaluation/graph/editor/ui/connections/MagneticConnectionView.tsx diff --git a/apps/web/e2e/connections.spec.ts b/apps/web/e2e/connections.spec.ts index ce9eacc5..c7f6d3df 100644 --- a/apps/web/e2e/connections.spec.ts +++ b/apps/web/e2e/connections.spec.ts @@ -92,6 +92,151 @@ test.describe("Connections", () => { 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); diff --git a/apps/web/e2e/context-menu.spec.ts b/apps/web/e2e/context-menu.spec.ts index 68f5af0e..2d7123a0 100644 --- a/apps/web/e2e/context-menu.spec.ts +++ b/apps/web/e2e/context-menu.spec.ts @@ -13,9 +13,9 @@ test.describe("Context menu", () => { await expect(page.getByTestId("context-menu")).toBeVisible(); await expect(page.getByTestId("context-menu-item")).toHaveText([ - "Limit", + "Check", "Result", - "Limit Item", + "Check Item", "Logical", "Action", ]); @@ -37,7 +37,7 @@ test.describe("Context menu", () => { }); const groups = [ - { label: "Limit Item", children: ["Position", "Area", "Count"] }, + { label: "Check Item", children: ["Position", "Area", "Count"] }, { label: "Logical", children: ["And", "Or"] }, { label: "Action", children: ["Warning", "Alert"] }, ]; diff --git a/apps/web/e2e/creation.spec.ts b/apps/web/e2e/creation.spec.ts index 3b5dd94a..8f4bceef 100644 --- a/apps/web/e2e/creation.spec.ts +++ b/apps/web/e2e/creation.spec.ts @@ -49,14 +49,14 @@ test.describe("Node creation", () => { expect(nodes.map((n) => n.label).sort()).toEqual(["AND", "OR"]); }); - test("context menu creates a Limit node", async ({ page }) => { + test("context menu creates a Check node", async ({ page }) => { await canvas(page).click({ button: "right", position: { x: 100, y: 100 } }); - const limitItem = page + const checkItem = page .getByTestId("context-menu-item") - .filter({ hasText: /^Limit$/ }); - await expect(limitItem).toBeVisible(); - await limitItem.click(); + .filter({ hasText: /^Check$/ }); + await expect(checkItem).toBeVisible(); + await checkItem.click(); const nodes = await getNodes(page); expect(nodes).toHaveLength(1); diff --git a/apps/web/e2e/viewport.spec.ts b/apps/web/e2e/viewport.spec.ts index e68349bc..69e6f4ae 100644 --- a/apps/web/e2e/viewport.spec.ts +++ b/apps/web/e2e/viewport.spec.ts @@ -1,19 +1,11 @@ import { ZOOM_MAX, ZOOM_MIN, - ZOOM_STEP, } from "@/modules/evaluation/graph/editor/constants"; import { expect, test } from "@playwright/test"; -import { - addNodeAt, - canvasCentre, - getZoom, - gotoEditor, - nodeById, -} from "./helpers"; +import { canvasCentre, getZoom, gotoEditor } from "./helpers"; const PRECISION = 5; -const CLICKS_TO_CLAMP = Math.ceil((ZOOM_MAX - ZOOM_MIN) / ZOOM_STEP) + 2; test.describe("Canvas viewport", () => { test.beforeEach(async ({ page }) => { @@ -52,78 +44,6 @@ test.describe("Canvas viewport", () => { await expect.poll(() => getZoom(page)).toBeCloseTo(ZOOM_MIN, PRECISION); }); - test("the zoom-out button zooms out by one step", async ({ page }) => { - expect(await getZoom(page)).toBe(ZOOM_MAX); - - await page.getByRole("button", { name: "Zoom out" }).click(); - - await expect - .poll(() => getZoom(page)) - .toBeCloseTo(ZOOM_MAX - ZOOM_STEP, PRECISION); - }); - - test("the zoom-in button is clamped at the max", async ({ page }) => { - expect(await getZoom(page)).toBe(ZOOM_MAX); - - await page.getByRole("button", { name: "Zoom in" }).click(); - - await expect.poll(() => getZoom(page)).toBe(ZOOM_MAX); - }); - - test("the zoom-in button reverses the zoom-out button", async ({ page }) => { - const zoomOut = page.getByRole("button", { name: "Zoom out" }); - await zoomOut.click(); - await zoomOut.click(); - await expect - .poll(() => getZoom(page)) - .toBeCloseTo(ZOOM_MAX - 2 * ZOOM_STEP, PRECISION); - - await page.getByRole("button", { name: "Zoom in" }).click(); - - await expect - .poll(() => getZoom(page)) - .toBeCloseTo(ZOOM_MAX - ZOOM_STEP, PRECISION); - }); - - test("the zoom-out button is clamped at the min", async ({ page }) => { - const zoomOut = page.getByRole("button", { name: "Zoom out" }); - for (let i = 0; i < CLICKS_TO_CLAMP; i++) { - await zoomOut.click(); - } - - await expect.poll(() => getZoom(page)).toBeCloseTo(ZOOM_MIN, PRECISION); - }); - - test("the fit-to-view button brings an off-screen node into view", async ({ - page, - }) => { - const id = await addNodeAt(page, "1", 0, 0); - - 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 - 1200, centre.y - 1200, { steps: 10 }); - await page.mouse.up({ button: "left" }); - - await page.getByRole("button", { name: "Fit to view" }).click(); - - await expect - .poll(async () => { - const node = await nodeById(page, id).boundingBox(); - const canvasBox = await page.getByTestId("editor-canvas").boundingBox(); - if (!node || !canvasBox) return false; - const cx = node.x + node.width / 2; - const cy = node.y + node.height / 2; - return ( - cx >= canvasBox.x && - cx <= canvasBox.x + canvasBox.width && - cy >= canvasBox.y && - cy <= canvasBox.y + canvasBox.height - ); - }) - .toBe(true); - }); - test("dragging the background pans the canvas", async ({ page }) => { const before = await page.evaluate(() => ({ x: window.__editor!.area.area.transform.x, diff --git a/apps/web/src/modules/evaluation/graph/editor/constants.ts b/apps/web/src/modules/evaluation/graph/editor/constants.ts index 5c5ebcd6..191b7d8d 100644 --- a/apps/web/src/modules/evaluation/graph/editor/constants.ts +++ b/apps/web/src/modules/evaluation/graph/editor/constants.ts @@ -3,3 +3,6 @@ export const GRID = 20; export const ZOOM_MIN = 0.4; export const ZOOM_MAX = 1; export const ZOOM_STEP = 0.1; + +export const MAGNETIC_SNAP_DISTANCE = 50; +export const MAGNETIC_SNAP_SOCKET_OFFSET = 10; diff --git a/apps/web/src/modules/evaluation/graph/editor/setup/connectionRules.ts b/apps/web/src/modules/evaluation/graph/editor/setup/connectionRules.ts new file mode 100644 index 00000000..995c41ac --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/setup/connectionRules.ts @@ -0,0 +1,128 @@ +import { ActionNodeBase } from "@/modules/evaluation/graph/editor/nodes/action/actionBase"; +import { LimitNode } from "@/modules/evaluation/graph/editor/nodes/limit/limit"; +import { ResultNode } from "@/modules/evaluation/graph/editor/nodes/result/result"; +import { AppSocket } from "@/modules/evaluation/graph/editor/sockets/appSocket"; +import type { Schemes } from "@/modules/evaluation/graph/editor/types"; +import type { NodeEditor } from "rete"; + +/** + * Shared connection legality rules. + * + * Used by both the drag flow (ClassicFlow.canMakeConnection in setupConnections) + * and the magnetic snap (setupMagneticConnection) + */ + +export type SocketRef = { nodeId: string; key: string }; + +export type ConnectionValidation = + | { ok: true } + | { ok: false; reason?: string }; + +type PortRecord = Record; + +function getSocket( + editor: NodeEditor, + nodeId: string, + key: string, + side: "input" | "output", +): unknown { + const node = editor.getNode(nodeId); + if (!node) return null; + + const ports = (side === "output" ? node.outputs : node.inputs) as PortRecord; + return ports[key]?.socket ?? null; +} + +/** + * Walks the existing graph from `targetNodeId` to see if it can reach + * `sourceNodeId` — i.e. whether adding source -> target would close a loop. + */ +export function wouldCreateCycle( + editor: NodeEditor, + sourceNodeId: string, + targetNodeId: string, +): boolean { + if (sourceNodeId === targetNodeId) return true; + + const adjacency = new Map(); + + for (const node of editor.getNodes()) { + adjacency.set(node.id, []); + } + + for (const connection of editor.getConnections()) { + const list = adjacency.get(connection.source) ?? []; + list.push(connection.target); + adjacency.set(connection.source, list); + } + + const stack = [targetNodeId]; + const visited = new Set(); + + while (stack.length > 0) { + const current = stack.pop(); + if (!current) continue; + + if (current === sourceNodeId) return true; + if (visited.has(current)) continue; + visited.add(current); + + for (const next of adjacency.get(current) ?? []) { + if (!visited.has(next)) stack.push(next); + } + } + + return false; +} + +/** + * Decides whether a connection source(output) -> target(input) is legal. + */ +export function validateConnection( + editor: NodeEditor, + source: SocketRef, + target: SocketRef, +): ConnectionValidation { + if (source.nodeId === target.nodeId) return { ok: false }; + + const sourceNode = editor.getNode(source.nodeId); + const targetNode = editor.getNode(target.nodeId); + if (!sourceNode || !targetNode) return { ok: false }; + + const sourceSocket = getSocket(editor, source.nodeId, source.key, "output"); + const targetSocket = getSocket(editor, target.nodeId, target.key, "input"); + if (!sourceSocket || !targetSocket) return { ok: false }; + + if ( + !(sourceSocket instanceof AppSocket) || + !(targetSocket instanceof AppSocket) + ) { + return { ok: false }; + } + + if (!sourceSocket.isCompatibleWith(targetSocket)) { + return { ok: false, reason: "Sockets are not compatible" }; + } + + if (wouldCreateCycle(editor, source.nodeId, target.nodeId)) { + return { ok: false, reason: "Loops are not allowed" }; + } + + if (sourceNode instanceof ResultNode && !(targetNode instanceof ActionNodeBase)) { + return { ok: false, reason: "Result node can only connect to an action node" }; + } + + if (targetNode instanceof ActionNodeBase) { + const sourceCanConnectToAction = + sourceNode instanceof LimitNode || sourceNode instanceof ResultNode; + + if (!sourceCanConnectToAction) { + return { + ok: false, + reason: "Action nodes can only be connected from a Limit node or Result node", + }; + } + } + + return { ok: true }; +} diff --git a/apps/web/src/modules/evaluation/graph/editor/setup/createEditor.ts b/apps/web/src/modules/evaluation/graph/editor/setup/createEditor.ts index e85be7b7..6c8d13d0 100644 --- a/apps/web/src/modules/evaluation/graph/editor/setup/createEditor.ts +++ b/apps/web/src/modules/evaluation/graph/editor/setup/createEditor.ts @@ -15,6 +15,7 @@ import { setupBackground } from "./setupBackground"; import { setupConnection } from "./setupConnections"; import { setupContextMenu } from "./setupContextMenu"; import { setupKeyListeners } from "./setupKeyListeners"; +import { setupMagneticConnection } from "./setupMagneticConnection"; import { setupRender } from "./setupRender"; import { setupScopes } from "./setupScopes"; import { setupSelector } from "./setupSelector"; @@ -101,6 +102,8 @@ export async function createEditor( window.addEventListener("keydown", keyListeners.handler); + const magnetic = setupMagneticConnection({ editor, area }); + setupBackground(area); return { @@ -112,6 +115,7 @@ export async function createEditor( destroy: () => { window.removeEventListener("keydown", keyListeners.handler); keyListeners.dispose(); + magnetic.dispose(); validation.destroy(); area.destroy(); }, diff --git a/apps/web/src/modules/evaluation/graph/editor/setup/setupConnections.ts b/apps/web/src/modules/evaluation/graph/editor/setup/setupConnections.ts index 1839baeb..c54aa791 100644 --- a/apps/web/src/modules/evaluation/graph/editor/setup/setupConnections.ts +++ b/apps/web/src/modules/evaluation/graph/editor/setup/setupConnections.ts @@ -2,8 +2,6 @@ import { BooleanConnection } from "@/modules/evaluation/graph/editor/connections import { LimitItemConnection } from "@/modules/evaluation/graph/editor/connections/limitItemConnection"; import { ActionNodeBase } from "@/modules/evaluation/graph/editor/nodes/action/actionBase"; import { LimitNode } from "@/modules/evaluation/graph/editor/nodes/limit/limit"; -import { ResultNode } from "@/modules/evaluation/graph/editor/nodes/result/result"; -import { AppSocket } from "@/modules/evaluation/graph/editor/sockets/appSocket"; import { BooleanSocket } from "@/modules/evaluation/graph/editor/sockets/booleanSocket"; import { RuleSocket } from "@/modules/evaluation/graph/editor/sockets/ruleSocket"; import type { @@ -19,6 +17,7 @@ import { getSourceTarget, } from "rete-connection-plugin"; import type { AreaExtra } from "./createEditor"; +import { validateConnection } from "./connectionRules"; type Props = { editor: NodeEditor; @@ -91,6 +90,16 @@ export function setupConnection( if (!isTrackedSocket(socket)) return; + if ( + pickedSocket && + pickedSocket.nodeId === nodeId && + String(pickedSocket.key) === key && + pickedSocket.side === side + ) { + socket.connected = true; + return; + } + const connections = editor.getConnections(); const isConnected = @@ -130,45 +139,6 @@ export function setupConnection( refreshConnectionNodes(data.source, data.target); } - function wouldCreateCycle(sourceNodeId: string, targetNodeId: string) { - if (sourceNodeId === targetNodeId) return true; - - const adjacency = new Map(); - - for (const node of editor.getNodes()) { - adjacency.set(node.id, []); - } - - for (const connection of editor.getConnections()) { - const list = adjacency.get(connection.source) ?? []; - list.push(connection.target); - adjacency.set(connection.source, list); - } - - const stack = [targetNodeId]; - const visited = new Set(); - - while (stack.length > 0) { - const current = stack.pop(); - if (!current) continue; - - if (current === sourceNodeId) { - return true; - } - - if (visited.has(current)) continue; - visited.add(current); - - for (const next of adjacency.get(current) ?? []) { - if (!visited.has(next)) { - stack.push(next); - } - } - } - - return false; - } - function getExistingOutgoingActionConnections(nodeId: string) { return editor.getConnections().filter((existingConnection) => { if (existingConnection.source !== nodeId) return false; @@ -240,63 +210,18 @@ export function setupConnection( if (!source || !target || from === to) return false; - const sourceNode = editor.getNode(source.nodeId); - const targetNode = editor.getNode(target.nodeId); - - if (!sourceNode || !targetNode) return false; - - const sourceSocket = getOutputSocket( - source.nodeId, - String(source.key), + const result = validateConnection( + editor, + { nodeId: source.nodeId, key: String(source.key) }, + { nodeId: target.nodeId, key: String(target.key) }, ); - const targetSocket = getInputSocket( - target.nodeId, - String(target.key), - ); - - if (!sourceSocket || !targetSocket) return false; - if ( - !(sourceSocket instanceof AppSocket) || - !(targetSocket instanceof AppSocket) - ) { - return false; - } - - if (!sourceSocket.isCompatibleWith(targetSocket)) { - log("Sockets are not compatible", "error"); - connection.drop(); - return false; - } - - if (wouldCreateCycle(source.nodeId, target.nodeId)) { - log("Loops are not allowed", "error"); - connection.drop(); - return false; - } - - if ( - sourceNode instanceof ResultNode && - !(targetNode instanceof ActionNodeBase) - ) { - log("Result node can only connect to an action node", "error"); - connection.drop(); - return false; - } - - if (targetNode instanceof ActionNodeBase) { - const sourceCanConnectToAction = - sourceNode instanceof LimitNode || - sourceNode instanceof ResultNode; - - if (!sourceCanConnectToAction) { - log( - "Action nodes can only be connected from a Limit node or Result node", - "error", - ); + if (!result.ok) { + if (result.reason) { + log(result.reason, "error"); connection.drop(); - return false; } + return false; } return true; diff --git a/apps/web/src/modules/evaluation/graph/editor/setup/setupMagneticConnection.ts b/apps/web/src/modules/evaluation/graph/editor/setup/setupMagneticConnection.ts new file mode 100644 index 00000000..ec23523f --- /dev/null +++ b/apps/web/src/modules/evaluation/graph/editor/setup/setupMagneticConnection.ts @@ -0,0 +1,255 @@ +import { + MAGNETIC_SNAP_DISTANCE, + MAGNETIC_SNAP_SOCKET_OFFSET, +} from "@/modules/evaluation/graph/editor/constants"; +import type { Schemes } from "@/modules/evaluation/graph/editor/types"; +import { ClassicPreset, type NodeEditor } from "rete"; +import type { Area2D, AreaPlugin } from "rete-area-plugin"; +import { + type SocketData, + createPseudoconnection, +} from "rete-connection-plugin"; +import { validateConnection } from "./connectionRules"; +import type { AreaExtra } from "./createEditor"; + +type Point = { x: number; y: number }; + +function nearest( + points: T[], + target: Point, + max: number, +): T | null { + let best: { point: T; dist: number } | null = null; + for (const point of points) { + const dist = Math.hypot(point.x - target.x, point.y - target.y); + if (dist > max) continue; + if (!best || dist < best.dist) best = { point, dist }; + } + return best?.point ?? null; +} + +type Props = { + editor: NodeEditor; + area: AreaPlugin; +}; + +/** + * Magnetic connections — snap a new connection to the nearest compatible socket + * within DISTANCE so the user doesn't have to aim precisely. + * + * Deliberately does NOT add a pipe to the connection plugin: rete's re-pick flow + * (PickedExisting) finishes setting itself up in an async microtask after the + * connectionpick emit, and any extra connection pipe shifts that timing enough + * to break re-pick. + */ +export function setupMagneticConnection(props: Props): { dispose: () => void } { + const { editor, area } = props; + + const sockets = new Map(); + const pseudo = createPseudoconnection>({ + isMagnetic: true, + } as Partial); + const pseudoArea = area as unknown as AreaPlugin>; + + let picked: SocketData | null = null; + let target: (SocketData & Point) | null = null; + let cancelled = false; + + function inputConnection(socket: SocketData) { + if (socket.side !== "input") return null; + return ( + editor + .getConnections() + .find( + (conn) => + conn.target === socket.nodeId && + String(conn.targetInput) === socket.key, + ) ?? null + ); + } + + function findSocketData( + nodeId: string, + side: SocketData["side"], + key: string, + ): SocketData | null { + for (const data of sockets.values()) { + if (data.nodeId === nodeId && data.side === side && data.key === key) { + return data; + } + } + return null; + } + + function clientToContent(clientX: number, clientY: number): Point { + const box = area.container.getBoundingClientRect(); + const { x, y, k } = area.area.transform; + return { x: (clientX - box.left - x) / k, y: (clientY - box.top - y) / k }; + } + + function socketContentCenter(element: HTMLElement): Point { + const rect = element.getBoundingClientRect(); + return clientToContent( + rect.left + rect.width / 2, + rect.top + rect.height / 2, + ); + } + + function socketAt(clientX: number, clientY: number): SocketData | null { + for (const element of document.elementsFromPoint(clientX, clientY)) { + const data = sockets.get(element as HTMLElement); + if (data) return data; + } + return null; + } + + function asSourceTarget(from: SocketData, to: SocketData) { + if (from.side === to.side) return null; + const [source, target] = from.side === "output" ? [from, to] : [to, from]; + return { + source: { nodeId: source.nodeId, key: source.key }, + target: { nodeId: target.nodeId, key: target.key }, + }; + } + + function canConnect(from: SocketData, to: SocketData): boolean { + const ordered = asSourceTarget(from, to); + return ordered ? validateConnection(editor, ordered.source, ordered.target).ok : false; + } + + function clearPreview() { + target = null; + if (pseudo.isMounted()) pseudo.unmount(pseudoArea); + } + + function reset() { + picked = null; + cancelled = false; + clearPreview(); + } + + function refreshMagnet(clientX: number, clientY: number) { + if (!picked) return; + const source = picked; + const point = clientToContent(clientX, clientY); + + const candidates: (SocketData & Point)[] = []; + for (const [element, socket] of sockets) { + if (socket.side === source.side || socket.nodeId === source.nodeId) { + continue; + } + const center = socketContentCenter(element); + candidates.push({ ...socket, x: center.x, y: center.y }); + } + + const found = nearest(candidates, point, MAGNETIC_SNAP_DISTANCE); + target = found && canConnect(source, found) ? found : null; + + if (target) { + if (!pseudo.isMounted()) pseudo.mount(pseudoArea); + pseudo.render( + pseudoArea, + { + x: + target.x + + (target.side === "input" + ? -MAGNETIC_SNAP_SOCKET_OFFSET + : MAGNETIC_SNAP_SOCKET_OFFSET), + y: target.y, + }, + source, + ); + } else if (pseudo.isMounted()) { + pseudo.unmount(pseudoArea); + } + } + + function commit() { + if (!picked || !target) return; + const ordered = asSourceTarget(picked, target); + if (!ordered) return; + + const sourceNode = editor.getNode(ordered.source.nodeId); + const targetNode = editor.getNode(ordered.target.nodeId); + if ( + !sourceNode || + !targetNode || + !validateConnection(editor, ordered.source, ordered.target).ok + ) { + return; + } + + void editor.addConnection( + new ClassicPreset.Connection( + sourceNode as ClassicPreset.Node, + ordered.source.key, + targetNode as ClassicPreset.Node, + ordered.target.key, + ) as unknown as Schemes["Connection"], + ); + } + + function onPointerDown(event: PointerEvent) { + reset(); + const socket = socketAt(event.clientX, event.clientY); + if (!socket) return; + + const existing = inputConnection(socket); + if (existing) { + picked = findSocketData( + existing.source, + "output", + String(existing.sourceOutput), + ); + return; + } + + picked = socket; + } + + function onPointerMove(event: PointerEvent) { + if (!picked) return; + if (event.buttons === 0) { + reset(); + return; + } + refreshMagnet(event.clientX, event.clientY); + } + + function onPointerUp(event: PointerEvent) { + if (picked && target && !cancelled && !socketAt(event.clientX, event.clientY)) commit(); + reset(); + } + + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape" && picked) cancelled = true; + } + + area.addPipe((context) => { + if (!context || typeof context !== "object" || !("type" in context)) { + return context; + } + if (context.type === "render" && context.data.type === "socket") { + const data = context.data as unknown as SocketData; + sockets.set(data.element, data); + } else if (context.type === "unmount") { + sockets.delete(context.data.element); + } + return context; + }); + + window.addEventListener("pointerdown", onPointerDown, true); + window.addEventListener("pointermove", onPointerMove, true); + window.addEventListener("pointerup", onPointerUp, true); + window.addEventListener("keydown", onKeyDown, true); + + return { + dispose() { + window.removeEventListener("pointerdown", onPointerDown, true); + window.removeEventListener("pointermove", onPointerMove, true); + window.removeEventListener("pointerup", onPointerUp, true); + window.removeEventListener("keydown", onKeyDown, true); + if (pseudo.isMounted()) pseudo.unmount(pseudoArea); + }, + }; +} diff --git a/apps/web/src/modules/evaluation/graph/editor/setup/setupRender.ts b/apps/web/src/modules/evaluation/graph/editor/setup/setupRender.ts index 3088cc92..66b9a8cd 100644 --- a/apps/web/src/modules/evaluation/graph/editor/setup/setupRender.ts +++ b/apps/web/src/modules/evaluation/graph/editor/setup/setupRender.ts @@ -21,6 +21,7 @@ import { RuleSocket } from "@/modules/evaluation/graph/editor/sockets/ruleSocket import type { Schemes } from "@/modules/evaluation/graph/editor/types"; import { createBooleanConnectionView } from "@/modules/evaluation/graph/editor/ui/connections/BooleanConnectionView"; import { CustomConnectionView } from "@/modules/evaluation/graph/editor/ui/connections/CustomConnectionView"; +import { MagneticConnectionView } from "@/modules/evaluation/graph/editor/ui/connections/MagneticConnectionView"; import { createLimitItemConnectionView } from "@/modules/evaluation/graph/editor/ui/connections/LimitItemConnectionView"; import { IntegerRangeControlView } from "@/modules/evaluation/graph/editor/ui/controls/IntegerRangeControlView"; import { LabelControlView } from "@/modules/evaluation/graph/editor/ui/controls/LabelControlView"; @@ -102,6 +103,10 @@ export function setupRender(props: Props): ReactPlugin { return null; }, connection(context) { + if ((context.payload as { isMagnetic?: boolean }).isMagnetic) { + return MagneticConnectionView; + } + const sourceNode = editor.getNode(context.payload.source); const targetNode = editor.getNode(context.payload.target); 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/connectionStyles.ts b/apps/web/src/modules/evaluation/graph/editor/ui/connections/connectionStyles.ts index 5895d064..a6ddb0fe 100644 --- a/apps/web/src/modules/evaluation/graph/editor/ui/connections/connectionStyles.ts +++ b/apps/web/src/modules/evaluation/graph/editor/ui/connections/connectionStyles.ts @@ -8,3 +8,6 @@ export const CONNECTION_SVG_CLASS = 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]";