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

@@ -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,50 +436,62 @@ 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 =>
const filtered = GESTURE_SINGLES.filter(single =>
single.toLowerCase().includes(newValue.toLowerCase())
);
setFilteredSuggestions(filtered);
@@ -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}>{