diff --git a/application/ui/src/features/annotator/annotations/annotation-labels.component.test.tsx b/application/ui/src/features/annotator/annotations/annotation-labels.component.test.tsx new file mode 100644 index 0000000000..d6f7909d9e --- /dev/null +++ b/application/ui/src/features/annotator/annotations/annotation-labels.component.test.tsx @@ -0,0 +1,136 @@ +// Copyright (C) 2025 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +import { getMockedLabel } from 'mocks/mock-labels'; +import { fireEvent, render, screen } from 'test-utils/render'; +import { describe, expect, it, vi } from 'vitest'; + +import { Label } from '../types'; +import { AnnotationLabels } from './annotation-labels.component'; + +const mockZoom = { scale: 1, maxZoomIn: 10, hasAnimation: false, translate: { x: 0, y: 0 } }; + +vi.mock('src/components/zoom/zoom.provider', () => ({ + useZoom: () => mockZoom, +})); + +describe('AnnotationLabels', () => { + const mockOnRemove = vi.fn(); + + afterEach(() => { + mockOnRemove.mockClear(); + }); + + it('renders placeholder when no labels provided', () => { + render( + + + + ); + + expect(screen.getByText('No label')).toBeInTheDocument(); + }); + + it('renders single label with name and color', () => { + const label = getMockedLabel({ name: 'Person', color: '#FF0000' }); + + render( + + + + ); + + expect(screen.getByText('Person')).toBeInTheDocument(); + + const rect = screen.getByLabelText('label Person background'); + expect(rect).toHaveAttribute('fill', '#FF0000'); + }); + + it('renders multiple labels horizontally', () => { + const labels: Label[] = [ + getMockedLabel({ id: '1', name: 'Person', color: '#FF0000' }), + getMockedLabel({ id: '2', name: 'Car', color: '#00FF00' }), + ]; + + render( + + + + ); + + expect(screen.getByText('Person')).toBeInTheDocument(); + expect(screen.getByText('Car')).toBeInTheDocument(); + }); + + it('calls onRemove when close button clicked', () => { + const label = getMockedLabel({ id: 'label-1', name: 'Person' }); + + render( + + + + ); + + const closeButton = screen.getByLabelText('Remove Person'); + fireEvent.pointerDown(closeButton); + + expect(mockOnRemove).toHaveBeenCalledTimes(1); + expect(mockOnRemove).toHaveBeenCalledWith('label-1'); + }); + + it('adjusts sizes based on zoom scale', () => { + mockZoom.scale = 2; + const label = getMockedLabel({ name: 'Person' }); + + render( + + + + ); + + const text = screen.getByLabelText('label Person'); + + // Font size should be 14 / 2 = 7 + expect(text).toHaveAttribute('font-size', '7'); + }); + + it('prevents event propagation on close button click', () => { + const label = getMockedLabel({ id: 'label-1', name: 'Person' }); + const mockParentHandler = vi.fn(); + + render( + + + + ); + + const closeButton = screen.getByLabelText('Remove Person'); + fireEvent.pointerDown(closeButton); + + expect(mockOnRemove).toHaveBeenCalled(); + // Parent handler should not be called due to stopPropagation + expect(mockParentHandler).not.toHaveBeenCalled(); + }); + + it('renders labels with correct positioning (no overlap)', () => { + const labels: Label[] = [ + getMockedLabel({ id: '1', name: 'A', color: '#FF0000' }), + getMockedLabel({ id: '2', name: 'B', color: '#00FF00' }), + ]; + + render( + + + + ); + + const firstRect = screen.getByLabelText('label A background'); + const secondRect = screen.getByLabelText('label B background'); + + const firstX = parseFloat(firstRect.getAttribute('x') || '0'); + const secondX = parseFloat(secondRect.getAttribute('x') || '0'); + + // Second label should be positioned after the first + expect(secondX).toBeGreaterThan(firstX); + }); +}); diff --git a/application/ui/src/features/annotator/annotations/annotation-labels.component.tsx b/application/ui/src/features/annotator/annotations/annotation-labels.component.tsx index f1976ae969..e4a5b20e37 100644 --- a/application/ui/src/features/annotator/annotations/annotation-labels.component.tsx +++ b/application/ui/src/features/annotator/annotations/annotation-labels.component.tsx @@ -3,6 +3,7 @@ import { PointerEvent, useCallback } from 'react'; +import { isEmpty } from 'lodash-es'; import { useZoom } from 'src/components/zoom/zoom.provider'; import { v4 as uuid } from 'uuid'; @@ -85,8 +86,15 @@ export const AnnotationLabels = ({ labels, onRemove }: AnnotationLabelsProps) => fill={placeholderLabel.color} stroke='none' rx={styles.borderRadius} + aria-label={`label ${placeholderLabel.name} background`} /> - + {placeholderLabel.name} @@ -94,7 +102,7 @@ export const AnnotationLabels = ({ labels, onRemove }: AnnotationLabelsProps) => } return labels.map((label) => { - const labelWidth = calculateLabelWidth(label.name) + closeButtonWidth; + const labelWidth = !isEmpty(label.name) ? calculateLabelWidth(label.name) + closeButtonWidth : 0; const xOffset = fullLengthOfAllLabels; fullLengthOfAllLabels += labelWidth + gap; @@ -110,12 +118,19 @@ export const AnnotationLabels = ({ labels, onRemove }: AnnotationLabelsProps) => fill={label.color} stroke='none' rx={styles.borderRadius} + aria-label={`label ${label.name} background`} /> - + {label.name} - {/* Close button */} + {/* Remove button */} y={yOffset + styles.textYOffset} fontSize={fontSize} fill='#fff' + aria-label={`Remove ${label.name}`} > x