Add comprehensive test suite: 219 new tests across backend and frontend
Backend (103 tests): - Unit tests for listing_service, export_service, district_service - Regression tests for API response contracts and query parameter validation - Integration tests for API workflows, Redis listing cache, listing processor pipeline, and repository advanced queries - E2E tests for streaming with filters, batching, caching, and task management Frontend (116 tests): - Service tests for apiClient, streamingService, taskService, listingService, healthService - Hook tests for useTaskProgress (WebSocket + polling) - Component tests for PropertyCard, FilterPanel, Header, ListView, TaskProgressDrawer, TaskIndicator, StreamingProgressBar, HealthIndicator - E2E tests for filter-stream-display flow Infrastructure: - Add pytest-xdist and test markers (regression, integration, e2e) - Add conftest fixtures: fake_redis, rent_listing_factory, seeded_repository - Add vitest + testing-library + MSW for frontend testing
This commit is contained in:
parent
a3ac9cc060
commit
8d22c97320
36 changed files with 5447 additions and 19 deletions
108
frontend/src/components/__tests__/FilterPanel.test.tsx
Normal file
108
frontend/src/components/__tests__/FilterPanel.test.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { FilterPanel, ListingType, Metric, DEFAULT_FILTER_VALUES } from '@/components/FilterPanel';
|
||||
|
||||
// Mock the POIManager to avoid its dependencies
|
||||
vi.mock('@/components/POIManager', () => ({
|
||||
POIManager: () => <div data-testid="poi-manager">POIManager</div>,
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
onSubmit: vi.fn(),
|
||||
currentMetric: Metric.qmprice,
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
describe('FilterPanel', () => {
|
||||
it('renders listing type tabs (Rent and Buy)', () => {
|
||||
render(<FilterPanel {...defaultProps} />);
|
||||
expect(screen.getByText('Rent')).toBeInTheDocument();
|
||||
expect(screen.getByText('Buy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders price range slider', () => {
|
||||
render(<FilterPanel {...defaultProps} />);
|
||||
expect(screen.getByText(/Price/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders bedroom range slider', () => {
|
||||
render(<FilterPanel {...defaultProps} />);
|
||||
expect(screen.getByText('Bedrooms')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onSubmit when Apply Filters is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
render(<FilterPanel {...defaultProps} onSubmit={onSubmit} />);
|
||||
|
||||
const applyBtn = screen.getByText('Apply Filters');
|
||||
await user.click(applyBtn);
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith('visualize', expect.objectContaining({
|
||||
listing_type: ListingType.RENT,
|
||||
}));
|
||||
});
|
||||
|
||||
it('disables submit button when loading', () => {
|
||||
render(<FilterPanel {...defaultProps} isLoading />);
|
||||
const applyBtn = screen.getByText('Loading...');
|
||||
expect(applyBtn.closest('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows furnish types only for rent', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FilterPanel {...defaultProps} />);
|
||||
|
||||
// Open the Advanced Filters accordion
|
||||
const advancedTrigger = screen.getByText('Advanced Filters');
|
||||
await user.click(advancedTrigger);
|
||||
|
||||
// Furnish options should be visible for RENT
|
||||
expect(screen.getByText('Furnished')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders min size input', () => {
|
||||
render(<FilterPanel {...defaultProps} />);
|
||||
expect(screen.getByText(/Min Size/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders last seen days in advanced filters', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FilterPanel {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText('Advanced Filters'));
|
||||
expect(screen.getByText(/Last Seen/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('initializes with default values', () => {
|
||||
render(<FilterPanel {...defaultProps} />);
|
||||
// Default listing type is RENT, so Rent tab should be active
|
||||
// The Rent tab should exist
|
||||
expect(screen.getByText('Rent')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders available from picker in advanced for rent', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FilterPanel {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText('Advanced Filters'));
|
||||
expect(screen.getByText('Available From')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders district input in advanced filters', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FilterPanel {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText('Advanced Filters'));
|
||||
expect(screen.getByText('District')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders price per sqm fields in advanced filters', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FilterPanel {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText('Advanced Filters'));
|
||||
expect(screen.getByText('Min £/m²')).toBeInTheDocument();
|
||||
expect(screen.getByText('Max £/m²')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
89
frontend/src/components/__tests__/Header.test.tsx
Normal file
89
frontend/src/components/__tests__/Header.test.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Header } from '@/components/Header';
|
||||
import { mockUser, createMockTaskState } from '@/__tests__/helpers';
|
||||
|
||||
// Mock auth services to prevent actual calls
|
||||
vi.mock('@/auth/authService', () => ({
|
||||
logout: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock('@/auth/passkeyService', () => ({
|
||||
clearPasskeyUser: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock HealthIndicator to avoid async health check calls
|
||||
vi.mock('@/components/HealthIndicator', () => ({
|
||||
HealthIndicator: () => <div data-testid="health-indicator">HealthIndicator</div>,
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
user: mockUser({ email: 'alice@example.com' }),
|
||||
tasks: {} as Record<string, import('@/types').TaskState>,
|
||||
activeTaskId: null,
|
||||
isConnected: true,
|
||||
onCancelTask: vi.fn(async () => true),
|
||||
onClearAllTasks: vi.fn(async () => true),
|
||||
};
|
||||
|
||||
describe('Header', () => {
|
||||
it('renders user email', () => {
|
||||
render(<Header {...defaultProps} />);
|
||||
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Logout button', () => {
|
||||
render(<Header {...defaultProps} />);
|
||||
expect(screen.getByText('Logout')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls logout on button click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { logout } = await import('@/auth/authService');
|
||||
|
||||
render(<Header {...defaultProps} />);
|
||||
await user.click(screen.getByText('Logout'));
|
||||
|
||||
expect(logout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders health indicator', () => {
|
||||
render(<Header {...defaultProps} />);
|
||||
expect(screen.getByTestId('health-indicator')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders task indicator when tasks are present', () => {
|
||||
const tasks = { 't1': createMockTaskState({ status: 'STARTED' }) };
|
||||
const { container } = render(
|
||||
<Header {...defaultProps} tasks={tasks} activeTaskId="t1" />,
|
||||
);
|
||||
// TaskIndicator renders animate-spin for running tasks
|
||||
expect(container.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows active filter count badge', () => {
|
||||
render(
|
||||
<Header
|
||||
{...defaultProps}
|
||||
activeFilterCount={5}
|
||||
showFilterToggle
|
||||
onToggleFilters={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders brand name', () => {
|
||||
render(<Header {...defaultProps} />);
|
||||
expect(screen.getByText('Wrongmove')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows mobile filter toggle when enabled', () => {
|
||||
const onToggle = vi.fn();
|
||||
const { container } = render(
|
||||
<Header {...defaultProps} showFilterToggle onToggleFilters={onToggle} />,
|
||||
);
|
||||
// The filter toggle button has sm:hidden class
|
||||
const filterButton = container.querySelector('.sm\\:hidden');
|
||||
expect(filterButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
61
frontend/src/components/__tests__/HealthIndicator.test.tsx
Normal file
61
frontend/src/components/__tests__/HealthIndicator.test.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { render, screen, waitFor, act } from '@testing-library/react';
|
||||
import { HealthIndicator } from '@/components/HealthIndicator';
|
||||
|
||||
vi.mock('@/services', async () => {
|
||||
const actual = await vi.importActual('@/services');
|
||||
return { ...actual, checkBackendHealth: vi.fn() };
|
||||
});
|
||||
|
||||
import { checkBackendHealth } from '@/services';
|
||||
const mockCheck = vi.mocked(checkBackendHealth);
|
||||
|
||||
describe('HealthIndicator', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('shows Checking... initially', () => {
|
||||
// Never resolve so the component stays in "checking" state
|
||||
mockCheck.mockReturnValue(new Promise(() => {}));
|
||||
render(<HealthIndicator />);
|
||||
expect(screen.getByText('Checking...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Connected when healthy', async () => {
|
||||
mockCheck.mockResolvedValue({ status: 'healthy', latencyMs: 50 });
|
||||
render(<HealthIndicator />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Connected')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Disconnected when unhealthy', async () => {
|
||||
mockCheck.mockResolvedValue({ status: 'unhealthy', error: 'timeout' });
|
||||
render(<HealthIndicator />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Disconnected')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('checks health periodically', async () => {
|
||||
vi.useFakeTimers();
|
||||
mockCheck.mockResolvedValue({ status: 'healthy', latencyMs: 10 });
|
||||
|
||||
render(<HealthIndicator interval={1000} />);
|
||||
|
||||
// Initial call
|
||||
expect(mockCheck).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance past one interval
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(mockCheck).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(mockCheck).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
88
frontend/src/components/__tests__/ListView.test.tsx
Normal file
88
frontend/src/components/__tests__/ListView.test.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ListView } from '@/components/ListView';
|
||||
import { createMockFeatureCollection, createMockFeature } from '@/__tests__/helpers';
|
||||
|
||||
// Mock react-virtuoso since it needs a real DOM with dimensions
|
||||
vi.mock('react-virtuoso', () => ({
|
||||
Virtuoso: ({ data, itemContent }: { data: unknown[]; itemContent: (index: number, item: unknown) => React.ReactNode }) => (
|
||||
<div data-testid="virtuoso">
|
||||
{data.map((item, index) => (
|
||||
<div key={index}>{itemContent(index, item)}</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('ListView', () => {
|
||||
it('renders listing count', () => {
|
||||
const data = createMockFeatureCollection(3);
|
||||
render(<ListView listingData={data} />);
|
||||
expect(screen.getByText(/3\s*properties/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders property cards for each feature', () => {
|
||||
const data = createMockFeatureCollection(3);
|
||||
render(<ListView listingData={data} />);
|
||||
// Each PropertyCard renders a price with £ sign
|
||||
const prices = screen.getAllByText(/£/);
|
||||
expect(prices.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('renders sort controls', () => {
|
||||
const data = createMockFeatureCollection(1);
|
||||
render(<ListView listingData={data} />);
|
||||
expect(screen.getByText('Sort:')).toBeInTheDocument();
|
||||
expect(screen.getByText('Price')).toBeInTheDocument();
|
||||
expect(screen.getByText('£/m²')).toBeInTheDocument();
|
||||
expect(screen.getByText('Size')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('highlights selected property', () => {
|
||||
const feature = createMockFeature({ url: 'https://rightmove.co.uk/selected' });
|
||||
const data = { type: 'FeatureCollection' as const, features: [feature] };
|
||||
const { container } = render(
|
||||
<ListView listingData={data} highlightedPropertyUrl="https://rightmove.co.uk/selected" />,
|
||||
);
|
||||
expect(container.querySelector('.ring-2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state for zero features', () => {
|
||||
const data = createMockFeatureCollection(0);
|
||||
render(<ListView listingData={data} />);
|
||||
expect(screen.getByText(/0\s*properties/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('changes sort order when clicking a sort button', async () => {
|
||||
const user = userEvent.setup();
|
||||
const data = createMockFeatureCollection(3);
|
||||
render(<ListView listingData={data} />);
|
||||
|
||||
const priceButton = screen.getByText('Price');
|
||||
await user.click(priceButton);
|
||||
|
||||
// The button should now be active (secondary variant)
|
||||
// Clicking toggles sort - this doesn't crash, which validates the sort logic
|
||||
await user.click(priceButton);
|
||||
});
|
||||
|
||||
it('uses compact variant for property cards', () => {
|
||||
const data = createMockFeatureCollection(1);
|
||||
const { container } = render(<ListView listingData={data} />);
|
||||
// Compact cards have the flex gap-3 p-3 layout
|
||||
expect(container.querySelector('.flex.gap-3.p-3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calculates avg price per sqm for deal badges', () => {
|
||||
// Create properties where one is clearly a good deal
|
||||
const features = [
|
||||
createMockFeature({ qmprice: 20, total_price: 1000 }),
|
||||
createMockFeature({ url: 'https://rightmove.co.uk/2', qmprice: 50, total_price: 3000 }),
|
||||
createMockFeature({ url: 'https://rightmove.co.uk/3', qmprice: 50, total_price: 3000 }),
|
||||
];
|
||||
// avg = 40, so 20 < 40*0.9=36 → Good deal
|
||||
const data = { type: 'FeatureCollection' as const, features };
|
||||
render(<ListView listingData={data} />);
|
||||
expect(screen.getByText('Good deal')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
91
frontend/src/components/__tests__/PropertyCard.test.tsx
Normal file
91
frontend/src/components/__tests__/PropertyCard.test.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { PropertyCard } from '@/components/PropertyCard';
|
||||
import { createMockProperty } from '@/__tests__/helpers';
|
||||
|
||||
describe('PropertyCard', () => {
|
||||
it('renders rent price with /mo suffix', () => {
|
||||
const property = createMockProperty({ listing_type: 'RENT', total_price: 2500 });
|
||||
render(<PropertyCard property={property} />);
|
||||
expect(screen.getByText('/mo')).toBeInTheDocument();
|
||||
expect(screen.getByText(/2,500/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders buy price without /mo suffix', () => {
|
||||
const property = createMockProperty({ listing_type: 'BUY', total_price: 500000 });
|
||||
render(<PropertyCard property={property} />);
|
||||
expect(screen.getByText(/500,000/)).toBeInTheDocument();
|
||||
expect(screen.queryByText('/mo')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders bedrooms count', () => {
|
||||
const property = createMockProperty({ rooms: 3 });
|
||||
render(<PropertyCard property={property} />);
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders size with m\u00B2', () => {
|
||||
const property = createMockProperty({ qm: 65 });
|
||||
render(<PropertyCard property={property} />);
|
||||
expect(screen.getByText(/65\s*m\u00B2/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders price per sqm', () => {
|
||||
const property = createMockProperty({ qmprice: 38 });
|
||||
render(<PropertyCard property={property} />);
|
||||
expect(screen.getByText(/38\/m\u00B2/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders agency name', () => {
|
||||
const property = createMockProperty({ agency: 'Foxtons' });
|
||||
render(<PropertyCard property={property} />);
|
||||
expect(screen.getByText('Foxtons')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders photo thumbnail when present', () => {
|
||||
const property = createMockProperty({ photo_thumbnail: 'https://example.com/img.jpg' });
|
||||
render(<PropertyCard property={property} />);
|
||||
const img = screen.getByRole('img');
|
||||
expect(img).toHaveAttribute('src', 'https://example.com/img.jpg');
|
||||
});
|
||||
|
||||
it('shows Good deal badge when qmprice is below 90% of average', () => {
|
||||
const property = createMockProperty({ qmprice: 40 });
|
||||
render(<PropertyCard property={property} avgPricePerSqm={50} />);
|
||||
expect(screen.getByText('Good deal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Above avg badge when qmprice exceeds 110% of average', () => {
|
||||
const property = createMockProperty({ qmprice: 40 });
|
||||
render(<PropertyCard property={property} avgPricePerSqm={30} />);
|
||||
expect(screen.getByText('Above avg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no badge when qmprice is near average', () => {
|
||||
const property = createMockProperty({ qmprice: 38 });
|
||||
render(<PropertyCard property={property} avgPricePerSqm={40} />);
|
||||
expect(screen.queryByText('Good deal')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Above avg')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClick and opens window on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
|
||||
const property = createMockProperty({ url: 'https://rightmove.co.uk/123' });
|
||||
render(<PropertyCard property={property} onClick={onClick} />);
|
||||
|
||||
await user.click(screen.getByText(/2,500/));
|
||||
expect(openSpy).toHaveBeenCalledWith('https://rightmove.co.uk/123', '_blank', 'noopener,noreferrer');
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('applies ring-2 class when highlighted', () => {
|
||||
const property = createMockProperty();
|
||||
const { container } = render(<PropertyCard property={property} isHighlighted />);
|
||||
expect(container.querySelector('.ring-2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import { StreamingProgressBar } from '@/components/StreamingProgressBar';
|
||||
import type { StreamingProgress } from '@/services';
|
||||
|
||||
describe('StreamingProgressBar', () => {
|
||||
it('returns null when not loading', () => {
|
||||
const { container } = render(
|
||||
<StreamingProgressBar progress={null} isLoading={false} />,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('shows loading text when loading with no progress', () => {
|
||||
render(<StreamingProgressBar progress={null} isLoading />);
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows count and total when both provided', () => {
|
||||
const progress: StreamingProgress = { count: 25, total: 100 };
|
||||
render(<StreamingProgressBar progress={progress} isLoading />);
|
||||
expect(screen.getByText(/25/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/100/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows count without total', () => {
|
||||
const progress: StreamingProgress = { count: 25 };
|
||||
render(<StreamingProgressBar progress={progress} isLoading />);
|
||||
expect(screen.getByText(/25/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/loaded/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sets progress bar width based on count/total ratio', () => {
|
||||
const progress: StreamingProgress = { count: 50, total: 100 };
|
||||
const { container } = render(
|
||||
<StreamingProgressBar progress={progress} isLoading />,
|
||||
);
|
||||
const progressBar = container.querySelector('.bg-primary.transition-all');
|
||||
expect(progressBar).toHaveStyle({ width: '50%' });
|
||||
});
|
||||
});
|
||||
108
frontend/src/components/__tests__/TaskIndicator.test.tsx
Normal file
108
frontend/src/components/__tests__/TaskIndicator.test.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TaskIndicator } from '@/components/TaskIndicator';
|
||||
import { createMockTaskState } from '@/__tests__/helpers';
|
||||
import type { TaskState } from '@/types';
|
||||
|
||||
const defaultProps = {
|
||||
isConnected: true,
|
||||
onCancelTask: vi.fn(async () => true),
|
||||
onClearAllTasks: vi.fn(async () => true),
|
||||
};
|
||||
|
||||
function renderIndicator(tasks: Record<string, TaskState>, activeTaskId: string | null, extra = {}) {
|
||||
return render(
|
||||
<TaskIndicator tasks={tasks} activeTaskId={activeTaskId} {...defaultProps} {...extra} />,
|
||||
);
|
||||
}
|
||||
|
||||
describe('TaskIndicator', () => {
|
||||
it('returns null when no active task', () => {
|
||||
const { container } = renderIndicator({}, null);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when activeTaskId has no matching task', () => {
|
||||
const { container } = renderIndicator({}, 'missing-id');
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('shows spinner when task is running', () => {
|
||||
const tasks = { 't1': createMockTaskState({ status: 'STARTED' }) };
|
||||
const { container } = renderIndicator(tasks, 't1');
|
||||
// Loader2 renders an svg with animate-spin class
|
||||
expect(container.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows check icon on success', () => {
|
||||
const tasks = { 't1': createMockTaskState({ status: 'SUCCESS' }) };
|
||||
const { container } = renderIndicator(tasks, 't1');
|
||||
expect(container.querySelector('.text-green-500')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows X icon on failure', () => {
|
||||
const tasks = { 't1': createMockTaskState({ status: 'FAILURE' }) };
|
||||
const { container } = renderIndicator(tasks, 't1');
|
||||
expect(container.querySelector('.text-red-500')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows progress text', () => {
|
||||
const tasks = {
|
||||
't1': createMockTaskState({ status: 'STARTED', progress: 0.5, processed: 50, total: 100, phase: 'processing' }),
|
||||
};
|
||||
renderIndicator(tasks, 't1');
|
||||
expect(screen.getByText('50 / 100')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens drawer on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const tasks = { 't1': createMockTaskState({ status: 'STARTED' }) };
|
||||
renderIndicator(tasks, 't1');
|
||||
|
||||
// Click the task indicator area
|
||||
const clickable = screen.getByText(/50/).closest('[class*="cursor-pointer"]');
|
||||
if (clickable) {
|
||||
await user.click(clickable);
|
||||
}
|
||||
|
||||
// The drawer (Sheet) should now be open - SheetTitle renders "Job Progress"
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Job Progress/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows task count badge when multiple active tasks', () => {
|
||||
const tasks = {
|
||||
't1': createMockTaskState({ task_id: 't1', status: 'STARTED' }),
|
||||
't2': createMockTaskState({ task_id: 't2', status: 'STARTED' }),
|
||||
};
|
||||
renderIndicator(tasks, 't1');
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fires onTaskCompleted when active task transitions to SUCCESS', async () => {
|
||||
const onTaskCompleted = vi.fn();
|
||||
const tasks = { 't1': createMockTaskState({ status: 'STARTED' }) };
|
||||
|
||||
const { rerender } = render(
|
||||
<TaskIndicator
|
||||
tasks={tasks}
|
||||
activeTaskId="t1"
|
||||
{...defaultProps}
|
||||
onTaskCompleted={onTaskCompleted}
|
||||
/>,
|
||||
);
|
||||
|
||||
const updatedTasks = { 't1': createMockTaskState({ status: 'SUCCESS' }) };
|
||||
rerender(
|
||||
<TaskIndicator
|
||||
tasks={updatedTasks}
|
||||
activeTaskId="t1"
|
||||
{...defaultProps}
|
||||
onTaskCompleted={onTaskCompleted}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(onTaskCompleted).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
162
frontend/src/components/__tests__/TaskProgressDrawer.test.tsx
Normal file
162
frontend/src/components/__tests__/TaskProgressDrawer.test.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TaskProgressDrawer } from '@/components/TaskProgressDrawer';
|
||||
import { createMockTaskState } from '@/__tests__/helpers';
|
||||
import { TaskStatus } from '@/types';
|
||||
import type { TaskResult, TaskState } from '@/types';
|
||||
|
||||
const baseProps = {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
taskID: 'test-task-123',
|
||||
onCancel: vi.fn(),
|
||||
isCancelling: false,
|
||||
};
|
||||
|
||||
function makeResult(overrides: Partial<TaskResult> = {}): TaskResult {
|
||||
return {
|
||||
progress: 0.5,
|
||||
processed: 50,
|
||||
total: 100,
|
||||
phase: 'fetching',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('TaskProgressDrawer', () => {
|
||||
it('renders phase timeline labels for scrape task', () => {
|
||||
render(
|
||||
<TaskProgressDrawer
|
||||
{...baseProps}
|
||||
taskResult={makeResult({ phase: 'fetching' })}
|
||||
taskStatus={TaskStatus.STARTED}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Splitting queries')).toBeInTheDocument();
|
||||
// "Fetching & processing" appears in both the timeline and phase details
|
||||
expect(screen.getAllByText('Fetching & processing').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('Processing remaining')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Running badge when task is in progress', () => {
|
||||
render(
|
||||
<TaskProgressDrawer
|
||||
{...baseProps}
|
||||
taskResult={makeResult()}
|
||||
taskStatus={TaskStatus.STARTED}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Running')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Complete badge on success', () => {
|
||||
render(
|
||||
<TaskProgressDrawer
|
||||
{...baseProps}
|
||||
taskResult={makeResult({ phase: 'processing' })}
|
||||
taskStatus={TaskStatus.SUCCESS}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Complete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Cancelled badge when revoked', () => {
|
||||
render(
|
||||
<TaskProgressDrawer
|
||||
{...baseProps}
|
||||
taskResult={makeResult()}
|
||||
taskStatus={TaskStatus.REVOKED}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Cancelled')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Failed badge on failure', () => {
|
||||
render(
|
||||
<TaskProgressDrawer
|
||||
{...baseProps}
|
||||
taskResult={makeResult()}
|
||||
taskStatus={TaskStatus.FAILURE}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Failed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows processing metrics when in fetching phase', () => {
|
||||
render(
|
||||
<TaskProgressDrawer
|
||||
{...baseProps}
|
||||
taskResult={makeResult({
|
||||
phase: 'fetching',
|
||||
details_fetched: 25,
|
||||
images_downloaded: 10,
|
||||
ocr_completed: 5,
|
||||
total: 100,
|
||||
})}
|
||||
taskStatus={TaskStatus.STARTED}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Details fetched')).toBeInTheDocument();
|
||||
expect(screen.getByText('Images downloaded')).toBeInTheDocument();
|
||||
expect(screen.getByText('OCR completed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Cancel Job button when task is running', async () => {
|
||||
const onCancel = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<TaskProgressDrawer
|
||||
{...baseProps}
|
||||
taskResult={makeResult()}
|
||||
taskStatus={TaskStatus.STARTED}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
const cancelBtn = screen.getByText('Cancel Job');
|
||||
expect(cancelBtn).toBeInTheDocument();
|
||||
await user.click(cancelBtn);
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('infers POI task type from computing phase', () => {
|
||||
const tasks: Record<string, TaskState> = {
|
||||
'poi-1': createMockTaskState({
|
||||
task_id: 'poi-1',
|
||||
status: 'STARTED',
|
||||
phase: 'computing',
|
||||
distances_computed: 10,
|
||||
}),
|
||||
};
|
||||
render(
|
||||
<TaskProgressDrawer
|
||||
{...baseProps}
|
||||
taskResult={null}
|
||||
taskStatus={TaskStatus.STARTED}
|
||||
taskID="poi-1"
|
||||
tasks={tasks}
|
||||
selectedTaskId="poi-1"
|
||||
/>,
|
||||
);
|
||||
// Title should say "POI Distances Job Progress"
|
||||
expect(screen.getByText('POI Distances Job Progress')).toBeInTheDocument();
|
||||
// POI phase details section
|
||||
expect(screen.getByText('Computing distances')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows ETA when eta_seconds is provided', () => {
|
||||
render(
|
||||
<TaskProgressDrawer
|
||||
{...baseProps}
|
||||
taskResult={makeResult({
|
||||
phase: 'processing',
|
||||
progress: 0.75,
|
||||
processed: 75,
|
||||
total: 100,
|
||||
eta_seconds: 120,
|
||||
})}
|
||||
taskStatus={TaskStatus.STARTED}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/2m.*remaining/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue