Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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(
<svg>
<AnnotationLabels labels={[]} onRemove={mockOnRemove} />
</svg>
);

expect(screen.getByText('No label')).toBeInTheDocument();
});

it('renders single label with name and color', () => {
const label = getMockedLabel({ name: 'Person', color: '#FF0000' });

render(
<svg>
<AnnotationLabels labels={[label]} onRemove={mockOnRemove} />
</svg>
);

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(
<svg>
<AnnotationLabels labels={labels} onRemove={mockOnRemove} />
</svg>
);

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(
<svg>
<AnnotationLabels labels={[label]} onRemove={mockOnRemove} />
</svg>
);

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(
<svg>
<AnnotationLabels labels={[label]} onRemove={mockOnRemove} />
</svg>
);

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(
<svg onPointerDown={mockParentHandler}>
<AnnotationLabels labels={[label]} onRemove={mockOnRemove} />
</svg>
);

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(
<svg>
<AnnotationLabels labels={labels} onRemove={mockOnRemove} />
</svg>
);

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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -85,16 +86,23 @@ export const AnnotationLabels = ({ labels, onRemove }: AnnotationLabelsProps) =>
fill={placeholderLabel.color}
stroke='none'
rx={styles.borderRadius}
aria-label={`label ${placeholderLabel.name} background`}
/>
<text x={padding} y={yOffset + styles.textYOffset} fontSize={fontSize} fill='#fff'>
<text
x={padding}
y={yOffset + styles.textYOffset}
fontSize={fontSize}
fill='#fff'
aria-label={`label ${placeholderLabel.name}`}
>
{placeholderLabel.name}
</text>
</g>
);
}

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;
Expand All @@ -110,12 +118,19 @@ export const AnnotationLabels = ({ labels, onRemove }: AnnotationLabelsProps) =>
fill={label.color}
stroke='none'
rx={styles.borderRadius}
aria-label={`label ${label.name} background`}
/>
<text x={xOffset + padding} y={yOffset + styles.textYOffset} fontSize={fontSize} fill='#fff'>
<text
x={xOffset + padding}
y={yOffset + styles.textYOffset}
fontSize={fontSize}
fill='#fff'
aria-label={`label ${label.name}`}
>
{label.name}
</text>

{/* Close button */}
{/* Remove button */}
<g style={{ cursor: 'pointer', pointerEvents: 'auto' }} onPointerDown={onDeleteLabel(label.id)}>
<rect
x={xOffset + labelWidth - closeButtonWidth}
Expand All @@ -129,6 +144,7 @@ export const AnnotationLabels = ({ labels, onRemove }: AnnotationLabelsProps) =>
y={yOffset + styles.textYOffset}
fontSize={fontSize}
fill='#fff'
aria-label={`Remove ${label.name}`}
>
x
</text>
Expand Down
Loading