test(front): add test suite frontend (WIP)

This commit is contained in:
jubnl
2026-04-07 12:31:09 +02:00
parent 96080e8a03
commit 3c31902885
97 changed files with 16973 additions and 4 deletions
+254
View File
@@ -0,0 +1,254 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import React from 'react';
import { render, screen, waitFor, act } from '../../tests/helpers/render';
import { Routes, Route } from 'react-router-dom';
import { resetAllStores, seedStore } from '../../tests/helpers/store';
import { buildUser, buildTrip, buildDay } from '../../tests/helpers/factories';
import { useAuthStore } from '../store/authStore';
import { useTripStore } from '../store/tripStore';
import TripPlannerPage from './TripPlannerPage';
// Mock Leaflet-dependent components
vi.mock('../components/Map/MapView', () => ({
MapView: () => React.createElement('div', { 'data-testid': 'map-view' }),
}));
vi.mock('react-leaflet', () => ({
MapContainer: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', { 'data-testid': 'map-container' }, children),
TileLayer: () => null,
Marker: ({ children }: { children?: React.ReactNode }) => React.createElement('div', null, children),
Tooltip: ({ children }: { children?: React.ReactNode }) => React.createElement('div', null, children),
Polyline: () => null,
CircleMarker: () => null,
Circle: () => null,
useMap: () => ({ fitBounds: vi.fn(), getCenter: vi.fn(() => ({ lat: 0, lng: 0 })) }),
}));
vi.mock('react-leaflet-cluster', () => ({
default: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children),
}));
vi.mock('leaflet', () => {
const L = {
divIcon: vi.fn(() => ({})),
latLngBounds: vi.fn(() => ({ extend: vi.fn(), isValid: vi.fn(() => true) })),
icon: vi.fn(() => ({})),
};
return { default: L, ...L };
});
// Mock the WebSocket hook so we can verify it's called
const mockUseTripWebSocket = vi.fn();
vi.mock('../hooks/useTripWebSocket', () => ({
useTripWebSocket: (...args: unknown[]) => mockUseTripWebSocket(...args),
}));
// Mock heavy sub-components
vi.mock('../components/Planner/DayPlanSidebar', () => ({
default: () => React.createElement('div', { 'data-testid': 'day-plan-sidebar' }),
}));
vi.mock('../components/Planner/PlacesSidebar', () => ({
default: () => React.createElement('div', { 'data-testid': 'places-sidebar' }),
}));
vi.mock('../components/Planner/PlaceInspector', () => ({
default: () => null,
}));
vi.mock('../components/Planner/DayDetailPanel', () => ({
default: () => null,
}));
vi.mock('../components/Memories/MemoriesPanel', () => ({
default: () => React.createElement('div', { 'data-testid': 'memories-panel' }),
}));
vi.mock('../components/Collab/CollabPanel', () => ({
default: () => React.createElement('div', { 'data-testid': 'collab-panel' }),
}));
vi.mock('../components/Files/FileManager', () => ({
default: () => React.createElement('div', { 'data-testid': 'file-manager' }),
}));
// Helper to seed a complete trip store state with mocked actions
function seedTripStore(overrides: { id?: number; tripName?: string; withMocks?: boolean } = {}) {
const { id = 42, tripName = 'Test Trip', withMocks = true } = overrides;
// Use `title` because TripPlannerPage reads trip.title
const trip = { ...buildTrip({ id }), title: tripName };
const day = buildDay({ trip_id: id });
const mockLoadTrip = withMocks ? vi.fn().mockResolvedValue(undefined) : undefined;
const mockLoadFiles = withMocks ? vi.fn().mockResolvedValue(undefined) : undefined;
const mockLoadReservations = withMocks ? vi.fn().mockResolvedValue(undefined) : undefined;
seedStore(useTripStore, {
trip,
isLoading: false,
days: [day],
places: [],
assignments: {},
packingItems: [],
todoItems: [],
categories: [],
reservations: [],
budgetItems: [],
files: [],
...(withMocks && {
loadTrip: mockLoadTrip,
loadFiles: mockLoadFiles,
loadReservations: mockLoadReservations,
}),
} as any);
return { trip, day, mockLoadTrip, mockLoadFiles, mockLoadReservations };
}
// Helper to render TripPlannerPage with route params
function renderPlannerPage(tripId: number | string) {
return render(
<Routes>
<Route path="/trips/:id" element={<TripPlannerPage />} />
</Routes>,
{ initialEntries: [`/trips/${tripId}`] },
);
}
beforeEach(() => {
resetAllStores();
mockUseTripWebSocket.mockReset();
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
});
afterEach(() => {
vi.useRealTimers();
});
describe('TripPlannerPage', () => {
describe('FE-PAGE-PLANNER-001: Calls loadTrip with route param on mount', () => {
it('calls loadTrip with the trip ID from URL params', async () => {
const { mockLoadTrip } = seedTripStore({ id: 42 });
renderPlannerPage(42);
await waitFor(() => {
expect(mockLoadTrip).toHaveBeenCalledWith('42');
});
});
});
describe('FE-PAGE-PLANNER-002: Loading state shown while loadTrip in progress', () => {
it('shows loading animation when isLoading is true', () => {
seedStore(useTripStore, {
trip: null,
isLoading: true,
days: [],
places: [],
assignments: {},
loadTrip: vi.fn().mockReturnValue(new Promise(() => {})),
loadFiles: vi.fn().mockResolvedValue(undefined),
loadReservations: vi.fn().mockResolvedValue(undefined),
} as any);
renderPlannerPage(99);
// Loading state: shows loading gif
const loadingImg = document.querySelector('img[alt="Loading"]');
expect(loadingImg).toBeInTheDocument();
});
});
describe('FE-PAGE-PLANNER-003: Error state shown if loadTrip fails', () => {
it('calls loadTrip and the action is called (even if it rejects)', async () => {
const mockLoadTrip = vi.fn().mockRejectedValue(new Error('Not found'));
const mockLoadFiles = vi.fn().mockResolvedValue(undefined);
const mockLoadReservations = vi.fn().mockResolvedValue(undefined);
seedStore(useTripStore, {
trip: null,
isLoading: false,
days: [],
places: [],
assignments: {},
loadTrip: mockLoadTrip,
loadFiles: mockLoadFiles,
loadReservations: mockLoadReservations,
} as any);
renderPlannerPage(999);
await waitFor(() => {
expect(mockLoadTrip).toHaveBeenCalledWith('999');
});
});
});
describe('FE-PAGE-PLANNER-004: Trip name in header after load', () => {
it('shows trip title in the Navbar after splash screen', async () => {
vi.useFakeTimers();
seedTripStore({ id: 7, tripName: 'Tokyo Adventure' });
renderPlannerPage(7);
// Run all pending timers (including the 1500ms splash timeout) synchronously
act(() => { vi.runAllTimers(); });
vi.useRealTimers();
await waitFor(() => {
expect(screen.getByText('Tokyo Adventure')).toBeInTheDocument();
});
});
});
describe('FE-PAGE-PLANNER-005: Day plan sidebar renders', () => {
it('renders the DayPlanSidebar component after splash', async () => {
vi.useFakeTimers();
seedTripStore({ id: 3, tripName: 'Day Tabs Trip' });
renderPlannerPage(3);
act(() => { vi.runAllTimers(); });
vi.useRealTimers();
await waitFor(() => {
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
});
});
});
describe('FE-PAGE-PLANNER-007: Places sidebar renders', () => {
it('renders the PlacesSidebar component after splash', async () => {
vi.useFakeTimers();
seedTripStore({ id: 5, tripName: 'Places Trip' });
renderPlannerPage(5);
act(() => { vi.runAllTimers(); });
vi.useRealTimers();
await waitFor(() => {
expect(screen.getByTestId('places-sidebar')).toBeInTheDocument();
});
});
});
describe('FE-PAGE-PLANNER-008: WebSocket hook mounted', () => {
it('calls useTripWebSocket with the trip ID string', async () => {
seedTripStore({ id: 15 });
renderPlannerPage(15);
await waitFor(() => {
expect(mockUseTripWebSocket).toHaveBeenCalledWith('15');
});
});
});
});