feat: create tests, more integration testing, fix ID tests, use UUID (almost) everywhere

ref: N25B-412
This commit is contained in:
Björn Otgaar
2026-01-04 18:29:19 +01:00
parent c5f44536b7
commit 149b82cb66
11 changed files with 332 additions and 146 deletions

View File

@@ -140,78 +140,8 @@
filter: drop-shadow(0 0 0.25rem plum);
}
.planDialog {
overflow:visible;
width: 80vw;
max-width: 900px;
transition: width 0.25s ease;
}
.planDialog::backdrop {
background: rgba(0, 0, 0, 0.4);
}
.planEditor {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
min-width: 600px;
}
.planEditorLeft {
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.planEditorRight {
display: flex;
flex-direction: column;
gap: 0.5rem;
border-left: 1px solid var(--border-color, #ccc);
padding-left: 1rem;
max-height: 300px;
overflow-y: auto;
}
.planStep {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
transition: text-decoration 0.2s;
}
.planStep:hover {
text-decoration: line-through;
}
.stepType {
opacity: 0.7;
font-size: 0.85em;
}
.stepIndex {
opacity: 0.6;
}
.emptySteps {
opacity: 0.5;
font-style: italic;
}
.stepSuggestion {
opacity: 0.5;
font-style: italic;
}
.planNoIterate {
opacity: 0.5;
font-style: italic;
text-decoration: line-through;
}

View File

@@ -1,13 +1,23 @@
import { useState, useEffect, useRef } from "react";
import { useState, useRef } from "react";
import styles from './GestureValueEditor.module.css'
/**
* Props for the GestureValueEditor component.
* - value: current gesture value (controlled by parent)
* - setValue: callback to update the gesture value in parent state
* - placeholder: optional placeholder text for the input field
*/
type GestureValueEditorProps = {
value: string;
setValue: (value: string) => void;
placeholder?: string;
};
// Define your gesture tags here
/**
* List of high-level gesture "tags".
* These are human-readable categories or semantic labels.
* In a real app, these would likely be loaded from an external source.
*/
const GESTURE_TAGS = ["above", "affirmative", "afford", "agitated", "all", "allright", "alright", "any",
"assuage", "attemper", "back", "bashful", "beg", "beseech", "blank",
"body language", "bored", "bow", "but", "call", "calm", "choose", "choice", "cloud",
@@ -23,6 +33,11 @@ const GESTURE_TAGS = ["above", "affirmative", "afford", "agitated", "all", "allr
"think", "timid", "top", "unless", "up", "upstairs", "void", "warm", "winner", "yeah",
"yes", "yoo-hoo", "you", "your", "zero", "zestful"];
/**
* List of concrete gesture animation paths.
* These represent specific animation assets and are used in "single" mode
* with autocomplete-style selection, also would be loaded from an external source.
*/
const GESTURE_SINGLES = [
"animations/Stand/BodyTalk/Listening/Listening_1",
"animations/Stand/BodyTalk/Listening/Listening_2",
@@ -421,48 +436,60 @@ const GESTURE_SINGLES = [
"animations/Stand/Waiting/Zombie_1"]
/**
* Returns a gesture value editor component.
* @returns JSX.Element
*/
export default function GestureValueEditor({
value,
setValue,
placeholder = "Gesture name",
}: GestureValueEditorProps) {
/** Input mode: semantic tag vs concrete animation path */
const [mode, setMode] = useState<"single" | "tag">("tag");
/** Raw text value for single-gesture input */
const [customValue, setCustomValue] = useState("");
/** Autocomplete dropdown state */
const [showSuggestions, setShowSuggestions] = useState(true);
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
/** Reserved for future click-outside / positioning logic */
const containerRef = useRef<HTMLDivElement>(null);
/** Switch between tag and single input modes */
const handleModeChange = (newMode: "single" | "tag") => {
setMode(newMode);
if (newMode === "single") {
// When switching to single, use custom value or existing value
setValue(customValue || value);
setFilteredSuggestions(GESTURE_SINGLES);
setShowSuggestions(true);
} else {
// When switching to tag, clear value if not a valid tag
const isCurrentValueTag = GESTURE_TAGS.some(tag =>
tag.toLowerCase() === value.toLowerCase()
// Clear value if it does not match a valid tag
const isValidTag = GESTURE_TAGS.some(
tag => tag.toLowerCase() === value.toLowerCase()
);
if (!isCurrentValueTag) {
setValue("");
}
if (!isValidTag) setValue("");
setShowSuggestions(false);
}
};
/** Select a semantic gesture tag */
const handleTagSelect = (tag: string) => {
setValue(tag);
};
/** Update single-gesture input and filter suggestions */
const handleCustomChange = (newValue: string) => {
setCustomValue(newValue);
setValue(newValue);
// Filter suggestions based on input
if (newValue.trim() === "") {
setShowSuggestions(true)
setFilteredSuggestions(GESTURE_SINGLES);
setShowSuggestions(true);
} else {
const filtered = GESTURE_SINGLES.filter(single =>
single.toLowerCase().includes(newValue.toLowerCase())
@@ -472,30 +499,32 @@ export default function GestureValueEditor({
}
};
/** Commit autocomplete selection */
const handleSuggestionSelect = (suggestion: string) => {
setCustomValue(suggestion);
setValue(suggestion);
setShowSuggestions(false);
};
/** Refresh suggestions on refocus */
const handleInputFocus = () => {
if (customValue.trim() !== "") {
const filtered = GESTURE_SINGLES.filter(tag =>
tag.toLowerCase().includes(customValue.toLowerCase())
);
setFilteredSuggestions(filtered);
setShowSuggestions(filtered.length > 0);
}
if (!customValue.trim()) return;
const filtered = GESTURE_SINGLES.filter(single =>
single.toLowerCase().includes(customValue.toLowerCase())
);
setFilteredSuggestions(filtered);
setShowSuggestions(filtered.length > 0);
};
const handleInputBlur = (_e: React.FocusEvent) => {
// Delay hiding suggestions to allow clicking on them
/** Exists to allow delayed blur handling if needed */
const handleInputBlur = (_e: React.FocusEvent) => {};
};
/** Build the JSX component */
return (
<div className={styles.gestureEditor} ref={containerRef}>
{/* Mode selector */}
{/* Mode toggle */}
<div className={styles.modeSelector}>
<label className={styles.modeLabel}>Input Mode:</label>
<div className={styles.toggleContainer}>
@@ -516,8 +545,7 @@ export default function GestureValueEditor({
</div>
</div>
{/* Value editor based on mode */}
<div className={styles.valueEditor}>
<div className={styles.valueEditor} data-testid={"valueEditorTestID"}>
{mode === "single" ? (
<div className={styles.autocompleteContainer}>
{showSuggestions && (
@@ -527,7 +555,7 @@ export default function GestureValueEditor({
key={suggestion}
className={styles.suggestionItem}
onClick={() => handleSuggestionSelect(suggestion)}
onMouseDown={(e) => e.preventDefault()}
onMouseDown={(e) => e.preventDefault()} // prevent blur before click
>
{suggestion}
</div>
@@ -551,14 +579,14 @@ export default function GestureValueEditor({
value={value}
onChange={(e) => handleTagSelect(e.target.value)}
className={styles.tagSelect}
data-testid={"tagSelectorTestID"}
>
<option value="">Select a gesture tag...</option>
<option value="" >Select a gesture tag...</option>
{GESTURE_TAGS.map((tag) => (
<option key={tag} value={tag}>
{tag}
</option>
<option key={tag} value={tag}>{tag}</option>
))}
</select>
<div className={styles.tagList}>
{GESTURE_TAGS.map((tag) => (
<button

View File

@@ -65,6 +65,5 @@ export function GetActionValue(action: Action) {
returnAction = action as LLMAction
return returnAction.goal;
default:
break;
}
}

View File

@@ -0,0 +1,71 @@
.planDialog {
overflow:visible;
width: 80vw;
max-width: 900px;
transition: width 0.25s ease;
overscroll-behavior: contain;
}
.planDialog::backdrop {
background: rgba(0, 0, 0, 0.4);
}
.planEditor {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
min-width: 600px;
}
.planEditorLeft {
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.planEditorRight {
display: flex;
flex-direction: column;
gap: 0.5rem;
border-left: 1px solid var(--border-color, #ccc);
padding-left: 1rem;
max-height: 300px;
overflow-y: auto;
}
.planStep {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
transition: text-decoration 0.2s;
}
.planStep:hover {
text-decoration: line-through;
}
.stepType {
opacity: 0.7;
font-size: 0.85em;
}
.stepIndex {
opacity: 0.6;
}
.emptySteps {
opacity: 0.5;
font-style: italic;
}
.stepSuggestion {
opacity: 0.5;
font-style: italic;
}

View File

@@ -1,9 +1,9 @@
import { useRef, useState } from "react";
import styles from '../../VisProg.module.css';
import styles from './PlanEditor.module.css';
import { GetActionValue, type Action, type ActionTypes, type Plan } from "../components/Plan";
import { defaultPlan } from "../components/Plan.default";
import { TextField } from "../../../../components/TextField";
import GestureValueEditor from "./GestureValueEditor"; // Add this import
import GestureValueEditor from "./GestureValueEditor";
type PlanEditorDialogProps = {
plan?: Plan;
@@ -76,10 +76,10 @@ export default function PlanEditorDialog({
ref={dialogRef}
className={`${styles.planDialog}`}
onWheel={(e) => e.stopPropagation()}
data-testid={"PlanEditorDialogTestID"}
>
<form method="dialog" className="flex-col gap-md">
<h3> {draftPlan?.id === plan?.id ? "Edit Plan" : "Create Plan"} </h3>
{/* Plan name text field */}
{draftPlan && (
<TextField
@@ -115,8 +115,9 @@ export default function PlanEditorDialog({
</select>
</label>
{/* Action value editor - UPDATED SECTION */}
{/* Action value editor*/}
{newActionType === "gesture" ? (
// Gesture get their own editor component
<GestureValueEditor
value={newActionValue}
setValue={setNewActionValue}
@@ -168,13 +169,23 @@ export default function PlanEditorDialog({
{/* Map over all steps */}
{draftPlan.steps.map((step, index) => (
<div
role="button"
tabIndex={0}
key={step.id}
className={styles.planStep}
// Extra logic for screen readers to access using keyboard
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
setDraftPlan({
...draftPlan,
steps: draftPlan.steps.filter((s) => s.id !== step.id),});
}}}
onClick={() => {
setDraftPlan({
...draftPlan,
steps: draftPlan.steps.filter((s) => s.id !== step.id),});
}}>
<span className={styles.stepIndex}>{index + 1}.</span>
<span className={styles.stepType}>{step.type}:</span>
<span className={styles.stepName}>{

View File

@@ -95,7 +95,11 @@ describe("Drag & drop node creation", () => {
const node = nodes[0];
expect(node.type).toBe("phase");
expect(node.id).toBe("phase-1");
// UUID Expression
expect(node.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
);
// screenToFlowPosition was mocked to subtract 100
expect(node.position).toEqual({

View File

@@ -0,0 +1,131 @@
import { useState } from 'react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders, screen } from '../../../../test-utils/test-utils.tsx';
import GestureValueEditor from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor';
function TestHarness({ initialValue = '', placeholder = 'Gesture name' } : { initialValue?: string, placeholder?: string }) {
const [value, setValue] = useState(initialValue);
return (
<GestureValueEditor value={value} setValue={setValue} placeholder={placeholder} />
);
}
describe('GestureValueEditor', () => {
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
user = userEvent.setup();
});
test('renders in tag mode by default and allows selecting a tag via button and select', async () => {
renderWithProviders(<TestHarness />);
// Tag selector should be present
const select = screen.getByTestId('tagSelectorTestID') as HTMLSelectElement;
expect(select).toBeInTheDocument();
expect(select.value).toBe('');
// Choose a tag via select
await user.selectOptions(select, 'happy');
expect(select.value).toBe('happy');
// The corresponding tag button should reflect the selection (have the selected class)
const happyButton = screen.getByRole('button', { name: /happy/i });
expect(happyButton).toBeInTheDocument();
expect(happyButton.className).toMatch(/selected/);
});
test('switches to single mode and shows suggestions list', async () => {
renderWithProviders(<TestHarness initialValue={'happy'} />);
const singleButton = screen.getByRole('button', { name: /^single$/i });
await user.click(singleButton);
// Input should be present with placeholder
const input = screen.getByPlaceholderText('Gesture name') as HTMLInputElement;
expect(input).toBeInTheDocument();
// Because switching to single populates suggestions, we expect at least one suggestion item
const suggestion = await screen.findByText(/Listening_1/);
expect(suggestion).toBeInTheDocument();
});
test('typing filters suggestions and selecting a suggestion commits the value and hides the list', async () => {
renderWithProviders(<TestHarness />);
// Switch to single mode
await user.click(screen.getByRole('button', { name: /^single$/i }));
const input = screen.getByPlaceholderText('Gesture name') as HTMLInputElement;
// Type a substring that matches some suggestions
await user.type(input, 'Listening_2');
// The suggestion should appear and include the text we typed
const matching = await screen.findByText(/Listening_2/);
expect(matching).toBeInTheDocument();
// Click the suggestion
await user.click(matching);
// After selecting, input should contain that suggestion and suggestions should be hidden
expect(input.value).toContain('Listening_2');
expect(screen.queryByText(/Listening_1/)).toBeNull();
});
test('typing a non-matching string hides the suggestions list', async () => {
renderWithProviders(<TestHarness />);
await user.click(screen.getByRole('button', { name: /^single$/i }));
const input = screen.getByPlaceholderText('Gesture name') as HTMLInputElement;
await user.type(input, 'no-match-zzz');
// There should be no suggestion that includes that gibberish
expect(screen.queryByText(/no-match-zzz/)).toBeNull();
});
test('switching back to tag mode clears value when it is not a valid tag and preserves it when it is', async () => {
renderWithProviders(<TestHarness />);
// Switch to single mode and pick a suggestion (which is not a semantic tag)
await user.click(screen.getByRole('button', { name: /^single$/i }));
const input = screen.getByPlaceholderText('Gesture name') as HTMLInputElement;
await user.type(input, 'Listening_3');
const suggestion = await screen.findByText(/Listening_3/);
await user.click(suggestion);
// Switch back to tag mode -> value should be cleared (not in tag list)
await user.click(screen.getByRole('button', { name: /^tag$/i }));
const select = screen.getByTestId('tagSelectorTestID') as HTMLSelectElement;
expect(select.value).toBe('');
// Now pick a valid tag and switch to single then back to tag
await user.selectOptions(select, 'happy');
expect(select.value).toBe('happy');
// Switch to single and then back to tag; since 'happy' is a valid tag, it should remain
await user.click(screen.getByRole('button', { name: /^single$/i }));
await user.click(screen.getByRole('button', { name: /^tag$/i }));
expect(select.value).toBe('happy');
});
test('focus on input re-shows filtered suggestions when customValue is present', async () => {
renderWithProviders(<TestHarness />);
// Switch to single mode and type to filter
await user.click(screen.getByRole('button', { name: /^single$/i }));
const input = screen.getByPlaceholderText('Gesture name') as HTMLInputElement;
await user.type(input, 'Listening_4');
const found = await screen.findByText(/Listening_4/);
expect(found).toBeInTheDocument();
// Blur the input
input.blur();
expect(found).toBeInTheDocument();
// Focus the input again and ensure the suggestions remain or reappear
await user.click(input);
const foundAgain = await screen.findByText(/Listening_4/);
expect(foundAgain).toBeInTheDocument();
});
});

View File

@@ -1,30 +1,17 @@
// PlanEditorDialog.test.tsx
import { describe, it, beforeEach, jest } from '@jest/globals';
import { screen } from '@testing-library/react';
import { screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../../../test-utils/test-utils.tsx';
import PlanEditorDialog from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor';
import type { Plan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan';
import '@testing-library/jest-dom';
// Mock crypto.randomUUID for consistent IDs in tests
const mockUUID = 'test-uuid-123';
Object.defineProperty(globalThis, 'crypto', {
value: {
randomUUID: () => mockUUID,
},
writable: true,
});
// Mock structuredClone
(globalThis as any).structuredClone = jest.fn((val) => JSON.parse(JSON.stringify(val)));
// Mock HTMLDialogElement methods
const mockDialogMethods = {
showModal: jest.fn(),
close: jest.fn(),
};
// UUID Regex for checking ID's
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
describe('PlanEditorDialog', () => {
let user: ReturnType<typeof userEvent.setup>;
@@ -33,9 +20,6 @@ describe('PlanEditorDialog', () => {
beforeEach(() => {
user = userEvent.setup();
jest.clearAllMocks();
// Mock dialog element methods
HTMLDialogElement.prototype.showModal = mockDialogMethods.showModal;
HTMLDialogElement.prototype.close = mockDialogMethods.close;
});
const defaultPlan: Plan = {
@@ -90,7 +74,6 @@ describe('PlanEditorDialog', () => {
await user.click(screen.getByRole('button', { name: 'Create Plan' }));
expect(mockDialogMethods.showModal).toHaveBeenCalled();
// One for button, one for dialog.
expect(screen.getAllByText('Create Plan').length).toEqual(2);
@@ -101,7 +84,6 @@ describe('PlanEditorDialog', () => {
await user.click(screen.getByRole('button', { name: 'Edit Plan' }));
expect(mockDialogMethods.showModal).toHaveBeenCalled();
// One for button, one for dialog
expect(screen.getAllByText('Edit Plan').length).toEqual(2);
});
@@ -121,7 +103,7 @@ describe('PlanEditorDialog', () => {
await user.click(screen.getByRole('button', { name: 'Create Plan' }));
await user.click(screen.getByText('Cancel'));
expect(mockDialogMethods.close).toHaveBeenCalled();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
@@ -198,7 +180,7 @@ describe('PlanEditorDialog', () => {
it('should add a gesture action to the plan', async () => {
renderDialog({ plan: defaultPlan });
await user.click(screen.getByRole('button', { name: 'Edit Plan' }));
await user.click(screen.getByRole('button', { name: /edit plan/i }));
const actionTypeSelect = screen.getByLabelText(/Action Type/i);
const addButton = screen.getByText('Add Step');
@@ -207,14 +189,14 @@ describe('PlanEditorDialog', () => {
await user.selectOptions(actionTypeSelect, 'gesture');
// Find the input field after type change
const gestureInput = screen.getByPlaceholderText(/Gesture name|text/i);
await user.type(gestureInput, 'Wave hand');
const select = screen.getByTestId("tagSelectorTestID")
const options = within(select).getAllByRole('option')
await user.selectOptions(select, options[1])
await user.click(addButton);
// Check if step was added
expect(screen.getByText('gesture:')).toBeInTheDocument();
expect(screen.getByText('Wave hand')).toBeInTheDocument();
});
it('should add an LLM action to the plan', async () => {
@@ -329,17 +311,17 @@ describe('PlanEditorDialog', () => {
await user.click(screen.getByText('Create'));
expect(mockOnSave).toHaveBeenCalledWith({
id: mockUUID,
id: expect.stringMatching(uuidRegex),
name: 'My New Plan',
steps: [
{
id: mockUUID,
id: expect.stringMatching(uuidRegex),
text: 'First step',
type: 'speech',
},
],
});
expect(mockDialogMethods.close).toHaveBeenCalled();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('should call onSave with updated plan when editing', async () => {
@@ -366,13 +348,13 @@ describe('PlanEditorDialog', () => {
name: 'Updated Plan Name',
steps: [
{
id: mockUUID,
id: expect.stringMatching(uuidRegex),
text: 'New speech action',
type: 'speech',
},
],
});
expect(mockDialogMethods.close).toHaveBeenCalled();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('should call onSave with undefined when reset button is clicked', async () => {
@@ -382,7 +364,7 @@ describe('PlanEditorDialog', () => {
await user.click(screen.getByText('Reset'));
expect(mockOnSave).toHaveBeenCalledWith(undefined);
expect(mockDialogMethods.close).toHaveBeenCalled();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('should disable save button when no draft plan exists', async () => {
@@ -438,7 +420,7 @@ describe('PlanEditorDialog', () => {
// Check gesture placeholder
await user.selectOptions(actionTypeSelect, 'gesture');
const gestureInput = screen.getByPlaceholderText(/Gesture|text/i);
const gestureInput = screen.getByTestId("valueEditorTestID")
expect(gestureInput).toBeInTheDocument();
// Check LLM placeholder

View File

@@ -404,6 +404,16 @@ describe('NormNode', () => {
describe('NormReduce Function', () => {
it('should reduce a norm node to its essential data', () => {
const condition: Node = {
id: "belief-1",
type: 'basic_belief',
position: {x: 10, y: 10},
data: {
...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults))
}
}
const normNode: Node = {
id: 'norm-1',
type: 'norm',
@@ -414,17 +424,21 @@ describe('NormNode', () => {
droppable: true,
norm: 'Never harm humans',
hasReduce: true,
condition: "belief-1"
},
};
const allNodes: Node[] = [normNode];
const allNodes: Node[] = [normNode, condition];
const result = NormReduce(normNode, allNodes);
expect(result).toEqual({
id: 'norm-1',
label: 'Safety Norm',
norm: 'Never harm humans',
critical: false,
condition: {
id: "belief-1",
keyword: "help"
},
});
});

View File

@@ -76,8 +76,10 @@ describe('PhaseNode', () => {
// Find nodes
const nodes = useFlowStore.getState().nodes;
const p1 = nodes.find((x) => x.id === 'phase-1')!;
const p2 = nodes.find((x) => x.id === 'phase-2')!;
const phaseNodes = nodes.filter((x) => x.type === 'phase');
const p1 = phaseNodes[0];
const p2 = phaseNodes[1];
// expect same value, not same reference
expect(p1.data.children).not.toBe(p2.data.children);

View File

@@ -61,6 +61,7 @@ export const mockReactFlow = () => {
width: 200,
height: 200,
});
};
@@ -88,3 +89,16 @@ afterEach(() => {
});
});
if (typeof HTMLDialogElement !== 'undefined') {
if (!HTMLDialogElement.prototype.showModal) {
HTMLDialogElement.prototype.showModal = function () {
// basic behavior: mark as open
this.setAttribute('open', '');
};
}
if (!HTMLDialogElement.prototype.close) {
HTMLDialogElement.prototype.close = function () {
this.removeAttribute('open');
};
}
}