Merging dev into main #49
@@ -140,78 +140,8 @@
|
|||||||
filter: drop-shadow(0 0 0.25rem plum);
|
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 {
|
.planNoIterate {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import styles from './GestureValueEditor.module.css'
|
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 = {
|
type GestureValueEditorProps = {
|
||||||
value: string;
|
value: string;
|
||||||
setValue: (value: string) => void;
|
setValue: (value: string) => void;
|
||||||
placeholder?: string;
|
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",
|
const GESTURE_TAGS = ["above", "affirmative", "afford", "agitated", "all", "allright", "alright", "any",
|
||||||
"assuage", "attemper", "back", "bashful", "beg", "beseech", "blank",
|
"assuage", "attemper", "back", "bashful", "beg", "beseech", "blank",
|
||||||
"body language", "bored", "bow", "but", "call", "calm", "choose", "choice", "cloud",
|
"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",
|
"think", "timid", "top", "unless", "up", "upstairs", "void", "warm", "winner", "yeah",
|
||||||
"yes", "yoo-hoo", "you", "your", "zero", "zestful"];
|
"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 = [
|
const GESTURE_SINGLES = [
|
||||||
"animations/Stand/BodyTalk/Listening/Listening_1",
|
"animations/Stand/BodyTalk/Listening/Listening_1",
|
||||||
"animations/Stand/BodyTalk/Listening/Listening_2",
|
"animations/Stand/BodyTalk/Listening/Listening_2",
|
||||||
@@ -421,50 +436,62 @@ const GESTURE_SINGLES = [
|
|||||||
"animations/Stand/Waiting/Zombie_1"]
|
"animations/Stand/Waiting/Zombie_1"]
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a gesture value editor component.
|
||||||
|
* @returns JSX.Element
|
||||||
|
*/
|
||||||
export default function GestureValueEditor({
|
export default function GestureValueEditor({
|
||||||
value,
|
value,
|
||||||
setValue,
|
setValue,
|
||||||
placeholder = "Gesture name",
|
placeholder = "Gesture name",
|
||||||
}: GestureValueEditorProps) {
|
}: GestureValueEditorProps) {
|
||||||
|
|
||||||
|
/** Input mode: semantic tag vs concrete animation path */
|
||||||
const [mode, setMode] = useState<"single" | "tag">("tag");
|
const [mode, setMode] = useState<"single" | "tag">("tag");
|
||||||
|
|
||||||
|
/** Raw text value for single-gesture input */
|
||||||
const [customValue, setCustomValue] = useState("");
|
const [customValue, setCustomValue] = useState("");
|
||||||
|
|
||||||
|
/** Autocomplete dropdown state */
|
||||||
const [showSuggestions, setShowSuggestions] = useState(true);
|
const [showSuggestions, setShowSuggestions] = useState(true);
|
||||||
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
|
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
|
||||||
|
|
||||||
|
/** Reserved for future click-outside / positioning logic */
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
/** Switch between tag and single input modes */
|
||||||
const handleModeChange = (newMode: "single" | "tag") => {
|
const handleModeChange = (newMode: "single" | "tag") => {
|
||||||
setMode(newMode);
|
setMode(newMode);
|
||||||
|
|
||||||
if (newMode === "single") {
|
if (newMode === "single") {
|
||||||
// When switching to single, use custom value or existing value
|
|
||||||
setValue(customValue || value);
|
setValue(customValue || value);
|
||||||
setFilteredSuggestions(GESTURE_SINGLES);
|
setFilteredSuggestions(GESTURE_SINGLES);
|
||||||
setShowSuggestions(true);
|
setShowSuggestions(true);
|
||||||
} else {
|
} else {
|
||||||
// When switching to tag, clear value if not a valid tag
|
// Clear value if it does not match a valid tag
|
||||||
const isCurrentValueTag = GESTURE_TAGS.some(tag =>
|
const isValidTag = GESTURE_TAGS.some(
|
||||||
tag.toLowerCase() === value.toLowerCase()
|
tag => tag.toLowerCase() === value.toLowerCase()
|
||||||
);
|
);
|
||||||
if (!isCurrentValueTag) {
|
if (!isValidTag) setValue("");
|
||||||
setValue("");
|
|
||||||
}
|
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Select a semantic gesture tag */
|
||||||
const handleTagSelect = (tag: string) => {
|
const handleTagSelect = (tag: string) => {
|
||||||
setValue(tag);
|
setValue(tag);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Update single-gesture input and filter suggestions */
|
||||||
const handleCustomChange = (newValue: string) => {
|
const handleCustomChange = (newValue: string) => {
|
||||||
setCustomValue(newValue);
|
setCustomValue(newValue);
|
||||||
setValue(newValue);
|
setValue(newValue);
|
||||||
|
|
||||||
// Filter suggestions based on input
|
|
||||||
if (newValue.trim() === "") {
|
if (newValue.trim() === "") {
|
||||||
setShowSuggestions(true)
|
|
||||||
setFilteredSuggestions(GESTURE_SINGLES);
|
setFilteredSuggestions(GESTURE_SINGLES);
|
||||||
|
setShowSuggestions(true);
|
||||||
} else {
|
} else {
|
||||||
const filtered = GESTURE_SINGLES.filter(single =>
|
const filtered = GESTURE_SINGLES.filter(single =>
|
||||||
single.toLowerCase().includes(newValue.toLowerCase())
|
single.toLowerCase().includes(newValue.toLowerCase())
|
||||||
);
|
);
|
||||||
setFilteredSuggestions(filtered);
|
setFilteredSuggestions(filtered);
|
||||||
@@ -472,30 +499,32 @@ export default function GestureValueEditor({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Commit autocomplete selection */
|
||||||
const handleSuggestionSelect = (suggestion: string) => {
|
const handleSuggestionSelect = (suggestion: string) => {
|
||||||
setCustomValue(suggestion);
|
setCustomValue(suggestion);
|
||||||
setValue(suggestion);
|
setValue(suggestion);
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Refresh suggestions on refocus */
|
||||||
const handleInputFocus = () => {
|
const handleInputFocus = () => {
|
||||||
if (customValue.trim() !== "") {
|
if (!customValue.trim()) return;
|
||||||
const filtered = GESTURE_SINGLES.filter(tag =>
|
|
||||||
tag.toLowerCase().includes(customValue.toLowerCase())
|
const filtered = GESTURE_SINGLES.filter(single =>
|
||||||
);
|
single.toLowerCase().includes(customValue.toLowerCase())
|
||||||
setFilteredSuggestions(filtered);
|
);
|
||||||
setShowSuggestions(filtered.length > 0);
|
setFilteredSuggestions(filtered);
|
||||||
}
|
setShowSuggestions(filtered.length > 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInputBlur = (_e: React.FocusEvent) => {
|
/** Exists to allow delayed blur handling if needed */
|
||||||
// Delay hiding suggestions to allow clicking on them
|
const handleInputBlur = (_e: React.FocusEvent) => {};
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
|
/** Build the JSX component */
|
||||||
return (
|
return (
|
||||||
<div className={styles.gestureEditor} ref={containerRef}>
|
<div className={styles.gestureEditor} ref={containerRef}>
|
||||||
{/* Mode selector */}
|
{/* Mode toggle */}
|
||||||
<div className={styles.modeSelector}>
|
<div className={styles.modeSelector}>
|
||||||
<label className={styles.modeLabel}>Input Mode:</label>
|
<label className={styles.modeLabel}>Input Mode:</label>
|
||||||
<div className={styles.toggleContainer}>
|
<div className={styles.toggleContainer}>
|
||||||
@@ -516,8 +545,7 @@ export default function GestureValueEditor({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Value editor based on mode */}
|
<div className={styles.valueEditor} data-testid={"valueEditorTestID"}>
|
||||||
<div className={styles.valueEditor}>
|
|
||||||
{mode === "single" ? (
|
{mode === "single" ? (
|
||||||
<div className={styles.autocompleteContainer}>
|
<div className={styles.autocompleteContainer}>
|
||||||
{showSuggestions && (
|
{showSuggestions && (
|
||||||
@@ -527,7 +555,7 @@ export default function GestureValueEditor({
|
|||||||
key={suggestion}
|
key={suggestion}
|
||||||
className={styles.suggestionItem}
|
className={styles.suggestionItem}
|
||||||
onClick={() => handleSuggestionSelect(suggestion)}
|
onClick={() => handleSuggestionSelect(suggestion)}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()} // prevent blur before click
|
||||||
>
|
>
|
||||||
{suggestion}
|
{suggestion}
|
||||||
</div>
|
</div>
|
||||||
@@ -551,14 +579,14 @@ export default function GestureValueEditor({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => handleTagSelect(e.target.value)}
|
onChange={(e) => handleTagSelect(e.target.value)}
|
||||||
className={styles.tagSelect}
|
className={styles.tagSelect}
|
||||||
|
data-testid={"tagSelectorTestID"}
|
||||||
>
|
>
|
||||||
<option value="">Select a gesture tag...</option>
|
<option value="" >Select a gesture tag...</option>
|
||||||
{GESTURE_TAGS.map((tag) => (
|
{GESTURE_TAGS.map((tag) => (
|
||||||
<option key={tag} value={tag}>
|
<option key={tag} value={tag}>{tag}</option>
|
||||||
{tag}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<div className={styles.tagList}>
|
<div className={styles.tagList}>
|
||||||
{GESTURE_TAGS.map((tag) => (
|
{GESTURE_TAGS.map((tag) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -65,6 +65,5 @@ export function GetActionValue(action: Action) {
|
|||||||
returnAction = action as LLMAction
|
returnAction = action as LLMAction
|
||||||
return returnAction.goal;
|
return returnAction.goal;
|
||||||
default:
|
default:
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useRef, useState } from "react";
|
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 { GetActionValue, type Action, type ActionTypes, type Plan } from "../components/Plan";
|
||||||
import { defaultPlan } from "../components/Plan.default";
|
import { defaultPlan } from "../components/Plan.default";
|
||||||
import { TextField } from "../../../../components/TextField";
|
import { TextField } from "../../../../components/TextField";
|
||||||
import GestureValueEditor from "./GestureValueEditor"; // Add this import
|
import GestureValueEditor from "./GestureValueEditor";
|
||||||
|
|
||||||
type PlanEditorDialogProps = {
|
type PlanEditorDialogProps = {
|
||||||
plan?: Plan;
|
plan?: Plan;
|
||||||
@@ -76,10 +76,10 @@ export default function PlanEditorDialog({
|
|||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
className={`${styles.planDialog}`}
|
className={`${styles.planDialog}`}
|
||||||
onWheel={(e) => e.stopPropagation()}
|
onWheel={(e) => e.stopPropagation()}
|
||||||
|
data-testid={"PlanEditorDialogTestID"}
|
||||||
>
|
>
|
||||||
<form method="dialog" className="flex-col gap-md">
|
<form method="dialog" className="flex-col gap-md">
|
||||||
<h3> {draftPlan?.id === plan?.id ? "Edit Plan" : "Create Plan"} </h3>
|
<h3> {draftPlan?.id === plan?.id ? "Edit Plan" : "Create Plan"} </h3>
|
||||||
|
|
||||||
{/* Plan name text field */}
|
{/* Plan name text field */}
|
||||||
{draftPlan && (
|
{draftPlan && (
|
||||||
<TextField
|
<TextField
|
||||||
@@ -115,8 +115,9 @@ export default function PlanEditorDialog({
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{/* Action value editor - UPDATED SECTION */}
|
{/* Action value editor*/}
|
||||||
{newActionType === "gesture" ? (
|
{newActionType === "gesture" ? (
|
||||||
|
// Gesture get their own editor component
|
||||||
<GestureValueEditor
|
<GestureValueEditor
|
||||||
value={newActionValue}
|
value={newActionValue}
|
||||||
setValue={setNewActionValue}
|
setValue={setNewActionValue}
|
||||||
@@ -168,13 +169,23 @@ export default function PlanEditorDialog({
|
|||||||
{/* Map over all steps */}
|
{/* Map over all steps */}
|
||||||
{draftPlan.steps.map((step, index) => (
|
{draftPlan.steps.map((step, index) => (
|
||||||
<div
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
key={step.id}
|
key={step.id}
|
||||||
className={styles.planStep}
|
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={() => {
|
onClick={() => {
|
||||||
setDraftPlan({
|
setDraftPlan({
|
||||||
...draftPlan,
|
...draftPlan,
|
||||||
steps: draftPlan.steps.filter((s) => s.id !== step.id),});
|
steps: draftPlan.steps.filter((s) => s.id !== step.id),});
|
||||||
}}>
|
}}>
|
||||||
|
|
||||||
<span className={styles.stepIndex}>{index + 1}.</span>
|
<span className={styles.stepIndex}>{index + 1}.</span>
|
||||||
<span className={styles.stepType}>{step.type}:</span>
|
<span className={styles.stepType}>{step.type}:</span>
|
||||||
<span className={styles.stepName}>{
|
<span className={styles.stepName}>{
|
||||||
|
|||||||
@@ -95,7 +95,11 @@ describe("Drag & drop node creation", () => {
|
|||||||
const node = nodes[0];
|
const node = nodes[0];
|
||||||
|
|
||||||
expect(node.type).toBe("phase");
|
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
|
// screenToFlowPosition was mocked to subtract 100
|
||||||
expect(node.position).toEqual({
|
expect(node.position).toEqual({
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,30 +1,17 @@
|
|||||||
// PlanEditorDialog.test.tsx
|
// PlanEditorDialog.test.tsx
|
||||||
import { describe, it, beforeEach, jest } from '@jest/globals';
|
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 userEvent from '@testing-library/user-event';
|
||||||
import { renderWithProviders } from '../../../../test-utils/test-utils.tsx';
|
import { renderWithProviders } from '../../../../test-utils/test-utils.tsx';
|
||||||
import PlanEditorDialog from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor';
|
import PlanEditorDialog from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor';
|
||||||
import type { Plan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan';
|
import type { Plan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan';
|
||||||
import '@testing-library/jest-dom';
|
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
|
// Mock structuredClone
|
||||||
(globalThis as any).structuredClone = jest.fn((val) => JSON.parse(JSON.stringify(val)));
|
(globalThis as any).structuredClone = jest.fn((val) => JSON.parse(JSON.stringify(val)));
|
||||||
|
|
||||||
// Mock HTMLDialogElement methods
|
// UUID Regex for checking ID's
|
||||||
const mockDialogMethods = {
|
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;
|
||||||
showModal: jest.fn(),
|
|
||||||
close: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('PlanEditorDialog', () => {
|
describe('PlanEditorDialog', () => {
|
||||||
let user: ReturnType<typeof userEvent.setup>;
|
let user: ReturnType<typeof userEvent.setup>;
|
||||||
@@ -33,9 +20,6 @@ describe('PlanEditorDialog', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
user = userEvent.setup();
|
user = userEvent.setup();
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
// Mock dialog element methods
|
|
||||||
HTMLDialogElement.prototype.showModal = mockDialogMethods.showModal;
|
|
||||||
HTMLDialogElement.prototype.close = mockDialogMethods.close;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultPlan: Plan = {
|
const defaultPlan: Plan = {
|
||||||
@@ -90,7 +74,6 @@ describe('PlanEditorDialog', () => {
|
|||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Create Plan' }));
|
await user.click(screen.getByRole('button', { name: 'Create Plan' }));
|
||||||
|
|
||||||
expect(mockDialogMethods.showModal).toHaveBeenCalled();
|
|
||||||
|
|
||||||
// One for button, one for dialog.
|
// One for button, one for dialog.
|
||||||
expect(screen.getAllByText('Create Plan').length).toEqual(2);
|
expect(screen.getAllByText('Create Plan').length).toEqual(2);
|
||||||
@@ -101,7 +84,6 @@ describe('PlanEditorDialog', () => {
|
|||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Edit Plan' }));
|
await user.click(screen.getByRole('button', { name: 'Edit Plan' }));
|
||||||
|
|
||||||
expect(mockDialogMethods.showModal).toHaveBeenCalled();
|
|
||||||
// One for button, one for dialog
|
// One for button, one for dialog
|
||||||
expect(screen.getAllByText('Edit Plan').length).toEqual(2);
|
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.getByRole('button', { name: 'Create Plan' }));
|
||||||
await user.click(screen.getByText('Cancel'));
|
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 () => {
|
it('should add a gesture action to the plan', async () => {
|
||||||
renderDialog({ plan: defaultPlan });
|
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 actionTypeSelect = screen.getByLabelText(/Action Type/i);
|
||||||
const addButton = screen.getByText('Add Step');
|
const addButton = screen.getByText('Add Step');
|
||||||
@@ -207,14 +189,14 @@ describe('PlanEditorDialog', () => {
|
|||||||
await user.selectOptions(actionTypeSelect, 'gesture');
|
await user.selectOptions(actionTypeSelect, 'gesture');
|
||||||
|
|
||||||
// Find the input field after type change
|
// Find the input field after type change
|
||||||
const gestureInput = screen.getByPlaceholderText(/Gesture name|text/i);
|
const select = screen.getByTestId("tagSelectorTestID")
|
||||||
await user.type(gestureInput, 'Wave hand');
|
const options = within(select).getAllByRole('option')
|
||||||
|
|
||||||
|
await user.selectOptions(select, options[1])
|
||||||
await user.click(addButton);
|
await user.click(addButton);
|
||||||
|
|
||||||
// Check if step was added
|
// Check if step was added
|
||||||
expect(screen.getByText('gesture:')).toBeInTheDocument();
|
expect(screen.getByText('gesture:')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Wave hand')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add an LLM action to the plan', async () => {
|
it('should add an LLM action to the plan', async () => {
|
||||||
@@ -329,17 +311,17 @@ describe('PlanEditorDialog', () => {
|
|||||||
await user.click(screen.getByText('Create'));
|
await user.click(screen.getByText('Create'));
|
||||||
|
|
||||||
expect(mockOnSave).toHaveBeenCalledWith({
|
expect(mockOnSave).toHaveBeenCalledWith({
|
||||||
id: mockUUID,
|
id: expect.stringMatching(uuidRegex),
|
||||||
name: 'My New Plan',
|
name: 'My New Plan',
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
id: mockUUID,
|
id: expect.stringMatching(uuidRegex),
|
||||||
text: 'First step',
|
text: 'First step',
|
||||||
type: 'speech',
|
type: 'speech',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(mockDialogMethods.close).toHaveBeenCalled();
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call onSave with updated plan when editing', async () => {
|
it('should call onSave with updated plan when editing', async () => {
|
||||||
@@ -366,13 +348,13 @@ describe('PlanEditorDialog', () => {
|
|||||||
name: 'Updated Plan Name',
|
name: 'Updated Plan Name',
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
id: mockUUID,
|
id: expect.stringMatching(uuidRegex),
|
||||||
text: 'New speech action',
|
text: 'New speech action',
|
||||||
type: 'speech',
|
type: 'speech',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(mockDialogMethods.close).toHaveBeenCalled();
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call onSave with undefined when reset button is clicked', async () => {
|
it('should call onSave with undefined when reset button is clicked', async () => {
|
||||||
@@ -382,7 +364,7 @@ describe('PlanEditorDialog', () => {
|
|||||||
await user.click(screen.getByText('Reset'));
|
await user.click(screen.getByText('Reset'));
|
||||||
|
|
||||||
expect(mockOnSave).toHaveBeenCalledWith(undefined);
|
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 () => {
|
it('should disable save button when no draft plan exists', async () => {
|
||||||
@@ -438,7 +420,7 @@ describe('PlanEditorDialog', () => {
|
|||||||
|
|
||||||
// Check gesture placeholder
|
// Check gesture placeholder
|
||||||
await user.selectOptions(actionTypeSelect, 'gesture');
|
await user.selectOptions(actionTypeSelect, 'gesture');
|
||||||
const gestureInput = screen.getByPlaceholderText(/Gesture|text/i);
|
const gestureInput = screen.getByTestId("valueEditorTestID")
|
||||||
expect(gestureInput).toBeInTheDocument();
|
expect(gestureInput).toBeInTheDocument();
|
||||||
|
|
||||||
// Check LLM placeholder
|
// Check LLM placeholder
|
||||||
|
|||||||
@@ -404,6 +404,16 @@ describe('NormNode', () => {
|
|||||||
|
|
||||||
describe('NormReduce Function', () => {
|
describe('NormReduce Function', () => {
|
||||||
it('should reduce a norm node to its essential data', () => {
|
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 = {
|
const normNode: Node = {
|
||||||
id: 'norm-1',
|
id: 'norm-1',
|
||||||
type: 'norm',
|
type: 'norm',
|
||||||
@@ -414,17 +424,21 @@ describe('NormNode', () => {
|
|||||||
droppable: true,
|
droppable: true,
|
||||||
norm: 'Never harm humans',
|
norm: 'Never harm humans',
|
||||||
hasReduce: true,
|
hasReduce: true,
|
||||||
|
condition: "belief-1"
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const allNodes: Node[] = [normNode];
|
const allNodes: Node[] = [normNode, condition];
|
||||||
const result = NormReduce(normNode, allNodes);
|
const result = NormReduce(normNode, allNodes);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
id: 'norm-1',
|
id: 'norm-1',
|
||||||
label: 'Safety Norm',
|
label: 'Safety Norm',
|
||||||
norm: 'Never harm humans',
|
norm: 'Never harm humans',
|
||||||
critical: false,
|
critical: false,
|
||||||
|
condition: {
|
||||||
|
id: "belief-1",
|
||||||
|
keyword: "help"
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -76,8 +76,10 @@ describe('PhaseNode', () => {
|
|||||||
|
|
||||||
// Find nodes
|
// Find nodes
|
||||||
const nodes = useFlowStore.getState().nodes;
|
const nodes = useFlowStore.getState().nodes;
|
||||||
const p1 = nodes.find((x) => x.id === 'phase-1')!;
|
const phaseNodes = nodes.filter((x) => x.type === 'phase');
|
||||||
const p2 = nodes.find((x) => x.id === 'phase-2')!;
|
const p1 = phaseNodes[0];
|
||||||
|
const p2 = phaseNodes[1];
|
||||||
|
|
||||||
|
|
||||||
// expect same value, not same reference
|
// expect same value, not same reference
|
||||||
expect(p1.data.children).not.toBe(p2.data.children);
|
expect(p1.data.children).not.toBe(p2.data.children);
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export const mockReactFlow = () => {
|
|||||||
width: 200,
|
width: 200,
|
||||||
height: 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');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user