diff --git a/src/App.css b/src/App.css index 8ce14c8..a241d03 100644 --- a/src/App.css +++ b/src/App.css @@ -109,6 +109,10 @@ main { padding: 1rem 0; } +input[type="checkbox"] { + cursor: pointer; +} + .flex-row { display: flex; flex-direction: row; diff --git a/src/components/TextField.module.css b/src/components/TextField.module.css new file mode 100644 index 0000000..de66531 --- /dev/null +++ b/src/components/TextField.module.css @@ -0,0 +1,27 @@ +.text-field { + border: 1px solid transparent; + border-radius: 5pt; + padding: 4px 8px; + outline: none; + background-color: canvas; + transition: border-color 0.2s, box-shadow 0.2s; + cursor: text; +} + +.text-field.invalid { + border-color: red; + color: red; +} + +.text-field:focus:not(.invalid) { + border-color: color-mix(in srgb, canvas, #777 10%); +} + +.text-field:read-only { + cursor: pointer; + background-color: color-mix(in srgb, canvas, #777 5%); +} + +.text-field:read-only:hover:not(.invalid) { + border-color: color-mix(in srgb, canvas, #777 10%); +} diff --git a/src/components/TextField.tsx b/src/components/TextField.tsx new file mode 100644 index 0000000..58de55d --- /dev/null +++ b/src/components/TextField.tsx @@ -0,0 +1,101 @@ +import {useState} from "react"; +import styles from "./TextField.module.css"; + +/** + * A text input element in our own style that calls `setValue` at every keystroke. + * + * @param {Object} props - The component props. + * @param {string} props.value - The value of the text input. + * @param {(value: string) => void} props.setValue - A function that sets the value of the text input. + * @param {string} [props.placeholder] - The placeholder text for the text input. + * @param {string} [props.className] - Additional CSS classes for the text input. + * @param {string} [props.id] - The ID of the text input. + * @param {string} [props.ariaLabel] - The ARIA label for the text input. + */ +export function RealtimeTextField({ + value = "", + setValue, + onCommit, + placeholder, + className, + id, + ariaLabel, + invalid = false, +} : { + value: string, + setValue: (value: string) => void, + onCommit: () => void, + placeholder?: string, + className?: string, + id?: string, + ariaLabel?: string, + invalid?: boolean, +}) { + const [readOnly, setReadOnly] = useState(true); + + const updateData = () => { + setReadOnly(true); + onCommit(); + }; + + const updateOnEnter = (event: React.KeyboardEvent) => { if (event.key === "Enter") (event.target as HTMLInputElement).blur(); }; + + return setValue(e.target.value)} + onFocus={() => setReadOnly(false)} + onBlur={updateData} + onKeyDown={updateOnEnter} + readOnly={readOnly} + id={id} + // ReactFlow uses the "drag" / "nodrag" classes to enable / disable dragging of nodes + className={`${readOnly ? "drag" : "nodrag"} ${styles.textField} ${invalid ? styles.invalid : ""} ${className}`} + aria-label={ariaLabel} + />; +} + +/** + * A text input element in our own style that calls `setValue` once the user presses the enter key or clicks outside the input. + * + * @param {Object} props - The component props. + * @param {string} props.value - The value of the text input. + * @param {(value: string) => void} props.setValue - A function that sets the value of the text input. + * @param {string} [props.placeholder] - The placeholder text for the text input. + * @param {string} [props.className] - Additional CSS classes for the text input. + * @param {string} [props.id] - The ID of the text input. + * @param {string} [props.ariaLabel] - The ARIA label for the text input. + */ +export function TextField({ + value = "", + setValue, + placeholder, + className, + id, + ariaLabel, + invalid = false, +} : { + value: string, + setValue: (value: string) => void, + placeholder?: string, + className?: string, + id?: string, + ariaLabel?: string, + invalid?: boolean, +}) { + const [inputValue, setInputValue] = useState(value); + + const onCommit = () => setValue(inputValue); + + return ; +} diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index 34a6ecc..e19b34a 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -71,6 +71,16 @@ filter: drop-shadow(0 0 0.25rem forestgreen); } +.node-goal { + outline: yellow solid 2pt; + filter: drop-shadow(0 0 0.25rem yellow); +} + +.node-trigger { + outline: teal solid 2pt; + filter: drop-shadow(0 0 0.25rem teal); +} + .node-phase { outline: dodgerblue solid 2pt; filter: drop-shadow(0 0 0.25rem dodgerblue); @@ -102,6 +112,22 @@ filter: drop-shadow(0 0 0.25rem forestgreen); } +.draggable-node-goal { + padding: 3px 10px; + background-color: canvas; + border-radius: 5pt; + outline: yellow solid 2pt; + filter: drop-shadow(0 0 0.25rem yellow); +} + +.draggable-node-trigger { + padding: 3px 10px; + background-color: canvas; + border-radius: 5pt; + outline: teal solid 2pt; + filter: drop-shadow(0 0 0.25rem teal); +} + .draggable-node-phase { padding: 3px 10px; background-color: canvas; diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index 829bbfc..1dd1804 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -13,13 +13,15 @@ import { StartNodeComponent, EndNodeComponent, PhaseNodeComponent, - NormNodeComponent + NormNodeComponent, + GoalNodeComponent, } from './visualProgrammingUI/components/NodeDefinitions.tsx'; import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx'; import graphReducer from "./visualProgrammingUI/GraphReducer.ts"; import useFlowStore from './visualProgrammingUI/VisProgStores.tsx'; import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx'; import styles from './VisProg.module.css' +import TriggerNodeComponent from "./visualProgrammingUI/components/TriggerNodeComponent.tsx"; // --| config starting params for flow |-- @@ -30,7 +32,9 @@ const NODE_TYPES = { start: StartNodeComponent, end: EndNodeComponent, phase: PhaseNodeComponent, - norm: NormNodeComponent + norm: NormNodeComponent, + goal: GoalNodeComponent, + trigger: TriggerNodeComponent, }; /** @@ -126,6 +130,7 @@ function VisualProgrammingUI() { function runProgram() { const program = graphReducer(); console.log(program); + console.log(JSON.stringify(program, null, 2)); } /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts b/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts index 138eb82..6a4ee55 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts @@ -15,14 +15,15 @@ import type { Phase, PhaseReducer, PreparedGraph, - PreparedPhase + PreparedPhase, Reduced, TriggerReducer } from "./GraphReducerTypes.ts"; import type { AppNode, GoalNode, NormNode, - PhaseNode + PhaseNode, TriggerNode } from "./VisProgTypes.tsx"; +import type {TriggerNodeProps} from "./components/TriggerNodeComponent.tsx"; /** * Reduces the current graph inside the visual programming editor into a BehaviorProgram @@ -31,13 +32,15 @@ import type { * @param {PhaseReducer} phaseReducer * @param {NormReducer} normReducer * @param {GoalReducer} goalReducer + * @param {TriggerReducer} triggerReducer * @returns {BehaviorProgram} */ export default function graphReducer( graphPreprocessor: GraphPreprocessor = defaultGraphPreprocessor, phaseReducer: PhaseReducer = defaultPhaseReducer, normReducer: NormReducer = defaultNormReducer, - goalReducer: GoalReducer = defaultGoalReducer + goalReducer: GoalReducer = defaultGoalReducer, + triggerReducer: TriggerReducer = defaultTriggerReducer, ) : BehaviorProgram { const nodes: AppNode[] = useFlowStore.getState().nodes; const edges: Edge[] = useFlowStore.getState().edges; @@ -47,7 +50,8 @@ export default function graphReducer( phaseReducer( preparedPhase, normReducer, - goalReducer + goalReducer, + triggerReducer, )); }; @@ -58,12 +62,14 @@ export default function graphReducer( * @param {PreparedPhase} phase * @param {NormReducer} normReducer * @param {GoalReducer} goalReducer + * @param {TriggerReducer} triggerReducer * @returns {Phase} */ export function defaultPhaseReducer( phase: PreparedPhase, normReducer: NormReducer = defaultNormReducer, - goalReducer: GoalReducer = defaultGoalReducer + goalReducer: GoalReducer = defaultGoalReducer, + triggerReducer: TriggerReducer = defaultTriggerReducer, ) : Phase { return { id: phase.phaseNode.id, @@ -71,7 +77,8 @@ export function defaultPhaseReducer( nextPhaseId: phase.nextPhaseId, phaseData: { norms: phase.connectedNorms.map(normReducer), - goals: phase.connectedGoals.map(goalReducer) + goals: phase.connectedGoals.map(goalReducer), + triggers: phase.connectedTriggers.map(triggerReducer), } } } @@ -82,11 +89,12 @@ export function defaultPhaseReducer( * @param {GoalNode} node * @returns {GoalData} */ -function defaultGoalReducer(node: GoalNode) : GoalData { +function defaultGoalReducer(node: GoalNode) : Reduced { return { id: node.id, name: node.data.label, - value: node.data.value + description: node.data.description, + achieved: node.data.achieved, } } @@ -96,7 +104,7 @@ function defaultGoalReducer(node: GoalNode) : GoalData { * @param {NormNode} node * @returns {NormData} */ -function defaultNormReducer(node: NormNode) :NormData { +function defaultNormReducer(node: NormNode) :Reduced { return { id: node.id, name: node.data.label, @@ -104,6 +112,13 @@ function defaultNormReducer(node: NormNode) :NormData { } } +function defaultTriggerReducer(node: TriggerNode): Reduced { + return { + id: node.id, + ...node.data, + } +} + // Graph preprocessing functions: /** @@ -117,6 +132,7 @@ function defaultNormReducer(node: NormNode) :NormData { export function defaultGraphPreprocessor(nodes: AppNode[], edges: Edge[]) : PreparedGraph { const norms : NormNode[] = nodes.filter((node) => node.type === 'norm') as NormNode[]; const goals : GoalNode[] = nodes.filter((node) => node.type === 'goal') as GoalNode[]; + const triggers : TriggerNode[] = nodes.filter((node) => node.type === 'trigger') as TriggerNode[]; const orderedPhases : OrderedPhases = orderPhases(nodes, edges); return orderedPhases.phaseNodes.map((phase: PhaseNode) : PreparedPhase => { @@ -125,7 +141,8 @@ export function defaultGraphPreprocessor(nodes: AppNode[], edges: Edge[]) : Prep phaseNode: phase, nextPhaseId: nextPhase as string, connectedNorms: getIncomers({id: phase.id}, norms,edges), - connectedGoals: getIncomers({id: phase.id}, goals,edges) + connectedGoals: getIncomers({id: phase.id}, goals,edges), + connectedTriggers: getIncomers({id: phase.id}, triggers, edges), }; }); } diff --git a/src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts b/src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts index 9151b56..1826286 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts @@ -1,12 +1,14 @@ import type {Edge} from "@xyflow/react"; -import type {AppNode, GoalNode, NormNode, PhaseNode} from "./VisProgTypes.tsx"; +import type {AppNode, GoalNode, NormNode, PhaseNode, TriggerNode} from "./VisProgTypes.tsx"; +import type {TriggerNodeProps} from "./components/TriggerNodeComponent.tsx"; +export type Reduced = { id: string } & T; + /** * defines how a norm is represented in the simplified behavior program */ export type NormData = { - id: string; name: string; value: string; }; @@ -15,9 +17,9 @@ export type NormData = { * defines how a goal is represented in the simplified behavior program */ export type GoalData = { - id: string; name: string; - value: string; + description: string; + achieved: boolean; }; /** @@ -27,6 +29,7 @@ export type GoalData = { export type PhaseData = { norms: NormData[]; goals: GoalData[]; + triggers: TriggerNodeProps[]; }; /** @@ -55,12 +58,14 @@ export type BehaviorProgram = Phase[]; -export type NormReducer = (node: NormNode) => NormData; -export type GoalReducer = (node: GoalNode) => GoalData; +export type NormReducer = (node: NormNode) => Reduced; +export type GoalReducer = (node: GoalNode) => Reduced; +export type TriggerReducer = (node: TriggerNode) => Reduced; export type PhaseReducer = ( preparedPhase: PreparedPhase, normReducer: NormReducer, - goalReducer: GoalReducer + goalReducer: GoalReducer, + triggerReducer: TriggerReducer, ) => Phase; /** @@ -90,6 +95,7 @@ export type PreparedPhase = { nextPhaseId: string; connectedNorms: NormNode[]; connectedGoals: GoalNode[]; + connectedTriggers: TriggerNode[]; }; /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx index bb7c28c..8bfc715 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx @@ -6,18 +6,21 @@ import { type OnConnect, type OnReconnect, } from '@xyflow/react'; +import type {TriggerNodeProps} from "./components/TriggerNodeComponent.tsx"; type defaultNodeData = { label: string; }; +type OurNode = Node; + export type StartNode = Node; export type EndNode = Node; -export type GoalNode = Node; +export type GoalNode = Node; export type NormNode = Node; export type PhaseNode = Node; - +export type TriggerNode = OurNode; /** * a type meant to house different node types, currently not used diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index c9e1496..ea6b387 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -11,7 +11,7 @@ import { } from 'react'; import useFlowStore from "../VisProgStores.tsx"; import styles from "../../VisProg.module.css" -import type {AppNode, PhaseNode, NormNode} from "../VisProgTypes.tsx"; +import type {AppNode, PhaseNode, NormNode, GoalNode, TriggerNode} from "../VisProgTypes.tsx"; @@ -106,10 +106,48 @@ export function addNode(nodeType: string, position: XYPosition) { id: `norm-${normNumber}`, type: nodeType, position, - data: {label: `new norm node`, value: "Pepper should be formal"}, + data: {label: `new norm node`, value: ""}, } return normNode; } + case "goal": + { + const goalNodes= nds.filter((node) => node.type === 'goal'); + let goalNumber + if (goalNodes.length > 0) { + const finalGoalId : number = +(goalNodes[goalNodes.length - 1].id.split('-')[1]); + goalNumber = finalGoalId + 1; + } else { + goalNumber = 1; + } + + const goalNode : GoalNode = { + id: `goal-${goalNumber}`, + type: nodeType, + position, + data: {label: `new goal node`, description: "", achieved: false}, + } + return goalNode; + } + case "trigger": + { + const triggerNodes= nds.filter((node) => node.type === 'trigger'); + let triggerNumber + if (triggerNodes.length > 0) { + const finalGoalId : number = +(triggerNodes[triggerNodes.length - 1].id.split('-')[1]); + triggerNumber = finalGoalId + 1; + } else { + triggerNumber = 1; + } + + const triggerNode : TriggerNode = { + id: `trigger-${triggerNumber}`, + type: nodeType, + position, + data: {label: `new trigger node`, type: "keywords", value: []}, + } + return triggerNode; + } default: { throw new Error(`Node ${nodeType} not found`); } @@ -161,6 +199,12 @@ export function DndToolbar() { norm Node + + goal Node + + + trigger Node + ); diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx index 19f56dd..3f7868d 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx @@ -11,8 +11,9 @@ import type { StartNode, EndNode, PhaseNode, - NormNode + NormNode, GoalNode } from "../VisProgTypes.tsx"; +import {TextField} from "../../../../components/TextField.tsx"; //Toolbar definitions @@ -44,56 +45,6 @@ export function Toolbar({nodeId, allowDelete}: ToolbarProps) { ); } -// Renaming component - -/** - * Adds a component that can be used to edit a node's label entry inside its Data - * can be added to any custom node that has a label inside its Data - * - * @param {string} nodeLabel - * @param {string} nodeId - * @returns {React.JSX.Element} - * @constructor - */ -export function EditableName({nodeLabel = "new node", nodeId} : { nodeLabel : string, nodeId: string}) { - const {updateNodeData} = useFlowStore(); - - const updateData = (event: React.FocusEvent) => { - const input = event.target.value; - updateNodeData(nodeId, {label: input}); - event.currentTarget.setAttribute("readOnly", "true"); - window.getSelection()?.empty(); - event.currentTarget.classList.replace("nodrag", "drag"); // enable dragging of the node with cursor on the input box - }; - - const updateOnEnter = (event: React.KeyboardEvent) => { if (event.key === "Enter") (event.target as HTMLInputElement).blur(); }; - - const enableEditing = (event: React.MouseEvent) => { - if(event.currentTarget.hasAttribute("readOnly")) { - event.currentTarget.removeAttribute("readOnly"); // enable editing - event.currentTarget.select(); // select the text input - window.getSelection()?.collapseToEnd(); // move the caret to the end of the current value - event.currentTarget.classList.replace("drag", "nodrag"); // disable dragging using input box - } - } - - return ( -
- - -
- ) -} - // Definitions of Nodes @@ -148,11 +99,25 @@ export const EndNodeComponent = ({id, data}: NodeProps) => { * @constructor */ export const PhaseNodeComponent = ({id, data}: NodeProps) => { + const {updateNodeData} = useFlowStore(); + + const updateLabel = (value: string) => updateNodeData(id, {...data, label: value}); + + const label_input_id = `phase_${id}_label_input`; + return ( <>
- +
+ + +
@@ -167,17 +132,69 @@ export const PhaseNodeComponent = ({id, data}: NodeProps) => { * * @param {string} id * @param {defaultNodeData & {value: string}} data - * @returns {React.JSX.Element} - * @constructor */ export const NormNodeComponent = ({id, data}: NodeProps) => { - return ( - <> - -
- - + const {updateNodeData} = useFlowStore(); + + const text_input_id = `norm_${id}_text_input`; + + const setValue = (value: string) => { + updateNodeData(id, {value: value}); + } + + return <> + +
+
+ + setValue(val)} + placeholder={"Pepper should ..."} + />
- - ); -}; \ No newline at end of file + +
+ ; +}; + +export const GoalNodeComponent = ({id, data}: NodeProps) => { + const {updateNodeData} = useFlowStore(); + + const text_input_id = `goal_${id}_text_input`; + const checkbox_id = `goal_${id}_checkbox`; + + const setDescription = (value: string) => { + updateNodeData(id, {...data, description: value}); + } + + const setAchieved = (value: boolean) => { + updateNodeData(id, {...data, achieved: value}); + } + + return <> + +
+
+ + setDescription(val)} + placeholder={"To ..."} + /> +
+
+ + setAchieved(e.target.checked)} + /> +
+ +
+ ; +} diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/TriggerNodeComponent.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/TriggerNodeComponent.tsx new file mode 100644 index 0000000..7fca1ff --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/TriggerNodeComponent.tsx @@ -0,0 +1,121 @@ +import {Handle, type NodeProps, Position} from "@xyflow/react"; +import type {TriggerNode} from "../VisProgTypes.tsx"; +import useFlowStore from "../VisProgStores.tsx"; +import styles from "../../VisProg.module.css"; +import {RealtimeTextField, TextField} from "../../../../components/TextField.tsx"; +import {Toolbar} from "./NodeDefinitions.tsx"; +import {useState} from "react"; +import duplicateIndices from "../../../../utils/duplicateIndices.ts"; + +export type EmotionTriggerNodeProps = { + type: "emotion"; + value: string; +} + +type Keyword = { id: string, keyword: string }; + +export type KeywordTriggerNodeProps = { + type: "keywords"; + value: Keyword[]; +} + +export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps; + +function KeywordAdder({ addKeyword }: { addKeyword: (keyword: string) => void }) { + const [input, setInput] = useState(""); + + const text_input_id = "keyword_adder_input"; + + return
+ + { + if (!input) return; + addKeyword(input); + setInput(""); + }} + placeholder={"..."} + className={"flex-1"} + /> +
; +} + +function Keywords({ + keywords, + setKeywords, +}: { + keywords: Keyword[]; + setKeywords: (keywords: Keyword[]) => void; +}) { + type Interpolatable = string | number | boolean | bigint | null | undefined; + + const inputElementId = (id: Interpolatable) => `keyword_${id}_input`; + + /** Indices of duplicates in the keyword array. */ + const [duplicates, setDuplicates] = useState([]); + + function replace(id: string, value: string) { + value = value.trim(); + const newKeywords = value === "" + ? keywords.filter((kw) => kw.id != id) + : keywords.map((kw) => kw.id === id ? {...kw, keyword: value} : kw); + setKeywords(newKeywords); + setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword))); + } + + function add(value: string) { + value = value.trim(); + if (value === "") return; + const newKeywords = [...keywords, {id: crypto.randomUUID(), keyword: value}]; + setKeywords(newKeywords); + setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword))); + } + + return <> + Triggers when {keywords.length <= 1 ? "the keyword is" : "all keywords are"} spoken. + {[...keywords].map(({id, keyword}, index) => { + return
+ + replace(id, val)} + placeholder={"..."} + className={"flex-1"} + invalid={duplicates.includes(index)} + /> +
; + })} + + ; +} + +export default function TriggerNodeComponent({ + id, + data, +}: NodeProps) { + const {updateNodeData} = useFlowStore(); + + const setKeywords = (keywords: Keyword[]) => { + updateNodeData(id, {...data, value: keywords}); + } + + return <> + +
+ {data.type === "emotion" && ( +
Emotion?
+ )} + {data.type === "keywords" && ( + + )} + +
+ ; +} diff --git a/src/utils/duplicateIndices.ts b/src/utils/duplicateIndices.ts new file mode 100644 index 0000000..08a4d43 --- /dev/null +++ b/src/utils/duplicateIndices.ts @@ -0,0 +1,19 @@ +/** + * Find the indices of all elements that occur more than once. + * + * @param array The array to search for duplicates. + * @returns An array of indices where an element occurs more than once, in no particular order. + */ +export default function duplicateIndices(array: T[]): number[] { + const positions = new Map(); + + array.forEach((value, i) => { + if (!positions.has(value)) positions.set(value, []); + positions.get(value)!.push(i); + }); + + // flatten all index lists with more than one element + return Array.from(positions.values()) + .filter(idxs => idxs.length > 1) + .flat(); +} diff --git a/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts b/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts index 4473b82..246972c 100644 --- a/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts +++ b/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts @@ -457,6 +457,7 @@ describe('Graph Reducer Tests', () => { nextPhaseId: 'end', connectedNorms: [], connectedGoals: [], + connectedTriggers: [], }] }, { @@ -472,6 +473,7 @@ describe('Graph Reducer Tests', () => { nextPhaseId: 'phase-2', connectedNorms: [], connectedGoals: [], + connectedTriggers: [], }, { phaseNode: { @@ -483,6 +485,7 @@ describe('Graph Reducer Tests', () => { nextPhaseId: 'phase-3', connectedNorms: [], connectedGoals: [], + connectedTriggers: [], }, { phaseNode: { @@ -494,6 +497,7 @@ describe('Graph Reducer Tests', () => { nextPhaseId: 'end', connectedNorms: [], connectedGoals: [], + connectedTriggers: [], }] }, { @@ -509,6 +513,7 @@ describe('Graph Reducer Tests', () => { nextPhaseId: 'phase-2', connectedNorms: [], connectedGoals: [], + connectedTriggers: [], }, { phaseNode: { @@ -525,6 +530,7 @@ describe('Graph Reducer Tests', () => { data: {label: 'Generic Norm', value: "generic"}, }], connectedGoals: [], + connectedTriggers: [], }, { phaseNode: { @@ -541,6 +547,7 @@ describe('Graph Reducer Tests', () => { data: {label: 'Generic Norm', value: "generic"}, }], connectedGoals: [], + connectedTriggers: [], }] }, { @@ -561,6 +568,7 @@ describe('Graph Reducer Tests', () => { data: {label: 'Generic Norm', value: "generic"}, }], connectedGoals: [], + connectedTriggers: [], }, { phaseNode: { @@ -583,6 +591,7 @@ describe('Graph Reducer Tests', () => { data: {label: 'Generic Norm', value: "generic"}, }], connectedGoals: [], + connectedTriggers: [], }, { phaseNode: { @@ -605,6 +614,7 @@ describe('Graph Reducer Tests', () => { data: {label: 'Generic Norm', value: "generic"}, }], connectedGoals: [], + connectedTriggers: [], }] }, { @@ -732,6 +742,7 @@ describe('Graph Reducer Tests', () => { nextPhaseId: 'end', connectedNorms: [], connectedGoals: [], + connectedTriggers: [], } const output = defaultPhaseReducer(input); expect(output).toEqual({ @@ -740,7 +751,8 @@ describe('Graph Reducer Tests', () => { nextPhaseId: 'end', phaseData: { norms: [], - goals: [] + goals: [], + triggers: [], } }); }); @@ -760,6 +772,7 @@ describe('Graph Reducer Tests', () => { data: {label: 'Generic Norm', value: "generic"}, }], connectedGoals: [], + connectedTriggers: [], } const output = defaultPhaseReducer(input); expect(output).toEqual({ @@ -772,7 +785,8 @@ describe('Graph Reducer Tests', () => { name: 'Generic Norm', value: "generic" }], - goals: [] + goals: [], + triggers: [], } }); }); @@ -790,8 +804,9 @@ describe('Graph Reducer Tests', () => { id: 'goal-1', type: 'goal', position: {x: 0, y: 150}, - data: {label: 'Generic Goal', value: "generic"}, + data: {label: 'Generic Goal', description: "generic", achieved: false}, }], + connectedTriggers: [], } const output = defaultPhaseReducer(input); expect(output).toEqual({ @@ -803,7 +818,50 @@ describe('Graph Reducer Tests', () => { goals: [{ id: 'goal-1', name: 'Generic Goal', - value: "generic" + description: "generic", + achieved: false, + }], + triggers: [], + } + }); + }); + test("defaultTriggerReducer reduces triggers correctly", () => { + const input : PreparedPhase = { + phaseNode: { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + nextPhaseId: 'end', + connectedNorms: [], + connectedGoals: [], + connectedTriggers: [{ + id: 'trigger-1', + type: 'trigger', + position: {x: 0, y: 150}, + data: {label: 'Keyword Trigger', type: "keywords", value: [ + {id: "some_id", keyword: "generic"}, + {id: "another_id", keyword: "another"}, + ]}, + }], + } + const output = defaultPhaseReducer(input); + expect(output).toEqual({ + id: 'phase-1', + name: 'Generic Phase', + nextPhaseId: 'end', + phaseData: { + norms: [], + goals: [], + triggers: [{ + id: 'trigger-1', + label: 'Keyword Trigger', + type: "keywords", + value: [ + {id: "some_id", keyword: "generic"}, + {id: "another_id", keyword: "another"}, + ] }] } }); @@ -820,7 +878,8 @@ describe('Graph Reducer Tests', () => { nextPhaseId: 'end', phaseData: { norms: [], - goals: [] + goals: [], + triggers: [], } }] }, @@ -833,7 +892,8 @@ describe('Graph Reducer Tests', () => { nextPhaseId: 'phase-2', phaseData: { norms: [], - goals: [] + goals: [], + triggers: [], } }, { @@ -842,7 +902,8 @@ describe('Graph Reducer Tests', () => { nextPhaseId: 'phase-3', phaseData: { norms: [], - goals: [] + goals: [], + triggers: [], } }, { @@ -851,7 +912,8 @@ describe('Graph Reducer Tests', () => { nextPhaseId: 'end', phaseData: { norms: [], - goals: [] + goals: [], + triggers: [], } }] }, @@ -864,7 +926,8 @@ describe('Graph Reducer Tests', () => { nextPhaseId: 'phase-2', phaseData: { norms: [], - goals: [] + goals: [], + triggers: [], } }, { @@ -879,7 +942,8 @@ describe('Graph Reducer Tests', () => { value: "generic" } ], - goals: [] + goals: [], + triggers: [], } }, { @@ -892,7 +956,8 @@ describe('Graph Reducer Tests', () => { name: 'Generic Norm', value: "generic" }], - goals: [] + goals: [], + triggers: [], } }] }, @@ -909,7 +974,8 @@ describe('Graph Reducer Tests', () => { name: 'Generic Norm', value: "generic" }], - goals: [] + goals: [], + triggers: [], } }, { @@ -929,7 +995,8 @@ describe('Graph Reducer Tests', () => { value: "generic" } ], - goals: [] + goals: [], + triggers: [], } }, { @@ -947,7 +1014,8 @@ describe('Graph Reducer Tests', () => { name: 'Generic Norm', value: "generic" }], - goals: [] + goals: [], + triggers: [], } }] }, diff --git a/test/utils/duplicateIndices.test.ts b/test/utils/duplicateIndices.test.ts new file mode 100644 index 0000000..25dce1a --- /dev/null +++ b/test/utils/duplicateIndices.test.ts @@ -0,0 +1,22 @@ +import duplicateIndices from "../../src/utils/duplicateIndices.ts"; + +describe("duplicateIndices (unit)", () => { + it("returns an empty array for empty input", () => { + expect(duplicateIndices([])).toEqual([]); + }); + + it("returns an empty array when no duplicates exist", () => { + expect(duplicateIndices([1, 2, 3, 4])).toEqual([]); + }); + + it("returns all positions for every duplicated value", () => { + const result = duplicateIndices(["a", "b", "a", "c", "b", "b"]); + expect(result.sort()).toEqual([0, 1, 2, 4, 5]); + }); + + it("only treats identical references as duplicate objects", () => { + const shared = { v: 1 }; + const result = duplicateIndices([shared, { v: 1 }, shared, shared]); + expect(result.sort()).toEqual([0, 2, 3]); + }); +});