diff --git a/packages/hotspot/configure/src/__tests__/DeleteWidget.test.jsx b/packages/hotspot/configure/src/__tests__/DeleteWidget.test.jsx new file mode 100644 index 0000000000..83cbc6935b --- /dev/null +++ b/packages/hotspot/configure/src/__tests__/DeleteWidget.test.jsx @@ -0,0 +1,366 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import Konva from 'konva'; +import DeleteWidget from '../DeleteWidget'; + +Konva.isBrowser = false; + +jest.mock('react-konva', () => { + const React = require('react'); + return { + Group: ({ children, onClick, ...props }) => { + return React.createElement('div', { 'data-testid': 'group', onClick, ...props }, children); + }, + }; +}); + +jest.mock('../image-konva', () => { + return function ImageComponent({ src, x, y }) { + return
; + }; +}); + +jest.mock('../utils', () => ({ + calculate: jest.fn((points) => { + const xValues = points.map(p => p.x); + const yValues = points.map(p => p.y); + return { + x: Math.max(...xValues), + y: Math.max(...yValues), + }; + }), +})); + +describe('DeleteWidget', () => { + let defaultProps; + + beforeEach(() => { + defaultProps = { + id: 'shape1', + x: 100, + y: 150, + handleWidgetClick: jest.fn(), + }; + }); + + describe('rendering', () => { + it('should render without crashing', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('should render Group component', () => { + const { getByTestId } = render(); + expect(getByTestId('group')).toBeInTheDocument(); + }); + + it('should render delete icon', () => { + const { getByTestId } = render(); + expect(getByTestId('delete-icon')).toBeInTheDocument(); + }); + }); + + describe('rectangle positioning', () => { + it('should position delete icon at bottom-right for rectangles', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('delete-icon'); + // positionX = x + width - offset = 100 + 200 - 20 = 280 + // positionY = y + height - offset = 150 + 150 - 20 = 280 + expect(icon).toHaveAttribute('data-x', '280'); + expect(icon).toHaveAttribute('data-y', '280'); + }); + + it('should handle different rectangle dimensions', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('delete-icon'); + // positionX = 50 + 300 - 20 = 330 + // positionY = 75 + 200 - 20 = 255 + expect(icon).toHaveAttribute('data-x', '330'); + expect(icon).toHaveAttribute('data-y', '255'); + }); + }); + + describe('circle positioning', () => { + it('should position delete icon above circle', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('delete-icon'); + // positionX = x + radius - offset = 200 + 50 - 20 = 230 + // positionY = y = 200 + expect(icon).toHaveAttribute('data-x', '230'); + expect(icon).toHaveAttribute('data-y', '200'); + }); + + it('should handle different circle sizes', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('delete-icon'); + // positionX = 100 + 75 - 20 = 155 + // positionY = 100 + expect(icon).toHaveAttribute('data-x', '155'); + expect(icon).toHaveAttribute('data-y', '100'); + }); + + it('should handle small circles', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('delete-icon'); + // positionX = 150 + 20 - 20 = 150 + // positionY = 150 + expect(icon).toHaveAttribute('data-x', '150'); + expect(icon).toHaveAttribute('data-y', '150'); + }); + }); + + describe('polygon positioning', () => { + it('should position delete icon using calculate function for polygons', () => { + const { calculate } = require('../utils'); + const points = [ + { x: 10, y: 10 }, + { x: 100, y: 20 }, + { x: 90, y: 90 }, + { x: 20, y: 80 }, + ]; + + const { getByTestId } = render( + + ); + + expect(calculate).toHaveBeenCalledWith(points); + + const icon = getByTestId('delete-icon'); + // Based on mocked calculate function: max x = 100, max y = 90 + expect(icon).toHaveAttribute('data-x', '100'); + expect(icon).toHaveAttribute('data-y', '90'); + }); + + it('should handle triangular polygons', () => { + const { calculate } = require('../utils'); + const points = [ + { x: 50, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 }, + ]; + + render( + + ); + + expect(calculate).toHaveBeenCalledWith(points); + }); + + it('should handle complex polygons', () => { + const { calculate } = require('../utils'); + const points = [ + { x: 10, y: 10 }, + { x: 50, y: 5 }, + { x: 90, y: 10 }, + { x: 100, y: 50 }, + { x: 90, y: 90 }, + { x: 50, y: 100 }, + { x: 10, y: 90 }, + { x: 0, y: 50 }, + ]; + + render( + + ); + + expect(calculate).toHaveBeenCalledWith(points); + }); + }); + + describe('interactions', () => { + it('should call handleWidgetClick when clicked', () => { + const handleWidgetClick = jest.fn(); + const { getByTestId } = render( + + ); + + const group = getByTestId('group'); + fireEvent.click(group); + + expect(handleWidgetClick).toHaveBeenCalledWith('shape1'); + }); + + it('should call handleWidgetClick with correct id for circles', () => { + const handleWidgetClick = jest.fn(); + const { getByTestId } = render( + + ); + + const group = getByTestId('group'); + fireEvent.click(group); + + expect(handleWidgetClick).toHaveBeenCalledWith('circle1'); + }); + + it('should call handleWidgetClick with correct id for polygons', () => { + const handleWidgetClick = jest.fn(); + const points = [ + { x: 10, y: 10 }, + { x: 100, y: 20 }, + { x: 50, y: 100 }, + ]; + const { getByTestId } = render( + + ); + + const group = getByTestId('group'); + fireEvent.click(group); + + expect(handleWidgetClick).toHaveBeenCalledWith('polygon1'); + }); + }); + + describe('edge cases', () => { + it('should handle zero dimensions for rectangles', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('delete-icon'); + // positionX = 0 + 0 - 20 = -20 + // positionY = 0 + 0 - 20 = -20 + expect(icon).toHaveAttribute('data-x', '-20'); + expect(icon).toHaveAttribute('data-y', '-20'); + }); + + it('should handle zero radius for circles', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('delete-icon'); + // positionX = 100 + 0 - 20 = 80 + // positionY = 100 + expect(icon).toHaveAttribute('data-x', '80'); + expect(icon).toHaveAttribute('data-y', '100'); + }); + + it('should handle negative coordinates', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('delete-icon'); + // positionX = -50 + 100 - 20 = 30 + // positionY = -75 + 100 - 20 = 5 + expect(icon).toHaveAttribute('data-x', '30'); + expect(icon).toHaveAttribute('data-y', '5'); + }); + + it('should handle single point polygon', () => { + const points = [{ x: 50, y: 50 }]; + + const { container } = render( + + ); + + expect(container).toBeTruthy(); + }); + }); + + describe('icon rendering', () => { + it('should pass correct src to ImageComponent', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('delete-icon'); + expect(icon).toHaveAttribute('data-src'); + }); + }); +}); diff --git a/packages/hotspot/configure/src/__tests__/button.test.jsx b/packages/hotspot/configure/src/__tests__/button.test.jsx new file mode 100644 index 0000000000..6d6360c388 --- /dev/null +++ b/packages/hotspot/configure/src/__tests__/button.test.jsx @@ -0,0 +1,198 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import RawButton from '../button'; + +describe('RawButton', () => { + describe('rendering', () => { + it('should render without crashing', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('should render with default label', () => { + const { getByText } = render(); + expect(getByText('Add')).toBeInTheDocument(); + }); + + it('should render with custom label', () => { + const { getByText } = render(); + expect(getByText('Custom Label')).toBeInTheDocument(); + }); + + it('should render as a button element', () => { + const { getByRole } = render(); + expect(getByRole('button')).toBeInTheDocument(); + }); + + it('should apply custom className', () => { + const { getByRole } = render(); + const button = getByRole('button'); + expect(button).toHaveClass('custom-class'); + }); + }); + + describe('interactions', () => { + it('should call onClick when clicked', () => { + const onClick = jest.fn(); + const { getByRole } = render(); + + const button = getByRole('button'); + fireEvent.click(button); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('should not call onClick when disabled', () => { + const onClick = jest.fn(); + const { getByRole } = render(); + + const button = getByRole('button'); + fireEvent.click(button); + + expect(onClick).not.toHaveBeenCalled(); + }); + + it('should call onClick multiple times', () => { + const onClick = jest.fn(); + const { getByRole } = render(); + + const button = getByRole('button'); + fireEvent.click(button); + fireEvent.click(button); + fireEvent.click(button); + + expect(onClick).toHaveBeenCalledTimes(3); + }); + }); + + describe('disabled state', () => { + it('should be enabled by default', () => { + const { getByRole } = render(); + const button = getByRole('button'); + expect(button).not.toBeDisabled(); + }); + + it('should be disabled when disabled prop is true', () => { + const { getByRole } = render(); + const button = getByRole('button'); + expect(button).toBeDisabled(); + }); + + it('should be enabled when disabled prop is false', () => { + const { getByRole } = render(); + const button = getByRole('button'); + expect(button).not.toBeDisabled(); + }); + }); + + describe('default props', () => { + it('should use default onClick when not provided', () => { + const { getByRole } = render(); + const button = getByRole('button'); + + // Should not throw error when clicked + expect(() => fireEvent.click(button)).not.toThrow(); + }); + + it('should use default label "Add" when not provided', () => { + const { getByText } = render(); + expect(getByText('Add')).toBeInTheDocument(); + }); + + it('should use empty className by default', () => { + const { getByRole } = render(); + const button = getByRole('button'); + expect(button.className).toBeTruthy(); // Will have MUI classes + }); + + it('should be enabled by default', () => { + const { getByRole } = render(); + const button = getByRole('button'); + expect(button).not.toBeDisabled(); + }); + }); + + describe('variant and size', () => { + it('should render with contained variant', () => { + const { getByRole } = render(); + const button = getByRole('button'); + expect(button).toHaveClass('MuiButton-contained'); + }); + + it('should render with small size', () => { + const { getByRole } = render(); + const button = getByRole('button'); + expect(button).toHaveClass('MuiButton-sizeSmall'); + }); + }); + + describe('edge cases', () => { + it('should handle empty label', () => { + const { getByRole } = render(); + const button = getByRole('button'); + expect(button).toBeInTheDocument(); + expect(button.textContent).toBe(''); + }); + + it('should handle very long label', () => { + const longLabel = 'This is a very long button label that might wrap or overflow'; + const { getByText } = render(); + expect(getByText(longLabel)).toBeInTheDocument(); + }); + + it('should handle special characters in label', () => { + const specialLabel = '!@#$%^&*()_+-=[]{}|;:",.<>?/~`'; + const { getByText } = render(); + expect(getByText(specialLabel)).toBeInTheDocument(); + }); + + it('should handle Unicode characters in label', () => { + const unicodeLabel = '🚀 Launch 你好 مرحبا'; + const { getByText } = render(); + expect(getByText(unicodeLabel)).toBeInTheDocument(); + }); + + it('should handle null onClick gracefully with default', () => { + const { getByRole } = render(); + const button = getByRole('button'); + + // Should use default onClick + expect(() => fireEvent.click(button)).not.toThrow(); + }); + }); + + describe('prop updates', () => { + it('should update label when prop changes', () => { + const { getByText, rerender } = render(); + expect(getByText('Initial')).toBeInTheDocument(); + + rerender(); + expect(getByText('Updated')).toBeInTheDocument(); + }); + + it('should update disabled state when prop changes', () => { + const { getByRole, rerender } = render(); + const button = getByRole('button'); + expect(button).not.toBeDisabled(); + + rerender(); + expect(button).toBeDisabled(); + }); + + it('should update onClick handler when prop changes', () => { + const onClick1 = jest.fn(); + const onClick2 = jest.fn(); + const { getByRole, rerender } = render(); + + const button = getByRole('button'); + fireEvent.click(button); + expect(onClick1).toHaveBeenCalledTimes(1); + expect(onClick2).not.toHaveBeenCalled(); + + rerender(); + fireEvent.click(button); + expect(onClick1).toHaveBeenCalledTimes(1); + expect(onClick2).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/hotspot/configure/src/__tests__/hotspot-circle.test.jsx b/packages/hotspot/configure/src/__tests__/hotspot-circle.test.jsx new file mode 100644 index 0000000000..2f67058e77 --- /dev/null +++ b/packages/hotspot/configure/src/__tests__/hotspot-circle.test.jsx @@ -0,0 +1,259 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import Konva from 'konva'; +import CircleComponent from '../hotspot-circle'; + +Konva.isBrowser = false; + +jest.mock('react-konva', () => { + const React = require('react'); + return { + Circle: React.forwardRef(({ onClick, onTap, onMouseEnter, onMouseLeave, onDragStart, onDragEnd, onTransformStart, onTransformEnd, ...props }, ref) => { + const handleClick = (e) => { + if (onClick) onClick(e); + if (onTap) onTap(e); + }; + return React.createElement('div', { + ref, + 'data-testid': 'circle', + onClick: handleClick, + onMouseEnter, + onMouseLeave, + ...props, + }); + }), + Group: ({ children, onMouseEnter, onMouseLeave, ...props }) => + React.createElement('div', { 'data-testid': 'group', onMouseEnter, onMouseLeave, ...props }, children), + Transformer: React.forwardRef(({ borderStroke, ...props }, ref) => { + return React.createElement('div', { ref, 'data-testid': 'transformer', 'data-border-stroke': borderStroke, ...props }); + }), + }; +}); + +jest.mock('../DeleteWidget', () => { + return function DeleteWidget({ id, handleWidgetClick }) { + return ( +
handleWidgetClick(id)} + /> + ); + }; +}); + +describe('CircleComponent', () => { + let defaultProps; + + beforeEach(() => { + defaultProps = { + id: 'circle1', + x: 100, + y: 150, + radius: 50, + hotspotColor: '#FF0000', + selectedHotspotColor: '#00FF00', + outlineColor: '#0000FF', + hoverOutlineColor: '#FFFF00', + correct: false, + isDrawing: false, + onClick: jest.fn(), + onDeleteShape: jest.fn(), + onDragEnd: jest.fn(), + strokeWidth: 5, + }; + + document.body.style.cursor = 'default'; + }); + + afterEach(() => { + document.body.style.cursor = 'default'; + }); + + describe('rendering', () => { + it('should render without crashing', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('should render Group and Circle', () => { + const { getByTestId } = render(); + expect(getByTestId('group')).toBeInTheDocument(); + expect(getByTestId('circle')).toBeInTheDocument(); + }); + + it('should render with correct radius', () => { + const { getByTestId } = render(); + const circle = getByTestId('circle'); + expect(circle).toHaveAttribute('radius', '75'); + }); + + it('should use minimum radius of 5 for invalid radius', () => { + const { getByTestId } = render(); + const circle = getByTestId('circle'); + expect(circle).toHaveAttribute('radius', '5'); + }); + + it('should use minimum radius of 5 for NaN radius', () => { + const { getByTestId } = render(); + const circle = getByTestId('circle'); + expect(circle).toHaveAttribute('radius', '5'); + }); + + it('should use minimum radius of 5 for negative radius', () => { + const { getByTestId } = render(); + const circle = getByTestId('circle'); + expect(circle).toHaveAttribute('radius', '5'); + }); + + it('should render with hotspot color when not correct', () => { + const { getByTestId } = render(); + const circle = getByTestId('circle'); + expect(circle).toHaveAttribute('fill', '#FF0000'); + }); + + it('should render with selected color when correct', () => { + const { getByTestId } = render(); + const circle = getByTestId('circle'); + expect(circle).toHaveAttribute('fill', '#00FF00'); + }); + }); + + describe('interactions', () => { + it('should call onClick when clicked', () => { + const onClick = jest.fn(); + const { getByTestId } = render(); + const circle = getByTestId('circle'); + + fireEvent.click(circle); + + expect(onClick).toHaveBeenCalledWith('circle1'); + }); + + it('should not call onClick when radius is 0 and isDrawing', () => { + const onClick = jest.fn(); + const { getByTestId } = render( + + ); + const circle = getByTestId('circle'); + + fireEvent.click(circle); + + expect(onClick).not.toHaveBeenCalled(); + }); + }); + + describe('hover state', () => { + it('should not show Transformer when not hovered', () => { + const { queryByTestId } = render(); + + expect(queryByTestId('transformer')).not.toBeInTheDocument(); + }); + }); + + describe('drag functionality', () => { + it('should call onDragEnd with new position', () => { + const onDragEnd = jest.fn(); + const { getByTestId } = render( + + ); + const circle = getByTestId('circle'); + + const mockEvent = { + target: { + x: () => 200, + y: () => 250, + }, + }; + + if (circle.onDragEnd) { + circle.onDragEnd(mockEvent); + } + }); + }); + + describe('resize functionality', () => { + it('should handle resize with minimum radius constraint', () => { + const onDragEnd = jest.fn(); + const { getByTestId } = render( + + ); + + // This tests the component's ability to handle transforms + // In actual usage, the transformer would modify the node's scale + const transformer = getByTestId('group'); + expect(transformer).toBeInTheDocument(); + }); + }); + + describe('edge cases', () => { + it('should handle very large radius', () => { + const { getByTestId } = render(); + const circle = getByTestId('circle'); + expect(circle).toHaveAttribute('radius', '500'); + }); + + it('should handle position at origin', () => { + const { getByTestId } = render(); + const circle = getByTestId('circle'); + expect(circle).toHaveAttribute('x', '0'); + expect(circle).toHaveAttribute('y', '0'); + }); + + it('should handle negative positions', () => { + const { getByTestId } = render(); + const circle = getByTestId('circle'); + expect(circle).toHaveAttribute('x', '-50'); + expect(circle).toHaveAttribute('y', '-75'); + }); + + it('should use default correct value of false', () => { + const { getByTestId } = render(); + const circle = getByTestId('circle'); + expect(circle).toHaveAttribute('fill', '#FF0000'); + }); + + it('should handle missing selectedHotspotColor', () => { + const { getByTestId } = render( + + ); + const circle = getByTestId('circle'); + // Should fall back to hotspotColor + expect(circle).toHaveAttribute('fill', '#FF0000'); + }); + }); + + describe('prop updates', () => { + it('should update radius when prop changes', () => { + const { getByTestId, rerender } = render(); + let circle = getByTestId('circle'); + expect(circle).toHaveAttribute('radius', '50'); + + rerender(); + circle = getByTestId('circle'); + expect(circle).toHaveAttribute('radius', '75'); + }); + + it('should update color when correct prop changes', () => { + const { getByTestId, rerender } = render(); + let circle = getByTestId('circle'); + expect(circle).toHaveAttribute('fill', '#FF0000'); + + rerender(); + circle = getByTestId('circle'); + expect(circle).toHaveAttribute('fill', '#00FF00'); + }); + + it('should update position when props change', () => { + const { getByTestId, rerender } = render(); + let circle = getByTestId('circle'); + expect(circle).toHaveAttribute('x', '100'); + expect(circle).toHaveAttribute('y', '150'); + + rerender(); + circle = getByTestId('circle'); + expect(circle).toHaveAttribute('x', '200'); + expect(circle).toHaveAttribute('y', '250'); + }); + }); +}); diff --git a/packages/hotspot/configure/src/__tests__/hotspot-palette.test.jsx b/packages/hotspot/configure/src/__tests__/hotspot-palette.test.jsx new file mode 100644 index 0000000000..c059497d7f --- /dev/null +++ b/packages/hotspot/configure/src/__tests__/hotspot-palette.test.jsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import Palette from '../hotspot-palette'; + +describe('Palette', () => { + let defaultProps; + + beforeEach(() => { + defaultProps = { + hotspotColor: '#FF0000', + outlineColor: '#0000FF', + hotspotList: ['#FF0000', '#00FF00', '#0000FF', '#FFFF00'], + outlineList: ['#000000', '#0000FF', '#FF0000', '#00FF00'], + onHotspotColorChange: jest.fn(), + onOutlineColorChange: jest.fn(), + }; + }); + + describe('rendering', () => { + it('should render without crashing', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + }); + + describe('edge cases', () => { + it('should handle empty hotspot list gracefully', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('should handle empty outline list gracefully', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + }); + + describe('onChange handler', () => { + it('should create onChange handler for hotspot', () => { + const component = new Palette(defaultProps); + const handler = component.onChange('hotspot'); + expect(typeof handler).toBe('function'); + }); + + it('should create onChange handler for outline', () => { + const component = new Palette(defaultProps); + const handler = component.onChange('outline'); + expect(typeof handler).toBe('function'); + }); + + it('should call onHotspotColorChange when hotspot handler is invoked', () => { + const onHotspotColorChange = jest.fn(); + const component = new Palette({ ...defaultProps, onHotspotColorChange }); + const handler = component.onChange('hotspot'); + + handler({ target: { value: '#00FF00' } }); + + expect(onHotspotColorChange).toHaveBeenCalledWith('#00FF00'); + }); + + it('should call onOutlineColorChange when outline handler is invoked', () => { + const onOutlineColorChange = jest.fn(); + const component = new Palette({ ...defaultProps, onOutlineColorChange }); + const handler = component.onChange('outline'); + + handler({ target: { value: '#FF0000' } }); + + expect(onOutlineColorChange).toHaveBeenCalledWith('#FF0000'); + }); + }); +}); diff --git a/packages/hotspot/configure/src/__tests__/image-konva.test.jsx b/packages/hotspot/configure/src/__tests__/image-konva.test.jsx new file mode 100644 index 0000000000..111d30aaee --- /dev/null +++ b/packages/hotspot/configure/src/__tests__/image-konva.test.jsx @@ -0,0 +1,226 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import Konva from 'konva'; +import ImageComponent from '../image-konva'; + +Konva.isBrowser = false; + +jest.mock('react-konva', () => { + const React = require('react'); + return { + Image: (props) => React.createElement('div', { 'data-testid': 'image', ...props }), + }; +}); + +describe('ImageComponent', () => { + let defaultProps; + + beforeEach(() => { + defaultProps = { + src: 'test-image.png', + x: 100, + y: 150, + }; + }); + + describe('rendering', () => { + it('should render without crashing', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('should render Image component', () => { + const { getByTestId } = render(); + expect(getByTestId('image')).toBeInTheDocument(); + }); + + it('should render with correct position', () => { + const { getByTestId } = render(); + const image = getByTestId('image'); + expect(image).toHaveAttribute('x', '200'); + expect(image).toHaveAttribute('y', '250'); + }); + + it('should render with fixed dimensions', () => { + const { getByTestId } = render(); + const image = getByTestId('image'); + expect(image).toHaveAttribute('width', '20'); + expect(image).toHaveAttribute('height', '20'); + }); + + it('should render at origin (0, 0)', () => { + const { getByTestId } = render(); + const image = getByTestId('image'); + expect(image).toHaveAttribute('x', '0'); + expect(image).toHaveAttribute('y', '0'); + }); + }); + + describe('image loading', () => { + it('should load image on mount', async () => { + const { getByTestId } = render(); + + const image = getByTestId('image'); + expect(image).toBeInTheDocument(); + }); + + it('should call loadImage on mount', () => { + const { container } = render(); + + expect(container).toBeTruthy(); + }); + }); + + describe('component lifecycle', () => { + it('should reload image when src changes', async () => { + const { rerender, container } = render(); + + expect(container).toBeTruthy(); + + rerender(); + + expect(container).toBeTruthy(); + }); + + it('should not reload image when src stays the same', async () => { + const { rerender, getByTestId } = render(); + + const image = getByTestId('image'); + expect(image).toBeInTheDocument(); + + rerender(); + + const updatedImage = getByTestId('image'); + expect(updatedImage).toHaveAttribute('x', '200'); + expect(updatedImage).toHaveAttribute('y', '250'); + }); + + it('should clean up event listener on unmount', () => { + const { unmount } = render(); + + expect(() => { + unmount(); + }).not.toThrow(); + }); + + it('should handle multiple mount/unmount cycles', () => { + const { unmount } = render(); + unmount(); + + expect(() => { + render(); + }).not.toThrow(); + }); + }); + + describe('position updates', () => { + it('should update x position', () => { + const { getByTestId, rerender } = render(); + let image = getByTestId('image'); + expect(image).toHaveAttribute('x', '100'); + + rerender(); + image = getByTestId('image'); + expect(image).toHaveAttribute('x', '200'); + }); + + it('should update y position', () => { + const { getByTestId, rerender } = render(); + let image = getByTestId('image'); + expect(image).toHaveAttribute('y', '150'); + + rerender(); + image = getByTestId('image'); + expect(image).toHaveAttribute('y', '250'); + }); + + it('should update both x and y positions', () => { + const { getByTestId, rerender } = render(); + let image = getByTestId('image'); + expect(image).toHaveAttribute('x', '100'); + expect(image).toHaveAttribute('y', '150'); + + rerender(); + image = getByTestId('image'); + expect(image).toHaveAttribute('x', '300'); + expect(image).toHaveAttribute('y', '400'); + }); + }); + + describe('edge cases', () => { + it('should handle negative positions', () => { + const { getByTestId } = render(); + const image = getByTestId('image'); + expect(image).toHaveAttribute('x', '-50'); + expect(image).toHaveAttribute('y', '-75'); + }); + + it('should handle very large positions', () => { + const { getByTestId } = render(); + const image = getByTestId('image'); + expect(image).toHaveAttribute('x', '10000'); + expect(image).toHaveAttribute('y', '20000'); + }); + + it('should handle data URI as src', () => { + const dataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + const { container } = render(); + + expect(container).toBeTruthy(); + }); + + it('should handle SVG as src', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('should handle empty src', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('should handle URL with query parameters', () => { + const srcWithParams = 'image.png?width=100&height=100'; + const { container } = render(); + + expect(container).toBeTruthy(); + }); + + it('should handle relative paths', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('should handle absolute paths', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + }); + + describe('image state', () => { + it('should initially render without image loaded', () => { + const { getByTestId } = render(); + const image = getByTestId('image'); + expect(image).toBeInTheDocument(); + }); + + it('should render component regardless of image load state', () => { + const { getByTestId } = render(); + const image = getByTestId('image'); + expect(image).toBeInTheDocument(); + }); + }); + + describe('error handling', () => { + it('should handle image load error gracefully', () => { + const { container } = render(); + + const imgElements = document.querySelectorAll('img[src="nonexistent.png"]'); + imgElements.forEach(img => { + img.dispatchEvent(new Event('error')); + }); + + expect(container).toBeTruthy(); + }); + }); +}); diff --git a/packages/hotspot/configure/src/hotspot-circle.jsx b/packages/hotspot/configure/src/hotspot-circle.jsx index c7787c606c..2baf20fa82 100644 --- a/packages/hotspot/configure/src/hotspot-circle.jsx +++ b/packages/hotspot/configure/src/hotspot-circle.jsx @@ -109,7 +109,6 @@ class CircleComponent extends React.Component { onTransformEnd={this.onResizeEnd} x={x} y={y} - opacity={0.5} cursor="pointer" /> diff --git a/packages/hotspot/configure/src/hotspot-polygon.jsx b/packages/hotspot/configure/src/hotspot-polygon.jsx index 80a363c8a1..090c436a25 100644 --- a/packages/hotspot/configure/src/hotspot-polygon.jsx +++ b/packages/hotspot/configure/src/hotspot-polygon.jsx @@ -232,7 +232,6 @@ class PolComponent extends React.Component { onDragEnd={(e) => this.handleOnDragEnd(e, true)} x={x} y={y} - opacity={0.5} /> {showPoints && diff --git a/packages/hotspot/configure/src/hotspot-rectangle.jsx b/packages/hotspot/configure/src/hotspot-rectangle.jsx index b25c4cf91b..b7d2c4d194 100644 --- a/packages/hotspot/configure/src/hotspot-rectangle.jsx +++ b/packages/hotspot/configure/src/hotspot-rectangle.jsx @@ -119,7 +119,6 @@ class RectComponent extends React.Component { onTransformEnd={this.onResizeEnd} x={x} y={y} - opacity={0.5} cursor="pointer" /> {!this.state.isDragging && this.state.hovered && ( diff --git a/packages/hotspot/src/hotspot/__tests__/circle.test.jsx b/packages/hotspot/src/hotspot/__tests__/circle.test.jsx new file mode 100644 index 0000000000..8ac04cba30 --- /dev/null +++ b/packages/hotspot/src/hotspot/__tests__/circle.test.jsx @@ -0,0 +1,464 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import Konva from 'konva'; +import CircleComponent from '../circle'; + +Konva.isBrowser = false; + +jest.mock('react-konva', () => { + const React = require('react'); + return { + Circle: ({ onClick, onTap, onMouseEnter, onMouseLeave, ...props }) => { + const handleClick = (e) => { + if (onClick) onClick(e); + if (onTap) onTap(e); + }; + return React.createElement('div', { + 'data-testid': 'circle', + onClick: handleClick, + onMouseEnter, + onMouseLeave, + ...props, + }); + }, + Rect: (props) => React.createElement('div', { 'data-testid': 'rect', ...props }), + Group: ({ children, ...props }) => React.createElement('div', { 'data-testid': 'group', ...props }, children), + }; +}); + +jest.mock('../image-konva-tooltip', () => { + return function ImageComponent({ src, x, y, tooltip }) { + return
; + }; +}); + +describe('CircleComponent', () => { + let defaultProps; + + beforeEach(() => { + defaultProps = { + id: 'circle1', + x: 50, + y: 50, + radius: 30, + hotspotColor: '#FF0000', + selectedHotspotColor: '#00FF00', + outlineColor: '#0000FF', + hoverOutlineColor: '#FFFF00', + selected: false, + isCorrect: false, + isEvaluateMode: false, + disabled: false, + onClick: jest.fn(), + strokeWidth: 5, + scale: 1, + markAsCorrect: false, + showCorrectEnabled: false, + }; + }); + + afterEach(() => { + document.body.style.cursor = 'default'; + }); + + describe('rendering', () => { + it('should render without crashing', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('should render with correct position and radius', () => { + const { getByTestId } = render(); + const circle = getByTestId('circle'); + + expect(circle).toHaveAttribute('x', '50'); + expect(circle).toHaveAttribute('y', '50'); + expect(circle).toHaveAttribute('radius', '30'); + }); + + it('should render with hotspot color when not selected', () => { + const { getByTestId } = render(); + const circle = getByTestId('circle'); + + expect(circle).toHaveAttribute('fill', '#FF0000'); + }); + + it('should render with selected color when selected', () => { + const { getByTestId } = render(); + const circle = getByTestId('circle'); + + expect(circle).toHaveAttribute('fill', '#00FF00'); + }); + + it('should apply scale transform', () => { + const { getByTestId } = render(); + const group = getByTestId('group'); + + expect(group).toHaveAttribute('scaleX', '2'); + expect(group).toHaveAttribute('scaleY', '2'); + }); + + it('should render with default scale of 1', () => { + const { getByTestId } = render(); + const group = getByTestId('group'); + + expect(group).toHaveAttribute('scaleX', '1'); + expect(group).toHaveAttribute('scaleY', '1'); + }); + }); + + describe('interactions', () => { + it('should call onClick when clicked', () => { + const onClick = jest.fn(); + const { getByTestId } = render(); + const circle = getByTestId('circle'); + + fireEvent.click(circle); + + expect(onClick).toHaveBeenCalledWith({ + id: 'circle1', + selected: true, + selector: 'Mouse', + }); + }); + + it('should toggle selection state on click', () => { + const onClick = jest.fn(); + const { getByTestId, rerender } = render(); + const circle = getByTestId('circle'); + + fireEvent.click(circle); + + expect(onClick).toHaveBeenCalledWith({ + id: 'circle1', + selected: true, + selector: 'Mouse', + }); + + rerender(); + + const circleAfter = getByTestId('circle'); + fireEvent.click(circleAfter); + + expect(onClick).toHaveBeenCalledWith({ + id: 'circle1', + selected: false, + selector: 'Mouse', + }); + }); + + it('should not call onClick when disabled', () => { + const onClick = jest.fn(); + const { getByTestId } = render(); + const circle = getByTestId('circle'); + + fireEvent.click(circle); + + expect(onClick).not.toHaveBeenCalled(); + }); + + it('should change cursor to pointer on mouse enter when not disabled', () => { + const { getByTestId } = render(); + const circle = getByTestId('circle'); + + fireEvent.mouseEnter(circle); + + expect(document.body.style.cursor).toBe('pointer'); + }); + + it('should not change cursor when disabled', () => { + const { getByTestId } = render(); + const circle = getByTestId('circle'); + + fireEvent.mouseEnter(circle); + + expect(document.body.style.cursor).toBe('default'); + }); + + it('should reset cursor to default on mouse leave', () => { + const { getByTestId } = render(); + const circle = getByTestId('circle'); + + fireEvent.mouseEnter(circle); + fireEvent.mouseLeave(circle); + + expect(document.body.style.cursor).toBe('default'); + }); + }); + + describe('hover styling', () => { + it('should show hover rect when hoverOutlineColor is provided', () => { + const { container, getByTestId } = render(); + const circle = getByTestId('circle'); + + fireEvent.mouseEnter(circle); + + const rects = container.querySelectorAll('[data-testid="rect"]'); + expect(rects.length).toBeGreaterThan(0); + }); + + it('should render hover rect with correct dimensions based on radius', () => { + const { container, getByTestId } = render( + + ); + const circle = getByTestId('circle'); + + fireEvent.mouseEnter(circle); + + const rect = container.querySelector('[data-testid="rect"]'); + if (rect) { + // Rect should be positioned at (x - radius, y - radius) with width/height = radius * 2 + expect(rect).toHaveAttribute('x', '20'); // 50 - 30 + expect(rect).toHaveAttribute('y', '20'); // 50 - 30 + expect(rect).toHaveAttribute('width', '60'); // 30 * 2 + expect(rect).toHaveAttribute('height', '60'); // 30 * 2 + } + }); + + it('should show transparent stroke when selected and hovering', () => { + const { container, getByTestId } = render( + + ); + const circle = getByTestId('circle'); + + fireEvent.mouseEnter(circle); + + const rect = container.querySelector('[data-testid="rect"]'); + if (rect) { + expect(rect).toHaveAttribute('stroke', 'transparent'); + } + }); + }); + + describe('evaluate mode', () => { + it('should show correct icon when correctly selected in evaluate mode', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('icon-image'); + expect(icon).toBeInTheDocument(); + }); + + it('should show wrong icon when incorrectly selected in evaluate mode', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('icon-image'); + expect(icon).toBeInTheDocument(); + }); + + it('should show wrong icon when incorrectly not selected', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('icon-image'); + expect(icon).toBeInTheDocument(); + }); + + it('should not show icon when correctly not selected in evaluate mode', () => { + const { queryByTestId } = render( + + ); + + const icon = queryByTestId('icon-image'); + expect(icon).not.toBeInTheDocument(); + }); + + it('should show correct icon for showCorrect mode when correctly selected', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('icon-image'); + expect(icon).toBeInTheDocument(); + }); + + it('should show correct icon for showCorrect mode when incorrectly not selected', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('icon-image'); + expect(icon).toBeInTheDocument(); + }); + + it('should not show icon in showCorrect mode when correctly not selected', () => { + const { queryByTestId } = render( + + ); + + const icon = queryByTestId('icon-image'); + expect(icon).not.toBeInTheDocument(); + }); + + it('should not show icon in showCorrect mode when incorrectly selected', () => { + const { queryByTestId } = render( + + ); + + const icon = queryByTestId('icon-image'); + expect(icon).not.toBeInTheDocument(); + }); + + it('should show green outline when markAsCorrect is true', () => { + const { getByTestId } = render( + + ); + + const circle = getByTestId('circle'); + expect(circle).toHaveAttribute('stroke', 'green'); + }); + + it('should show red outline when incorrect and not markAsCorrect', () => { + const { getByTestId } = render( + + ); + + const circle = getByTestId('circle'); + expect(circle).toHaveAttribute('stroke', 'red'); + }); + + it('should display evaluate text in tooltip', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('icon-image'); + expect(icon).toHaveAttribute('data-tooltip', 'Great job!'); + }); + }); + + describe('icon positioning', () => { + it('should position icon at center of circle minus offset', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('icon-image'); + // Icon should be at x - 10, y - 10 + expect(icon).toHaveAttribute('data-x', '90'); + expect(icon).toHaveAttribute('data-y', '90'); + }); + }); + + describe('edge cases', () => { + it('should handle very small radius', () => { + const { getByTestId } = render( + + ); + + const circle = getByTestId('circle'); + expect(circle).toHaveAttribute('radius', '5'); + }); + + it('should handle very large radius', () => { + const { getByTestId } = render( + + ); + + const circle = getByTestId('circle'); + expect(circle).toHaveAttribute('radius', '200'); + }); + + it('should handle different scale values', () => { + const { getByTestId } = render( + + ); + + const group = getByTestId('group'); + expect(group).toHaveAttribute('scaleX', '0.5'); + expect(group).toHaveAttribute('scaleY', '0.5'); + }); + }); +}); diff --git a/packages/hotspot/src/hotspot/__tests__/container.test.jsx b/packages/hotspot/src/hotspot/__tests__/container.test.jsx new file mode 100644 index 0000000000..2c5c5d70e4 --- /dev/null +++ b/packages/hotspot/src/hotspot/__tests__/container.test.jsx @@ -0,0 +1,546 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import Konva from 'konva'; +import { Container } from '../container'; + +Konva.isBrowser = false; + +jest.mock('react-konva', () => { + const React = require('react'); + return { + Stage: ({ children, ...props }) => React.createElement('div', { 'data-testid': 'stage', ...props }, children), + Layer: ({ children, ...props }) => React.createElement('div', { 'data-testid': 'layer', ...props }, children), + }; +}); + +jest.mock('../rectangle', () => { + return function Rectangle(props) { + return ( +
props.onClick({ id: props.id, selected: !props.selected })} + /> + ); + }; +}); + +jest.mock('../polygon', () => { + return function Polygon(props) { + return ( +
props.onClick({ id: props.id, selected: !props.selected })} + /> + ); + }; +}); + +jest.mock('../circle', () => { + return function Circle(props) { + return ( +
props.onClick({ id: props.id, selected: !props.selected })} + /> + ); + }; +}); + +describe('Container', () => { + let defaultProps; + + beforeEach(() => { + defaultProps = { + dimensions: { width: 800, height: 600 }, + disabled: false, + hotspotColor: '#FF0000', + hoverOutlineColor: '#FFFF00', + selectedHotspotColor: '#00FF00', + imageUrl: 'http://example.com/image.png', + isEvaluateMode: false, + onSelectChoice: jest.fn(), + outlineColor: '#0000FF', + session: { answers: [] }, + shapes: { + rectangles: [], + polygons: [], + circles: [], + }, + strokeWidth: 5, + scale: 1, + showCorrect: false, + }; + }); + + describe('rendering', () => { + it('should render without crashing', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('should render image when imageUrl is provided', () => { + const { getByAltText } = render(); + const image = getByAltText('hotspot-image'); + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('src', 'http://example.com/image.png'); + }); + + it('should not render image when imageUrl is not provided', () => { + const { queryByAltText } = render(); + expect(queryByAltText('hotspot-image')).not.toBeInTheDocument(); + }); + + it('should render Stage with correct dimensions', () => { + const { getByTestId } = render(); + const stage = getByTestId('stage'); + expect(stage).toHaveAttribute('height', '605'); // 600 + strokeWidth + expect(stage).toHaveAttribute('width', '805'); // 800 + strokeWidth + }); + + it('should apply scale to dimensions', () => { + const { getByTestId } = render(); + const stage = getByTestId('stage'); + expect(stage).toHaveAttribute('height', '1205'); // (600 * 2) + strokeWidth + expect(stage).toHaveAttribute('width', '1605'); // (800 * 2) + strokeWidth + }); + + it('should render Layer inside Stage', () => { + const { getByTestId } = render(); + const layer = getByTestId('layer'); + expect(layer).toBeInTheDocument(); + }); + }); + + describe('rectangle shapes', () => { + it('should render rectangles', () => { + const props = { + ...defaultProps, + shapes: { + rectangles: [ + { id: 'rect1', x: 10, y: 20, width: 100, height: 80, correct: false }, + { id: 'rect2', x: 150, y: 200, width: 120, height: 90, correct: false }, + ], + polygons: [], + circles: [], + }, + }; + const { getByTestId } = render(); + + expect(getByTestId('rectangle-rect1')).toBeInTheDocument(); + expect(getByTestId('rectangle-rect2')).toBeInTheDocument(); + }); + + it('should mark rectangle as selected when in session answers', () => { + const props = { + ...defaultProps, + session: { answers: [{ id: 'rect1' }] }, + shapes: { + rectangles: [ + { id: 'rect1', x: 10, y: 20, width: 100, height: 80, correct: false }, + ], + polygons: [], + circles: [], + }, + }; + const { getByTestId } = render(); + + const rect = getByTestId('rectangle-rect1'); + expect(rect).toHaveAttribute('data-selected', 'true'); + }); + + it('should call onSelectChoice when rectangle is clicked', () => { + const onSelectChoice = jest.fn(); + const props = { + ...defaultProps, + onSelectChoice, + shapes: { + rectangles: [ + { id: 'rect1', x: 10, y: 20, width: 100, height: 80, correct: false }, + ], + polygons: [], + circles: [], + }, + }; + const { getByTestId } = render(); + + const rect = getByTestId('rectangle-rect1'); + fireEvent.click(rect); + + expect(onSelectChoice).toHaveBeenCalledWith({ id: 'rect1', selected: true }); + }); + }); + + describe('polygon shapes', () => { + it('should render polygons', () => { + const props = { + ...defaultProps, + shapes: { + rectangles: [], + polygons: [ + { + id: 'poly1', + points: [{ x: 10, y: 10 }, { x: 100, y: 10 }, { x: 50, y: 100 }], + correct: false, + }, + ], + circles: [], + }, + }; + const { getByTestId } = render(); + + expect(getByTestId('polygon-poly1')).toBeInTheDocument(); + }); + + it('should mark polygon as selected when in session answers', () => { + const props = { + ...defaultProps, + session: { answers: [{ id: 'poly1' }] }, + shapes: { + rectangles: [], + polygons: [ + { + id: 'poly1', + points: [{ x: 10, y: 10 }, { x: 100, y: 10 }, { x: 50, y: 100 }], + correct: false, + }, + ], + circles: [], + }, + }; + const { getByTestId } = render(); + + const polygon = getByTestId('polygon-poly1'); + expect(polygon).toHaveAttribute('data-selected', 'true'); + }); + + it('should call onSelectChoice when polygon is clicked', () => { + const onSelectChoice = jest.fn(); + const props = { + ...defaultProps, + onSelectChoice, + shapes: { + rectangles: [], + polygons: [ + { + id: 'poly1', + points: [{ x: 10, y: 10 }, { x: 100, y: 10 }, { x: 50, y: 100 }], + correct: false, + }, + ], + circles: [], + }, + }; + const { getByTestId } = render(); + + const polygon = getByTestId('polygon-poly1'); + fireEvent.click(polygon); + + expect(onSelectChoice).toHaveBeenCalledWith({ id: 'poly1', selected: true }); + }); + }); + + describe('circle shapes', () => { + it('should render circles', () => { + const props = { + ...defaultProps, + shapes: { + rectangles: [], + polygons: [], + circles: [ + { id: 'circle1', x: 100, y: 100, radius: 50, correct: false }, + ], + }, + }; + const { getByTestId } = render(); + + expect(getByTestId('circle-circle1')).toBeInTheDocument(); + }); + + it('should mark circle as selected when in session answers', () => { + const props = { + ...defaultProps, + session: { answers: [{ id: 'circle1' }] }, + shapes: { + rectangles: [], + polygons: [], + circles: [ + { id: 'circle1', x: 100, y: 100, radius: 50, correct: false }, + ], + }, + }; + const { getByTestId } = render(); + + const circle = getByTestId('circle-circle1'); + expect(circle).toHaveAttribute('data-selected', 'true'); + }); + + it('should call onSelectChoice when circle is clicked', () => { + const onSelectChoice = jest.fn(); + const props = { + ...defaultProps, + onSelectChoice, + shapes: { + rectangles: [], + polygons: [], + circles: [ + { id: 'circle1', x: 100, y: 100, radius: 50, correct: false }, + ], + }, + }; + const { getByTestId } = render(); + + const circle = getByTestId('circle-circle1'); + fireEvent.click(circle); + + expect(onSelectChoice).toHaveBeenCalledWith({ id: 'circle1', selected: true }); + }); + }); + + describe('evaluate mode', () => { + it('should show correctness when in evaluate mode', () => { + const props = { + ...defaultProps, + isEvaluateMode: true, + session: { answers: [{ id: 'rect1' }] }, + shapes: { + rectangles: [ + { id: 'rect1', x: 10, y: 20, width: 100, height: 80, correct: true }, + ], + polygons: [], + circles: [], + }, + }; + const { getByTestId } = render(); + + const rect = getByTestId('rectangle-rect1'); + expect(rect).toHaveAttribute('data-iscorrect', 'true'); + }); + + it('should show incorrect when selected but not correct', () => { + const props = { + ...defaultProps, + isEvaluateMode: true, + session: { answers: [{ id: 'rect1' }] }, + shapes: { + rectangles: [ + { id: 'rect1', x: 10, y: 20, width: 100, height: 80, correct: false }, + ], + polygons: [], + circles: [], + }, + }; + const { getByTestId } = render(); + + const rect = getByTestId('rectangle-rect1'); + expect(rect).toHaveAttribute('data-iscorrect', 'false'); + }); + + it('should show incorrect when not selected but correct', () => { + const props = { + ...defaultProps, + isEvaluateMode: true, + session: { answers: [] }, + shapes: { + rectangles: [ + { id: 'rect1', x: 10, y: 20, width: 100, height: 80, correct: true }, + ], + polygons: [], + circles: [], + }, + }; + const { getByTestId } = render(); + + const rect = getByTestId('rectangle-rect1'); + expect(rect).toHaveAttribute('data-iscorrect', 'false'); + }); + + it('should show correct when not selected and not correct', () => { + const props = { + ...defaultProps, + isEvaluateMode: true, + session: { answers: [] }, + shapes: { + rectangles: [ + { id: 'rect1', x: 10, y: 20, width: 100, height: 80, correct: false }, + ], + polygons: [], + circles: [], + }, + }; + const { getByTestId } = render(); + + const rect = getByTestId('rectangle-rect1'); + expect(rect).toHaveAttribute('data-iscorrect', 'true'); + }); + }); + + describe('disabled state', () => { + it('should pass disabled prop to shapes', () => { + const props = { + ...defaultProps, + disabled: true, + shapes: { + rectangles: [ + { id: 'rect1', x: 10, y: 20, width: 100, height: 80, correct: false }, + ], + polygons: [], + circles: [], + }, + }; + const { getByTestId } = render(); + + const rect = getByTestId('rectangle-rect1'); + expect(rect).toHaveAttribute('data-disabled', 'true'); + }); + }); + + describe('mixed shapes', () => { + it('should render all types of shapes together', () => { + const props = { + ...defaultProps, + shapes: { + rectangles: [ + { id: 'rect1', x: 10, y: 20, width: 100, height: 80, correct: false }, + ], + polygons: [ + { + id: 'poly1', + points: [{ x: 10, y: 10 }, { x: 100, y: 10 }, { x: 50, y: 100 }], + correct: false, + }, + ], + circles: [ + { id: 'circle1', x: 100, y: 100, radius: 50, correct: false }, + ], + }, + }; + const { getByTestId } = render(); + + expect(getByTestId('rectangle-rect1')).toBeInTheDocument(); + expect(getByTestId('polygon-poly1')).toBeInTheDocument(); + expect(getByTestId('circle-circle1')).toBeInTheDocument(); + }); + + it('should handle multiple selections across different shape types', () => { + const props = { + ...defaultProps, + session: { answers: [{ id: 'rect1' }, { id: 'poly1' }, { id: 'circle1' }] }, + shapes: { + rectangles: [ + { id: 'rect1', x: 10, y: 20, width: 100, height: 80, correct: false }, + ], + polygons: [ + { + id: 'poly1', + points: [{ x: 10, y: 10 }, { x: 100, y: 10 }, { x: 50, y: 100 }], + correct: false, + }, + ], + circles: [ + { id: 'circle1', x: 100, y: 100, radius: 50, correct: false }, + ], + }, + }; + const { getByTestId } = render(); + + expect(getByTestId('rectangle-rect1')).toHaveAttribute('data-selected', 'true'); + expect(getByTestId('polygon-poly1')).toHaveAttribute('data-selected', 'true'); + expect(getByTestId('circle-circle1')).toHaveAttribute('data-selected', 'true'); + }); + }); + + describe('showCorrect mode', () => { + it('should pass showCorrect prop to shapes in evaluate mode', () => { + const props = { + ...defaultProps, + isEvaluateMode: true, + showCorrect: true, + shapes: { + rectangles: [ + { id: 'rect1', x: 10, y: 20, width: 100, height: 80, correct: true }, + ], + polygons: [], + circles: [], + }, + }; + const { container } = render(); + expect(container).toBeTruthy(); + }); + }); + + describe('getEvaluateText', () => { + it('should return correct text for correctly selected', () => { + const component = new Container(defaultProps); + const text = component.getEvaluateText(true, true); + expect(text).toBe('Correctly\nselected'); + }); + + it('should return correct text for incorrectly selected', () => { + const component = new Container(defaultProps); + const text = component.getEvaluateText(false, true); + expect(text).toBe('Should not have\nbeen selected'); + }); + + it('should return correct text for should have been selected', () => { + const component = new Container(defaultProps); + const text = component.getEvaluateText(true, false); + expect(text).toBe('Should have\nbeen selected'); + }); + + it('should return null for correctly not selected', () => { + const component = new Container(defaultProps); + const text = component.getEvaluateText(false, false); + expect(text).toBeNull(); + }); + }); + + describe('correctness calculation', () => { + it('should calculate correctness properly', () => { + const component = new Container(defaultProps); + + expect(component.correctness(true, true)).toBe(true); + expect(component.correctness(true, false)).toBe(false); + expect(component.correctness(false, true)).toBe(false); + expect(component.correctness(false, false)).toBe(true); + }); + }); + + describe('edge cases', () => { + it('should handle empty shapes object', () => { + const props = { + ...defaultProps, + shapes: {}, + }; + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('should handle null imageUrl', () => { + const props = { + ...defaultProps, + imageUrl: null, + }; + const { queryByAltText } = render(); + expect(queryByAltText('hotspot-image')).not.toBeInTheDocument(); + }); + + it('should handle default scale value', () => { + const props = { + ...defaultProps, + scale: undefined, + }; + const { getByTestId } = render(); + const stage = getByTestId('stage'); + expect(stage).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/hotspot/src/hotspot/__tests__/image-konva-tooltip.test.jsx b/packages/hotspot/src/hotspot/__tests__/image-konva-tooltip.test.jsx new file mode 100644 index 0000000000..e32d89d9e8 --- /dev/null +++ b/packages/hotspot/src/hotspot/__tests__/image-konva-tooltip.test.jsx @@ -0,0 +1,510 @@ +import React from 'react'; +import { render, waitFor, fireEvent } from '@testing-library/react'; +import Konva from 'konva'; +import ImageComponent from '../image-konva-tooltip'; + +Konva.isBrowser = false; + +jest.mock('react-konva', () => { + const React = require('react'); + return { + Group: ({ children, ...props }) => React.createElement('div', { 'data-testid': 'group', ...props }, children), + Image: ({ onMouseEnter, onMouseLeave, ...props }) => + React.createElement('div', { + 'data-testid': 'image', + onMouseEnter, + onMouseLeave, + ...props + }), + Text: (props) => React.createElement('div', { 'data-testid': 'text', ...props }), + Tag: (props) => React.createElement('div', { 'data-testid': 'tag', ...props }), + Label: ({ children, ...props }) => React.createElement('div', { 'data-testid': 'label', ...props }, children), + }; +}); + +class MockImage { + constructor() { + this.src = ''; + this._onload = null; + } + + addEventListener(event, callback) { + if (event === 'load') { + this._onload = callback; + setTimeout(() => { + if (this._onload) { + this._onload(); + } + }, 10); + } + } + + removeEventListener() { + this._onload = null; + } +} + +global.Image = MockImage; + +describe('ImageComponent', () => { + let defaultProps; + + beforeEach(() => { + defaultProps = { + src: 'test-image.png', + x: 100, + y: 150, + tooltip: 'Test tooltip', + }; + }); + + describe('rendering', () => { + it('should render without crashing', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('should render Group component', () => { + const { getByTestId } = render(); + const group = getByTestId('group'); + expect(group).toBeInTheDocument(); + }); + + it('should render Image component', () => { + const { getByTestId } = render(); + const image = getByTestId('image'); + expect(image).toBeInTheDocument(); + }); + + it('should have correct image dimensions', () => { + const { getByTestId } = render(); + const image = getByTestId('image'); + expect(image).toHaveAttribute('width', '20'); + expect(image).toHaveAttribute('height', '20'); + }); + + it('should have correct position', () => { + const { getByTestId } = render(); + const image = getByTestId('image'); + expect(image).toHaveAttribute('x', '100'); + expect(image).toHaveAttribute('y', '150'); + }); + }); + + describe('image loading', () => { + it('should load image on mount', async () => { + const { getByTestId } = render(); + const image = getByTestId('image'); + + await waitFor(() => { + expect(image).toHaveAttribute('image'); + }, { timeout: 100 }); + }); + + it('should reload image when src prop changes', async () => { + const { getByTestId, rerender } = render(); + + await waitFor(() => { + const image = getByTestId('image'); + expect(image).toBeInTheDocument(); + }); + + rerender(); + + await waitFor(() => { + const image = getByTestId('image'); + expect(image).toBeInTheDocument(); + }); + }); + + it('should set image state after load', async () => { + const { getByTestId } = render(); + + await waitFor(() => { + const image = getByTestId('image'); + expect(image).toBeInTheDocument(); + }, { timeout: 100 }); + }); + }); + + describe('tooltip functionality', () => { + it('should not show tooltip initially', () => { + const { queryByTestId } = render(); + const label = queryByTestId('label'); + expect(label).not.toBeInTheDocument(); + }); + + it('should show tooltip on mouse enter', async () => { + const { getByTestId, queryByTestId } = render(); + const image = getByTestId('image'); + + // Initially no tooltip + expect(queryByTestId('label')).not.toBeInTheDocument(); + + // Simulate mouse enter + fireEvent.mouseEnter(image); + + // Wait for state update + await waitFor(() => { + const label = queryByTestId('label'); + expect(label).toBeInTheDocument(); + }); + }); + + it('should hide tooltip on mouse leave', async () => { + const { getByTestId, queryByTestId } = render(); + const image = getByTestId('image'); + + // Show tooltip + fireEvent.mouseEnter(image); + + await waitFor(() => { + expect(queryByTestId('label')).toBeInTheDocument(); + }); + + // Hide tooltip + fireEvent.mouseLeave(image); + + await waitFor(() => { + expect(queryByTestId('label')).not.toBeInTheDocument(); + }); + }); + + it('should render tooltip with correct text', async () => { + const { getByTestId } = render( + + ); + const image = getByTestId('image'); + + fireEvent.mouseEnter(image); + + await waitFor(() => { + const text = getByTestId('text'); + expect(text).toHaveAttribute('text', 'Custom tooltip'); + }); + }); + + it('should not show tooltip when tooltip prop is empty', async () => { + const { getByTestId, queryByTestId } = render( + + ); + const image = getByTestId('image'); + + fireEvent.mouseEnter(image); + + // Even after mouse enter, tooltip should not show if text is empty + await new Promise(resolve => setTimeout(resolve, 50)); + expect(queryByTestId('label')).not.toBeInTheDocument(); + }); + + it('should handle long tooltip text', async () => { + const longTooltip = 'This is a very long tooltip text that should still render correctly without breaking the component layout'; + const { getByTestId } = render( + + ); + const image = getByTestId('image'); + + fireEvent.mouseEnter(image); + + await waitFor(() => { + const text = getByTestId('text'); + expect(text).toHaveAttribute('text', longTooltip); + }); + }); + }); + + describe('positioning', () => { + it('should render at correct x position', () => { + const { getByTestId } = render( + + ); + const image = getByTestId('image'); + expect(image).toHaveAttribute('x', '200'); + }); + + it('should render at correct y position', () => { + const { getByTestId } = render( + + ); + const image = getByTestId('image'); + expect(image).toHaveAttribute('y', '300'); + }); + + it('should handle position at origin (0, 0)', () => { + const { getByTestId } = render( + + ); + const image = getByTestId('image'); + expect(image).toHaveAttribute('x', '0'); + expect(image).toHaveAttribute('y', '0'); + }); + + it('should handle negative positions', () => { + const { getByTestId } = render( + + ); + const image = getByTestId('image'); + expect(image).toHaveAttribute('x', '-10'); + expect(image).toHaveAttribute('y', '-20'); + }); + + it('should handle large position values', () => { + const { getByTestId } = render( + + ); + const image = getByTestId('image'); + expect(image).toHaveAttribute('x', '1000'); + expect(image).toHaveAttribute('y', '2000'); + }); + + it('should position tooltip relative to image', async () => { + const { getByTestId } = render(); + const image = getByTestId('image'); + + fireEvent.mouseEnter(image); + + await waitFor(() => { + const label = getByTestId('label'); + // Tooltip should be positioned at x - 30, y + 25 + expect(label).toHaveAttribute('x', '70'); + expect(label).toHaveAttribute('y', '175'); + }); + }); + }); + + describe('image sources', () => { + it('should handle data URI image source', async () => { + const dataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + const { getByTestId } = render( + + ); + const image = getByTestId('image'); + expect(image).toBeInTheDocument(); + }); + + it('should handle relative path image source', () => { + const { getByTestId } = render( + + ); + const image = getByTestId('image'); + expect(image).toBeInTheDocument(); + }); + + it('should handle absolute URL image source', () => { + const { getByTestId } = render( + + ); + const image = getByTestId('image'); + expect(image).toBeInTheDocument(); + }); + + it('should handle SVG image source', () => { + const { getByTestId } = render( + + ); + const image = getByTestId('image'); + expect(image).toBeInTheDocument(); + }); + }); + + describe('component lifecycle', () => { + it('should clean up event listener on unmount', () => { + const { unmount } = render(); + + expect(() => { + unmount(); + }).not.toThrow(); + }); + + it('should handle multiple mount/unmount cycles', () => { + const { unmount } = render(); + + unmount(); + + expect(() => { + render(); + }).not.toThrow(); + }); + + it('should handle rapid prop changes', async () => { + const { rerender, container } = render(); + + rerender(); + + await new Promise(resolve => setTimeout(resolve, 20)); + + rerender(); + + const image = container.querySelector('[data-testid="image"]'); + expect(image).toHaveAttribute('x', '200'); + expect(image).toHaveAttribute('y', '250'); + }); + + it('should handle src changes during image load', async () => { + const { rerender, container } = render(); + + // Change src before image loads + rerender(); + + await waitFor(() => { + const image = container.querySelector('[data-testid="image"]'); + expect(image).toBeInTheDocument(); + }); + }); + }); + + describe('edge cases', () => { + it('should handle zero dimensions gracefully', () => { + const { getByTestId } = render( + + ); + const image = getByTestId('image'); + expect(image).toHaveAttribute('x', '0'); + expect(image).toHaveAttribute('y', '0'); + }); + + it('should render with special characters in tooltip', async () => { + const specialTooltip = 'Test <>&"\'tooltip'; + const { getByTestId } = render( + + ); + const image = getByTestId('image'); + + fireEvent.mouseEnter(image); + + await waitFor(() => { + const text = getByTestId('text'); + expect(text).toHaveAttribute('text', specialTooltip); + }); + }); + + it('should handle Unicode characters in tooltip', async () => { + const unicodeTooltip = 'Test 你好 🎉 tooltip'; + const { getByTestId } = render( + + ); + const image = getByTestId('image'); + + fireEvent.mouseEnter(image); + + await waitFor(() => { + const text = getByTestId('text'); + expect(text).toHaveAttribute('text', unicodeTooltip); + }); + }); + + it('should handle missing tooltip gracefully', () => { + const { getByTestId } = render( + + ); + const image = getByTestId('image'); + expect(image).toBeInTheDocument(); + }); + }); + + describe('tooltip styling', () => { + it('should render tooltip with Tag component', async () => { + const { getByTestId } = render(); + const image = getByTestId('image'); + + fireEvent.mouseEnter(image); + + await waitFor(() => { + const tag = getByTestId('tag'); + expect(tag).toBeInTheDocument(); + }); + }); + + it('should render tooltip with correct styling attributes', async () => { + const { getByTestId } = render(); + const image = getByTestId('image'); + + fireEvent.mouseEnter(image); + + await waitFor(() => { + const tag = getByTestId('tag'); + expect(tag).toHaveAttribute('fill', 'white'); + expect(tag).toHaveAttribute('cornerRadius', '5'); + expect(tag).toHaveAttribute('opacity', '0.9'); + }); + }); + + it('should render tooltip text with padding', async () => { + const { getByTestId } = render(); + const image = getByTestId('image'); + + fireEvent.mouseEnter(image); + + await waitFor(() => { + const text = getByTestId('text'); + expect(text).toHaveAttribute('padding', '5'); + }); + }); + }); + + describe('state management', () => { + it('should update showTooltip state on mouse events', async () => { + const { getByTestId, queryByTestId } = render(); + const image = getByTestId('image'); + + // Initially no tooltip + expect(queryByTestId('label')).not.toBeInTheDocument(); + + // Show tooltip + fireEvent.mouseEnter(image); + + await waitFor(() => { + expect(queryByTestId('label')).toBeInTheDocument(); + }); + + // Hide tooltip + fireEvent.mouseLeave(image); + + await waitFor(() => { + expect(queryByTestId('label')).not.toBeInTheDocument(); + }); + }); + + it('should update image state after load', async () => { + const { getByTestId } = render(); + + await waitFor(() => { + const image = getByTestId('image'); + expect(image).toHaveAttribute('image'); + }, { timeout: 100 }); + }); + }); + + describe('PropTypes validation', () => { + it('should accept all required props', () => { + const { getByTestId } = render(); + const image = getByTestId('image'); + expect(image).toBeInTheDocument(); + }); + + it('should render with string src prop', () => { + const { getByTestId } = render( + + ); + const image = getByTestId('image'); + expect(image).toBeInTheDocument(); + }); + + it('should render with number x and y props', () => { + const { getByTestId } = render( + + ); + const image = getByTestId('image'); + expect(image).toHaveAttribute('x', '100'); + expect(image).toHaveAttribute('y', '200'); + }); + + it('should render with string tooltip prop', () => { + const { getByTestId } = render( + + ); + const image = getByTestId('image'); + expect(image).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/hotspot/src/hotspot/__tests__/polygon.test.jsx b/packages/hotspot/src/hotspot/__tests__/polygon.test.jsx new file mode 100644 index 0000000000..b7f533e362 --- /dev/null +++ b/packages/hotspot/src/hotspot/__tests__/polygon.test.jsx @@ -0,0 +1,502 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import Konva from 'konva'; +import PolygonComponent from '../polygon'; + +Konva.isBrowser = false; + +jest.mock('react-konva', () => { + const React = require('react'); + return { + Line: ({ onClick, onTap, onMouseEnter, onMouseLeave, ...props }) => { + const handleClick = (e) => { + if (onClick) onClick(e); + if (onTap) onTap(e); + }; + return React.createElement('div', { + 'data-testid': 'line', + onClick: handleClick, + onMouseEnter, + onMouseLeave, + ...props, + }); + }, + Rect: (props) => React.createElement('div', { 'data-testid': 'rect', ...props }), + Group: ({ children, ...props }) => React.createElement('div', { 'data-testid': 'group', ...props }, children), + }; +}); + +jest.mock('../image-konva-tooltip', () => { + return function ImageComponent({ src, x, y, tooltip }) { + return
; + }; +}); + +describe('PolygonComponent', () => { + let defaultProps; + + beforeEach(() => { + defaultProps = { + id: 'polygon1', + points: [ + { x: 10, y: 10 }, + { x: 100, y: 10 }, + { x: 100, y: 100 }, + { x: 10, y: 100 }, + ], + hotspotColor: '#FF0000', + selectedHotspotColor: '#00FF00', + outlineColor: '#0000FF', + hoverOutlineColor: '#FFFF00', + selected: false, + isCorrect: false, + isEvaluateMode: false, + disabled: false, + onClick: jest.fn(), + strokeWidth: 5, + scale: 1, + markAsCorrect: false, + showCorrectEnabled: false, + }; + }); + + afterEach(() => { + document.body.style.cursor = 'default'; + }); + + describe('rendering', () => { + it('should render without crashing', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('should parse points correctly for Konva', () => { + const { container } = render(); + const line = container.querySelector('[data-testid="line"]'); + + expect(line).toHaveAttribute('points'); + const points = line.getAttribute('points'); + expect(points).toBe('10,10,100,10,100,100,10,100'); + }); + + it('should render with hotspot color when not selected', () => { + const { container } = render(); + const line = container.querySelector('[data-testid="line"]'); + + expect(line).toHaveAttribute('fill', '#FF0000'); + }); + + it('should render with selected color when selected', () => { + const { container } = render(); + const line = container.querySelector('[data-testid="line"]'); + + expect(line).toHaveAttribute('fill', '#00FF00'); + }); + + it('should apply scale transform', () => { + const { getByTestId } = render(); + const group = getByTestId('group'); + + expect(group).toHaveAttribute('scaleX', '1.5'); + expect(group).toHaveAttribute('scaleY', '1.5'); + }); + + it('should render with default scale of 1', () => { + const { getByTestId } = render(); + const group = getByTestId('group'); + + expect(group).toHaveAttribute('scaleX', '1'); + expect(group).toHaveAttribute('scaleY', '1'); + }); + }); + + describe('interactions', () => { + it('should call onClick when clicked', () => { + const onClick = jest.fn(); + const { container } = render(); + const line = container.querySelector('[data-testid="line"]'); + + fireEvent.click(line); + + expect(onClick).toHaveBeenCalledWith({ + id: 'polygon1', + selected: true, + selector: 'Mouse', + }); + }); + + it('should toggle selection state on click', () => { + const onClick = jest.fn(); + const { container, rerender } = render(); + const line = container.querySelector('[data-testid="line"]'); + + fireEvent.click(line); + + expect(onClick).toHaveBeenCalledWith({ + id: 'polygon1', + selected: true, + selector: 'Mouse', + }); + + rerender(); + + const lineAfter = container.querySelector('[data-testid="line"]'); + fireEvent.click(lineAfter); + + expect(onClick).toHaveBeenCalledWith({ + id: 'polygon1', + selected: false, + selector: 'Mouse', + }); + }); + + it('should not call onClick when disabled', () => { + const onClick = jest.fn(); + const { container } = render(); + const line = container.querySelector('[data-testid="line"]'); + + fireEvent.click(line); + + expect(onClick).not.toHaveBeenCalled(); + }); + + it('should change cursor to pointer on mouse enter when not disabled', () => { + const { container } = render(); + const line = container.querySelector('[data-testid="line"]'); + + fireEvent.mouseEnter(line); + + expect(document.body.style.cursor).toBe('pointer'); + }); + + it('should not change cursor when disabled', () => { + const { container } = render(); + const line = container.querySelector('[data-testid="line"]'); + + fireEvent.mouseEnter(line); + + expect(document.body.style.cursor).toBe('default'); + }); + + it('should reset cursor to default on mouse leave', () => { + const { container } = render(); + const line = container.querySelector('[data-testid="line"]'); + + fireEvent.mouseEnter(line); + fireEvent.mouseLeave(line); + + expect(document.body.style.cursor).toBe('default'); + }); + }); + + describe('center calculation', () => { + it('should calculate center correctly for square', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('icon-image'); + expect(icon).toHaveAttribute('data-x', '50'); + expect(icon).toHaveAttribute('data-y', '50'); + }); + + it('should calculate center correctly for triangle', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('icon-image'); + expect(icon).toHaveAttribute('data-x', '50'); + expect(icon).toHaveAttribute('data-y', '50'); + }); + + it('should calculate center correctly for irregular polygon', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('icon-image'); + expect(icon).toHaveAttribute('data-x', '50'); + expect(icon).toHaveAttribute('data-y', '45'); + }); + }); + + describe('hover styling', () => { + it('should show hover outline when hoverOutlineColor is provided', () => { + const { container } = render(); + const line = container.querySelector('[data-testid="line"]'); + + fireEvent.mouseEnter(line); + + const rects = container.querySelectorAll('[data-testid="rect"]'); + expect(rects.length).toBeGreaterThan(0); + }); + + it('should render hover rect with correct dimensions', () => { + const { container } = render( + + ); + + const line = container.querySelector('[data-testid="line"]'); + fireEvent.mouseEnter(line); + + const rect = container.querySelector('[data-testid="rect"]'); + if (rect) { + expect(rect).toHaveAttribute('x', '10'); + expect(rect).toHaveAttribute('y', '20'); + expect(rect).toHaveAttribute('width', '100'); + expect(rect).toHaveAttribute('height', '100'); + } + }); + }); + + describe('evaluate mode', () => { + it('should show correct icon when correctly selected in evaluate mode', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('icon-image'); + expect(icon).toBeInTheDocument(); + }); + + it('should show wrong icon when incorrectly selected in evaluate mode', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('icon-image'); + expect(icon).toBeInTheDocument(); + }); + + it('should show wrong icon when incorrectly not selected', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('icon-image'); + expect(icon).toBeInTheDocument(); + }); + + it('should not show icon when correctly not selected in evaluate mode', () => { + const { queryByTestId } = render( + + ); + + const icon = queryByTestId('icon-image'); + expect(icon).not.toBeInTheDocument(); + }); + + it('should show correct icon for showCorrect mode when correctly selected', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('icon-image'); + expect(icon).toBeInTheDocument(); + }); + + it('should show correct icon for showCorrect mode when incorrectly not selected', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('icon-image'); + expect(icon).toBeInTheDocument(); + }); + + it('should not show icon in showCorrect mode when correctly not selected', () => { + const { queryByTestId } = render( + + ); + + const icon = queryByTestId('icon-image'); + expect(icon).not.toBeInTheDocument(); + }); + + it('should not show icon in showCorrect mode when incorrectly selected', () => { + const { queryByTestId } = render( + + ); + + const icon = queryByTestId('icon-image'); + expect(icon).not.toBeInTheDocument(); + }); + + it('should show green outline when markAsCorrect is true', () => { + const { container } = render( + + ); + + const line = container.querySelector('[data-testid="line"]'); + expect(line).toHaveAttribute('stroke', 'green'); + }); + + it('should show red outline when incorrect and not markAsCorrect', () => { + const { container } = render( + + ); + + const line = container.querySelector('[data-testid="line"]'); + expect(line).toHaveAttribute('stroke', 'red'); + }); + + it('should display evaluate text in tooltip', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('icon-image'); + expect(icon).toHaveAttribute('data-tooltip', 'Well done!'); + }); + }); + + describe('edge cases', () => { + it('should handle triangular polygon', () => { + const { container } = render( + + ); + + const line = container.querySelector('[data-testid="line"]'); + expect(line).toHaveAttribute('points', '50,0,100,100,0,100'); + }); + + it('should handle complex polygon with many points', () => { + const points = [ + { x: 10, y: 10 }, + { x: 50, y: 5 }, + { x: 90, y: 10 }, + { x: 100, y: 50 }, + { x: 90, y: 90 }, + { x: 50, y: 100 }, + { x: 10, y: 90 }, + { x: 0, y: 50 }, + ]; + + const { container } = render( + + ); + + const line = container.querySelector('[data-testid="line"]'); + expect(line).toHaveAttribute('points', '10,10,50,5,90,10,100,50,90,90,50,100,10,90,0,50'); + }); + }); +}); diff --git a/packages/hotspot/src/hotspot/__tests__/rectangle.test.jsx b/packages/hotspot/src/hotspot/__tests__/rectangle.test.jsx new file mode 100644 index 0000000000..e865c9dbbe --- /dev/null +++ b/packages/hotspot/src/hotspot/__tests__/rectangle.test.jsx @@ -0,0 +1,418 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import Konva from 'konva'; +import RectComponent from '../rectangle'; + +Konva.isBrowser = false; + +jest.mock('react-konva', () => { + const React = require('react'); + return { + Rect: ({ onClick, onTap, onMouseEnter, onMouseLeave, ...props }) => { + const handleClick = (e) => { + if (onClick) onClick(e); + if (onTap) onTap(e); + }; + return React.createElement('div', { + 'data-testid': 'rect', + onClick: handleClick, + onMouseEnter, + onMouseLeave, + ...props, + }); + }, + Group: ({ children, ...props }) => React.createElement('div', { 'data-testid': 'group', ...props }, children), + }; +}); + +jest.mock('../image-konva-tooltip', () => { + return function ImageComponent({ src, x, y, tooltip }) { + return
; + }; +}); + +describe('RectComponent', () => { + let defaultProps; + + beforeEach(() => { + defaultProps = { + id: 'rect1', + x: 10, + y: 20, + width: 100, + height: 80, + hotspotColor: '#FF0000', + selectedHotspotColor: '#00FF00', + outlineColor: '#0000FF', + hoverOutlineColor: '#FFFF00', + selected: false, + isCorrect: false, + isEvaluateMode: false, + disabled: false, + onClick: jest.fn(), + strokeWidth: 5, + scale: 1, + markAsCorrect: false, + showCorrectEnabled: false, + }; + }); + + afterEach(() => { + document.body.style.cursor = 'default'; + }); + + describe('rendering', () => { + it('should render without crashing', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('should render with correct dimensions', () => { + const { container } = render(); + const rects = container.querySelectorAll('[data-testid="rect"]'); + const mainRect = rects[rects.length - 1]; + + expect(mainRect).toHaveAttribute('x', '10'); + expect(mainRect).toHaveAttribute('y', '20'); + expect(mainRect).toHaveAttribute('width', '100'); + expect(mainRect).toHaveAttribute('height', '80'); + }); + + it('should render with hotspot color when not selected', () => { + const { container } = render(); + const rects = container.querySelectorAll('[data-testid="rect"]'); + const mainRect = rects[rects.length - 1]; + + expect(mainRect).toHaveAttribute('fill', '#FF0000'); + }); + + it('should render with selected color when selected', () => { + const { container } = render(); + const rects = container.querySelectorAll('[data-testid="rect"]'); + const mainRect = rects[rects.length - 1]; + + expect(mainRect).toHaveAttribute('fill', '#00FF00'); + }); + + it('should apply scale transform', () => { + const { getByTestId } = render(); + const group = getByTestId('group'); + + expect(group).toHaveAttribute('scaleX', '1.5'); + expect(group).toHaveAttribute('scaleY', '1.5'); + }); + + it('should render with default scale of 1', () => { + const { getByTestId } = render(); + const group = getByTestId('group'); + + expect(group).toHaveAttribute('scaleX', '1'); + expect(group).toHaveAttribute('scaleY', '1'); + }); + }); + + describe('interactions', () => { + it('should call onClick when clicked', () => { + const onClick = jest.fn(); + const { container } = render(); + const rects = container.querySelectorAll('[data-testid="rect"]'); + const mainRect = rects[rects.length - 1]; + + fireEvent.click(mainRect); + + expect(onClick).toHaveBeenCalledWith({ + id: 'rect1', + selected: true, + selector: 'Mouse', + }); + }); + + it('should toggle selection state on click', () => { + const onClick = jest.fn(); + const { container, rerender } = render(); + const rects = container.querySelectorAll('[data-testid="rect"]'); + const mainRect = rects[rects.length - 1]; + + fireEvent.click(mainRect); + + expect(onClick).toHaveBeenCalledWith({ + id: 'rect1', + selected: true, + selector: 'Mouse', + }); + + rerender(); + + const rectsAfter = container.querySelectorAll('[data-testid="rect"]'); + const mainRectAfter = rectsAfter[rectsAfter.length - 1]; + fireEvent.click(mainRectAfter); + + expect(onClick).toHaveBeenCalledWith({ + id: 'rect1', + selected: false, + selector: 'Mouse', + }); + }); + + it('should not call onClick when disabled', () => { + const onClick = jest.fn(); + const { container } = render(); + const rects = container.querySelectorAll('[data-testid="rect"]'); + const mainRect = rects[rects.length - 1]; + + fireEvent.click(mainRect); + + expect(onClick).not.toHaveBeenCalled(); + }); + + it('should change cursor to pointer on mouse enter when not disabled', () => { + const { container } = render(); + const rects = container.querySelectorAll('[data-testid="rect"]'); + const mainRect = rects[rects.length - 1]; + + fireEvent.mouseEnter(mainRect); + + expect(document.body.style.cursor).toBe('pointer'); + }); + + it('should not change cursor when disabled', () => { + const { container } = render(); + const rects = container.querySelectorAll('[data-testid="rect"]'); + const mainRect = rects[rects.length - 1]; + + fireEvent.mouseEnter(mainRect); + + expect(document.body.style.cursor).toBe('default'); + }); + + it('should reset cursor to default on mouse leave', () => { + const { container } = render(); + const rects = container.querySelectorAll('[data-testid="rect"]'); + const mainRect = rects[rects.length - 1]; + + fireEvent.mouseEnter(mainRect); + fireEvent.mouseLeave(mainRect); + + expect(document.body.style.cursor).toBe('default'); + }); + }); + + describe('hover styling', () => { + it('should show hover outline when hoverOutlineColor is provided', () => { + const { container } = render(); + const rects = container.querySelectorAll('[data-testid="rect"]'); + const mainRect = rects[rects.length - 1]; + + fireEvent.mouseEnter(mainRect); + + const rectsAfterHover = container.querySelectorAll('[data-testid="rect"]'); + expect(rectsAfterHover.length).toBeGreaterThan(1); + }); + + it('should not show hover outline when selected', () => { + const { container } = render( + + ); + const rects = container.querySelectorAll('[data-testid="rect"]'); + const mainRect = rects[rects.length - 1]; + + fireEvent.mouseEnter(mainRect); + + const hoverRect = container.querySelector('[stroke="#FFFF00"]'); + if (hoverRect) { + expect(hoverRect).toHaveAttribute('stroke', 'transparent'); + } + }); + }); + + describe('evaluate mode', () => { + it('should show correct icon when correctly selected in evaluate mode', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('icon-image'); + expect(icon).toBeInTheDocument(); + expect(icon).toHaveAttribute('data-src'); + }); + + it('should show wrong icon when incorrectly selected in evaluate mode', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('icon-image'); + expect(icon).toBeInTheDocument(); + }); + + it('should show wrong icon when incorrectly not selected', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('icon-image'); + expect(icon).toBeInTheDocument(); + }); + + it('should not show icon when correctly not selected in evaluate mode', () => { + const { queryByTestId } = render( + + ); + + const icon = queryByTestId('icon-image'); + expect(icon).not.toBeInTheDocument(); + }); + + it('should show correct icon for showCorrect mode when correctly selected', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('icon-image'); + expect(icon).toBeInTheDocument(); + }); + + it('should show correct icon for showCorrect mode when incorrectly not selected', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('icon-image'); + expect(icon).toBeInTheDocument(); + }); + + it('should not show icon in showCorrect mode when correctly not selected', () => { + const { queryByTestId } = render( + + ); + + const icon = queryByTestId('icon-image'); + expect(icon).not.toBeInTheDocument(); + }); + + it('should not show icon in showCorrect mode when incorrectly selected', () => { + const { queryByTestId } = render( + + ); + + const icon = queryByTestId('icon-image'); + expect(icon).not.toBeInTheDocument(); + }); + + it('should show green outline when markAsCorrect is true', () => { + const { container } = render( + + ); + + const rects = container.querySelectorAll('[data-testid="rect"]'); + const mainRect = rects[rects.length - 1]; + expect(mainRect).toHaveAttribute('stroke', 'green'); + }); + + it('should show red outline when incorrect and not markAsCorrect', () => { + const { container } = render( + + ); + + const rects = container.querySelectorAll('[data-testid="rect"]'); + const mainRect = rects[rects.length - 1]; + expect(mainRect).toHaveAttribute('stroke', 'red'); + }); + + it('should display evaluate text in tooltip', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('icon-image'); + expect(icon).toHaveAttribute('data-tooltip', 'Correct answer!'); + }); + }); + + describe('icon positioning', () => { + it('should position icon at center of rectangle', () => { + const { getByTestId } = render( + + ); + + const icon = getByTestId('icon-image'); + // Icon should be centered: x + width/2 - 10, y + height/2 - 10 + expect(icon).toHaveAttribute('data-x', '50'); // 10 + 100/2 - 10 + expect(icon).toHaveAttribute('data-y', '50'); // 20 + 80/2 - 10 + }); + }); +}); diff --git a/packages/hotspot/src/hotspot/circle.jsx b/packages/hotspot/src/hotspot/circle.jsx index 2be0d62816..1856c9f8ae 100644 --- a/packages/hotspot/src/hotspot/circle.jsx +++ b/packages/hotspot/src/hotspot/circle.jsx @@ -114,7 +114,6 @@ class CircleComponent extends React.Component { onMouseEnter={this.handleMouseEnter} x={x} y={y} - opacity={0.5} /> {isEvaluateMode && iconSrc ? : null} diff --git a/packages/hotspot/src/hotspot/polygon.jsx b/packages/hotspot/src/hotspot/polygon.jsx index 1c1ea562e6..89168583d4 100644 --- a/packages/hotspot/src/hotspot/polygon.jsx +++ b/packages/hotspot/src/hotspot/polygon.jsx @@ -158,7 +158,6 @@ class PolygonComponent extends React.Component { strokeWidth={useHoveredStyle && !selected ? 0 : outlineWidth} onMouseLeave={this.handleMouseLeave} onMouseEnter={this.handleMouseEnter} - opacity={0.5} cursor='pointer' position='relative' /> diff --git a/packages/hotspot/src/hotspot/rectangle.jsx b/packages/hotspot/src/hotspot/rectangle.jsx index 544a4f2987..43dc26f58e 100644 --- a/packages/hotspot/src/hotspot/rectangle.jsx +++ b/packages/hotspot/src/hotspot/rectangle.jsx @@ -131,7 +131,6 @@ class RectComponent extends React.Component { strokeWidth={useHoveredStyle && !selected ? 0 : outlineWidth} onMouseLeave={this.handleMouseLeave} onMouseEnter={this.handleMouseEnter} - opacity={0.5} cursor="pointer" /> {isEvaluateMode && iconSrc ? : null}