mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 22:31:46 +00:00
247433fb2a
* fix(journey): authorize reads of the journey share link GET /api/journeys/:id/share-link now requires journey access (canAccessJourney), matching the create/delete share-link routes and the get_journey_share_link MCP tool. Returns no link when the caller lacks access to the journey. * feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile Renames the Budget addon to "Costs" (UI only) and reworks it into a Tricount/ Splitwise-style cost tracker: multiple payers per expense, equal split across chosen members, settle-up with persisted history + undo, 12 fixed categories, per-expense currency with live FX conversion to a user-set display currency (Settings -> Display), and locale-correct money formatting. Adds a desktop and a dedicated mobile layout. A migration backfills existing budget items (single payer, split members, currency). Closes #551 (per-expense currency). Also switches the app font to self-hosted Poppins (Geist for secondary subtext), replacing the Google Fonts CDN dependency. * fix(costs): neutral dashboard dark palette + liquid glass, full page width, entry-count badge - Dark mode used a warm oklch palette that read brownish; switch to the neutral zinc tokens used by the dashboard (#121215 bg, #f4f4f5 ink) and add a subtle backdrop-blur glass on cards. - Costs now uses the full available page width on desktop instead of a 1280px cap. - Render the expense count next to the Expenses title as a badge. - Adapt budget/journey unit tests to the new payer-based settlement model and the Costs rename (category default 'other', Costs tab/CostsPanel). * fix(costs): drop the entry-count badge, always show row edit/delete actions Removes the count badge next to the Expenses title and makes the per-row edit/delete actions permanently visible (no longer hover-only) on desktop too. * feat(costs): currency-native money formatting, custom select/date, rename addon to Costs - Format every amount in its own currency convention (symbol position, grouping and decimal separators) regardless of app language, via a currency->locale map (EUR -> '12,00 €', USD -> '$12.00', JPY -> '¥12', ...). Previously Intl used the app locale, so EUR showed the symbol in front under an English UI. - Use TREK's CustomSelect (searchable, with symbols) and CustomDatePicker in the add/edit expense modal instead of the native <select>/<input type=date>. - Rename the 'Budget Planner' add-on to 'Costs' in the admin list (display only; id/tables/permissions/MCP stay 'budget') via seed + a migration for existing DBs. * feat(auth): configurable session duration via SESSION_DURATION Adds a SESSION_DURATION env var (ms-style strings: 1h, 7d, 30d, ...) controlling how long a session stays valid before re-login. It drives both the trek_session JWT exp claim and the cookie maxAge from one source, so they never drift. Invalid values warn at startup and fall back to the default (24h — unchanged). The MFA challenge token and MCP OAuth tokens keep their own TTL. Implements the request from discussion #946. Documented in the env-var wiki page, .env.example and docker-compose.yml.
1561 lines
49 KiB
TypeScript
1561 lines
49 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import React from 'react';
|
|
import { render, screen, waitFor, act, fireEvent } from '../../tests/helpers/render';
|
|
import { Routes, Route } from 'react-router-dom';
|
|
import { resetAllStores, seedStore } from '../../tests/helpers/store';
|
|
import { buildUser, buildTrip, buildDay, buildPlace, buildAssignment } from '../../tests/helpers/factories';
|
|
import { useAuthStore } from '../store/authStore';
|
|
import { useTripStore } from '../store/tripStore';
|
|
import TripPlannerPage from './TripPlannerPage';
|
|
import { server } from '../../tests/helpers/msw/server';
|
|
import { http, HttpResponse } from 'msw';
|
|
|
|
// 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),
|
|
}));
|
|
|
|
// Prop-capturing refs for mock components — populated on each render
|
|
const capturedDayPlanSidebarProps: { current: Record<string, any> } = { current: {} };
|
|
const capturedPlacesSidebarProps: { current: Record<string, any> } = { current: {} };
|
|
|
|
// Mock heavy sub-components (capture props for handler testing)
|
|
vi.mock('../components/Planner/DayPlanSidebar', () => ({
|
|
default: (props: Record<string, any>) => {
|
|
capturedDayPlanSidebarProps.current = props;
|
|
return React.createElement('div', { 'data-testid': 'day-plan-sidebar' });
|
|
},
|
|
}));
|
|
|
|
vi.mock('../components/Planner/PlacesSidebar', () => ({
|
|
default: (props: Record<string, any>) => {
|
|
capturedPlacesSidebarProps.current = props;
|
|
return React.createElement('div', { 'data-testid': 'places-sidebar' });
|
|
},
|
|
}));
|
|
|
|
const capturedPlaceInspectorProps: { current: Record<string, any> } = { current: {} };
|
|
vi.mock('../components/Planner/PlaceInspector', () => ({
|
|
default: (props: Record<string, any>) => {
|
|
capturedPlaceInspectorProps.current = props;
|
|
return React.createElement('div', { 'data-testid': 'place-inspector' });
|
|
},
|
|
}));
|
|
|
|
const capturedDayDetailPanelProps: { current: Record<string, any> } = { current: {} };
|
|
vi.mock('../components/Planner/DayDetailPanel', () => ({
|
|
default: (props: Record<string, any>) => {
|
|
capturedDayDetailPanelProps.current = props;
|
|
return 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' }),
|
|
}));
|
|
|
|
const capturedFileManagerProps: { current: Record<string, any> } = { current: {} };
|
|
vi.mock('../components/Files/FileManager', () => ({
|
|
default: (props: Record<string, any>) => {
|
|
capturedFileManagerProps.current = props;
|
|
return React.createElement('div', { 'data-testid': 'file-manager' });
|
|
},
|
|
}));
|
|
|
|
vi.mock('../components/Budget/CostsPanel', () => ({
|
|
default: () => React.createElement('div', { 'data-testid': 'costs-panel' }),
|
|
}));
|
|
|
|
vi.mock('../components/Packing/PackingListPanel', () => ({
|
|
default: () => React.createElement('div', { 'data-testid': 'packing-list-panel' }),
|
|
}));
|
|
|
|
vi.mock('../components/Todo/TodoListPanel', () => ({
|
|
default: () => React.createElement('div', { 'data-testid': 'todo-list-panel' }),
|
|
}));
|
|
|
|
// Prop-capturing mocks for modal components (enable calling onSave/onDelete/etc. in tests)
|
|
const capturedReservationsPanelProps: { current: Record<string, any> } = { current: {} };
|
|
vi.mock('../components/Planner/ReservationsPanel', () => ({
|
|
default: (props: Record<string, any>) => {
|
|
capturedReservationsPanelProps.current = props;
|
|
return React.createElement('div', { 'data-testid': 'reservations-panel' });
|
|
},
|
|
}));
|
|
|
|
const capturedPlaceFormModalProps: { current: Record<string, any> } = { current: {} };
|
|
vi.mock('../components/Planner/PlaceFormModal', () => ({
|
|
default: (props: Record<string, any>) => {
|
|
capturedPlaceFormModalProps.current = props;
|
|
return null;
|
|
},
|
|
}));
|
|
|
|
const capturedReservationModalProps: { current: Record<string, any> } = { current: {} };
|
|
vi.mock('../components/Planner/ReservationModal', () => ({
|
|
ReservationModal: (props: Record<string, any>) => {
|
|
capturedReservationModalProps.current = props;
|
|
return null;
|
|
},
|
|
}));
|
|
|
|
const capturedConfirmDialogProps: { current: Record<string, any> } = { current: {} };
|
|
vi.mock('../components/shared/ConfirmDialog', () => ({
|
|
default: (props: Record<string, any>) => {
|
|
capturedConfirmDialogProps.current = props;
|
|
return null;
|
|
},
|
|
}));
|
|
|
|
const capturedTripFormModalProps: { current: Record<string, any> } = { current: {} };
|
|
vi.mock('../components/Trips/TripFormModal', () => ({
|
|
default: (props: Record<string, any>) => {
|
|
capturedTripFormModalProps.current = props;
|
|
return null;
|
|
},
|
|
}));
|
|
|
|
const capturedTripMembersModalProps: { current: Record<string, any> } = { current: {} };
|
|
vi.mock('../components/Trips/TripMembersModal', () => ({
|
|
default: (props: Record<string, any>) => {
|
|
capturedTripMembersModalProps.current = props;
|
|
return null;
|
|
},
|
|
}));
|
|
|
|
// Configurable usePlaceSelection mock — lets tests set a specific selected place
|
|
const mockPlaceSelectionState: { selectedPlaceId: number | null; selectedAssignmentId: number | null } = {
|
|
selectedPlaceId: null,
|
|
selectedAssignmentId: null,
|
|
};
|
|
const mockSetSelectedPlaceId = vi.fn();
|
|
const mockSelectAssignment = vi.fn();
|
|
|
|
vi.mock('../hooks/usePlaceSelection', () => ({
|
|
usePlaceSelection: () => ({
|
|
selectedPlaceId: mockPlaceSelectionState.selectedPlaceId,
|
|
selectedAssignmentId: mockPlaceSelectionState.selectedAssignmentId,
|
|
setSelectedPlaceId: mockSetSelectedPlaceId,
|
|
selectAssignment: mockSelectAssignment,
|
|
}),
|
|
}));
|
|
|
|
// 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(() => {
|
|
vi.clearAllMocks();
|
|
resetAllStores();
|
|
mockUseTripWebSocket.mockReset();
|
|
mockSetSelectedPlaceId.mockReset();
|
|
mockSelectAssignment.mockReset();
|
|
mockPlaceSelectionState.selectedPlaceId = null;
|
|
mockPlaceSelectionState.selectedAssignmentId = null;
|
|
capturedDayPlanSidebarProps.current = {};
|
|
capturedPlacesSidebarProps.current = {};
|
|
capturedReservationsPanelProps.current = {};
|
|
capturedPlaceFormModalProps.current = {};
|
|
capturedReservationModalProps.current = {};
|
|
capturedConfirmDialogProps.current = {};
|
|
capturedDayDetailPanelProps.current = {};
|
|
capturedTripFormModalProps.current = {};
|
|
capturedTripMembersModalProps.current = {};
|
|
capturedFileManagerProps.current = {};
|
|
capturedPlaceInspectorProps.current = {};
|
|
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 from URL params', async () => {
|
|
seedTripStore({ id: 15 });
|
|
|
|
renderPlannerPage(15);
|
|
|
|
await waitFor(() => {
|
|
expect(mockUseTripWebSocket).toHaveBeenCalledWith(15);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-009: Map view renders after splash', () => {
|
|
it('shows the MapView component after the splash screen is dismissed', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('map-view')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-010: Reservations tab renders ReservationsPanel', () => {
|
|
it('shows ReservationsPanel after clicking the Bookings tab', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
const bookingsTab = await screen.findByTitle('Bookings');
|
|
fireEvent.click(bookingsTab);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('reservations-panel')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-011: Packing tab renders PackingListPanel', () => {
|
|
it('shows PackingListPanel after clicking the Lists tab with packing addon enabled', async () => {
|
|
server.use(
|
|
http.get('/api/addons', () =>
|
|
HttpResponse.json({ addons: [{ id: 'packing', type: 'packing' }] })
|
|
)
|
|
);
|
|
|
|
vi.useFakeTimers();
|
|
|
|
seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
const listsTab = await screen.findByTitle('Lists');
|
|
fireEvent.click(listsTab);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('packing-list-panel')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-012: Costs tab renders CostsPanel', () => {
|
|
it('shows CostsPanel after clicking the Costs tab with budget addon enabled', async () => {
|
|
server.use(
|
|
http.get('/api/addons', () =>
|
|
HttpResponse.json({ addons: [{ id: 'budget', type: 'budget' }] })
|
|
)
|
|
);
|
|
|
|
vi.useFakeTimers();
|
|
|
|
seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
const costsTab = await screen.findByTitle('Costs');
|
|
fireEvent.click(costsTab);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('costs-panel')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-013: Files tab renders FileManager', () => {
|
|
it('shows FileManager after clicking the Files tab with documents addon enabled', async () => {
|
|
server.use(
|
|
http.get('/api/addons', () =>
|
|
HttpResponse.json({ addons: [{ id: 'documents', type: 'documents' }] })
|
|
)
|
|
);
|
|
|
|
vi.useFakeTimers();
|
|
|
|
seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
const filesTab = await screen.findByTitle('Files');
|
|
fireEvent.click(filesTab);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('file-manager')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-014: Collab tab renders CollabPanel', () => {
|
|
it('shows CollabPanel after clicking the Collab tab with collab addon enabled', async () => {
|
|
server.use(
|
|
http.get('/api/addons', () =>
|
|
HttpResponse.json({ addons: [{ id: 'collab', type: 'collab' }] })
|
|
)
|
|
);
|
|
|
|
vi.useFakeTimers();
|
|
|
|
seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
const collabTab = await screen.findByTitle('Collab');
|
|
fireEvent.click(collabTab);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('collab-panel')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-015: Tab state persists in sessionStorage', () => {
|
|
it('saves the active tab ID to sessionStorage on tab change', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
const bookingsTab = await screen.findByTitle('Bookings');
|
|
fireEvent.click(bookingsTab);
|
|
|
|
await waitFor(() => {
|
|
expect(sessionStorage.getItem('trip-tab-42')).toBe('buchungen');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-016: Left panel collapse toggle', () => {
|
|
it('collapses the left sidebar when the collapse button is clicked', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
|
|
});
|
|
|
|
const sidebarContainer = screen.getByTestId('day-plan-sidebar').parentElement!;
|
|
const collapseButton = sidebarContainer.previousElementSibling as HTMLElement;
|
|
|
|
fireEvent.click(collapseButton);
|
|
|
|
await waitFor(() => {
|
|
expect(sidebarContainer).toHaveStyle('opacity: 0');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-017: Trip navigation error redirects to dashboard', () => {
|
|
it('navigates to /dashboard when loadTrip rejects', async () => {
|
|
seedStore(useTripStore, {
|
|
trip: null,
|
|
isLoading: false,
|
|
days: [],
|
|
places: [],
|
|
assignments: {},
|
|
loadTrip: vi.fn().mockRejectedValue(new Error('Not found')),
|
|
loadFiles: vi.fn().mockResolvedValue(undefined),
|
|
loadReservations: vi.fn().mockResolvedValue(undefined),
|
|
} as any);
|
|
|
|
render(
|
|
<Routes>
|
|
<Route path="/trips/:id" element={<TripPlannerPage />} />
|
|
<Route path="/dashboard" element={<div data-testid="dashboard-page" />} />
|
|
</Routes>,
|
|
{ initialEntries: ['/trips/999'] },
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('dashboard-page')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
// FE-PAGE-PLANNER-018: Removed — MemoriesPanel moved to Journey addon
|
|
|
|
describe('FE-PAGE-PLANNER-019: Todo subtab in ListsContainer', () => {
|
|
it('shows TodoListPanel after switching to the Todo subtab inside Lists', async () => {
|
|
server.use(
|
|
http.get('/api/addons', () =>
|
|
HttpResponse.json({ addons: [{ id: 'packing', type: 'packing' }] })
|
|
)
|
|
);
|
|
|
|
vi.useFakeTimers();
|
|
|
|
seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
// Navigate to the Lists tab first
|
|
const listsTab = await screen.findByTitle('Lists');
|
|
fireEvent.click(listsTab);
|
|
|
|
// Find the Todo subtab button inside ListsContainer and click it
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('packing-list-panel')).toBeInTheDocument();
|
|
});
|
|
|
|
// Click the Todo subtab
|
|
const todoButtons = screen.getAllByRole('button');
|
|
const todoSubtab = todoButtons.find(btn => btn.textContent?.includes('Todo') || btn.textContent?.includes('todo'));
|
|
if (todoSubtab) {
|
|
fireEvent.click(todoSubtab);
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('todo-list-panel')).toBeInTheDocument();
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-020: handleSelectDay covers plan selection logic', () => {
|
|
it('calls handleSelectDay through captured DayPlanSidebar props', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const { day } = seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
|
|
});
|
|
|
|
// Call onSelectDay via the captured props — covers handleSelectDay body
|
|
await act(async () => {
|
|
capturedDayPlanSidebarProps.current.onSelectDay?.(day.id);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-021: handlePlaceClick covers place selection logic', () => {
|
|
it('calls handlePlaceClick through captured DayPlanSidebar props', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const place = buildPlace({ id: 1, trip_id: 42, lat: 48.8566, lng: 2.3522 });
|
|
seedTripStore({ id: 42 });
|
|
seedStore(useTripStore, { places: [place] } as any);
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
|
|
});
|
|
|
|
// Call onPlaceClick via captured props — covers handlePlaceClick body
|
|
await act(async () => {
|
|
capturedDayPlanSidebarProps.current.onPlaceClick?.(place.id, null);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-022: handleRemoveAssignment covers removal logic', () => {
|
|
it('calls onRemoveAssignment through captured DayPlanSidebar props', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const { day } = seedTripStore({ id: 42 });
|
|
const place = buildPlace({ id: 1, trip_id: 42 });
|
|
const assignment = buildAssignment({ id: 10, day_id: day.id, place });
|
|
seedStore(useTripStore, {
|
|
assignments: { [String(day.id)]: [assignment] },
|
|
places: [place],
|
|
} as any);
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
|
|
});
|
|
|
|
// Call onRemoveAssignment — covers handleRemoveAssignment body
|
|
await act(async () => {
|
|
capturedDayPlanSidebarProps.current.onRemoveAssignment?.(day.id, assignment.id);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-023: handleAssignToDay covers assignment logic', () => {
|
|
it('calls onAssignToDay through captured PlacesSidebar props with a selected day', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const { day } = seedTripStore({ id: 42 });
|
|
seedStore(useTripStore, { selectedDayId: day.id } as any);
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('places-sidebar')).toBeInTheDocument();
|
|
});
|
|
|
|
// Call onAssignToDay — covers handleAssignToDay body
|
|
await act(async () => {
|
|
capturedPlacesSidebarProps.current.onAssignToDay?.(1, day.id, 0);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-024: PlaceInspector renders when a place is selected', () => {
|
|
it('renders PlaceInspector when selectedPlaceId matches a store place', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const place = buildPlace({ id: 1, trip_id: 42, lat: 48.8566, lng: 2.3522 });
|
|
|
|
// Set selectedPlaceId before render so selectedPlace is computed non-null
|
|
mockPlaceSelectionState.selectedPlaceId = place.id;
|
|
|
|
seedTripStore({ id: 42 });
|
|
seedStore(useTripStore, { places: [place] } as any);
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
// PlaceInspector is mocked as () => null so nothing visual renders,
|
|
// but the conditional block lines 776-818 are covered
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('map-view')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-025: dayOrderMap and dayPlaces computed with selectedDayId', () => {
|
|
it('renders the planner with a selectedDayId and assignments to cover memo logic', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const { day } = seedTripStore({ id: 42 });
|
|
const place = buildPlace({ id: 1, trip_id: 42, lat: 48.8566, lng: 2.3522 });
|
|
const assignment = buildAssignment({ id: 10, day_id: day.id, place, order_index: 0 });
|
|
seedStore(useTripStore, {
|
|
selectedDayId: day.id,
|
|
places: [place],
|
|
assignments: { [String(day.id)]: [assignment] },
|
|
} as any);
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('map-view')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-026: handleReorder covers reorder logic', () => {
|
|
it('calls onReorder through captured DayPlanSidebar props', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const { day } = seedTripStore({ id: 42 });
|
|
const place = buildPlace({ id: 1, trip_id: 42 });
|
|
const assignment = buildAssignment({ id: 10, day_id: day.id, place, order_index: 0 });
|
|
seedStore(useTripStore, {
|
|
places: [place],
|
|
assignments: { [String(day.id)]: [assignment] },
|
|
} as any);
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
|
|
});
|
|
|
|
await act(async () => {
|
|
capturedDayPlanSidebarProps.current.onReorder?.(day.id, [assignment.id]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-027: handleUpdateDayTitle covers title update logic', () => {
|
|
it('calls onUpdateDayTitle through captured DayPlanSidebar props', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const { day } = seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
|
|
});
|
|
|
|
await act(async () => {
|
|
capturedDayPlanSidebarProps.current.onUpdateDayTitle?.(day.id, 'New Title');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-028: handleSavePlace add path covers addPlace logic', () => {
|
|
it('calls onSave on PlaceFormModal to exercise the add-place handler', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('map-view')).toBeInTheDocument();
|
|
});
|
|
|
|
// Call onSave with editingPlace=null (add path)
|
|
await act(async () => {
|
|
await capturedPlaceFormModalProps.current.onSave?.({ name: 'Test Place', lat: 1, lng: 2 });
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-029: handleSavePlace edit path covers updatePlace logic', () => {
|
|
it('calls onEditPlace then onSave on PlaceFormModal to exercise the edit-place handler', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const place = buildPlace({ id: 1, trip_id: 42, lat: 48.8566, lng: 2.3522 });
|
|
seedTripStore({ id: 42 });
|
|
seedStore(useTripStore, { places: [place] } as any);
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
|
|
});
|
|
|
|
// Set editingPlace via captured props (uses the inline lambda that calls setEditingPlace)
|
|
await act(async () => {
|
|
capturedDayPlanSidebarProps.current.onEditPlace?.(place, null);
|
|
});
|
|
|
|
// Now onSave uses the edit path (editingPlace is set)
|
|
await act(async () => {
|
|
await capturedPlaceFormModalProps.current.onSave?.({ name: 'Updated', lat: 1, lng: 2 });
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-030: confirmDeletePlace covers delete-place logic', () => {
|
|
it('calls onDeletePlace then ConfirmDialog onConfirm to exercise confirmDeletePlace', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const place = buildPlace({ id: 1, trip_id: 42, lat: 48.8566, lng: 2.3522 });
|
|
seedTripStore({ id: 42 });
|
|
seedStore(useTripStore, { places: [place] } as any);
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
|
|
});
|
|
|
|
// Trigger setDeletePlaceId by calling onDeletePlace inline lambda
|
|
await act(async () => {
|
|
capturedDayPlanSidebarProps.current.onDeletePlace?.(place.id);
|
|
});
|
|
|
|
// Wait for ConfirmDialog to receive the updated onConfirm
|
|
await waitFor(() => {
|
|
expect(typeof capturedConfirmDialogProps.current.onConfirm).toBe('function');
|
|
});
|
|
|
|
// Call onConfirm to run confirmDeletePlace body
|
|
await act(async () => {
|
|
await capturedConfirmDialogProps.current.onConfirm?.();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-031: handleSaveReservation add path covers reservation creation', () => {
|
|
it('calls onSave on ReservationModal to exercise the add-reservation handler', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('map-view')).toBeInTheDocument();
|
|
});
|
|
|
|
// Call onSave with editingReservation=null (add path)
|
|
await act(async () => {
|
|
await capturedReservationModalProps.current.onSave?.({ name: 'Test Booking', type: 'restaurant', status: 'confirmed' });
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-032: handleDeleteReservation covers reservation deletion', () => {
|
|
it('calls onDelete from ReservationsPanel to exercise the delete-reservation handler', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
const bookingsTab = await screen.findByTitle('Bookings');
|
|
fireEvent.click(bookingsTab);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('reservations-panel')).toBeInTheDocument();
|
|
});
|
|
|
|
await act(async () => {
|
|
await capturedReservationsPanelProps.current.onDelete?.(1);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-033: onDayDetail covers DayDetailPanel render path', () => {
|
|
it('shows DayDetailPanel section when onDayDetail is called via DayPlanSidebar props', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const { day } = seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
|
|
});
|
|
|
|
// Triggers showDayDetail = day, covering DayDetailPanel conditional block
|
|
await act(async () => {
|
|
capturedDayPlanSidebarProps.current.onDayDetail?.(day);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-034: onRouteCalculated covers route state setters', () => {
|
|
it('calls onRouteCalculated with route data and null to cover both branches', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
|
|
});
|
|
|
|
await act(async () => {
|
|
capturedDayPlanSidebarProps.current.onRouteCalculated?.({
|
|
coordinates: [[1, 2], [3, 4]],
|
|
distanceText: '1 km',
|
|
durationText: '10 min',
|
|
walkingText: '15 min',
|
|
drivingText: '5 min',
|
|
});
|
|
});
|
|
|
|
await act(async () => {
|
|
capturedDayPlanSidebarProps.current.onRouteCalculated?.(null);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-035: onAddReservation covers reservation modal open', () => {
|
|
it('calls onAddReservation to open the ReservationModal', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const { day } = seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
|
|
});
|
|
|
|
await act(async () => {
|
|
capturedDayPlanSidebarProps.current.onAddReservation?.(day.id);
|
|
});
|
|
|
|
// ReservationModal should now be open (isOpen=true in its props)
|
|
await waitFor(() => {
|
|
expect(capturedReservationModalProps.current.isOpen).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-036: handleUndo covers undo execution', () => {
|
|
it('calls onUndo through captured DayPlanSidebar props', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
|
|
});
|
|
|
|
await act(async () => {
|
|
capturedDayPlanSidebarProps.current.onUndo?.();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-038: DayDetailPanel onClose and onToggleCollapse callbacks', () => {
|
|
it('calls DayDetailPanel onClose and onToggleCollapse to cover those inline lambdas', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const { day } = seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
|
|
});
|
|
|
|
// Set showDayDetail
|
|
await act(async () => {
|
|
capturedDayPlanSidebarProps.current.onDayDetail?.(day);
|
|
});
|
|
|
|
// Call onClose — covers line 766 lambda: setShowDayDetail(null); handleSelectDay(null)
|
|
await act(async () => {
|
|
capturedDayDetailPanelProps.current.onClose?.();
|
|
});
|
|
|
|
// Re-open to test onToggleCollapse
|
|
await act(async () => {
|
|
capturedDayPlanSidebarProps.current.onDayDetail?.(day);
|
|
});
|
|
|
|
// Call onToggleCollapse — covers line 771 lambda: setDayDetailCollapsed(c => !c)
|
|
await act(async () => {
|
|
capturedDayDetailPanelProps.current.onToggleCollapse?.();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-039: PlaceFormModal onClose covers modal close lambda', () => {
|
|
it('calls PlaceFormModal onClose to cover the modal close handler', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('map-view')).toBeInTheDocument();
|
|
});
|
|
|
|
// Covers line 954 onClose lambda body
|
|
await act(async () => {
|
|
capturedPlaceFormModalProps.current.onClose?.();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-040: ReservationModal onClose covers modal close lambda', () => {
|
|
it('calls ReservationModal onClose to cover the modal close handler', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('map-view')).toBeInTheDocument();
|
|
});
|
|
|
|
// Covers line 957 onClose lambda body
|
|
await act(async () => {
|
|
capturedReservationModalProps.current.onClose?.();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-041: handleSaveReservation edit path covers update reservation', () => {
|
|
it('calls onEdit then onSave on ReservationModal to exercise the edit-reservation handler', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
// Navigate to Bookings tab so ReservationsPanel is rendered
|
|
const bookingsTab = await screen.findByTitle('Bookings');
|
|
fireEvent.click(bookingsTab);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('reservations-panel')).toBeInTheDocument();
|
|
});
|
|
|
|
// Set editingReservation via captured onEdit prop (inline lambda in JSX)
|
|
const fakeReservation = { id: 1, trip_id: 42, name: 'Test', type: 'restaurant', status: 'confirmed' };
|
|
await act(async () => {
|
|
capturedReservationsPanelProps.current.onEdit?.(fakeReservation);
|
|
});
|
|
|
|
// Call onSave — now takes edit path (editingReservation is set)
|
|
await act(async () => {
|
|
await capturedReservationModalProps.current.onSave?.({
|
|
name: 'Updated Booking',
|
|
type: 'restaurant',
|
|
status: 'confirmed',
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-042: TripMembersModal onClose covers modal close lambda', () => {
|
|
it('calls TripMembersModal onClose to cover the inline lambda', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('map-view')).toBeInTheDocument();
|
|
});
|
|
|
|
// Covers TripMembersModal onClose lambda: () => setShowMembersModal(false)
|
|
await act(async () => {
|
|
capturedTripMembersModalProps.current.onClose?.();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-043: TripFormModal onClose covers modal close lambda', () => {
|
|
it('calls TripFormModal onClose to cover the inline lambda', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('map-view')).toBeInTheDocument();
|
|
});
|
|
|
|
// Covers TripFormModal onClose lambda: () => setShowTripForm(false)
|
|
await act(async () => {
|
|
capturedTripFormModalProps.current.onClose?.();
|
|
});
|
|
|
|
// Also cover TripFormModal onSave lambda
|
|
await act(async () => {
|
|
await capturedTripFormModalProps.current.onSave?.({ name: 'Updated Trip' });
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-044: FileManager callbacks cover file operation lambdas', () => {
|
|
it('calls FileManager onUpload/onDelete/onUpdate to cover inline lambda bodies', async () => {
|
|
server.use(
|
|
http.get('/api/addons', () =>
|
|
HttpResponse.json({ addons: [{ id: 'documents', type: 'documents' }] })
|
|
)
|
|
);
|
|
|
|
vi.useFakeTimers();
|
|
|
|
seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
const filesTab = await screen.findByTitle('Files');
|
|
fireEvent.click(filesTab);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('file-manager')).toBeInTheDocument();
|
|
});
|
|
|
|
// Call FileManager callbacks — covers lines 928-930 lambda bodies
|
|
await act(async () => {
|
|
const fd = new FormData();
|
|
await capturedFileManagerProps.current.onUpload?.(fd).catch(() => {});
|
|
});
|
|
|
|
await act(async () => {
|
|
await capturedFileManagerProps.current.onDelete?.(1).catch(() => {});
|
|
});
|
|
|
|
await act(async () => {
|
|
capturedFileManagerProps.current.onUpdate?.(1, {});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-045: ReservationsPanel onNavigateToFiles covers inline lambda', () => {
|
|
it('calls onNavigateToFiles to cover the inline lambda body', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
const bookingsTab = await screen.findByTitle('Bookings');
|
|
fireEvent.click(bookingsTab);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('reservations-panel')).toBeInTheDocument();
|
|
});
|
|
|
|
// Covers line 907 lambda: () => handleTabChange('dateien')
|
|
await act(async () => {
|
|
capturedReservationsPanelProps.current.onNavigateToFiles?.();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-046: Invalid session tab resets to plan', () => {
|
|
it('resets activeTab to "plan" when saved tab is no longer in TRIP_TABS', async () => {
|
|
// Save a tab id that requires the "memories" addon (disabled by default)
|
|
sessionStorage.setItem('trip-tab-42', 'memories');
|
|
seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
|
|
// The useEffect should detect the invalid tab and reset it
|
|
await waitFor(() => {
|
|
expect(sessionStorage.getItem('trip-tab-42')).toBe('plan');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-047: Desktop PlaceInspector onEdit with selectedAssignment', () => {
|
|
it('calls onEdit on desktop PlaceInspector with selectedAssignmentId to cover if-branch', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const place = buildPlace({ id: 1, trip_id: 42, lat: 48.8566, lng: 2.3522 });
|
|
const assignment = buildAssignment({ id: 10, day_id: 99, place, order_index: 0 });
|
|
|
|
mockPlaceSelectionState.selectedPlaceId = place.id;
|
|
mockPlaceSelectionState.selectedAssignmentId = assignment.id;
|
|
|
|
seedTripStore({ id: 42 });
|
|
seedStore(useTripStore, {
|
|
places: [place],
|
|
assignments: { '99': [assignment] },
|
|
} as any);
|
|
|
|
renderPlannerPage(42);
|
|
act(() => { vi.runAllTimers(); });
|
|
vi.useRealTimers();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('place-inspector')).toBeInTheDocument();
|
|
});
|
|
|
|
// onEdit with selectedAssignmentId set — covers lines 795-798 (if branch)
|
|
await act(async () => {
|
|
capturedPlaceInspectorProps.current.onEdit?.();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-048: Mobile PlaceInspector portal renders when isMobile is true', () => {
|
|
it('renders PlaceInspector in mobile portal and covers mobile callbacks', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
// Simulate mobile viewport
|
|
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 375 });
|
|
|
|
const place = buildPlace({ id: 1, trip_id: 42, lat: 48.8566, lng: 2.3522 });
|
|
|
|
mockPlaceSelectionState.selectedPlaceId = place.id;
|
|
|
|
seedTripStore({ id: 42 });
|
|
seedStore(useTripStore, { places: [place] } as any);
|
|
|
|
renderPlannerPage(42);
|
|
act(() => { vi.runAllTimers(); });
|
|
vi.useRealTimers();
|
|
|
|
// Mobile portal renders the PlaceInspector (lines 830-879)
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('place-inspector')).toBeInTheDocument();
|
|
});
|
|
|
|
// onEdit without assignment — covers else branch at line 799
|
|
await act(async () => {
|
|
capturedPlaceInspectorProps.current.onEdit?.();
|
|
});
|
|
|
|
// onClose — covers mobile onClose lambda
|
|
await act(async () => {
|
|
capturedPlaceInspectorProps.current.onClose?.();
|
|
});
|
|
|
|
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1024 });
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-049: Mobile sidebar left panel opens via Plan button', () => {
|
|
it('clicking the mobile Plan button opens the left sidebar portal (lines 882-893)', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 375 });
|
|
|
|
seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
act(() => { vi.runAllTimers(); });
|
|
vi.useRealTimers();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
|
|
});
|
|
|
|
// The mobile portal buttons are rendered to document.body.
|
|
// The "Plan" tab button has title="Plan"; the mobile portal button does not.
|
|
const mobilePlanBtn = Array.from(document.body.querySelectorAll('button')).find(
|
|
b => b.textContent === 'Plan' && !b.getAttribute('title'),
|
|
);
|
|
|
|
if (mobilePlanBtn) {
|
|
await act(async () => { fireEvent.click(mobilePlanBtn); });
|
|
|
|
// Mobile sidebar portal renders DayPlanSidebar — now two instances
|
|
await waitFor(() => {
|
|
expect(screen.getAllByTestId('day-plan-sidebar').length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
// Close the mobile sidebar via the X button inside the portal header
|
|
const closeButtons = Array.from(document.body.querySelectorAll('button')).filter(
|
|
b => !b.textContent || b.textContent.trim() === '',
|
|
);
|
|
if (closeButtons.length > 0) {
|
|
await act(async () => { fireEvent.click(closeButtons[0]); });
|
|
}
|
|
}
|
|
|
|
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1024 });
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-050: Mobile sidebar right panel opens via Places button', () => {
|
|
it('clicking the mobile Places button opens the right sidebar portal (lines 894)', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 375 });
|
|
|
|
seedTripStore({ id: 42 });
|
|
|
|
renderPlannerPage(42);
|
|
act(() => { vi.runAllTimers(); });
|
|
vi.useRealTimers();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('places-sidebar')).toBeInTheDocument();
|
|
});
|
|
|
|
// "Places" tab doesn't exist; the mobile portal "Places" button has no title
|
|
const mobilePlacesBtn = Array.from(document.body.querySelectorAll('button')).find(
|
|
b => b.textContent === 'Places' && !b.getAttribute('title'),
|
|
);
|
|
|
|
if (mobilePlacesBtn) {
|
|
await act(async () => { fireEvent.click(mobilePlacesBtn); });
|
|
|
|
// PlacesSidebar renders in mobile sidebar portal
|
|
await waitFor(() => {
|
|
expect(screen.getAllByTestId('places-sidebar').length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
}
|
|
|
|
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1024 });
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-051: Mobile Plan sidebar stays mounted after onPlaceClick (issue #932)', () => {
|
|
it('does not unmount the mobile Plan portal when a place is tapped, preserving scroll position', async () => {
|
|
vi.useFakeTimers();
|
|
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 375 });
|
|
|
|
const place = buildPlace({ id: 1, trip_id: 42, lat: 48.8566, lng: 2.3522 });
|
|
const assignment = buildAssignment({ id: 10, day_id: 99, place, order_index: 0 });
|
|
seedTripStore({ id: 42 });
|
|
seedStore(useTripStore, {
|
|
places: [place],
|
|
assignments: { '99': [assignment] },
|
|
} as any);
|
|
|
|
renderPlannerPage(42);
|
|
act(() => { vi.runAllTimers(); });
|
|
vi.useRealTimers();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
|
|
});
|
|
|
|
// Open the mobile Plan portal via the bottom-nav Plan button (selector mirrors FE-PAGE-PLANNER-049).
|
|
const mobilePlanBtn = Array.from(document.body.querySelectorAll('button')).find(
|
|
b => b.textContent === 'Plan' && !b.getAttribute('title'),
|
|
);
|
|
expect(mobilePlanBtn).toBeTruthy();
|
|
await act(async () => { fireEvent.click(mobilePlanBtn!); });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getAllByTestId('day-plan-sidebar').length).toBe(2);
|
|
});
|
|
|
|
// The mock factory overwrites capturedDayPlanSidebarProps on each mount,
|
|
// so current holds the mobile portal instance's props.
|
|
const mobileOnPlaceClick = capturedDayPlanSidebarProps.current.onPlaceClick;
|
|
expect(typeof mobileOnPlaceClick).toBe('function');
|
|
|
|
await act(async () => {
|
|
mobileOnPlaceClick(place.id, assignment.id);
|
|
});
|
|
|
|
// Invariant: portal must NOT unmount — both instances persist.
|
|
// Pre-fix: collapses to 1 (setMobileSidebarOpen(null) destroyed scroll container).
|
|
// Post-fix: stays at 2, browser preserves scrollTop on the living DOM node.
|
|
expect(screen.getAllByTestId('day-plan-sidebar').length).toBe(2);
|
|
|
|
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1024 });
|
|
});
|
|
});
|
|
|
|
describe('FE-PAGE-PLANNER-037: onExpandedDaysChange covers mapPlaces hidden logic', () => {
|
|
it('calls onExpandedDaysChange to trigger mapPlaces hidden set computation', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const { day } = seedTripStore({ id: 42 });
|
|
const place = buildPlace({ id: 1, trip_id: 42, lat: 48.8566, lng: 2.3522 });
|
|
const assignment = buildAssignment({ id: 10, day_id: day.id, place, order_index: 0 });
|
|
seedStore(useTripStore, {
|
|
places: [place],
|
|
assignments: { [String(day.id)]: [assignment] },
|
|
} as any);
|
|
|
|
renderPlannerPage(42);
|
|
|
|
act(() => { vi.runAllTimers(); });
|
|
|
|
vi.useRealTimers();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
|
|
});
|
|
|
|
// Set expandedDayIds — some day not in the set → place is hidden in mapPlaces
|
|
await act(async () => {
|
|
capturedDayPlanSidebarProps.current.onExpandedDaysChange?.(new Set([999]));
|
|
});
|
|
|
|
// Then include the actual day → place is un-hidden
|
|
await act(async () => {
|
|
capturedDayPlanSidebarProps.current.onExpandedDaysChange?.(new Set([day.id]));
|
|
});
|
|
});
|
|
});
|
|
});
|