From 3d7997e8d04796fecffb3d5a43338e1e9f9e26ee Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Tue, 2 Dec 2025 15:02:48 +0100 Subject: [PATCH 1/6] feat: introduce git hooks Make installing git hooks easy using Husky. Also, updating the commit message checks. Includes setup instructions in the README. ref: N25B-366 --- .githooks/check-branch-name.sh | 77 ++++++++++++++++++ .githooks/check-commit-msg.sh | 138 +++++++++++++++++++++++++++++++++ .githooks/commit-msg | 16 ---- .githooks/pre-commit | 17 ---- .githooks/prepare-commit-msg | 9 --- .husky/commit-msg | 1 + .husky/pre-commit | 3 + README.md | 19 +++-- package-lock.json | 17 ++++ package.json | 6 +- 10 files changed, 251 insertions(+), 52 deletions(-) create mode 100755 .githooks/check-branch-name.sh create mode 100755 .githooks/check-commit-msg.sh delete mode 100755 .githooks/commit-msg delete mode 100755 .githooks/pre-commit delete mode 100755 .githooks/prepare-commit-msg create mode 100644 .husky/commit-msg create mode 100644 .husky/pre-commit diff --git a/.githooks/check-branch-name.sh b/.githooks/check-branch-name.sh new file mode 100755 index 0000000..6a6669a --- /dev/null +++ b/.githooks/check-branch-name.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +# This script checks if the current branch name follows the specified format. +# It's designed to be used as a 'pre-commit' git hook. + +# Format: / +# Example: feat/add-user-login + +# --- Configuration --- +# An array of allowed commit types +ALLOWED_TYPES=(feat fix refactor perf style test docs build chore revert) +# An array of branches to ignore +IGNORED_BRANCHES=(main dev demo) + +# --- Colors for Output --- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# --- Helper Functions --- +error_exit() { + echo -e "${RED}ERROR: $1${NC}" >&2 + echo -e "${YELLOW}Branch name format is incorrect. Aborting commit.${NC}" >&2 + exit 1 +} + +# --- Main Logic --- + +# 1. Get the current branch name +BRANCH_NAME=$(git symbolic-ref --short HEAD) + +# 2. Check if the current branch is in the ignored list +for ignored_branch in "${IGNORED_BRANCHES[@]}"; do + if [ "$BRANCH_NAME" == "$ignored_branch" ]; then + echo -e "${GREEN}Branch check skipped for default branch: $BRANCH_NAME${NC}" + exit 0 + fi +done + +# 3. Validate the overall structure: / +if ! [[ "$BRANCH_NAME" =~ ^[a-z]+/.+$ ]]; then + error_exit "Branch name must be in the format: /\nExample: feat/add-user-login" +fi + +# 4. Extract the type and description +TYPE=$(echo "$BRANCH_NAME" | cut -d'/' -f1) +DESCRIPTION=$(echo "$BRANCH_NAME" | cut -d'/' -f2-) + +# 5. Validate the +type_valid=false +for allowed_type in "${ALLOWED_TYPES[@]}"; do + if [ "$TYPE" == "$allowed_type" ]; then + type_valid=true + break + fi +done + +if [ "$type_valid" == false ]; then + error_exit "Invalid type '$TYPE'.\nAllowed types are: ${ALLOWED_TYPES[*]}" +fi + +# 6. Validate the +# Regex breakdown: +# ^[a-z0-9]+ - Starts with one or more lowercase letters/numbers (the first word). +# (-[a-z0-9]+){0,5} - Followed by a group of (dash + word) 0 to 5 times. +# $ - End of the string. +# This entire pattern enforces 1 to 6 words total, separated by dashes. +DESCRIPTION_REGEX="^[a-z0-9]+(-[a-z0-9]+){0,5}$" + +if ! [[ "$DESCRIPTION" =~ $DESCRIPTION_REGEX ]]; then + error_exit "Invalid short description '$DESCRIPTION'.\nIt must be a maximum of 6 words, all lowercase, separated by dashes.\nExample: add-new-user-authentication-feature" +fi + +# If all checks pass, exit successfully +echo -e "${GREEN}Branch name '$BRANCH_NAME' is valid.${NC}" +exit 0 diff --git a/.githooks/check-commit-msg.sh b/.githooks/check-commit-msg.sh new file mode 100755 index 0000000..2dd592c --- /dev/null +++ b/.githooks/check-commit-msg.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash + +# This script checks if a commit message follows the specified format. +# It's designed to be used as a 'commit-msg' git hook. + +# Format: +# : +# +# [optional] +# +# [ref/close]: + +# --- Configuration --- +# An array of allowed commit types +ALLOWED_TYPES=(feat fix refactor perf style test docs build chore revert) + +# --- Colors for Output --- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# The first argument to the hook is the path to the file containing the commit message +COMMIT_MSG_FILE=$1 + +# --- Automated Commit Detection --- + +# Read the first line (header) for initial checks +HEADER=$(head -n 1 "$COMMIT_MSG_FILE") + +echo 'Given commit message:' +echo $HEADER + +# Check for Merge commits (covers 'git merge' and PR merges from GitHub/GitLab) +# Examples: "Merge branch 'main' into ...", "Merge pull request #123 from ..." +MERGE_PATTERN="^Merge (remote-tracking )?(branch|pull request|tag) .*" +if [[ "$HEADER" =~ $MERGE_PATTERN ]]; then + echo -e "${GREEN}Merge commit detected by message content. Skipping validation.${NC}" + exit 0 +fi + +# Check for Revert commits +# Example: "Revert "feat: add new feature"" +REVERT_PATTERN="^Revert \".*\"" +if [[ "$HEADER" =~ $REVERT_PATTERN ]]; then + echo -e "${GREEN}Revert commit detected by message content. Skipping validation.${NC}" + exit 0 +fi + +# Check for Cherry-pick commits (this pattern appears at the end of the message) +# Example: "(cherry picked from commit deadbeef...)" +# We use grep -q to search the whole file quietly. +CHERRY_PICK_PATTERN="\(cherry picked from commit [a-f0-9]{7,40}\)" +if grep -qE "$CHERRY_PICK_PATTERN" "$COMMIT_MSG_FILE"; then + echo -e "${GREEN}Cherry-pick detected by message content. Skipping validation.${NC}" + exit 0 +fi + +# Check for Squash +# Example: "Squash commits ..." +SQUASH_PATTERN="^Squash .+" +if [[ "$HEADER" =~ $SQUASH_PATTERN ]]; then + echo -e "${GREEN}Squash commit detected by message content. Skipping validation.${NC}" + exit 0 +fi + +# --- Validation Functions --- + +# Function to print an error message and exit +# Usage: error_exit "Your error message here" +error_exit() { + # >&2 redirects echo to stderr + echo -e "${RED}ERROR: $1${NC}" >&2 + echo -e "${YELLOW}Commit message format is incorrect. Aborting commit.${NC}" >&2 + exit 1 +} + +# --- Main Logic --- + +# 1. Read the header (first line) of the commit message +HEADER=$(head -n 1 "$COMMIT_MSG_FILE") + +# 2. Validate the header format: : +# Regex breakdown: +# ^(type1|type2|...) - Starts with one of the allowed types +# : - Followed by a literal colon +# \s - Followed by a single space +# .+ - Followed by one or more characters for the description +# $ - End of the line +TYPES_REGEX=$( + IFS="|" + echo "${ALLOWED_TYPES[*]}" +) +HEADER_REGEX="^($TYPES_REGEX): .+$" + +if ! [[ "$HEADER" =~ $HEADER_REGEX ]]; then + error_exit "Invalid header format.\n\nHeader must be in the format: : \nAllowed types: ${ALLOWED_TYPES[*]}\nExample: feat: add new user authentication feature" +fi + +# Only validate footer if commit type is not chore +TYPE=$(echo "$HEADER" | cut -d':' -f1) +if [ "$TYPE" != "chore" ]; then + # 3. Validate the footer (last line) of the commit message + FOOTER=$(tail -n 1 "$COMMIT_MSG_FILE") + + # Regex breakdown: + # ^(ref|close) - Starts with 'ref' or 'close' + # : - Followed by a literal colon + # \s - Followed by a single space + # N25B- - Followed by the literal string 'N25B-' + # [0-9]+ - Followed by one or more digits + # $ - End of the line + FOOTER_REGEX="^(ref|close): N25B-[0-9]+$" + + if ! [[ "$FOOTER" =~ $FOOTER_REGEX ]]; then + error_exit "Invalid footer format.\n\nFooter must be in the format: [ref/close]: \nExample: ref: N25B-123" + fi +fi + +# 4. If the message has more than 2 lines, validate the separator +# A blank line must exist between the header and the body. +LINE_COUNT=$(wc -l <"$COMMIT_MSG_FILE" | xargs) # xargs trims whitespace + +# We only care if there is a body. Header + Footer = 2 lines. +# Header + Blank Line + Body... + Footer > 2 lines. +if [ "$LINE_COUNT" -gt 2 ]; then + # Get the second line + SECOND_LINE=$(sed -n '2p' "$COMMIT_MSG_FILE") + + # Check if the second line is NOT empty. If it's not, it's an error. + if [ -n "$SECOND_LINE" ]; then + error_exit "Missing blank line between header and body.\n\nThe second line of your commit message must be empty if a body is present." + fi +fi + +# If all checks pass, exit with success +echo -e "${GREEN}Commit message is valid.${NC}" +exit 0 diff --git a/.githooks/commit-msg b/.githooks/commit-msg deleted file mode 100755 index 41992ad..0000000 --- a/.githooks/commit-msg +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/sh - -commit_msg_file=$1 -commit_msg=$(cat "$commit_msg_file") - -if echo "$commit_msg" | grep -Eq "^(feat|fix|refactor|perf|style|test|docs|build|chore|revert): .+"; then - if echo "$commit_msg" | grep -Eq "^(ref|close):\sN25B-.+"; then - exit 0 - else - echo "❌ Commit message invalid! Must end with [ref/close]: N25B-000" - exit 1 - fi -else - echo "❌ Commit message invalid! Must start with : " - exit 1 -fi \ No newline at end of file diff --git a/.githooks/pre-commit b/.githooks/pre-commit deleted file mode 100755 index 7e94937..0000000 --- a/.githooks/pre-commit +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/sh - -# Get current branch -branch=$(git rev-parse --abbrev-ref HEAD) - -if echo "$branch" | grep -Eq "(dev|main)"; then - echo 0 -fi - -# allowed pattern -if echo "$branch" | grep -Eq "^(feat|fix|refactor|perf|style|test|docs|build|chore|revert)\/\w+(-\w+){0,5}$"; then - exit 0 -else - echo "❌ Invalid branch name: $branch" - echo "Branch must be named / (must have one to six words separated by a dash)" - exit 1 -fi \ No newline at end of file diff --git a/.githooks/prepare-commit-msg b/.githooks/prepare-commit-msg deleted file mode 100755 index 5b706c1..0000000 --- a/.githooks/prepare-commit-msg +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/sh - -echo "#: - -#[optional body] - -#[optional footer(s)] - -#[ref/close]: " > $1 \ No newline at end of file diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000..297870d --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +sh .githooks/check-commit-msg.sh $1 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..822552c --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,3 @@ +sh .githooks/check-branch-name.sh + +npm run lint diff --git a/README.md b/README.md index 9d8fee2..47e2054 100644 --- a/README.md +++ b/README.md @@ -28,18 +28,21 @@ npm run dev It should automatically reload when you save changes. -## GitHooks +## Git Hooks -To activate automatic commits/branch name checks run: +To activate automatic linting, branch name checks and commit message checks, run: -```shell -git config --local core.hooksPath .githooks +```bash +npm run prepare ``` -If your commit fails its either: -branch name != /description-of-branch , -commit name != : description of the commit. - : N25B-Num's +You might get an error along the lines of `Can't install pre-commit with core.hooksPath` set. To fix this, simply unset the hooksPath by running: + +```bash +git config --local --unset core.hooksPath +``` + +Then run the pre-commit install commands again. ## Documentation diff --git a/package-lock.json b/package-lock.json index 395326d..b225239 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.4.0", + "husky": "^9.1.7", "identity-obj-proxy": "^3.0.0", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", @@ -5051,6 +5052,22 @@ "node": ">=10.17.0" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", diff --git a/package.json b/package.json index a493ed2..6e7ea28 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,9 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "lint": "eslint .", - "preview": "vite preview" + "lint": "eslint src test", + "preview": "vite preview", + "prepare": "husky" }, "dependencies": { "@neodrag/react": "^2.3.1", @@ -30,6 +31,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.4.0", + "husky": "^9.1.7", "identity-obj-proxy": "^3.0.0", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", From 7d3c63630a091d0d4b6bd79607ef74b1777edb3b Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Tue, 2 Dec 2025 16:06:14 +0100 Subject: [PATCH 2/6] feat: introduce CI/CD runner Installs dependencies, checks style, runs tests. ref: N25B-366 --- .gitlab-ci.yml | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .gitlab-ci.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..46727b7 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,40 @@ +# ---------- GLOBAL SETUP ---------- # +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + +stages: + - install + - lint + - test + +variables: + NODE_VERSION: "24.11.1" + BASE_LAYER: trixie-slim + +default: + image: docker.io/library/node:${NODE_VERSION}-${BASE_LAYER} + +# --------- INSTALLING --------- # +install: + stage: install + tags: + - install + script: + - npm ci + +# ---------- LINTING ---------- # +lint: + stage: lint + tags: + - lint + script: + - npm run lint + +# ---------- TESTING ---------- # +test: + stage: test + tags: + - test + script: + - npm run test From ea85a05f273c9749c144c430a0644f195f5a2ff5 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Tue, 2 Dec 2025 16:42:00 +0100 Subject: [PATCH 3/6] fix: use install artifacts Uses install artifacts in later stages. ref: N25B-366 --- .gitlab-ci.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 46727b7..371c0a9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -14,6 +14,11 @@ variables: default: image: docker.io/library/node:${NODE_VERSION}-${BASE_LAYER} + cache: + key: "${CI_COMMIT_REF_SLUG}" + paths: + - node_modules/ + policy: pull-push # --------- INSTALLING --------- # install: @@ -22,10 +27,16 @@ install: - install script: - npm ci + artifacts: + paths: + - node_modules/ + expire_in: 1h # ---------- LINTING ---------- # lint: stage: lint + needs: + - install tags: - lint script: @@ -34,6 +45,8 @@ lint: # ---------- TESTING ---------- # test: stage: test + needs: + - install tags: - test script: From e680ad3195d0469ea10c076199f3e5b9df52dd21 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Tue, 2 Dec 2025 16:46:56 +0100 Subject: [PATCH 4/6] fix: add `test` script to package.json ref: N25B-366 --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 6e7ea28..cd08dca 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "tsc -b && vite build", "lint": "eslint src test", "preview": "vite preview", + "test": "jest", "prepare": "husky" }, "dependencies": { From 5e22ed8806ff1aafc0e1a5b36f652b28f30d4cd8 Mon Sep 17 00:00:00 2001 From: "Gerla, J. (Justin)" Date: Sun, 7 Dec 2025 15:21:59 +0000 Subject: [PATCH 5/6] feat: added undo and redo functionality --- src/components/TextField.tsx | 6 +- src/pages/VisProgPage/VisProg.tsx | 31 ++- .../visualProgrammingUI/EditorUndoRedo.ts | 129 ++++++++++ .../visualProgrammingUI/VisProgStores.tsx | 95 ++++--- .../visualProgrammingUI/VisProgTypes.tsx | 12 + .../components/DragDropSidebar.tsx | 8 +- .../visualProgrammingUI/nodes/GoalNode.tsx | 15 +- .../EditorUndoRedo.test.ts | 239 ++++++++++++++++++ test/setupFlowTests.ts | 6 + 9 files changed, 490 insertions(+), 51 deletions(-) create mode 100644 src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts create mode 100644 test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts diff --git a/src/components/TextField.tsx b/src/components/TextField.tsx index f9527c8..6dbc47b 100644 --- a/src/components/TextField.tsx +++ b/src/components/TextField.tsx @@ -1,4 +1,4 @@ -import {useState} from "react"; +import {useEffect, useState} from "react"; import styles from "./TextField.module.css"; /** @@ -105,6 +105,10 @@ export function TextField({ }) { const [inputValue, setInputValue] = useState(value); + useEffect(() => { + setInputValue(value); + }, [value]); + const onCommit = () => setValue(inputValue); return ({ onConnect: state.onConnect, onReconnectStart: state.onReconnectStart, onReconnectEnd: state.onReconnectEnd, - onReconnect: state.onReconnect + onReconnect: state.onReconnect, + undo: state.undo, + redo: state.redo, + beginBatchAction: state.beginBatchAction, + endBatchAction: state.endBatchAction }); // --| define ReactFlow editor |-- @@ -60,9 +65,23 @@ const VisProgUI = () => { onConnect, onReconnect, onReconnectStart, - onReconnectEnd + onReconnectEnd, + undo, + redo, + beginBatchAction, + endBatchAction } = useFlowStore(useShallow(selector)); // instructs the editor to use the corresponding functions from the FlowStore + // adds ctrl+z and ctrl+y support to respectively undo and redo actions + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.ctrlKey && e.key === 'z') undo(); + if (e.ctrlKey && e.key === 'y') redo(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }); + return (
{ onReconnectStart={onReconnectStart} onReconnectEnd={onReconnectEnd} onConnect={onConnect} + onNodeDragStart={beginBatchAction} + onNodeDragStop={endBatchAction} snapToGrid fitView proOptions={{hideAttribution: true}} @@ -83,6 +104,10 @@ const VisProgUI = () => { {/* contains the drag and drop panel for nodes */} + + + + @@ -90,8 +115,6 @@ const VisProgUI = () => { ); }; - - /** * Places the VisProgUI component inside a ReactFlowProvider * diff --git a/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts b/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts new file mode 100644 index 0000000..70c4c01 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts @@ -0,0 +1,129 @@ +import type {Edge, Node} from "@xyflow/react"; +import type {StateCreator, StoreApi } from 'zustand/vanilla'; +import type {FlowState} from "./VisProgTypes.tsx"; + +export type FlowSnapshot = { + nodes: Node[]; + edges: Edge[]; +} + +/** + * A reduced version of the flowState type, + * This removes the functions that are provided by UndoRedo from the expected input type + */ +type BaseFlowState = Omit; + + +/** + * UndoRedo is implemented as a middleware for the FlowState store, + * this allows us to keep the undo redo logic separate from the flowState, + * and thus from the internal editor logic + * + * Allows users to undo and redo actions in the visual programming editor + * + * @param {(set: StoreApi["setState"], get: () => FlowState, api: StoreApi) => BaseFlowState} config + * @returns {StateCreator} + * @constructor + */ +export const UndoRedo = ( + config: ( + set: StoreApi['setState'], + get: () => FlowState, + api: StoreApi + ) => BaseFlowState ) : StateCreator => (set, get, api) => { + let batchTimeout: number | null = null; + + /** + * Captures the current state for + * + * @param {BaseFlowState} state - the current state of the editor + * @returns {FlowSnapshot} - returns a snapshot of the current editor state + */ + const getSnapshot = (state : BaseFlowState) : FlowSnapshot => ({ + nodes: state.nodes, + edges: state.edges + }); + + const initialState = config(set, get, api); + + return { + ...initialState, + + /** + * Adds a snapshot of the current state to the undo history + */ + pushSnapshot: () => { + const state = get(); + // we don't add new snapshots during an ongoing batch action + if (!state.isBatchAction) { + set({ + past: [...state.past, getSnapshot(state)], + future: [] + }); + } + + }, + + /** + * Undoes the last action from the editor, + * The state before undoing is added to the future for potential redoing + */ + undo: () => { + const state = get(); + if (!state.past.length) return; + + const snapshot = state.past.pop()!; // pop last snapshot + const currentSnapshot: FlowSnapshot = getSnapshot(state); + + set({ + nodes: snapshot.nodes, + edges: snapshot.edges, + }); + + state.future.push(currentSnapshot); // push current to redo + }, + + /** + * redoes the last undone action, + * The state before redoing is added to the past for potential undoing + */ + redo: () => { + const state = get(); + if (!state.future.length) return; + + const snapshot = state.future.pop()!; // pop last redo + const currentSnapshot: FlowSnapshot = getSnapshot(state); + + set({ + nodes: snapshot.nodes, + edges: snapshot.edges, + }); + + state.past.push(currentSnapshot); // push current to undo + }, + + /** + * Begins a batched action + * + * An example of a batched action is dragging a node in the editor, + * where we want the entire action of moving a node to a different position + * to be covered by one undoable snapshot + */ + beginBatchAction: () => { + get().pushSnapshot(); + set({ isBatchAction: true }); + if (batchTimeout) clearTimeout(batchTimeout); + }, + + /** + * Ends a batched action, + * a very short timeout is used to prevent new snapshots from being added + * until we are certain that the batch event is finished + */ + endBatchAction: () => { + batchTimeout = window.setTimeout(() => { + set({ isBatchAction: false }); + }, 10); + } + } +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index e79715f..5bcd855 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -10,6 +10,7 @@ import { } from '@xyflow/react'; import type { FlowState } from './VisProgTypes'; import { NodeDefaults, NodeConnects, NodeDeletes } from './NodeRegistry'; +import { UndoRedo } from "./EditorUndoRedo.ts"; /** @@ -34,7 +35,7 @@ function createNode(id: string, type: string, position: XYPosition, data: Record return {...defaultData, ...newData} } -//* Initial nodes to populate the flow at startup. +//* Initial nodes, created by using createNode. */ const initialNodes : Node[] = [ createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false), createNode('end', 'end', {x: 500, y: 100}, {label: "End"}, false), @@ -42,7 +43,7 @@ const initialNodes : Node[] = [ createNode('norms-1', 'norm', {x:-200, y:100}, {label: "Initial Norms", normList: ["Be a robot", "get good"]}), ]; -//* Initial edges to connect the startup nodes. +// * Initial edges * / const initialEdges: Edge[] = [ { id: 'start-phase-1', source: 'start', target: 'phase-1' }, { id: 'phase-1-end', source: 'phase-1', target: 'end' }, @@ -50,17 +51,17 @@ const initialEdges: Edge[] = [ /** - * How we have defined the functions for our FlowState. - * We have the normal functionality of a default FlowState with some exceptions to account for extra functionality. - * The biggest changes are in onConnect and onDelete, which we have given extra functionality based on the nodes defined functions. - * + * useFlowStore contains the implementation for all editor functionality + * and stores the current state of the visual programming editor + * * * Provides: * - Node and edge state management * - Node creation, deletion, and updates * - Custom connection handling via NodeConnects * - Edge reconnection handling + * - Undo Redo functionality through custom middleware */ -const useFlowStore = create((set, get) => ({ +const useFlowStore = create(UndoRedo((set, get) => ({ nodes: initialNodes, edges: initialEdges, edgeReconnectSuccessful: true, @@ -68,8 +69,7 @@ const useFlowStore = create((set, get) => ({ /** * Handles changes to nodes triggered by ReactFlow. */ - onNodesChange: (changes) => - set({nodes: applyNodeChanges(changes, get().nodes)}), + onNodesChange: (changes) => set({nodes: applyNodeChanges(changes, get().nodes)}), /** * Handles changes to edges triggered by ReactFlow. @@ -81,28 +81,34 @@ const useFlowStore = create((set, get) => ({ * Updates edges and calls the node-specific connection functions. */ onConnect: (connection) => { - const edges = addEdge(connection, get().edges); - const nodes = get().nodes; - // connection has: { source, sourceHandle, target, targetHandle } - // Let's find the source and target ID's. - const sourceNode = nodes.find((n) => n.id == connection.source); - const targetNode = nodes.find((n) => n.id == connection.target); - - // In case the nodes weren't found, return basic functionality. - if (sourceNode == undefined || targetNode == undefined || sourceNode.type == undefined || targetNode.type == undefined) { - set({ nodes, edges }); - return; - } + get().pushSnapshot(); - // We should find out how their data changes by calling their respective functions. - const sourceConnectFunction = NodeConnects[sourceNode.type as keyof typeof NodeConnects] - const targetConnectFunction = NodeConnects[targetNode.type as keyof typeof NodeConnects] - - // We're going to have to update their data based on how they want to update it. - sourceConnectFunction(sourceNode, targetNode, true) - targetConnectFunction(targetNode, sourceNode, false) - set({ nodes, edges }); -}, + const edges = addEdge(connection, get().edges); + const nodes = get().nodes; + // connection has: { source, sourceHandle, target, targetHandle } + // Let's find the source and target ID's. + const sourceNode = nodes.find((n) => n.id == connection.source); + const targetNode = nodes.find((n) => n.id == connection.target); + + // In case the nodes weren't found, return basic functionality. + if ( sourceNode == undefined + || targetNode == undefined + || sourceNode.type == undefined + || targetNode.type == undefined + ){ + set({ nodes, edges }); + return; + } + + // We should find out how their data changes by calling their respective functions. + const sourceConnectFunction = NodeConnects[sourceNode.type as keyof typeof NodeConnects] + const targetConnectFunction = NodeConnects[targetNode.type as keyof typeof NodeConnects] + + // We're going to have to update their data based on how they want to update it. + sourceConnectFunction(sourceNode, targetNode, true) + targetConnectFunction(targetNode, sourceNode, false) + set({ nodes, edges }); + }, /** * Handles reconnecting an edge between nodes. @@ -112,19 +118,32 @@ const useFlowStore = create((set, get) => ({ set({ edges: reconnectEdge(oldEdge, newConnection, get().edges) }); }, - onReconnectStart: () => set({ edgeReconnectSuccessful: false }), + onReconnectStart: () => { + get().pushSnapshot(); + set({ edgeReconnectSuccessful: false }) + }, + + /** + * handles potential dropping (deleting) of an edge + * if it is not reconnected to a node after detaching it + * + * @param _evt - the event + * @param {{id: string}} edge - the described edge + */ onReconnectEnd: (_evt, edge) => { if (!get().edgeReconnectSuccessful) { set({ edges: get().edges.filter((e) => e.id !== edge.id) }); } set({ edgeReconnectSuccessful: true }); }, - + /** * Deletes a node by ID, respecting NodeDeletes rules. * Also removes all edges connected to that node. */ deleteNode: (nodeId) => { + get().pushSnapshot(); + // Let's find our node to check if they have a special deletion function const ourNode = get().nodes.find((n)=>n.id==nodeId); const ourFunction = Object.entries(NodeDeletes).find(([t])=>t==ourNode?.type)?.[1] @@ -135,7 +154,7 @@ const useFlowStore = create((set, get) => ({ nodes: get().nodes.filter((n) => n.id !== nodeId), edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId), })} - }, + }, /** * Replaces the entire nodes array in the store. @@ -151,6 +170,7 @@ const useFlowStore = create((set, get) => ({ * Updates the data of a node by merging new data with existing data. */ updateNodeData: (nodeId, data) => { + get().pushSnapshot(); set({ nodes: get().nodes.map((node) => { if (node.id === nodeId) { @@ -165,8 +185,15 @@ const useFlowStore = create((set, get) => ({ * Adds a new node to the flow store. */ addNode: (node: Node) => { + get().pushSnapshot(); set({ nodes: [...get().nodes, node] }); }, -})); + + // undo redo default values + past: [], + future: [], + isBatchAction: false, + })) +); export default useFlowStore; diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx index e466bed..b35bbf2 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx @@ -1,6 +1,8 @@ // VisProgTypes.ts import type { Edge, OnNodesChange, OnEdgesChange, OnConnect, OnReconnect, Node } from '@xyflow/react'; import type { NodeTypes } from './NodeRegistry'; +import type {FlowSnapshot} from "./EditorUndoRedo.ts"; + /** * Type representing all registered node types. @@ -74,4 +76,14 @@ export type FlowState = { * @param node - the Node object to add */ addNode: (node: Node) => void; + + // UndoRedo Types + past: FlowSnapshot[]; + future: FlowSnapshot[]; + pushSnapshot: () => void; + isBatchAction: boolean; + beginBatchAction: () => void; + endBatchAction: () => void; + undo: () => void; + redo: () => void; }; diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 92f211c..94ce1dd 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -64,8 +64,8 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP * @param nodeType - The type of node to create (from `NodeTypes`). * @param position - The XY position in the flow canvas where the node will appear. */ -function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { - const { nodes, setNodes } = useFlowStore.getState(); +function addNodeToFlow(nodeType: keyof typeof NodeTypes, position: XYPosition) { + const { nodes, addNode } = useFlowStore.getState(); // Load any predefined data for this node type. const defaultData = NodeDefaults[nodeType] ?? {} @@ -90,7 +90,7 @@ function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { position, data: {...defaultData} } - setNodes([...nodes, newNode]); + addNode(newNode); } /** @@ -125,7 +125,7 @@ export function DndToolbar() { if (isInFlow) { const position = screenToFlowPosition(screenPosition); - addNode(nodeType, position); + addNodeToFlow(nodeType, position); } }, [screenToFlowPosition], diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index 5be666b..6168f32 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -32,23 +32,22 @@ export type GoalNode = Node * @param props NodeProps, like id, label, children * @returns React.JSX.Element */ -export default function GoalNode(props: NodeProps) { - const data = props.data +export default function GoalNode({id, data}: NodeProps) { const {updateNodeData} = useFlowStore(); - const text_input_id = `goal_${props.id}_text_input`; - const checkbox_id = `goal_${props.id}_checkbox`; + const text_input_id = `goal_${id}_text_input`; + const checkbox_id = `goal_${id}_checkbox`; const setDescription = (value: string) => { - updateNodeData(props.id, {...data, description: value}); + updateNodeData(id, {...data, description: value}); } const setAchieved = (value: boolean) => { - updateNodeData(props.id, {...data, achieved: value}); + updateNodeData(id, {...data, achieved: value}); } return <> - +
@@ -64,7 +63,7 @@ export default function GoalNode(props: NodeProps) { setAchieved(e.target.checked)} />
diff --git a/test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts b/test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts new file mode 100644 index 0000000..76e7e96 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts @@ -0,0 +1,239 @@ +import {act} from '@testing-library/react'; +import useFlowStore from '../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; +import { mockReactFlow } from '../../../setupFlowTests.ts'; + + +beforeAll(() => { + mockReactFlow(); +}); + +describe("UndoRedo Middleware", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + test("pushSnapshot adds a snapshot to past and clears future", () => { + const store = useFlowStore; + + store.setState({ + nodes: [{ + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }], + edges: [], + past: [], + future: [{ + nodes: [ + { + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }, + ], + edges: [] + }], + }); + + act(() => { + store.getState().pushSnapshot(); + }) + + const state = store.getState(); + expect(state.past.length).toBe(1); + expect(state.past[0]).toEqual({ + nodes: [{ + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }], + edges: [] + }); + expect(state.future).toEqual([]); + }); + + test("pushSnapshot does nothing during batch action", () => { + const store = useFlowStore; + + act(() => { + store.setState({ isBatchAction: true }); + store.getState().pushSnapshot(); + }) + + expect(store.getState().past.length).toBe(0); + }); + + test("undo restores last snapshot and pushes current snapshot to future", () => { + const store = useFlowStore; + + // initial state + store.setState({ + nodes: [{ + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }], + edges: [] + }); + + act(() => { + store.getState().pushSnapshot(); + + // modified state + store.setState({ + nodes: [{ + id: 'B', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'B'} + }], + edges: [] + }); + + store.getState().undo(); + }) + + expect(store.getState().nodes).toEqual([{ + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }]); + expect(store.getState().future.length).toBe(1); + expect(store.getState().future[0]).toEqual({ + nodes: [{ + id: 'B', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'B'} + }], + edges: [] + }); + }); + + test("undo does nothing when past is empty", () => { + const store = useFlowStore; + + store.setState({past: []}); + + act(() => { store.getState().undo(); }); + + expect(store.getState().nodes).toEqual([]); + expect(store.getState().future).toEqual([]); + }); + + test("redo restores last future snapshot and pushes current to past", () => { + const store = useFlowStore; + + // initial + store.setState({ + nodes: [{ + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }], + edges: [] + }); + + act(() => { + store.getState().pushSnapshot(); + store.setState({ + nodes: [{ + id: 'B', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'B'} + }], + edges: [] + }); + + + store.getState().undo(); + + // redo should restore node with id 'B' + store.getState().redo(); + }) + + expect(store.getState().nodes).toEqual([{ + id: 'B', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'B'} + }]); + expect(store.getState().past.length).toBe(1); // snapshot A stored + expect(store.getState().past[0]).toEqual({ + nodes: [{ + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }], + edges: [] + }); + }); + + test("redo does nothing when future is empty", () => { + const store = useFlowStore; + + store.setState({past: []}); + act(() => { store.getState().redo(); }); + + expect(store.getState().nodes).toEqual([]); + }); + + test("beginBatchAction pushes snapshot and sets isBatchAction=true", () => { + const store = useFlowStore; + + store.setState({ + nodes: [{ + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }], + edges: [] + }); + + act(() => { store.getState().beginBatchAction(); }); + + expect(store.getState().isBatchAction).toBe(true); + expect(store.getState().past.length).toBe(1); + }); + + test("endBatchAction sets isBatchAction=false after timeout", () => { + const store = useFlowStore; + + store.setState({ isBatchAction: true }); + act(() => { store.getState().endBatchAction(); }); + + // isBatchAction should remain true before the timer has advanced + expect(store.getState().isBatchAction).toBe(true); + + jest.advanceTimersByTime(10); + + // it should now be set to false as the timer has advanced enough + expect(store.getState().isBatchAction).toBe(false); + }); + + test("multiple beginBatchAction calls clear the timeout", () => { + const store = useFlowStore; + + act(() => { + store.getState().beginBatchAction(); + store.getState().endBatchAction(); // starts timeout + store.getState().beginBatchAction(); // should clear previous timeout + }); + + + jest.advanceTimersByTime(10); + + // After advancing the timers, isBatchAction should still be true, + // as the timeout should have been cleared + expect(store.getState().isBatchAction).toBe(true); + }); +}); diff --git a/test/setupFlowTests.ts b/test/setupFlowTests.ts index 21a4945..3ce8c3a 100644 --- a/test/setupFlowTests.ts +++ b/test/setupFlowTests.ts @@ -69,6 +69,9 @@ beforeAll(() => { useFlowStore.setState({ nodes: [], edges: [], + past: [], + future: [], + isBatchAction: false, edgeReconnectSuccessful: true }); }); @@ -78,6 +81,9 @@ afterEach(() => { useFlowStore.setState({ nodes: [], edges: [], + past: [], + future: [], + isBatchAction: false, edgeReconnectSuccessful: true }); }); From 086caea7375749716802cbd12f54efea84e6fe9d Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Sun, 7 Dec 2025 15:32:20 +0000 Subject: [PATCH 6/6] test: high coverage for all UI tests --- .../visualProgrammingUI/NodeRegistry.ts | 4 +- .../components/DragDropSidebar.tsx | 7 +- .../visualProgrammingUI/nodes/EndNode.tsx | 19 +- .../visualProgrammingUI/nodes/GoalNode.tsx | 19 +- .../visualProgrammingUI/nodes/NormNode.tsx | 20 +- .../visualProgrammingUI/nodes/PhaseNode.tsx | 6 +- .../visualProgrammingUI/nodes/StartNode.tsx | 19 +- .../visualProgrammingUI/nodes/TriggerNode.tsx | 24 +- test/components/Logging/Logging.test.tsx | 12 +- test/pages/robot/Robot.test.tsx | 167 ++++ .../components/DragDropSidebar.test.tsx | 109 ++- .../components/ScrollIntoView.test.tsx | 14 + .../nodes/NormNode.test.tsx | 744 ++++++++++++++++++ .../nodes/StartNode.test.tsx | 98 +++ .../nodes/TriggerNode.test.tsx | 246 ++++++ .../nodes/UniversalNodes.test.tsx | 151 ++++ test/test-utils/mocks.ts | 41 + test/test-utils/test-utils.tsx | 24 + 18 files changed, 1641 insertions(+), 83 deletions(-) create mode 100644 test/pages/robot/Robot.test.tsx create mode 100644 test/pages/visProgPage/visualProgrammingUI/components/ScrollIntoView.test.tsx create mode 100644 test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx create mode 100644 test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx create mode 100644 test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx create mode 100644 test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx create mode 100644 test/test-utils/mocks.ts create mode 100644 test/test-utils/test-utils.tsx diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts index e64acc1..8812434 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -68,7 +68,7 @@ export const NodeConnects = { phase: PhaseConnects, norm: NormConnects, goal: GoalConnects, - trigger: TriggerConnects, + trigger: TriggerConnects, } /** @@ -79,6 +79,7 @@ export const NodeConnects = { export const NodeDeletes = { start: () => false, end: () => false, + test: () => false, // Used for coverage of universal/ undefined nodes } /** @@ -91,4 +92,5 @@ export const NodesInPhase = { start: () => false, end: () => false, phase: () => false, + test: () => false, // Used for coverage of universal/ undefined nodes } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 94ce1dd..9a41f06 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -47,7 +47,11 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP }); return ( -
+
{children}
); @@ -149,6 +153,7 @@ export function DndToolbar() { {/* Maps over all the nodes that are droppable, and puts them in the panel */} {droppableNodes.map(({type, data}) => ( ) { /** * Functionality for reducing this node into its more compact json program * @param node the node to reduce - * @param nodes all nodes present + * @param _nodes all nodes present * @returns Dictionary, {id: node.id} */ -export function EndReduce(node: Node, nodes: Node[]) { +export function EndReduce(node: Node, _nodes: Node[]) { // Replace this for nodes functionality - if (nodes.length <= -1) { - console.warn("Impossible nodes length in EndReduce") - } return { id: node.id } @@ -55,13 +52,9 @@ export function EndReduce(node: Node, nodes: Node[]) { /** * Any connection functionality that should get called when a connection is made to this node type (end) - * @param thisNode the node of which the functionality gets called - * @param otherNode the other node which has connected - * @param isThisSource whether this node is the one that is the source of the connection + * @param _thisNode the node of which the functionality gets called + * @param _otherNode the other node which has connected + * @param _isThisSource whether this node is the one that is the source of the connection */ -export function EndConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { - // Replace this for connection logic - if (thisNode == undefined && otherNode == undefined && isThisSource == false) { - console.warn("Impossible node connection called in EndConnects") - } +export function EndConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index 6168f32..bbacdf0 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -76,13 +76,9 @@ export default function GoalNode({id, data}: NodeProps) { /** * Reduces each Goal, including its children down into its relevant data. * @param node: The Node Properties of this node. - * @param nodes: all the nodes in the graph + * @param _nodes: all the nodes in the graph */ -export function GoalReduce(node: Node, nodes: Node[]) { - // Replace this for nodes functionality - if (nodes.length <= -1) { - console.warn("Impossible nodes length in GoalReduce") - } +export function GoalReduce(node: Node, _nodes: Node[]) { const data = node.data as GoalNodeData; return { id: node.id, @@ -94,13 +90,10 @@ export function GoalReduce(node: Node, nodes: Node[]) { /** * This function is called whenever a connection is made with this node type (Goal) - * @param thisNode the node of this node type which function is called - * @param otherNode the other node which was part of the connection - * @param isThisSource whether this instance of the node was the source in the connection, true = yes. + * @param _thisNode the node of this node type which function is called + * @param _otherNode the other node which was part of the connection + * @param _isThisSource whether this instance of the node was the source in the connection, true = yes. */ -export function GoalConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { +export function GoalConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { // Replace this for connection logic - if (thisNode == undefined && otherNode == undefined && isThisSource == false) { - console.warn("Impossible node connection called in EndConnects") - } } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index d2ca50d..31d92a5 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -61,13 +61,9 @@ export default function NormNode(props: NodeProps) { /** * Reduces each Norm, including its children down into its relevant data. * @param node: The Node Properties of this node. - * @param nodes: all the nodes in the graph + * @param _nodes: all the nodes in the graph */ -export function NormReduce(node: Node, nodes: Node[]) { - // Replace this for nodes functionality - if (nodes.length <= -1) { - console.warn("Impossible nodes length in NormReduce") - } +export function NormReduce(node: Node, _nodes: Node[]) { const data = node.data as NormNodeData; return { id: node.id, @@ -78,13 +74,9 @@ export function NormReduce(node: Node, nodes: Node[]) { /** * This function is called whenever a connection is made with this node type (Norm) - * @param thisNode the node of this node type which function is called - * @param otherNode the other node which was part of the connection - * @param isThisSource whether this instance of the node was the source in the connection, true = yes. + * @param _thisNode the node of this node type which function is called + * @param _otherNode the other node which was part of the connection + * @param _isThisSource whether this instance of the node was the source in the connection, true = yes. */ -export function NormConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { - // Replace this for connection logic - if (thisNode == undefined && otherNode == undefined && isThisSource == false) { - console.warn("Impossible node connection called in EndConnects") - } +export function NormConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index 7234e34..56c762c 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -78,8 +78,10 @@ export function PhaseReduce(node: Node, nodes: Node[]) { .filter(([t]) => !nodesNotInPhase.includes(t)) .map(([t]) => t); - // children nodes - const childrenNodes = nodes.filter((node) => data.children.includes(node.id)); + // children nodes - make sure to check for empty arrays + let childrenNodes: Node[] = []; + if (data.children) + childrenNodes = nodes.filter((node) => data.children.includes(node.id)); // Build the result object const result: Record = { diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx index 6d74c08..f994090 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx @@ -40,14 +40,11 @@ export default function StartNode(props: NodeProps) { /** * The reduce function for this node type. * @param node this node - * @param nodes all the nodes in the graph + * @param _nodes all the nodes in the graph * @returns a reduced structure of this node */ -export function StartReduce(node: Node, nodes: Node[]) { +export function StartReduce(node: Node, _nodes: Node[]) { // Replace this for nodes functionality - if (nodes.length <= -1) { - console.warn("Impossible nodes length in StartReduce") - } return { id: node.id } @@ -55,13 +52,9 @@ export function StartReduce(node: Node, nodes: Node[]) { /** * This function is called whenever a connection is made with this node type (start) - * @param thisNode the node of this node type which function is called - * @param otherNode the other node which was part of the connection - * @param isThisSource whether this instance of the node was the source in the connection, true = yes. + * @param _thisNode the node of this node type which function is called + * @param _otherNode the other node which was part of the connection + * @param _isThisSource whether this instance of the node was the source in the connection, true = yes. */ -export function StartConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { - // Replace this for connection logic - if (thisNode == undefined && otherNode == undefined && isThisSource == false) { - console.warn("Impossible node connection called in EndConnects") - } +export function StartConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index 5c40aeb..2e7b732 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -80,14 +80,10 @@ export default function TriggerNode(props: NodeProps) { /** * Reduces each Trigger, including its children down into its core data. * @param node - The Trigger node to reduce. - * @param nodes - The list of all nodes in the current flow graph. + * @param _nodes - The list of all nodes in the current flow graph. * @returns A simplified object containing the node label and its list of triggers. */ -export function TriggerReduce(node: Node, nodes: Node[]) { - // Replace this for nodes functionality - if (nodes.length <= -1) { - console.warn("Impossible nodes length in TriggerReduce") - } +export function TriggerReduce(node: Node, _nodes: Node[]) { const data = node.data; switch (data.triggerType) { case "keywords": @@ -106,17 +102,13 @@ export function TriggerReduce(node: Node, nodes: Node[]) { } /** - * Handles logic that occurs when a connection is made involving a Trigger node. - * - * @param thisNode - The current Trigger node being connected. - * @param otherNode - The other node involved in the connection. - * @param isThisSource - Whether this node was the source of the connection. + * This function is called whenever a connection is made with this node type (trigger) + * @param _thisNode the node of this node type which function is called + * @param _otherNode the other node which was part of the connection + * @param _isThisSource whether this instance of the node was the source in the connection, true = yes. */ -export function TriggerConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { - // Replace this for connection logic - if (thisNode == undefined && otherNode == undefined && isThisSource == false) { - console.warn("Impossible node connection called in EndConnects") - } +export function TriggerConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { + } // Definitions for the possible triggers, being keywords and emotions diff --git a/test/components/Logging/Logging.test.tsx b/test/components/Logging/Logging.test.tsx index 03d4a92..a3b6d09 100644 --- a/test/components/Logging/Logging.test.tsx +++ b/test/components/Logging/Logging.test.tsx @@ -127,10 +127,10 @@ describe("Logging component", () => { render(); - expect(screen.getByText("Logs")).toBeInTheDocument(); - expect(screen.getByText("WARNING")).toBeInTheDocument(); - expect(screen.getByText("logging")).toBeInTheDocument(); - expect(screen.getByText("Ping")).toBeInTheDocument(); + expect(screen.getByText("Logs")).toBeDefined(); + expect(screen.getByText("WARNING")).toBeDefined(); + expect(screen.getByText("logging")).toBeDefined(); + expect(screen.getByText("Ping")).toBeDefined(); let timestamp = screen.queryByText("ABS TIME"); if (!timestamp) { @@ -141,7 +141,7 @@ describe("Logging component", () => { } await user.click(timestamp); - expect(screen.getByText("00:00:12.345")).toBeInTheDocument(); + expect(screen.getByText("00:00:12.345")).toBeDefined(); }); it("shows the scroll-to-bottom button after a manual scroll and scrolls when clicked", async () => { @@ -188,7 +188,7 @@ describe("Logging component", () => { logCell.set({...current, message: "Updated"}); }); - expect(screen.getByText("Updated")).toBeInTheDocument(); + expect(screen.getByText("Updated")).toBeDefined(); await waitFor(() => { expect(scrollSpy).toHaveBeenCalledTimes(1); }); diff --git a/test/pages/robot/Robot.test.tsx b/test/pages/robot/Robot.test.tsx new file mode 100644 index 0000000..bcebac8 --- /dev/null +++ b/test/pages/robot/Robot.test.tsx @@ -0,0 +1,167 @@ +import { render, screen, act, cleanup, fireEvent } from '@testing-library/react'; +import Robot from '../../../src/pages/Robot/Robot'; + +// Mock EventSource +const mockInstances: MockEventSource[] = []; +class MockEventSource { + url: string; + onmessage: ((event: MessageEvent) => void) | null = null; + closed = false; + + constructor(url: string) { + this.url = url; + mockInstances.push(this); + } + + sendMessage(data: string) { + this.onmessage?.({ data } as MessageEvent); + } + + close() { + this.closed = true; + } +} + +// Mock global EventSource +beforeAll(() => { + (globalThis as any).EventSource = jest.fn((url: string) => new MockEventSource(url)); +}); + +// Mock fetch +beforeEach(() => { + globalThis.fetch = jest.fn(() => + Promise.resolve({ + json: () => Promise.resolve({ reply: 'ok' }), + }) + ) as jest.Mock; +}); + +// Cleanup +afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + mockInstances.length = 0; +}); + +describe('Robot', () => { + test('renders initial state', () => { + render(); + expect(screen.getByText('Robot interaction')).toBeInTheDocument(); + expect(screen.getByText('Force robot speech')).toBeInTheDocument(); + expect(screen.getByText('Listening 🔴')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Enter a message')).toBeInTheDocument(); + }); + + test('sends message via button', async () => { + render(); + const input = screen.getByPlaceholderText('Enter a message'); + const button = screen.getByText('Speak'); + + fireEvent.change(input, { target: { value: 'Hello' } }); + await act(async () => fireEvent.click(button)); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'http://localhost:8000/message', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'Hello' }), + }) + ); + }); + + test('sends message via Enter key', async () => { + render(); + const input = screen.getByPlaceholderText('Enter a message'); + fireEvent.change(input, { target: { value: 'Hi Enter' } }); + + await act(async () => + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 }) + ); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'http://localhost:8000/message', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'Hi Enter' }), + }) + ); + expect((input as HTMLInputElement).value).toBe(''); + }); + + test('handles fetch errors', async () => { + globalThis.fetch = jest.fn(() => Promise.reject('Network error')) as jest.Mock; + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + render(); + const input = screen.getByPlaceholderText('Enter a message'); + const button = screen.getByText('Speak'); + fireEvent.change(input, { target: { value: 'Error test' } }); + + await act(async () => fireEvent.click(button)); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Error sending message: ', + 'Network error' + ); + }); + + test('updates conversation on SSE', async () => { + render(); + const eventSource = mockInstances[0]; + + await act(async () => { + eventSource.sendMessage(JSON.stringify({ voice_active: true })); + eventSource.sendMessage(JSON.stringify({ speech: 'User says hi' })); + eventSource.sendMessage(JSON.stringify({ llm_response: 'Assistant replies' })); + }); + + expect(screen.getByText('Listening 🟢')).toBeInTheDocument(); + expect(screen.getByText('User says hi')).toBeInTheDocument(); + expect(screen.getByText('Assistant replies')).toBeInTheDocument(); + }); + + test('handles invalid SSE JSON', async () => { + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + render(); + const eventSource = mockInstances[0]; + + await act(async () => eventSource.sendMessage('bad-json')); + + expect(logSpy).toHaveBeenCalledWith('Unparsable SSE message:', 'bad-json'); + }); + + test('resets conversation with Reset button', async () => { + render(); + const eventSource = mockInstances[0]; + + await act(async () => + eventSource.sendMessage(JSON.stringify({ speech: 'Hello' })) + ); + expect(screen.getByText('Hello')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Reset')); + expect(screen.queryByText('Hello')).not.toBeInTheDocument(); + }); + + test('toggles conversationIndex with Stop/Start button', () => { + render(); + const stopButton = screen.getByText('Stop'); + fireEvent.click(stopButton); + expect(screen.getByText('Start')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Start')); + expect(screen.getByText('Stop')).toBeInTheDocument(); + }); + + test('closes EventSource on unmount', () => { + const { unmount } = render(); + const eventSource = mockInstances[0]; + const closeSpy = jest.spyOn(eventSource, 'close'); + + unmount(); + expect(closeSpy).toHaveBeenCalled(); + expect(eventSource.closed).toBe(true); + }); +}); diff --git a/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx index 70087ee..486d41f 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx @@ -1,5 +1,106 @@ -describe('Not implemented', () => { - test('nothing yet', () => { - expect(true) - }); +import { getByTestId, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; +import VisProgPage from '../../../../../src/pages/VisProgPage/VisProg'; + + + +class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +window.ResizeObserver = ResizeObserver; + +jest.mock('@neodrag/react', () => ({ + useDraggable: (ref: React.RefObject, options: any) => { + // We access the real useEffect from React to attach a listener + // This bridges the gap between the test's userEvent and the component's logic + const { useEffect } = jest.requireActual('react'); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + // When the test fires a "pointerup" (end of click/drag), + // we manually trigger the library's onDragEnd callback. + const handlePointerUp = (e: PointerEvent) => { + if (options.onDragEnd) { + options.onDragEnd({ event: e }); + } + }; + + element.addEventListener('pointerup', handlePointerUp as EventListener); + return () => { + element.removeEventListener('pointerup', handlePointerUp as EventListener); + }; + }, [ref, options]); + }, +})); + +// We will mock @xyflow/react so we control screenToFlowPosition +jest.mock('@xyflow/react', () => { + const actual = jest.requireActual('@xyflow/react'); + return { + ...actual, + useReactFlow: () => ({ + screenToFlowPosition: ({ x, y }: { x: number; y: number }) => ({ + x: x - 100, + y: y - 100, + }), + }), + }; }); + +describe("Drag & drop node creation", () => { + + test("drops a phase node inside the canvas and adds it with transformed position", async () => { + const user = userEvent.setup(); + + const { container } = render(); + + // --- Mock ReactFlow bounding box --- + // Your DndToolbar checks these values: + const flowEl = container.querySelector('.react-flow'); + jest.spyOn(flowEl!, 'getBoundingClientRect').mockReturnValue({ + left: 0, + right: 800, + top: 0, + bottom: 600, + width: 800, + height: 600, + x: 0, + y: 0, + toJSON: () => {}, + }); + + + const phaseLabel = getByTestId(container, 'draggable-phase') + + await user.pointer([ + // touch the screen at element1 + {keys: '[TouchA>]', target: phaseLabel}, + // move the touch pointer to element2 + {pointerName: 'TouchA', coords: {x: 300, y: 250}}, + // release the touch pointer at the last position (element2) + {keys: '[/TouchA]'}, + ]); + + // Read the Zustand store + const { nodes } = useFlowStore.getState(); + + // --- Assertions --- + expect(nodes.length).toBe(1); + + const node = nodes[0]; + + expect(node.type).toBe("phase"); + expect(node.id).toBe("phase-1"); + + // screenToFlowPosition was mocked to subtract 100 + expect(node.position).toEqual({ + x: 200, + y: 150, + }); + }); +}); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/components/ScrollIntoView.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/ScrollIntoView.test.tsx new file mode 100644 index 0000000..2a91e85 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/components/ScrollIntoView.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@testing-library/react'; +import { act } from '@testing-library/react'; +import ScrollIntoView from '../../../../../src/components/ScrollIntoView'; + +test('scrolls the element into view on render', () => { + const scrollMock = jest.fn(); + HTMLElement.prototype.scrollIntoView = scrollMock; + + act(() => { + render(); + }); + + expect(scrollMock).toHaveBeenCalledWith({ behavior: 'smooth' }); +}); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx new file mode 100644 index 0000000..25c9947 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx @@ -0,0 +1,744 @@ +import { describe, it, beforeEach } from '@jest/globals'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '../.././/./../../test-utils/test-utils'; +import NormNode, { NormReduce, NormConnects, type NormNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode' +import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; +import type { Node } from '@xyflow/react'; +import '@testing-library/jest-dom' + + + +describe('NormNode', () => { + let user: ReturnType; + + beforeEach(() => { + user = userEvent.setup(); + }); + + describe('Rendering', () => { + it('should render the norm node with default data', () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + expect(screen.getByPlaceholderText('Pepper should ...')).toBeInTheDocument(); + }); + + it('should render with pre-populated norm text', () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Be respectful to humans', + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + const input = screen.getByDisplayValue('Be respectful to humans'); + expect(input).toBeInTheDocument(); + }); + + it('should render with selected state', () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + const norm = screen.getByText("Norm :") + expect(norm).toBeInTheDocument(); + }); + + it('should render with dragging state', () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Dragged norm', + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + const input = screen.getByDisplayValue('Dragged norm'); + expect(input).toBeInTheDocument(); + }); + }); + + describe('User Interactions', () => { + it('should update norm text when user types in the input field', async () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('Pepper should ...'); + await user.type(input, 'Be polite to guests{enter}'); + + await waitFor(() => { + const state = useFlowStore.getState(); + const updatedNode = state.nodes.find(n => n.id === 'norm-1'); + expect(updatedNode?.data.norm).toBe('Be polite to guests'); + }); + }); + + it('should handle clearing the norm text', async () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Initial norm text', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByDisplayValue('Initial norm text') as HTMLInputElement; + + // clearing the norm text is the same as just deleting all characters one by one + // TODO: FIGURE OUT A MORE EFFICIENT WAY, I"M SORRY, user.clear() just doesn't work:/ + for (let a = 0; a < 'Initial norm text'.length; a++){ + await user.type(input, '{backspace}') + } + await user.type(input,'{enter}') + + await waitFor(() => { + const state = useFlowStore.getState(); + const updatedNode = state.nodes.find(n => n.id === 'norm-1'); + expect(updatedNode?.data.norm).toBe(''); + }); + }); + + it('should update norm text multiple times', async () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('Pepper should ...'); + await user.type(input, 'First norm{enter}'); + await waitFor(() => { + expect(useFlowStore.getState().nodes[0].data.norm).toBe('First norm'); + }); + + + // TODO: FIGURE OUT A MORE EFFICIENT WAY, I"M SORRY, user.clear() just doesn't work:/ + for (let a = 0; a < 'First norm'.length; a++){ + await user.type(input, '{backspace}') + } + + await user.type(input, 'Second norm{enter}'); + await waitFor(() => { + expect(useFlowStore.getState().nodes[0].data.norm).toBe('Second norm'); + }); + }); + + it('should handle special characters in norm text', async () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('Pepper should ...'); + await user.type(input, "Don't harm & be nice!{enter}" ); + + await waitFor(() => { + expect(useFlowStore.getState().nodes[0].data.norm).toBe("Don't harm & be nice!"); + }); + }); + + it('should handle long norm text', async () => { + const longText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'; + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('Pepper should ...'); + await user.type(input, longText); + await user.type(input, "{enter}") + + await waitFor(() => { + expect(useFlowStore.getState().nodes[0].data.norm).toBe(longText); + }); + }); + }); + + describe('NormReduce Function', () => { + it('should reduce a norm node to its essential data', () => { + const normNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Safety Norm', + droppable: true, + norm: 'Never harm humans', + hasReduce: true, + }, + }; + + const allNodes: Node[] = [normNode]; + const result = NormReduce(normNode, allNodes); + + expect(result).toEqual({ + id: 'norm-1', + label: 'Safety Norm', + norm: 'Never harm humans', + }); + }); + + it('should reduce multiple norm nodes independently', () => { + const norm1: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Norm 1', + droppable: true, + norm: 'Be helpful', + hasReduce: true, + }, + }; + + const norm2: Node = { + id: 'norm-2', + type: 'norm', + position: { x: 100, y: 0 }, + data: { + label: 'Norm 2', + droppable: true, + norm: 'Be honest', + hasReduce: true, + }, + }; + + const allNodes: Node[] = [norm1, norm2]; + + const result1 = NormReduce(norm1, allNodes); + const result2 = NormReduce(norm2, allNodes); + + expect(result1.id).toBe('norm-1'); + expect(result1.norm).toBe('Be helpful'); + expect(result2.id).toBe('norm-2'); + expect(result2.norm).toBe('Be honest'); + }); + + it('should handle empty norm text', () => { + const normNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Empty Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + const result = NormReduce(normNode, [normNode]); + + expect(result.norm).toBe(''); + expect(result.id).toBe('norm-1'); + }); + + it('should preserve node label in reduction', () => { + const normNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Custom Label', + droppable: false, + norm: 'Test norm', + hasReduce: false, + }, + }; + + const result = NormReduce(normNode, [normNode]); + + expect(result.label).toBe('Custom Label'); + }); + }); + + describe('NormConnects Function', () => { + it('should handle connection without errors', () => { + const normNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Test', + hasReduce: true, + }, + }; + + const phaseNode: Node = { + id: 'phase-1', + type: 'phase', + position: { x: 100, y: 0 }, + data: { + label: 'Phase 1', + droppable: true, + children: [], + hasReduce: true, + }, + }; + + expect(() => { + NormConnects(normNode, phaseNode, true); + }).not.toThrow(); + }); + + it('should handle connection when norm is target', () => { + const normNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Test', + hasReduce: true, + }, + }; + + const phaseNode: Node = { + id: 'phase-1', + type: 'phase', + position: { x: 100, y: 0 }, + data: { + label: 'Phase 1', + droppable: true, + children: [], + hasReduce: true, + }, + }; + + expect(() => { + NormConnects(normNode, phaseNode, false); + }).not.toThrow(); + }); + + it('should handle self-connection', () => { + const normNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Test', + hasReduce: true, + }, + }; + + expect(() => { + NormConnects(normNode, normNode, true); + }).not.toThrow(); + }); + }); + + describe('Integration with Store', () => { + it('should properly update the store when editing norm text', async () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('Pepper should ...'); + + // TODO: FIGURE OUT A MORE EFFICIENT WAY, I"M SORRY, user.clear() just doesn't work:/ + for (let a = 0; a < 20; a++){ + await user.type(input, '{backspace}') + } + await user.type(input, 'New norm value{enter}'); + + await waitFor(() => { + const state = useFlowStore.getState(); + expect(state.nodes).toHaveLength(1); + expect(state.nodes[0].id).toBe('norm-1'); + expect(state.nodes[0].data.norm).toBe('New norm value'); + }); + }); + + it('should not affect other nodes when updating one norm node', async () => { + const norm1: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Norm 1', + droppable: true, + norm: 'Original norm 1', + hasReduce: true, + }, + }; + + const norm2: Node = { + id: 'norm-2', + type: 'norm', + position: { x: 100, y: 0 }, + data: { + label: 'Norm 2', + droppable: true, + norm: 'Original norm 2', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [norm1, norm2], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByDisplayValue('Original norm 1') as HTMLInputElement; + + + // TODO: FIGURE OUT A MORE EFFICIENT WAY, I"M SORRY, user.clear() just doesn't work:/ + for (let a = 0; a < 20; a++){ + await user.type(input, '{backspace}') + } + await user.type(input, 'Updated norm 1{enter}'); + + await waitFor(() => { + const state = useFlowStore.getState(); + const updatedNorm1 = state.nodes.find(n => n.id === 'norm-1'); + const unchangedNorm2 = state.nodes.find(n => n.id === 'norm-2'); + + expect(updatedNorm1?.data.norm).toBe('Updated norm 1'); + expect(unchangedNorm2?.data.norm).toBe('Original norm 2'); + }); + }); + + it('should maintain data consistency with multiple rapid updates', async () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'haa haa fuyaaah - link', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('Pepper should ...'); + + await user.type(input, 'a'); + await waitFor(() => { + expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link'); + }); + + await user.type(input, 'b'); + await waitFor(() => { + expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link'); + }); + + await user.type(input, 'c'); + await waitFor(() => { + expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link'); + }, { timeout: 3000 }); + }); + }); +}); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx new file mode 100644 index 0000000..c5ec43a --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx @@ -0,0 +1,98 @@ +import { describe, it } from '@jest/globals'; +import '@testing-library/jest-dom'; +import { screen } from '@testing-library/react'; +import type { Node } from '@xyflow/react'; +import { renderWithProviders } from '../.././/./../../test-utils/test-utils'; +import StartNode, { StartReduce, StartConnects } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode'; + + +describe('StartNode', () => { + + + describe('Rendering', () => { + it('renders the StartNode correctly', () => { + const mockNode: Node = { + id: 'start-1', + type: 'start', // TypeScript now knows this is a string + position: { x: 0, y: 0 }, + data: { + label: 'Start Node', + droppable: false, + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + + expect(screen.getByText('Start')).toBeInTheDocument(); + + // The handle should exist in the DOM + expect(document.querySelector('[data-handleid="source"]')).toBeInTheDocument(); + + }); + }); + + describe('StartReduce Function', () => { + it('reduces the StartNode to its minimal structure', () => { + const mockNode: Node = { + id: 'start-1', + type: 'start', + position: { x: 0, y: 0 }, + data: { + label: 'Start Node', + droppable: false, + hasReduce: true, + }, + }; + + const result = StartReduce(mockNode, [mockNode]); + expect(result).toEqual({ id: 'start-1' }); + }); + }); + + describe('StartConnects Function', () => { + it('handles connections without throwing', () => { + const startNode: Node = { + id: 'start-1', + type: 'start', + position: { x: 0, y: 0 }, + data: { + label: 'Start Node', + droppable: false, + hasReduce: true, + }, + }; + + const otherNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 100, y: 0 }, + data: { + label: 'Norm Node', + droppable: true, + norm: 'test', + hasReduce: true, + }, + }; + + expect(() => StartConnects(startNode, otherNode, true)).not.toThrow(); + expect(() => StartConnects(startNode, otherNode, false)).not.toThrow(); + }); + }); +}); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx new file mode 100644 index 0000000..55a46e3 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx @@ -0,0 +1,246 @@ +import { describe, it, beforeEach } from '@jest/globals'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '../.././/./../../test-utils/test-utils'; +import TriggerNode, { TriggerReduce, TriggerConnects, TriggerNodeCanConnect, type TriggerNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode'; +import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; +import type { Node } from '@xyflow/react'; +import '@testing-library/jest-dom'; + +describe('TriggerNode', () => { + let user: ReturnType; + + beforeEach(() => { + user = userEvent.setup(); + }); + + describe('Rendering', () => { + it('should render TriggerNode with keywords type', () => { + const mockNode: Node = { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + label: 'Keyword Trigger', + droppable: true, + triggerType: 'keywords', + triggers: [], + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + expect(screen.getByText(/Triggers when the keyword is spoken/i)).toBeInTheDocument(); + expect(screen.getByPlaceholderText('...')).toBeInTheDocument(); + }); + + it('should render TriggerNode with emotion type', () => { + const mockNode: Node = { + id: 'trigger-2', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + label: 'Emotion Trigger', + droppable: true, + triggerType: 'emotion', + triggers: [], + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + expect(screen.getByText(/Emotion\?/i)).toBeInTheDocument(); + }); + }); + + describe('User Interactions', () => { + it('should add a new keyword', async () => { + const mockNode: Node = { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + label: 'Keyword Trigger', + droppable: true, + triggerType: 'keywords', + triggers: [], + hasReduce: true, + }, + }; + + useFlowStore.setState({ nodes: [mockNode], edges: [] }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('...'); + await user.type(input, 'hello{enter}'); + + await waitFor(() => { + const node = useFlowStore.getState().nodes.find(n => n.id === 'trigger-1') as Node | undefined; + expect(node?.data.triggers.length).toBe(1); + expect(node?.data.triggers[0].keyword).toBe('hello'); + }); + + }); + + it('should remove a keyword when cleared', async () => { + const mockNode: Node = { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + label: 'Keyword Trigger', + droppable: true, + triggerType: 'keywords', + triggers: [{ id: 'kw1', keyword: 'hello' }], + hasReduce: true, + }, + }; + + useFlowStore.setState({ nodes: [mockNode], edges: [] }); + + renderWithProviders( + + ); + + const input = screen.getByDisplayValue('hello'); + for (let i = 0; i < 'hello'.length; i++) { + await user.type(input, '{backspace}'); + } + await user.type(input, '{enter}'); + + await waitFor(() => { + const node = useFlowStore.getState().nodes.find(n => n.id === 'trigger-1') as Node | undefined; + expect(node?.data.triggers.length).toBe(0); + }); + + }); + }); + + describe('TriggerReduce Function', () => { + it('should reduce a trigger node to its essential data', () => { + const triggerNode: Node = { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + label: 'Keyword Trigger', + droppable: true, + triggerType: 'keywords', + triggers: [{ id: 'kw1', keyword: 'hello' }], + hasReduce: true, + }, + }; + + const allNodes: Node[] = [triggerNode]; + const result = TriggerReduce(triggerNode, allNodes); + + expect(result).toEqual({ + id: 'trigger-1', + type: 'keywords', + label: 'Keyword Trigger', + keywords: [{ id: 'kw1', keyword: 'hello' }], + }); + }); + }); + + + describe('TriggerConnects Function', () => { + it('should handle connection without errors', () => { + const node1: Node = { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + label: 'Trigger 1', + droppable: true, + triggerType: 'keywords', + triggers: [], + hasReduce: true, + }, + }; + + const node2: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 100, y: 0 }, + data: { + label: 'Norm 1', + droppable: true, + norm: 'test', + hasReduce: true, + }, + }; + + expect(() => { + TriggerConnects(node1, node2, true); + TriggerConnects(node1, node2, false); + }).not.toThrow(); + }); + + it('should return true for TriggerNodeCanConnect if connection exists', () => { + const connection = { source: 'trigger-1', target: 'norm-1' }; + expect(TriggerNodeCanConnect(connection as any)).toBe(true); + }); + }); +}); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx new file mode 100644 index 0000000..7fb0709 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx @@ -0,0 +1,151 @@ +import { describe, beforeEach } from '@jest/globals'; +import { screen } from '@testing-library/react'; +import { renderWithProviders } from '../.././/./../../test-utils/test-utils'; +import type { XYPosition } from '@xyflow/react'; +import { NodeTypes, NodeDefaults, NodeConnects, NodeReduces, NodesInPhase } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/NodeRegistry'; +import '@testing-library/jest-dom' +import { createElement } from 'react'; +import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; + + +describe('NormNode', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + function createNode(id: string, type: string, position: XYPosition, data: Record, deletable?: boolean) { + const defaultData = NodeDefaults[type as keyof typeof NodeDefaults] + const newData = { + id: id, + type: type, + position: position, + data: data, + deletable: deletable, + } + return {...defaultData, ...newData} + } + + + /** + * Reduces the graph into its phases' information and recursively calls their reducing function + */ + function graphReducer() { + const { nodes } = useFlowStore.getState(); + return nodes + .filter((n) => n.type == 'phase') + .map((n) => { + const reducer = NodeReduces['phase']; + return reducer(n, nodes) + }); + } + + function getAllTypes() { + return Object.entries(NodeTypes).map(([t])=>t) + } + + describe('Rendering', () => { + test.each(getAllTypes())('it should render %s node with the default data', (nodeType) => { + const lengthBefore = screen.getAllByText(/.*/).length; + + const newNode = createNode(nodeType + "1", nodeType, {x: 200, y:200}, {}); + + const found = Object.entries(NodeTypes).find(([t]) => t === nodeType); + const uiElement = found ? found[1] : null; + + expect(uiElement).not.toBeNull(); + const props = { + id: newNode.id, + type: newNode.type as string, + data: newNode.data as any, + selected: false, + isConnectable: true, + zIndex: 0, + dragging: false, + selectable: true, + deletable: true, + draggable: true, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + }; + + renderWithProviders(createElement(uiElement as React.ComponentType, props)); + const lengthAfter = screen.getAllByText(/.*/).length; + + expect(lengthBefore + 1 === lengthAfter); + }); + + }); + + + describe('Connecting', () => { + test.each(getAllTypes())('it should call the connect function when %s node is connected', (nodeType) => { + // Create two nodes - one of the current type and one to connect to + const sourceNode = createNode('source-1', nodeType, {x: 100, y: 100}, {}); + const targetNode = createNode('target-1', 'end', {x: 300, y: 100}, {}); + + // Add nodes to store + useFlowStore.setState({ nodes: [sourceNode, targetNode] }); + + // Spy on the connect functions + const sourceConnectSpy = jest.spyOn(NodeConnects, nodeType as keyof typeof NodeConnects); + const targetConnectSpy = jest.spyOn(NodeConnects, 'end'); + + // Simulate connection + useFlowStore.getState().onConnect({ + source: 'source-1', + target: 'target-1', + sourceHandle: null, + targetHandle: null, + }); + + // Verify the connect functions were called + expect(sourceConnectSpy).toHaveBeenCalledWith(sourceNode, targetNode, true); + expect(targetConnectSpy).toHaveBeenCalledWith(targetNode, sourceNode, false); + + sourceConnectSpy.mockRestore(); + targetConnectSpy.mockRestore(); + }); + }); + + describe('Reducing', () => { + test.each(getAllTypes())('it should correctly call/ not call the reduce function when %s node is in a phase', (nodeType) => { + // Create a phase node and a node of the current type + const phaseNode = createNode('phase-1', 'phase', {x: 200, y: 100}, { label: 'Test Phase', children: [] }); + const testNode = createNode('node-1', nodeType, {x: 100, y: 100}, {}); + + // Add the test node as a child of the phase + (phaseNode.data as any).children.push(testNode.id); + + // Add nodes to store + useFlowStore.setState({ nodes: [phaseNode, testNode] }); + + // Spy on the reduce functions + const phaseReduceSpy = jest.spyOn(NodeReduces, 'phase'); + const nodeReduceSpy = jest.spyOn(NodeReduces, nodeType as keyof typeof NodeReduces); + + // Simulate reducing - using the graphReducer + const result = graphReducer(); + + // Verify the reduce functions were called + expect(phaseReduceSpy).toHaveBeenCalledWith(phaseNode, [phaseNode, testNode]); + // Check if this node type is in NodesInPhase and returns false + const nodesInPhaseFunc = NodesInPhase[nodeType as keyof typeof NodesInPhase]; + if (nodesInPhaseFunc && nodesInPhaseFunc() === false && nodeType !== 'phase') { + // Node is NOT in phase, so it should NOT be called + expect(nodeReduceSpy).not.toHaveBeenCalled(); + } else { + // Node IS in phase, so it SHOULD be called + expect(nodeReduceSpy).toHaveBeenCalled(); + } + + // Verify the correct structure is present using NodesInPhase + expect(result).toHaveLength(nodeType !== 'phase' ? 1 : 2); + expect(result[0]).toHaveProperty('id', 'phase-1'); + expect(result[0]).toHaveProperty('label', 'Test Phase'); + + // Restore mocks + phaseReduceSpy.mockRestore(); + nodeReduceSpy.mockRestore(); + }); + }); +}); \ No newline at end of file diff --git a/test/test-utils/mocks.ts b/test/test-utils/mocks.ts new file mode 100644 index 0000000..21971c1 --- /dev/null +++ b/test/test-utils/mocks.ts @@ -0,0 +1,41 @@ +import { jest } from '@jest/globals'; +import React from 'react'; +import '@testing-library/jest-dom'; + +/** + * Mock for @xyflow/react + * Provides simplified versions of React Flow hooks and components + */ +jest.mock('@xyflow/react', () => ({ + useReactFlow: jest.fn(() => ({ + screenToFlowPosition: jest.fn((pos: any) => pos), + getNode: jest.fn(), + getNodes: jest.fn(() => []), + getEdges: jest.fn(() => []), + setNodes: jest.fn(), + setEdges: jest.fn(), + })), + ReactFlowProvider: ({ children }: { children: React.ReactNode }) => + React.createElement('div', { 'data-testid': 'react-flow-provider' }, children), + ReactFlow: ({ children, ...props }: any) => + React.createElement('div', { 'data-testid': 'react-flow', ...props }, children), + Handle: ({ type, position, id }: any) => + React.createElement('div', { 'data-testid': `handle-${type}-${id}`, 'data-position': position }), + Panel: ({ children, position }: any) => + React.createElement('div', { 'data-testid': 'panel', 'data-position': position }, children), + Controls: () => React.createElement('div', { 'data-testid': 'controls' }), + Background: () => React.createElement('div', { 'data-testid': 'background' }), +})); + +/** + * Mock for @neodrag/react + * Simplifies drag behavior for testing + */ +jest.mock('@neodrag/react', () => ({ + useDraggable: jest.fn((ref: any, options?: any) => { + // Store the options so we can trigger them in tests + if (ref && ref.current) { + (ref.current as any)._dragOptions = options; + } + }), +})); \ No newline at end of file diff --git a/test/test-utils/test-utils.tsx b/test/test-utils/test-utils.tsx new file mode 100644 index 0000000..2379d9c --- /dev/null +++ b/test/test-utils/test-utils.tsx @@ -0,0 +1,24 @@ +// __tests__/utils/test-utils.tsx +import { render, type RenderOptions } from '@testing-library/react'; +import { type ReactElement, type ReactNode } from 'react'; +import { ReactFlowProvider } from '@xyflow/react'; + +/** + * Custom render function that wraps components with necessary providers + * This ensures all components have access to ReactFlow context + */ +export function renderWithProviders( + ui: ReactElement, + options?: Omit +) { + function Wrapper({ children }: { children: ReactNode }) { + return {children}; + } + + return render(ui, { wrapper: Wrapper, ...options }); +} + + +// Re-export everything from testing library +//eslint-disable-next-line react-refresh/only-export-components +export * from '@testing-library/react';