import { describe, it, beforeEach, jest } from '@jest/globals'; import { screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { Node } from '@xyflow/react'; import { renderWithProviders } from '../../../../test-utils/test-utils.tsx'; import PlanEditorDialog from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor'; import { PlanReduce, type Plan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan'; import '@testing-library/jest-dom'; import { GoalReduce, type GoalNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx'; import { GoalNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts'; import { insertGoalInPlan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/PlanEditingFunctions.tsx'; // Mock structuredClone (globalThis as any).structuredClone = jest.fn((val) => JSON.parse(JSON.stringify(val))); // 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; const mockOnSave = jest.fn(); beforeEach(() => { user = userEvent.setup(); jest.clearAllMocks(); }); const defaultPlan: Plan = { id: 'plan-1', name: 'Test Plan', steps: [], }; const extendedPlan: Plan = { id: 'extended-plan-1', name: 'extended test plan', steps: [ // Step 1: A wave tag gesture { id: 'firststep', type: 'gesture', isTag: true, gesture: "hello" }, // Step 2: A single tag gesture { id: 'secondstep', type: 'gesture', isTag: false, gesture: "somefolder/somegesture" }, // Step 3: A LLM action { id: 'thirdstep', type: 'llm', goal: 'ask the user something or whatever' }, // Step 4: A speech action { id: 'fourthstep', type: 'speech', text: "I'm a cyborg ninja :>" }, ] } const planWithSteps: Plan = { id: 'plan-2', name: 'Existing Plan', steps: [ { id: 'step-1', text: 'Hello world', type: 'speech' as const }, { id: 'step-2', gesture: 'Wave', isTag:true, type: 'gesture' as const }, ], }; const renderDialog = (props: Partial> = {}) => { const defaultProps = { plan: undefined, onSave: mockOnSave, description: undefined, }; return renderWithProviders(); }; describe('Rendering', () => { it('should show "Create Plan" button when no plan is provided', () => { renderDialog(); // The button should be visible expect(screen.getByRole('button', { name: 'Create Plan' })).toBeInTheDocument(); // The dialog content should NOT be visible initially expect(screen.queryByText(/Add Action/i)).not.toBeInTheDocument(); }); it('should show "Edit Plan" button when a plan is provided', () => { renderDialog({ plan: defaultPlan }); expect(screen.getByRole('button', { name: 'Edit Plan' })).toBeInTheDocument(); }); it('should not show "Create Plan" button when a plan exists', () => { renderDialog({ plan: defaultPlan }); // Query for the button text specifically, not dialog title expect(screen.queryByRole('button', { name: 'Create Plan' })).not.toBeInTheDocument(); }); }); describe('Dialog Interactions', () => { it('should open dialog with "Create Plan" title when creating new plan', async () => { renderDialog(); await user.click(screen.getByRole('button', { name: 'Create Plan' })); // One for button, one for dialog. expect(screen.getAllByText('Create Plan').length).toEqual(2); }); it('should open dialog with "Edit Plan" title when editing existing plan', async () => { renderDialog({ plan: defaultPlan }); await user.click(screen.getByRole('button', { name: 'Edit Plan' })); // One for button, one for dialog expect(screen.getAllByText('Edit Plan').length).toEqual(2); }); it('should pre-fill plan name when editing', async () => { renderDialog({ plan: defaultPlan }); await user.click(screen.getByRole('button', { name: 'Edit Plan' })); const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement; expect(nameInput.value).toBe(defaultPlan.name); }); it('should close dialog when cancel button is clicked', async () => { renderDialog(); await user.click(screen.getByRole('button', { name: 'Create Plan' })); await user.click(screen.getByText('Cancel')); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); }); describe('Plan Creation', () => { it('should create a new plan with default values', async () => { renderDialog(); await user.click(screen.getByRole('button', { name: 'Create Plan' })); // One for the button, one for the dialog expect(screen.getAllByText('Create Plan').length).toEqual(2); const nameInput = screen.getByPlaceholderText('Plan name'); expect(nameInput).toBeInTheDocument(); }); it('should auto-fill with description when provided', async () => { const description = 'Achieve world peace'; renderDialog({ description }); await user.click(screen.getByRole('button', { name: 'Create Plan' })); // Check if plan name is pre-filled with description const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement; expect(nameInput.value).toBe(description); // Check if action type is set to LLM const actionTypeSelect = screen.getByLabelText(/Action Type/i) as HTMLSelectElement; expect(actionTypeSelect.value).toBe('llm'); // Check if suggestion text is shown expect(screen.getByText('Filled in as a suggestion!')).toBeInTheDocument(); expect(screen.getByText('Feel free to change!')).toBeInTheDocument(); }); it('should allow changing plan name', async () => { renderDialog(); await user.click(screen.getByRole('button', { name: 'Create Plan' })); const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement; const newName = 'My Custom Plan'; // Instead of clear(), select all text and type new value await user.click(nameInput); await user.keyboard('{Control>}a{/Control}'); // Select all (Ctrl+A) await user.keyboard(newName); expect(nameInput.value).toBe(newName); }); }); describe('Action Management', () => { it('should add a speech action to the plan', async () => { renderDialog({ plan: defaultPlan }); await user.click(screen.getByRole('button', { name: 'Edit Plan' })); const actionTypeSelect = screen.getByLabelText(/Action Type/i); const actionValueInput = screen.getByPlaceholderText("Speech text") const addButton = screen.getByText('Add Step'); // Set up a speech action await user.selectOptions(actionTypeSelect, 'speech'); await user.type(actionValueInput, 'Hello there!'); await user.click(addButton); // Check if step was added expect(screen.getByText('speech:')).toBeInTheDocument(); expect(screen.getByText('Hello there!')).toBeInTheDocument(); }); it('should add a gesture action to the plan', async () => { renderDialog({ plan: defaultPlan }); await user.click(screen.getByRole('button', { name: /edit plan/i })); const actionTypeSelect = screen.getByLabelText(/Action Type/i); const addButton = screen.getByText('Add Step'); // Set up a gesture action await user.selectOptions(actionTypeSelect, 'gesture'); // Find the input field after type change 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(); }); it('should add an LLM action to the plan', async () => { renderDialog({ plan: defaultPlan }); await user.click(screen.getByRole('button', { name: 'Edit Plan' })); const actionTypeSelect = screen.getByLabelText(/Action Type/i); const addButton = screen.getByText('Add Step'); // Set up an LLM action await user.selectOptions(actionTypeSelect, 'llm'); // Find the input field after type change const llmInput = screen.getByPlaceholderText(/LLM goal|text/i); await user.type(llmInput, 'Generate a story'); await user.click(addButton); // Check if step was added expect(screen.getByText('llm:')).toBeInTheDocument(); expect(screen.getByText('Generate a story')).toBeInTheDocument(); }); it('should disable "Add Step" button when action value is empty', async () => { renderDialog({ plan: defaultPlan }); await user.click(screen.getByRole('button', { name: 'Edit Plan' })); const addButton = screen.getByText('Add Step'); expect(addButton).toBeDisabled(); }); it('should reset action form after adding a step', async () => { renderDialog({ plan: defaultPlan }); await user.click(screen.getByRole('button', { name: 'Edit Plan' })); const actionValueInput = screen.getByPlaceholderText("Speech text") const addButton = screen.getByText('Add Step'); await user.type(actionValueInput, 'Test speech'); await user.click(addButton); // Action value should be cleared expect(actionValueInput).toHaveValue(''); // Action type should be reset to speech (default) const actionTypeSelect = screen.getByLabelText(/Action Type/i) as HTMLSelectElement; expect(actionTypeSelect.value).toBe('speech'); }); }); describe('Step Management', () => { it('should show existing steps when editing a plan', async () => { renderDialog({ plan: planWithSteps }); await user.click(screen.getByRole('button', { name: 'Edit Plan' })); // Check if existing steps are shown expect(screen.getByText('speech:')).toBeInTheDocument(); expect(screen.getByText('Hello world')).toBeInTheDocument(); expect(screen.getByText('gesture:')).toBeInTheDocument(); expect(screen.getByText('Wave')).toBeInTheDocument(); }); it('should show "No steps yet" message when plan has no steps', async () => { renderDialog({ plan: defaultPlan }); await user.click(screen.getByRole('button', { name: 'Edit Plan' })); expect(screen.getByText('No steps yet')).toBeInTheDocument(); }); it('should remove a step when clicked', async () => { renderDialog({ plan: planWithSteps }); await user.click(screen.getByRole('button', { name: 'Edit Plan' })); // Initially have 2 steps expect(screen.getByText('speech:')).toBeInTheDocument(); expect(screen.getByText('gesture:')).toBeInTheDocument(); // Click on the first step to remove it await user.click(screen.getByText('Hello world')); // First step should be removed expect(screen.queryByText('Hello world')).not.toBeInTheDocument(); // Second step should still exist expect(screen.getByText('Wave')).toBeInTheDocument(); }); }); describe('Save Functionality', () => { it('should call onSave with new plan when creating', async () => { renderDialog(); await user.click(screen.getByRole('button', { name: 'Create Plan' })); // Set plan name const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement; await user.click(nameInput); await user.keyboard('{Control>}a{/Control}'); await user.keyboard('My New Plan'); // Add a step const actionValueInput = screen.getByPlaceholderText(/text/i); await user.type(actionValueInput, 'First step'); await user.click(screen.getByText('Add Step')); // Save the plan await user.click(screen.getByText('Create')); expect(mockOnSave).toHaveBeenCalledWith({ id: expect.stringMatching(uuidRegex), name: 'My New Plan', steps: [ { id: expect.stringMatching(uuidRegex), text: 'First step', type: 'speech', }, ], }); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); it('should call onSave with updated plan when editing', async () => { renderDialog({ plan: defaultPlan }); await user.click(screen.getByRole('button', { name: 'Edit Plan' })); // Change plan name const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement; await user.click(nameInput); await user.keyboard('{Control>}a{/Control}'); await user.keyboard('Updated Plan Name'); // Add a step const actionValueInput = screen.getByPlaceholderText(/text/i); await user.type(actionValueInput, 'New speech action'); await user.click(screen.getByText('Add Step')); // Save the plan await user.click(screen.getByText('Confirm')); expect(mockOnSave).toHaveBeenCalledWith({ id: defaultPlan.id, name: 'Updated Plan Name', steps: [ { id: expect.stringMatching(uuidRegex), text: 'New speech action', type: 'speech', }, ], }); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); it('should call onSave with undefined when reset button is clicked', async () => { renderDialog({ plan: defaultPlan }); await user.click(screen.getByRole('button', { name: 'Edit Plan' })); await user.click(screen.getByText('Reset')); expect(mockOnSave).toHaveBeenCalledWith(undefined); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); it('should disable save button when no draft plan exists', async () => { renderDialog(); await user.click(screen.getByRole('button', { name: 'Create Plan' })); // The save button should be enabled since draftPlan exists after clicking Create Plan const saveButton = screen.getByText('Create'); expect(saveButton).not.toBeDisabled(); }); }); describe('Step Indexing', () => { it('should show correct step numbers', async () => { renderDialog({ plan: defaultPlan }); await user.click(screen.getByRole('button', { name: 'Edit Plan' })); // Add multiple steps const actionValueInput = screen.getByPlaceholderText(/text/i); const addButton = screen.getByText('Add Step'); await user.type(actionValueInput, 'First'); await user.click(addButton); await user.type(actionValueInput, 'Second'); await user.click(addButton); await user.type(actionValueInput, 'Third'); await user.click(addButton); // Check step numbers expect(screen.getByText('1.')).toBeInTheDocument(); expect(screen.getByText('2.')).toBeInTheDocument(); expect(screen.getByText('3.')).toBeInTheDocument(); }); }); describe('Action Type Switching', () => { it('should update placeholder text when action type changes', async () => { renderDialog(); await user.click(screen.getByRole('button', { name: 'Create Plan' })); const actionTypeSelect = screen.getByLabelText(/Action Type/i); // Check speech placeholder await user.selectOptions(actionTypeSelect, 'speech'); // The placeholder might be set dynamically, so we need to check the input const speechInput = screen.getByPlaceholderText(/text/i); expect(speechInput).toBeInTheDocument(); // Check gesture placeholder await user.selectOptions(actionTypeSelect, 'gesture'); const gestureInput = screen.getByTestId("valueEditorTestID") expect(gestureInput).toBeInTheDocument(); // Check LLM placeholder await user.selectOptions(actionTypeSelect, 'llm'); const llmInput = screen.getByPlaceholderText(/LLM|text/i); expect(llmInput).toBeInTheDocument(); }); }); describe('Plan reducing', () => { it('should correctly reduce the plan given the elements of the plan', () => { // Create a plan for testing const testplan = extendedPlan const mockGoalNode: Node = { id: 'goal-1', type: 'goal', position: { x: 0, y: 0 }, data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), name: 'mock goal', plan: defaultPlan }, }; // Insert the goal and retrieve its expected data const newTestPlan = insertGoalInPlan(testplan, mockGoalNode) const goalReduced = GoalReduce(mockGoalNode, [mockGoalNode]) const expectedResult = { id: "extended-plan-1", steps: [ { id: "firststep", gesture: { type: "tag", name: "hello" } }, { id: "secondstep", gesture: { type: "single", name: "somefolder/somegesture" } }, { id: "thirdstep", goal: "ask the user something or whatever" }, { id: "fourthstep", text: "I'm a cyborg ninja :>" }, goalReduced, ] } // Check to see it the goal got added, and its reduced data was added to the goals' const actualResult = PlanReduce([mockGoalNode], newTestPlan) expect(actualResult).toEqual(expectedResult) }); }) });