mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
10a987fec1
A hotel booking reaches its place through the stay, not its own place_id, so the card listed the booking but not its confirmation. Links to a stop, which MCP's link_trip_file and plugins can make, were never returned by the file list at all; it now carries linked_assignment_ids. Without files the row no longer opens to nothing.
1804 lines
91 KiB
TypeScript
1804 lines
91 KiB
TypeScript
import { render, screen, waitFor, fireEvent, act, within } from '../../../tests/helpers/render';
|
||
import userEvent from '@testing-library/user-event';
|
||
import { buildUser, buildTrip, buildPlace, buildCategory, buildReservation } from '../../../tests/helpers/factories';
|
||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||
import { useAuthStore } from '../../store/authStore';
|
||
import { useTripStore } from '../../store/tripStore';
|
||
import { useSettingsStore } from '../../store/settingsStore';
|
||
import { useAddonStore } from '../../store/addonStore';
|
||
import { usePluginStore } from '../../store/pluginStore';
|
||
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore';
|
||
import { usePermissionsStore } from '../../store/permissionsStore';
|
||
import { http, HttpResponse } from 'msw';
|
||
import { server } from '../../../tests/helpers/msw/server';
|
||
import type { AssignmentsMap, Place, Reservation } from '../../types';
|
||
import { isBlurred } from '../../../tests/helpers/bookingCodeBlur';
|
||
|
||
// ── Module mocks ──────────────────────────────────────────────────────────────
|
||
|
||
vi.mock('../../api/client', async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import('../../api/client')>();
|
||
return {
|
||
...actual,
|
||
mapsApi: {
|
||
details: vi.fn().mockResolvedValue({ place: null }),
|
||
placePhotoCredit: vi.fn().mockResolvedValue({ credit: null }),
|
||
},
|
||
};
|
||
});
|
||
|
||
vi.mock('../../api/authUrl', () => ({
|
||
getAuthUrl: vi.fn().mockResolvedValue('http://test/file'),
|
||
}));
|
||
|
||
vi.mock('../../services/photoService', () => ({
|
||
getCached: vi.fn(() => null),
|
||
isLoading: vi.fn(() => false),
|
||
fetchPhoto: vi.fn(),
|
||
onThumbReady: vi.fn(() => () => {}),
|
||
}));
|
||
|
||
// ── IntersectionObserver stub ─────────────────────────────────────────────────
|
||
|
||
class MockIO {
|
||
observe = vi.fn();
|
||
disconnect = vi.fn();
|
||
unobserve = vi.fn();
|
||
}
|
||
|
||
beforeAll(() => {
|
||
(globalThis as any).IntersectionObserver = MockIO;
|
||
});
|
||
|
||
// ── Import component after mocks ──────────────────────────────────────────────
|
||
|
||
import PlaceInspector from './PlaceInspector';
|
||
import { mapsApi } from '../../api/client';
|
||
|
||
// ── Shared fixtures ───────────────────────────────────────────────────────────
|
||
|
||
const place = buildPlace({
|
||
id: 1,
|
||
name: 'Eiffel Tower',
|
||
address: 'Champ de Mars, Paris',
|
||
lat: 48.8584,
|
||
lng: 2.2945,
|
||
description: 'Famous iron tower',
|
||
});
|
||
|
||
const cat = buildCategory({ name: 'Landmark', icon: 'MapPin' });
|
||
|
||
const defaultProps = {
|
||
place,
|
||
categories: [cat],
|
||
days: [],
|
||
selectedDayId: null as number | null,
|
||
selectedAssignmentId: null as number | null,
|
||
assignments: {} as Record<string, any[]>,
|
||
reservations: [] as any[],
|
||
onClose: vi.fn(),
|
||
onEdit: vi.fn(),
|
||
onDelete: vi.fn(),
|
||
onAssignToDay: vi.fn(),
|
||
onRemoveAssignment: vi.fn(),
|
||
files: [] as any[],
|
||
onFileUpload: vi.fn().mockResolvedValue(undefined),
|
||
tripMembers: [] as any[],
|
||
onSetParticipants: vi.fn(),
|
||
onUpdatePlace: vi.fn(),
|
||
};
|
||
|
||
// ── Setup / teardown ──────────────────────────────────────────────────────────
|
||
|
||
beforeEach(() => {
|
||
resetAllStores();
|
||
vi.clearAllMocks();
|
||
sessionStorage.clear();
|
||
|
||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
|
||
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius' } });
|
||
|
||
vi.mocked(mapsApi.details).mockResolvedValue({ place: null });
|
||
});
|
||
|
||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||
|
||
describe('PlaceInspector', () => {
|
||
|
||
// ── Rendering ──────────────────────────────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-001: returns null when place is null', () => {
|
||
const { container } = render(<PlaceInspector {...defaultProps} place={null} />);
|
||
expect(container.firstChild).toBeNull();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-002: renders without crashing with a valid place', () => {
|
||
render(<PlaceInspector {...defaultProps} />);
|
||
expect(document.body).toBeTruthy();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-003: shows place name in header', () => {
|
||
render(<PlaceInspector {...defaultProps} />);
|
||
expect(screen.getByText('Eiffel Tower')).toBeTruthy();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-004: shows place address', () => {
|
||
render(<PlaceInspector {...defaultProps} />);
|
||
expect(screen.getByText(/Champ de Mars, Paris/)).toBeTruthy();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-005: shows category badge with category name', () => {
|
||
const placeWithCat = buildPlace({ id: 100, category_id: cat.id });
|
||
render(<PlaceInspector {...defaultProps} place={placeWithCat} categories={[cat]} />);
|
||
const matches = screen.getAllByText('Landmark');
|
||
expect(matches.length).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-006: shows lat/lng coordinates', () => {
|
||
render(<PlaceInspector {...defaultProps} />);
|
||
// The component renders Number(lat).toFixed(6), Number(lng).toFixed(6)
|
||
expect(screen.getByText(/48\.858400/)).toBeTruthy();
|
||
expect(screen.getByText(/2\.294500/)).toBeTruthy();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-007: shows time range when place_time and end_time are set', () => {
|
||
const p = buildPlace({ id: 101, place_time: '09:00', end_time: '17:00' });
|
||
render(<PlaceInspector {...defaultProps} place={p} />);
|
||
expect(screen.getByText(/09:00/)).toBeTruthy();
|
||
expect(screen.getByText(/17:00/)).toBeTruthy();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-008: shows only start time when no end_time', () => {
|
||
const p = buildPlace({ id: 102, place_time: '09:00', end_time: null });
|
||
render(<PlaceInspector {...defaultProps} place={p} />);
|
||
expect(screen.getByText(/09:00/)).toBeTruthy();
|
||
// The '–' separator should not be present
|
||
expect(screen.queryByText(/–/)).toBeNull();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-009: description is rendered as markdown', () => {
|
||
const p = buildPlace({ id: 103, description: '**Bold text**' });
|
||
const { container } = render(<PlaceInspector {...defaultProps} place={p} />);
|
||
const strong = container.querySelector('strong');
|
||
expect(strong).toBeTruthy();
|
||
expect(strong?.textContent).toBe('Bold text');
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-010: notes rendered when no description', () => {
|
||
const p = buildPlace({ id: 104, description: null, notes: 'Some notes' } as any);
|
||
render(<PlaceInspector {...defaultProps} place={p} />);
|
||
expect(screen.getByText(/Some notes/)).toBeTruthy();
|
||
});
|
||
|
||
// ── Close button ───────────────────────────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-011: close (X) button calls onClose', async () => {
|
||
const user = userEvent.setup();
|
||
const onClose = vi.fn();
|
||
render(<PlaceInspector {...defaultProps} onClose={onClose} />);
|
||
// The round X on the head band is named by its tooltip.
|
||
await user.click(screen.getByRole('button', { name: 'Close' }));
|
||
expect(onClose).toHaveBeenCalled();
|
||
});
|
||
|
||
// ── Edit / Delete buttons ──────────────────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-012: Edit button is visible', () => {
|
||
render(<PlaceInspector {...defaultProps} />);
|
||
// Edit button is in footer actions
|
||
const buttons = screen.getAllByRole('button');
|
||
expect(buttons.length).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-013: clicking Edit button calls onEdit', async () => {
|
||
const user = userEvent.setup();
|
||
const onEdit = vi.fn();
|
||
render(<PlaceInspector {...defaultProps} onEdit={onEdit} />);
|
||
// Edit is the primary action at the right end of the footer.
|
||
const allButtons = screen.getAllByRole('button');
|
||
const editBtn = screen.getByRole('button', { name: 'Edit' });
|
||
expect(allButtons[allButtons.length - 1]).toBe(editBtn);
|
||
await user.click(editBtn);
|
||
expect(onEdit).toHaveBeenCalled();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-014: clicking Delete button calls onDelete', async () => {
|
||
const user = userEvent.setup();
|
||
const onDelete = vi.fn();
|
||
render(<PlaceInspector {...defaultProps} onDelete={onDelete} />);
|
||
const allButtons = screen.getAllByRole('button');
|
||
// The square delete button sits just before Edit, named by its tooltip.
|
||
const deleteBtn = screen.getByRole('button', { name: 'Delete' });
|
||
expect(allButtons[allButtons.length - 2]).toBe(deleteBtn);
|
||
await user.click(deleteBtn);
|
||
expect(onDelete).toHaveBeenCalled();
|
||
});
|
||
|
||
// ── Assign to / remove from day ────────────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-015: "Add to day" button appears when selectedDayId is set and place NOT in that day', () => {
|
||
render(<PlaceInspector {...defaultProps} selectedDayId={1} assignments={{ '1': [] }} />);
|
||
const allButtons = screen.getAllByRole('button');
|
||
// The add-to-day button is the first footer button (Plus icon)
|
||
// It should exist when selectedDayId is set and place is not assigned
|
||
expect(allButtons.length).toBeGreaterThan(2);
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-016: clicking assign-to-day button calls onAssignToDay with placeId', async () => {
|
||
const user = userEvent.setup();
|
||
const onAssignToDay = vi.fn();
|
||
render(
|
||
<PlaceInspector
|
||
{...defaultProps}
|
||
selectedDayId={1}
|
||
assignments={{ '1': [] }}
|
||
onAssignToDay={onAssignToDay}
|
||
/>
|
||
);
|
||
const addBtn = screen.getByText('Add to Day').closest('button')!;
|
||
await user.click(addBtn);
|
||
expect(onAssignToDay).toHaveBeenCalledWith(place.id);
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-017: "Remove from day" button appears when place IS assigned to selectedDay', () => {
|
||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||
render(
|
||
<PlaceInspector
|
||
{...defaultProps}
|
||
selectedDayId={1}
|
||
assignments={{ '1': assignmentInDay }}
|
||
/>
|
||
);
|
||
const allButtons = screen.getAllByRole('button');
|
||
expect(allButtons.length).toBeGreaterThan(2);
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-018: clicking remove calls onRemoveAssignment with dayId and assignmentId', async () => {
|
||
const user = userEvent.setup();
|
||
const onRemoveAssignment = vi.fn();
|
||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||
render(
|
||
<PlaceInspector
|
||
{...defaultProps}
|
||
selectedDayId={1}
|
||
assignments={{ '1': assignmentInDay }}
|
||
onRemoveAssignment={onRemoveAssignment}
|
||
/>
|
||
);
|
||
// Find the remove button — it carries the "Remove from Day" label
|
||
const removeBtn = screen.getByText('Remove from Day').closest('button')!;
|
||
await user.click(removeBtn);
|
||
// Component calls onRemoveAssignment(selectedDayId, assignmentInDay.id)
|
||
expect(onRemoveAssignment).toHaveBeenCalledWith(1, 99);
|
||
});
|
||
|
||
// ── Inline name editing ────────────────────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-019: double-clicking name enters edit mode', async () => {
|
||
const user = userEvent.setup();
|
||
render(<PlaceInspector {...defaultProps} />);
|
||
const nameSpan = screen.getByText('Eiffel Tower');
|
||
await user.dblClick(nameSpan);
|
||
const input = screen.getByDisplayValue('Eiffel Tower');
|
||
expect(input).toBeTruthy();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-020: pressing Enter commits edit and calls onUpdatePlace', async () => {
|
||
const user = userEvent.setup();
|
||
const onUpdatePlace = vi.fn();
|
||
render(<PlaceInspector {...defaultProps} onUpdatePlace={onUpdatePlace} />);
|
||
const nameSpan = screen.getByText('Eiffel Tower');
|
||
await user.dblClick(nameSpan);
|
||
const input = screen.getByDisplayValue('Eiffel Tower');
|
||
await user.clear(input);
|
||
await user.type(input, 'New Tower Name');
|
||
await user.keyboard('{Enter}');
|
||
expect(onUpdatePlace).toHaveBeenCalledWith(place.id, { name: 'New Tower Name' });
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-021: pressing Escape cancels edit', async () => {
|
||
const user = userEvent.setup();
|
||
render(<PlaceInspector {...defaultProps} />);
|
||
const nameSpan = screen.getByText('Eiffel Tower');
|
||
await user.dblClick(nameSpan);
|
||
expect(screen.getByDisplayValue('Eiffel Tower')).toBeTruthy();
|
||
await user.keyboard('{Escape}');
|
||
expect(screen.queryByDisplayValue('Eiffel Tower')).toBeNull();
|
||
expect(screen.getByText('Eiffel Tower')).toBeTruthy();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-022: blank name does not call onUpdatePlace', async () => {
|
||
const user = userEvent.setup();
|
||
const onUpdatePlace = vi.fn();
|
||
render(<PlaceInspector {...defaultProps} onUpdatePlace={onUpdatePlace} />);
|
||
const nameSpan = screen.getByText('Eiffel Tower');
|
||
await user.dblClick(nameSpan);
|
||
const input = screen.getByDisplayValue('Eiffel Tower');
|
||
await user.clear(input);
|
||
await user.keyboard('{Enter}');
|
||
expect(onUpdatePlace).not.toHaveBeenCalled();
|
||
});
|
||
|
||
// ── Google Maps details (mapsApi) ──────────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-023: mapsApi.details called when place has google_place_id', async () => {
|
||
const p = buildPlace({ id: 200, google_place_id: 'ChIJ001' });
|
||
render(<PlaceInspector {...defaultProps} place={p} />);
|
||
await waitFor(() => {
|
||
expect(vi.mocked(mapsApi.details)).toHaveBeenCalledWith('ChIJ001', expect.any(String));
|
||
});
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-024: rating chip shown when googleDetails has rating', async () => {
|
||
vi.mocked(mapsApi.details).mockResolvedValue({
|
||
place: { rating: 4.5, rating_count: 1200 },
|
||
} as any);
|
||
const p = buildPlace({ id: 201, google_place_id: 'ChIJ002' });
|
||
render(<PlaceInspector {...defaultProps} place={p} />);
|
||
expect(await screen.findByText(/4\.5/)).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-025: opening hours shown when available', async () => {
|
||
vi.mocked(mapsApi.details).mockResolvedValue({
|
||
place: { opening_hours: ['Mon: 9:00 AM – 5:00 PM', 'Tue: 9:00 AM – 5:00 PM'] },
|
||
} as any);
|
||
const user = userEvent.setup();
|
||
const p = buildPlace({ id: 202, google_place_id: 'ChIJ003' });
|
||
render(<PlaceInspector {...defaultProps} place={p} />);
|
||
// Wait for hours to load: the section is labelled, its toggle shows a day's hours line
|
||
expect(await screen.findByText('Opening Hours')).toBeTruthy();
|
||
const btn = screen.getByRole('button', { name: /Show opening hours|Mon:|Tue:/i });
|
||
expect(btn).toHaveAttribute('aria-expanded', 'false');
|
||
await user.click(btn);
|
||
// After expand, the whole week is listed and the toggle folds it again
|
||
await waitFor(() => {
|
||
expect(screen.getByText(/Mon:/)).toBeTruthy();
|
||
expect(screen.getByText(/Tue:/)).toBeTruthy();
|
||
});
|
||
await user.click(screen.getByRole('button', { name: 'Collapse' }));
|
||
expect(screen.getByRole('button', { name: /Show opening hours|Mon:|Tue:/i })).toHaveAttribute('aria-expanded', 'false');
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-026: open/closed badge shown when open_now is available', async () => {
|
||
vi.mocked(mapsApi.details).mockResolvedValue({
|
||
place: { open_now: true },
|
||
} as any);
|
||
const p = buildPlace({ id: 203, google_place_id: 'ChIJ004' });
|
||
render(<PlaceInspector {...defaultProps} place={p} />);
|
||
expect(await screen.findByText(/open/i)).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-027: mapsApi.details NOT called when place has no google_place_id or osm_id', async () => {
|
||
const p = buildPlace({ id: 204, google_place_id: null, osm_id: null });
|
||
render(<PlaceInspector {...defaultProps} place={p} />);
|
||
// Wait a tick
|
||
await act(async () => { await new Promise(r => setTimeout(r, 50)) });
|
||
expect(vi.mocked(mapsApi.details)).not.toHaveBeenCalled();
|
||
});
|
||
|
||
// ── Files ──────────────────────────────────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-028: files section shows file names after expanding', async () => {
|
||
const user = userEvent.setup();
|
||
const file = {
|
||
id: 1,
|
||
trip_id: 1,
|
||
place_id: place.id,
|
||
original_name: 'photo.jpg',
|
||
url: '/uploads/photo.jpg',
|
||
filename: 'photo.jpg',
|
||
mime_type: 'image/jpeg',
|
||
file_size: 1024,
|
||
created_at: '2025-01-01T00:00:00.000Z',
|
||
};
|
||
render(<PlaceInspector {...defaultProps} files={[file as any]} />);
|
||
// The files section header/toggle is always visible; click to expand
|
||
const allButtons = screen.getAllByRole('button');
|
||
const filesBtn = allButtons.find(btn => btn.textContent?.includes('1'));
|
||
// Click the expand button (file count label button)
|
||
if (filesBtn) {
|
||
await user.click(filesBtn);
|
||
expect(await screen.findByText('photo.jpg')).toBeInTheDocument();
|
||
} else {
|
||
// Try clicking the last non-footer button
|
||
const toggleButtons = allButtons.filter(btn => !btn.closest('footer'));
|
||
await user.click(toggleButtons[0]);
|
||
}
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-029: hidden file input is present when onFileUpload provided', () => {
|
||
const { container } = render(<PlaceInspector {...defaultProps} />);
|
||
const fileInput = container.querySelector('input[type="file"]');
|
||
expect(fileInput).toBeTruthy();
|
||
});
|
||
|
||
// ── Reservation chip ───────────────────────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-030: linked reservation shown when selectedAssignmentId has a reservation', () => {
|
||
const reservation = buildReservation({ title: 'Museum Ticket', status: 'confirmed', assignment_id: 99 } as any);
|
||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||
render(
|
||
<PlaceInspector
|
||
{...defaultProps}
|
||
selectedDayId={1}
|
||
selectedAssignmentId={99}
|
||
assignments={{ '1': assignmentInDay }}
|
||
reservations={[reservation]}
|
||
/>
|
||
);
|
||
expect(screen.getByText('Museum Ticket')).toBeTruthy();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-030h: a hotel shows the stay booked for it and opens that booking (#2363)', () => {
|
||
const onOpenBooking = vi.fn();
|
||
const stay = buildReservation({
|
||
id: 540, title: 'Hotel Adlon, 3 nights', status: 'confirmed', type: 'hotel', assignment_id: null,
|
||
accommodation_place_id: place.id,
|
||
} as any);
|
||
const elsewhere = buildReservation({ id: 541, title: 'Other hotel', type: 'hotel', accommodation_place_id: place.id + 1 } as any);
|
||
render(<PlaceInspector {...defaultProps} reservations={[stay, elsewhere]} onOpenBooking={onOpenBooking} />);
|
||
|
||
expect(screen.queryByText('Other hotel')).toBeNull();
|
||
fireEvent.click(screen.getByText('Hotel Adlon, 3 nights').closest('[role="button"]') as HTMLElement);
|
||
expect(onOpenBooking).toHaveBeenCalledWith(stay);
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-030g: every booking on the stop gets its own strip (#2201)', () => {
|
||
const onEditReservation = vi.fn();
|
||
const parking = buildReservation({
|
||
id: 530, title: 'Parking pass', status: 'confirmed', type: 'parking', assignment_id: 99,
|
||
reservation_time: '2025-06-01T09:00:00',
|
||
} as any);
|
||
const tickets = buildReservation({
|
||
id: 531, title: 'Zoo tickets', status: 'pending', type: 'activity', assignment_id: 99,
|
||
reservation_time: '2025-06-01T10:15:00',
|
||
} as any);
|
||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||
render(
|
||
<PlaceInspector
|
||
{...defaultProps}
|
||
selectedDayId={1}
|
||
selectedAssignmentId={99}
|
||
assignments={{ '1': assignmentInDay }}
|
||
reservations={[tickets, parking]}
|
||
onEditReservation={onEditReservation}
|
||
/>
|
||
);
|
||
expect(screen.getByText('Parking pass')).toBeTruthy();
|
||
expect(screen.getByText('Zoo tickets')).toBeTruthy();
|
||
fireEvent.click(screen.getByText('Zoo tickets').closest('[role="button"]') as HTMLElement);
|
||
expect(onEditReservation).toHaveBeenCalledWith(tickets);
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-030b: the linked reservation opens its editor (#2012)', () => {
|
||
const onEditReservation = vi.fn();
|
||
const reservation = buildReservation({ title: 'Museum Ticket', status: 'confirmed', assignment_id: 99 } as any);
|
||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||
render(
|
||
<PlaceInspector
|
||
{...defaultProps}
|
||
selectedDayId={1}
|
||
selectedAssignmentId={99}
|
||
assignments={{ '1': assignmentInDay }}
|
||
reservations={[reservation]}
|
||
onEditReservation={onEditReservation}
|
||
/>
|
||
);
|
||
const strip = screen.getByText('Museum Ticket').closest('[role="button"]') as HTMLElement;
|
||
expect(strip).toBeTruthy();
|
||
fireEvent.click(strip);
|
||
expect(onEditReservation).toHaveBeenCalledWith(reservation);
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-030e: a transport booking goes to the transport editor (#2012)', () => {
|
||
const onEditTransport = vi.fn();
|
||
const onEditReservation = vi.fn();
|
||
// A ferry is a transport, and ReservationModal has no transport type to hold it.
|
||
const reservation = buildReservation({ title: 'Ferry to Corfu', status: 'pending', type: 'ferry', assignment_id: 99 } as any);
|
||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||
render(
|
||
<PlaceInspector
|
||
{...defaultProps}
|
||
selectedDayId={1}
|
||
selectedAssignmentId={99}
|
||
assignments={{ '1': assignmentInDay }}
|
||
reservations={[reservation]}
|
||
onEditTransport={onEditTransport}
|
||
onEditReservation={onEditReservation}
|
||
/>
|
||
);
|
||
fireEvent.click(screen.getByText('Ferry to Corfu').closest('[role="button"]') as HTMLElement);
|
||
expect(onEditTransport).toHaveBeenCalledWith(reservation);
|
||
expect(onEditReservation).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-030f: no transport handler means no affordance on a transport (#2012)', () => {
|
||
// A member who may edit bookings but not days must not get a button that no-ops.
|
||
const reservation = buildReservation({ title: 'Ferry to Corfu', status: 'pending', type: 'ferry', assignment_id: 99 } as any);
|
||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||
render(
|
||
<PlaceInspector
|
||
{...defaultProps}
|
||
selectedDayId={1}
|
||
selectedAssignmentId={99}
|
||
assignments={{ '1': assignmentInDay }}
|
||
reservations={[reservation]}
|
||
onEditReservation={vi.fn()}
|
||
/>
|
||
);
|
||
expect(screen.getByText('Ferry to Corfu').closest('[role="button"]')).toBeNull();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-030c: Enter and Space open it too (#2012)', () => {
|
||
const onEditReservation = vi.fn();
|
||
const reservation = buildReservation({ title: 'Museum Ticket', status: 'confirmed', assignment_id: 99 } as any);
|
||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||
render(
|
||
<PlaceInspector
|
||
{...defaultProps}
|
||
selectedDayId={1}
|
||
selectedAssignmentId={99}
|
||
assignments={{ '1': assignmentInDay }}
|
||
reservations={[reservation]}
|
||
onEditReservation={onEditReservation}
|
||
/>
|
||
);
|
||
const strip = screen.getByText('Museum Ticket').closest('[role="button"]') as HTMLElement;
|
||
expect(strip.getAttribute('tabindex')).toBe('0');
|
||
fireEvent.keyDown(strip, { key: 'Enter' });
|
||
fireEvent.keyDown(strip, { key: ' ' });
|
||
expect(onEditReservation).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-030d: without a handler the strip stays a read-only summary', () => {
|
||
const reservation = buildReservation({ title: 'Museum Ticket', status: 'confirmed', assignment_id: 99 } as any);
|
||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||
render(
|
||
<PlaceInspector
|
||
{...defaultProps}
|
||
selectedDayId={1}
|
||
selectedAssignmentId={99}
|
||
assignments={{ '1': assignmentInDay }}
|
||
reservations={[reservation]}
|
||
/>
|
||
);
|
||
// A viewer with no edit right gets no target, and no misleading pointer.
|
||
expect(screen.getByText('Museum Ticket').closest('[role="button"]')).toBeNull();
|
||
expect(screen.getByText('Museum Ticket')).toBeTruthy();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-109: with a detail to show, the booking card opens it instead of the editor', async () => {
|
||
const onOpenBooking = vi.fn();
|
||
const onEditTransport = vi.fn();
|
||
const onEditReservation = vi.fn();
|
||
const ferry = buildReservation({ title: 'Ferry to Corfu', status: 'pending', type: 'ferry', assignment_id: 99 } as Partial<Reservation>);
|
||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||
render(
|
||
<PlaceInspector
|
||
{...defaultProps}
|
||
selectedDayId={1}
|
||
selectedAssignmentId={99}
|
||
assignments={{ '1': assignmentInDay }}
|
||
reservations={[ferry]}
|
||
onEditTransport={onEditTransport}
|
||
onEditReservation={onEditReservation}
|
||
onOpenBooking={onOpenBooking}
|
||
/>
|
||
);
|
||
// The card is the booking, so it says it opens it rather than edits it.
|
||
expect(screen.queryByRole('button', { name: 'Edit Reservation' })).toBeNull();
|
||
await userEvent.click(screen.getByRole('button', { name: 'Open booking: Ferry to Corfu' }));
|
||
expect(onOpenBooking).toHaveBeenCalledWith(ferry);
|
||
expect(onEditTransport).not.toHaveBeenCalled();
|
||
expect(onEditReservation).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-110: with a detail to show, a viewer without an editor can still open the card, by keyboard too', () => {
|
||
const onOpenBooking = vi.fn();
|
||
const ticket = buildReservation({ title: 'Museum Ticket', status: 'confirmed', assignment_id: 99 } as Partial<Reservation>);
|
||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||
render(
|
||
<PlaceInspector
|
||
{...defaultProps}
|
||
selectedDayId={1}
|
||
selectedAssignmentId={99}
|
||
assignments={{ '1': assignmentInDay }}
|
||
reservations={[ticket]}
|
||
onOpenBooking={onOpenBooking}
|
||
/>
|
||
);
|
||
const card = screen.getByRole('button', { name: 'Open booking: Museum Ticket' });
|
||
fireEvent.keyDown(card, { key: 'Enter' });
|
||
fireEvent.keyDown(card, { key: ' ' });
|
||
expect(onOpenBooking).toHaveBeenCalledTimes(2);
|
||
expect(onOpenBooking).toHaveBeenCalledWith(ticket);
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-111: several bookings on one stop are told apart by name', async () => {
|
||
const onOpenBooking = vi.fn();
|
||
const lunch = buildReservation({ id: 61, title: 'Lunch at Nishiki', status: 'confirmed', type: 'restaurant', assignment_id: 99 } as Partial<Reservation>);
|
||
const tour = buildReservation({ id: 62, title: 'Market Tour', status: 'pending', type: 'tour', assignment_id: 99 } as Partial<Reservation>);
|
||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||
render(
|
||
<PlaceInspector
|
||
{...defaultProps}
|
||
selectedDayId={1}
|
||
selectedAssignmentId={99}
|
||
assignments={{ '1': assignmentInDay }}
|
||
reservations={[lunch, tour]}
|
||
onOpenBooking={onOpenBooking}
|
||
/>
|
||
);
|
||
|
||
await userEvent.click(screen.getByRole('button', { name: 'Open booking: Market Tour' }));
|
||
expect(onOpenBooking).toHaveBeenCalledWith(tour);
|
||
expect(screen.getByRole('button', { name: 'Open booking: Lunch at Nishiki' })).toBeInTheDocument();
|
||
});
|
||
|
||
// ── Participants ───────────────────────────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-031: participants section shown when tripMembers > 1 and selectedAssignmentId is set', () => {
|
||
const members = [buildUser({ id: 1 }), buildUser({ id: 2 })];
|
||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||
render(
|
||
<PlaceInspector
|
||
{...defaultProps}
|
||
tripMembers={members}
|
||
selectedDayId={1}
|
||
selectedAssignmentId={99}
|
||
assignments={{ '1': assignmentInDay }}
|
||
/>
|
||
);
|
||
// The participants section renders with a "participants" label
|
||
// It's visible when tripMembers.length > 1 && selectedAssignmentId is set
|
||
expect(screen.getByText(members[0].username)).toBeTruthy();
|
||
});
|
||
|
||
// ── Price chip ─────────────────────────────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-032: price chip shown when place.price > 0', () => {
|
||
const p = buildPlace({ id: 300, price: 15, currency: 'EUR' } as any);
|
||
render(<PlaceInspector {...defaultProps} place={p} />);
|
||
// formatMoney renders in the currency's home convention (de-DE for EUR).
|
||
expect(screen.getByText(/15,00/)).toBeTruthy();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-032b: price chip formats in the place currency with a neutral icon (#1561)', () => {
|
||
const p = buildPlace({ id: 300, price: 15, currency: 'USD' } as any);
|
||
render(<PlaceInspector {...defaultProps} place={p} />);
|
||
expect(screen.getByText('$15.00')).toBeTruthy();
|
||
// The chip icon must be currency-neutral, not the euro glyph.
|
||
expect(document.querySelector('.lucide-euro')).toBeNull();
|
||
expect(document.querySelector('.lucide-banknote')).not.toBeNull();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-032c: a currency-less price falls back to the trip currency (#1561)', () => {
|
||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'NOK' }) } as any);
|
||
const p = buildPlace({ id: 300, price: 250, currency: null } as any);
|
||
render(<PlaceInspector {...defaultProps} place={p} />);
|
||
expect(screen.getByText(/250,00\s?kr|kr\s?250,00/)).toBeTruthy();
|
||
});
|
||
|
||
// ── Phone number ───────────────────────────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-033: phone number shown when place has phone', () => {
|
||
const p = buildPlace({ id: 301, phone: '+33 1 23 45 67 89' } as any);
|
||
render(<PlaceInspector {...defaultProps} place={p} />);
|
||
expect(screen.getByText(/\+33 1 23 45 67 89/)).toBeTruthy();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-2472: the own e-mail and hours show, the hours ahead of looked-up ones', async () => {
|
||
vi.useFakeTimers({ toFake: ['Date'] });
|
||
try {
|
||
vi.setSystemTime(new Date('2026-06-02T12:00:00Z'));
|
||
const user = userEvent.setup();
|
||
const week = JSON.stringify(Array.from({ length: 7 }, (_, i) => (i === 6 ? { closed: true } : { closed: false, open: '08:00', close: '12:00' })));
|
||
const placeWithHours = { id: 302, email: 'hi@bakery.test', opening_hours: week };
|
||
const p = buildPlace(placeWithHours);
|
||
render(<PlaceInspector {...defaultProps} place={p} />);
|
||
expect(screen.getByRole('link', { name: /hi@bakery\.test/ })).toHaveAttribute('href', 'mailto:hi@bakery.test');
|
||
expect(screen.getByText('Opening Hours')).toBeInTheDocument();
|
||
await user.click(screen.getByRole('button', { expanded: false, name: /08:00|Show/ }));
|
||
expect(screen.getByText(/Sunday: Closed/)).toBeInTheDocument();
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
// ── File size display ──────────────────────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-034: file size displayed in KB for files < 1MB', async () => {
|
||
const user = userEvent.setup();
|
||
const file = {
|
||
id: 2,
|
||
trip_id: 1,
|
||
place_id: place.id,
|
||
original_name: 'doc.pdf',
|
||
url: '/uploads/doc.pdf',
|
||
filename: 'doc.pdf',
|
||
mime_type: 'application/pdf',
|
||
file_size: 2048,
|
||
created_at: '2025-01-01T00:00:00.000Z',
|
||
};
|
||
render(<PlaceInspector {...defaultProps} files={[file as any]} />);
|
||
// Click expand to see file details
|
||
const expandBtn = screen.getAllByRole('button').find(b => b.textContent?.includes('1'));
|
||
if (expandBtn) {
|
||
await user.click(expandBtn);
|
||
await waitFor(() => {
|
||
expect(screen.getByText(/2\.0 KB/)).toBeTruthy();
|
||
});
|
||
}
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-035: file size displayed in MB for files >= 1MB', async () => {
|
||
const user = userEvent.setup();
|
||
const file = {
|
||
id: 3,
|
||
trip_id: 1,
|
||
place_id: place.id,
|
||
original_name: 'video.mp4',
|
||
url: '/uploads/video.mp4',
|
||
filename: 'video.mp4',
|
||
mime_type: 'video/mp4',
|
||
file_size: 2 * 1024 * 1024,
|
||
created_at: '2025-01-01T00:00:00.000Z',
|
||
};
|
||
render(<PlaceInspector {...defaultProps} files={[file as any]} />);
|
||
const expandBtn = screen.getAllByRole('button').find(b => b.textContent?.includes('1'));
|
||
if (expandBtn) {
|
||
await user.click(expandBtn);
|
||
await waitFor(() => {
|
||
expect(screen.getByText(/2\.0 MB/)).toBeTruthy();
|
||
});
|
||
}
|
||
});
|
||
|
||
// ── GPX track stats ────────────────────────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-036: GPX track stats shown when route_geometry has 2D points', () => {
|
||
const pts = [[48.8584, 2.2945], [48.8600, 2.3000], [48.8620, 2.3050]];
|
||
const p = buildPlace({ id: 302, route_geometry: JSON.stringify(pts) } as any);
|
||
render(<PlaceInspector {...defaultProps} place={p} />);
|
||
// Track distance should be visible (e.g. "x.x km" or "xxx m")
|
||
const { container } = render(<PlaceInspector {...defaultProps} place={p} />);
|
||
expect(container.querySelector('svg')).toBeTruthy();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-037: GPX track stats shown with 3D points (elevation data)', () => {
|
||
const pts = [
|
||
[48.8584, 2.2945, 100],
|
||
[48.8600, 2.3000, 120],
|
||
[48.8620, 2.3050, 110],
|
||
[48.8640, 2.3100, 130],
|
||
];
|
||
const p = buildPlace({ id: 303, route_geometry: JSON.stringify(pts) } as any);
|
||
const { container } = render(<PlaceInspector {...defaultProps} place={p} />);
|
||
// Elevation stats should show max elevation 130m
|
||
expect(screen.getByText(/130 m/)).toBeTruthy();
|
||
});
|
||
|
||
// ── ParticipantsBox interactions ───────────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-038: participants list shows member names', () => {
|
||
const member1 = buildUser({ id: 10, username: 'alice' });
|
||
const member2 = buildUser({ id: 11, username: 'bob' });
|
||
const members = [member1, member2];
|
||
const assignmentInDay = [{
|
||
id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null,
|
||
participants: [{ user_id: 10, username: 'alice' }],
|
||
}];
|
||
render(
|
||
<PlaceInspector
|
||
{...defaultProps}
|
||
tripMembers={members}
|
||
selectedDayId={1}
|
||
selectedAssignmentId={99}
|
||
assignments={{ '1': assignmentInDay }}
|
||
/>
|
||
);
|
||
// alice is a participant, should appear
|
||
expect(screen.getByText('alice')).toBeTruthy();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-039: session storage cache prevents duplicate mapsApi calls', async () => {
|
||
// Prime the session storage cache with language 'en' (default)
|
||
sessionStorage.setItem('gdetails_ChIJ005_en', JSON.stringify({ rating: 3.0 }));
|
||
const p = buildPlace({ id: 304, google_place_id: 'ChIJ005' });
|
||
render(<PlaceInspector {...defaultProps} place={p} />);
|
||
// Wait for effect to run
|
||
await act(async () => { await new Promise(r => setTimeout(r, 50)) });
|
||
// mapsApi.details should NOT have been called (cache hit)
|
||
expect(vi.mocked(mapsApi.details)).not.toHaveBeenCalled();
|
||
// Rating from cache should be visible
|
||
await screen.findByText(/3\.0/);
|
||
});
|
||
|
||
// ── File upload interaction ────────────────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-040: file input change triggers onFileUpload', async () => {
|
||
const onFileUpload = vi.fn().mockResolvedValue(undefined);
|
||
const { container } = render(<PlaceInspector {...defaultProps} onFileUpload={onFileUpload} />);
|
||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
||
expect(fileInput).toBeTruthy();
|
||
const testFile = new File(['content'], 'test.txt', { type: 'text/plain' });
|
||
await act(async () => {
|
||
fireEvent.change(fileInput, { target: { files: [testFile] } });
|
||
});
|
||
await waitFor(() => {
|
||
expect(onFileUpload).toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
// ── formatTime: 12h format ─────────────────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-041: time shown in 12h format when setting is 12h', () => {
|
||
seedStore(useSettingsStore, { settings: { time_format: '12h' } });
|
||
const p = buildPlace({ id: 305, place_time: '14:30', end_time: null });
|
||
render(<PlaceInspector {...defaultProps} place={p} />);
|
||
// 14:30 in 12h = "2:30 PM"
|
||
expect(screen.getByText(/2:30 PM/)).toBeTruthy();
|
||
});
|
||
|
||
// ── convertHoursLine: 24h→12h conversion ──────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-042: opening hours converted to 12h when setting is 12h', async () => {
|
||
seedStore(useSettingsStore, { settings: { time_format: '12h' } });
|
||
vi.mocked(mapsApi.details).mockResolvedValue({
|
||
place: { opening_hours: ['Mon: 09:00 – 17:00'] },
|
||
} as any);
|
||
const user = userEvent.setup();
|
||
const p = buildPlace({ id: 306, google_place_id: 'ChIJ006' });
|
||
render(<PlaceInspector {...defaultProps} place={p} />);
|
||
const hoursSpan = await screen.findByText(/9:00 AM|Show opening hours/i);
|
||
const btn = hoursSpan.closest('button')!;
|
||
await user.click(btn);
|
||
await waitFor(() => {
|
||
expect(screen.getByText(/9:00 AM/)).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
// ── Google Maps URL action ─────────────────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-043: the navigation button offers every app the place can be opened in', async () => {
|
||
const user = userEvent.setup();
|
||
render(<PlaceInspector {...defaultProps} />);
|
||
// A place with coordinates reaches Google Maps and Waze (Apple Maps only on
|
||
// Apple platforms), so the button collects them behind one entry.
|
||
const navBtn = screen.getAllByRole('button').find(btn => btn.textContent?.includes('Navigation'))!;
|
||
expect(navBtn).toBeTruthy();
|
||
|
||
await user.click(navBtn);
|
||
const menu = await screen.findByRole('menu');
|
||
expect(within(menu).getByRole('menuitem', { name: 'Google Maps' })).toBeInTheDocument();
|
||
expect(within(menu).getByRole('menuitem', { name: 'Waze' })).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-043b: Google Maps action uses google_ftid over coordinates', async () => {
|
||
const user = userEvent.setup();
|
||
const mapsUrl = "https://www.google.com/maps/place/?q=St.%20Jacobs%20Farmers'%20Market&ftid=0x882bf179e806d471:0x8591dde29c821a93";
|
||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||
render(<PlaceInspector {...defaultProps} place={buildPlace({
|
||
name: "St. Jacobs Farmers' Market",
|
||
lat: 43.5118527,
|
||
lng: -80.5542617,
|
||
google_ftid: '0x882bf179e806d471:0x8591dde29c821a93',
|
||
})} />);
|
||
const navBtn = screen.getAllByRole('button').find(btn => btn.textContent?.includes('Navigation'))!;
|
||
await user.click(navBtn);
|
||
await user.click(await screen.findByRole('menuitem', { name: 'Google Maps' }));
|
||
expect(openSpy).toHaveBeenCalledWith(mapsUrl, '_blank', 'noopener,noreferrer');
|
||
openSpy.mockRestore();
|
||
});
|
||
|
||
// ── No files section when no upload handler and no files ──────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-044: files section hidden when no files and no onFileUpload', () => {
|
||
const { container } = render(
|
||
<PlaceInspector {...defaultProps} files={[]} onFileUpload={undefined} />
|
||
);
|
||
expect(container.querySelector('input[type="file"]')).toBeNull();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-044b: with upload but no files, the files row does not open to nothing', () => {
|
||
render(<PlaceInspector {...defaultProps} files={[]} />);
|
||
const row = screen.getByRole('button', { name: 'Files' });
|
||
expect(row).toBeDisabled();
|
||
expect(row).toHaveAttribute('aria-expanded', 'false');
|
||
});
|
||
|
||
// ── Participants section hidden when tripMembers <= 1 ─────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-045: participants section hidden when tripMembers has only 1 member', () => {
|
||
const member = buildUser({ id: 1, username: 'solo' });
|
||
render(
|
||
<PlaceInspector
|
||
{...defaultProps}
|
||
tripMembers={[member]}
|
||
selectedDayId={1}
|
||
selectedAssignmentId={99}
|
||
assignments={{ '1': [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }] }}
|
||
/>
|
||
);
|
||
// "solo" username might be visible from other parts but participants box should not render
|
||
// The participants box renders a "users" icon — check it's absent
|
||
const text = document.body.textContent || '';
|
||
// No second member to display
|
||
expect(screen.queryByText('Participants')).toBeNull();
|
||
});
|
||
|
||
// ── Scroll / overflow (issue #1195) ──────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-046: content area is a bounded flex scroll region', () => {
|
||
const longText = 'Lorem ipsum dolor sit amet. '.repeat(200);
|
||
const p = buildPlace({ id: 200, description: longText, notes: longText } as any);
|
||
render(<PlaceInspector {...defaultProps} place={p} />);
|
||
const scroll = screen.getByTestId('inspector-scroll') as HTMLElement;
|
||
const cls = scroll.className.split(/\s+/);
|
||
expect(cls).toContain('overflow-y-auto');
|
||
expect(cls).toContain('min-h-0');
|
||
// flex must allow the region to shrink/grow within the capped card
|
||
expect(cls).toContain('flex-1');
|
||
expect(cls).not.toContain('flex-none');
|
||
// and the card around it is the one that is capped
|
||
expect((scroll.parentElement as HTMLElement).className).toContain('max-h-[60vh]');
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-047: long unbroken description wraps instead of clipping horizontally', () => {
|
||
const longWord = 'https://example.com/' + 'a'.repeat(300);
|
||
const p = buildPlace({ id: 201, description: longWord } as any);
|
||
const { container } = render(<PlaceInspector {...defaultProps} place={p} />);
|
||
const descDiv = container.querySelector('.collab-note-md') as HTMLElement;
|
||
expect(descDiv).toBeTruthy();
|
||
expect(descDiv.style.overflowWrap).toBe('anywhere');
|
||
expect(descDiv.style.wordBreak).toBe('break-word');
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-048: description/notes do not shrink so the card scrolls instead of clipping', () => {
|
||
const longText = 'Lorem ipsum dolor sit amet. '.repeat(200);
|
||
const p = buildPlace({ id: 202, description: longText, notes: longText } as any);
|
||
const { container } = render(<PlaceInspector {...defaultProps} place={p} />);
|
||
const notes = Array.from(container.querySelectorAll('.collab-note-md')) as HTMLElement[];
|
||
// Both description and notes blocks must keep their natural height
|
||
// (flex-none), otherwise they compress inside the flex column and
|
||
// clip the text with no scroll (issue #1195).
|
||
expect(notes.length).toBe(2);
|
||
for (const el of notes) {
|
||
expect((el.closest('section') as HTMLElement).className.split(/\s+/)).toContain('flex-none');
|
||
expect(el.style.overflow).toBe('');
|
||
}
|
||
});
|
||
|
||
// ── Custom thumbnail upload (#1136) ──────────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-049: onUploadImage in trip mode renders the upload-capable avatar', () => {
|
||
render(<PlaceInspector {...defaultProps} onUploadImage={vi.fn()} />);
|
||
// The place carries no image yet, so the avatar offers "Upload image".
|
||
expect(screen.getByRole('button', { name: 'Upload image' })).toBeTruthy();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-050: without onUploadImage the avatar has no upload control', () => {
|
||
render(<PlaceInspector {...defaultProps} />);
|
||
expect(screen.queryByRole('button', { name: 'Upload image' })).toBeNull();
|
||
expect(screen.queryByRole('button', { name: 'Change image' })).toBeNull();
|
||
});
|
||
|
||
// ── Track colour (#776) ──────────────────────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-051: the colour row only exists for a place with geometry', () => {
|
||
const { rerender } = render(<PlaceInspector {...defaultProps} />);
|
||
expect(screen.queryByText('Track color')).toBeNull();
|
||
|
||
rerender(<PlaceInspector {...defaultProps} place={{ ...place, route_geometry: '[[48.0,2.0],[49.0,3.0]]' }} />);
|
||
expect(screen.getAllByText('Track color').length).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-052: picking a swatch saves that colour', () => {
|
||
const onUpdatePlace = vi.fn();
|
||
const track = { ...place, route_geometry: '[[48.0,2.0],[49.0,3.0]]' };
|
||
render(<PlaceInspector {...defaultProps} place={track} onUpdatePlace={onUpdatePlace} />);
|
||
|
||
// The labelled row shows the colour in use and opens the picker.
|
||
const toggle = screen.getByRole('button', { name: /^Track color/ });
|
||
expect(toggle).toHaveTextContent('Automatic color');
|
||
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||
fireEvent.click(toggle);
|
||
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||
fireEvent.click(screen.getByRole('button', { name: '#059669' }));
|
||
expect(onUpdatePlace).toHaveBeenCalledWith(track.id, { route_color: '#059669' });
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-053: the auto cell saves null, not undefined', () => {
|
||
const onUpdatePlace = vi.fn();
|
||
const track = { ...place, route_geometry: '[[48.0,2.0],[49.0,3.0]]', route_color: '#059669' };
|
||
render(<PlaceInspector {...defaultProps} place={track} onUpdatePlace={onUpdatePlace} />);
|
||
|
||
const toggle = screen.getByRole('button', { name: /^Track color/ });
|
||
expect(toggle).toHaveTextContent('#059669');
|
||
fireEvent.click(toggle);
|
||
fireEvent.click(screen.getByRole('button', { name: 'Automatic color' }));
|
||
// null is what clears the column; undefined would leave the colour in place.
|
||
expect(onUpdatePlace).toHaveBeenCalledWith(track.id, { route_color: null });
|
||
});
|
||
|
||
// ── Google details cache ─────────────────────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-054: a cached detail payload is reused without a second request', async () => {
|
||
const withId = buildPlace({ id: 700, name: 'Cached Place', google_place_id: 'gp-700' });
|
||
vi.mocked(mapsApi.details).mockResolvedValue({ place: { phone: '+49 30 111', rating: 4.2, rating_count: 12 } } as any);
|
||
const { unmount } = render(<PlaceInspector {...defaultProps} place={withId} />);
|
||
expect(await screen.findByText('+49 30 111')).toBeTruthy();
|
||
expect(vi.mocked(mapsApi.details)).toHaveBeenCalledTimes(1);
|
||
unmount();
|
||
|
||
render(<PlaceInspector {...defaultProps} place={withId} />);
|
||
expect(await screen.findByText('+49 30 111')).toBeTruthy();
|
||
// Second mount is served from the in-memory cache.
|
||
expect(vi.mocked(mapsApi.details)).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-055: an unreadable session cache entry falls back to a fresh fetch', async () => {
|
||
const withId = buildPlace({ id: 701, name: 'Broken Cache', google_place_id: 'gp-701' });
|
||
sessionStorage.setItem('gdetails_gp-701_en', '{not json');
|
||
vi.mocked(mapsApi.details).mockResolvedValue({ place: { phone: '+49 30 222' } } as any);
|
||
render(<PlaceInspector {...defaultProps} place={withId} />);
|
||
expect(await screen.findByText('+49 30 222')).toBeTruthy();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-056: the rating chip shows the review count and the first usable review', async () => {
|
||
const withId = buildPlace({ id: 702, name: 'Rated Place', google_place_id: 'gp-702' });
|
||
vi.mocked(mapsApi.details).mockResolvedValue({
|
||
place: { rating: 4.5, rating_count: 1200, reviews: [{ text: 'ok' }, { text: 'Great view over the city' }] },
|
||
} as any);
|
||
render(<PlaceInspector {...defaultProps} place={withId} />);
|
||
expect(await screen.findByText('4.5')).toBeTruthy();
|
||
expect(screen.getByText(/Great view over the city/)).toBeTruthy();
|
||
});
|
||
|
||
// ── Opening hours conversion ─────────────────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-057: 12h source hours are converted to 24h, midnight included', async () => {
|
||
const withId = buildPlace({ id: 703, name: 'Hours Place', google_place_id: 'gp-703' });
|
||
vi.mocked(mapsApi.details).mockResolvedValue({
|
||
place: { opening_hours: ['Monday: 12:30 AM – 9:00 PM', 'Tuesday: 10:00 AM – 12:00 PM'] },
|
||
} as any);
|
||
render(<PlaceInspector {...defaultProps} place={withId} />);
|
||
fireEvent.click(await screen.findByText(/Show opening hours|Monday|Tuesday/));
|
||
expect(await screen.findByText('Monday: 00:30 – 21:00')).toBeTruthy();
|
||
expect(screen.getByText('Tuesday: 10:00 – 12:00')).toBeTruthy();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-058: 24h source hours are left untouched in 24h mode', async () => {
|
||
const withId = buildPlace({ id: 704, name: 'Hours 24h', google_place_id: 'gp-704' });
|
||
vi.mocked(mapsApi.details).mockResolvedValue({
|
||
place: { opening_hours: ['Monday: 09:00 – 18:00'] },
|
||
} as any);
|
||
render(<PlaceInspector {...defaultProps} place={withId} />);
|
||
fireEvent.click(await screen.findByText(/Show opening hours|Monday: 09:00/));
|
||
expect(await screen.findByText('Monday: 09:00 – 18:00')).toBeTruthy();
|
||
});
|
||
|
||
// ── Name editing ─────────────────────────────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-059: without onUpdatePlace a double-click does not start an edit', async () => {
|
||
render(<PlaceInspector {...defaultProps} onUpdatePlace={undefined} />);
|
||
fireEvent.doubleClick(screen.getByText('Eiffel Tower'));
|
||
expect(screen.queryByDisplayValue('Eiffel Tower')).toBeNull();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-060: Escape abandons the edit and the following blur saves nothing', async () => {
|
||
const onUpdatePlace = vi.fn();
|
||
render(<PlaceInspector {...defaultProps} onUpdatePlace={onUpdatePlace} />);
|
||
fireEvent.doubleClick(screen.getByText('Eiffel Tower'));
|
||
const input = await screen.findByDisplayValue('Eiffel Tower');
|
||
fireEvent.change(input, { target: { value: 'Tour Eiffel' } });
|
||
fireEvent.keyDown(input, { key: 'Escape' });
|
||
fireEvent.blur(input);
|
||
expect(onUpdatePlace).not.toHaveBeenCalled();
|
||
expect(screen.getByText('Eiffel Tower')).toBeTruthy();
|
||
});
|
||
|
||
// ── Files ────────────────────────────────────────────────────────────────────
|
||
|
||
const placeFile = (over: Record<string, unknown> = {}) => ({
|
||
id: 1, trip_id: 1, place_id: 1, original_name: 'map.pdf', filename: 'map.pdf',
|
||
mime_type: 'application/pdf', url: '/uploads/map.pdf', created_at: '2025-01-01T00:00:00.000Z',
|
||
...over,
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-061: file sizes render in B, KB and MB, and a zero size is omitted', async () => {
|
||
const files = [
|
||
placeFile({ id: 1, original_name: 'tiny.txt', file_size: 512, mime_type: 'text/plain' }),
|
||
placeFile({ id: 2, original_name: 'medium.pdf', file_size: 2048 }),
|
||
placeFile({ id: 3, original_name: 'big.png', file_size: 3 * 1024 * 1024, mime_type: 'image/png' }),
|
||
placeFile({ id: 4, original_name: 'unknown.bin', file_size: 0 }),
|
||
];
|
||
render(<PlaceInspector {...defaultProps} files={files as any} />);
|
||
fireEvent.click(screen.getByText('4 files'));
|
||
expect(await screen.findByText('512 B')).toBeTruthy();
|
||
expect(screen.getByText('2.0 KB')).toBeTruthy();
|
||
expect(screen.getByText('3.0 MB')).toBeTruthy();
|
||
expect(screen.getByText('unknown.bin')).toBeTruthy();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-062: clicking an attached file opens it', async () => {
|
||
const { openFile } = await import('../../utils/fileDownload');
|
||
const spy = vi.spyOn({ openFile }, 'openFile');
|
||
render(<PlaceInspector {...defaultProps} files={[placeFile()] as any} />);
|
||
fireEvent.click(screen.getByText('1 file'));
|
||
const link = await screen.findByText('map.pdf');
|
||
fireEvent.click(link);
|
||
// The click is handled without throwing; the row stays in the list.
|
||
expect(screen.getByText('map.pdf')).toBeTruthy();
|
||
spy.mockRestore();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-063: an empty file selection is ignored', async () => {
|
||
const onFileUpload = vi.fn().mockResolvedValue(undefined);
|
||
render(<PlaceInspector {...defaultProps} onFileUpload={onFileUpload} />);
|
||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||
fireEvent.change(input, { target: { files: [] } });
|
||
expect(onFileUpload).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-064: a failing upload surfaces an error and re-enables the control', async () => {
|
||
const onFileUpload = vi.fn().mockRejectedValue(new Error('disk full'));
|
||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||
render(<PlaceInspector {...defaultProps} onFileUpload={onFileUpload} />);
|
||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||
fireEvent.change(input, { target: { files: [new File(['x'], 'a.pdf', { type: 'application/pdf' })] } });
|
||
await waitFor(() => expect(onFileUpload).toHaveBeenCalled());
|
||
await waitFor(() => expect(screen.getByText('Upload')).toBeTruthy());
|
||
expect(errorSpy).toHaveBeenCalled();
|
||
errorSpy.mockRestore();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-065: a successful upload expands the file list', async () => {
|
||
const onFileUpload = vi.fn().mockResolvedValue(undefined);
|
||
render(<PlaceInspector {...defaultProps} files={[placeFile()] as any} onFileUpload={onFileUpload} />);
|
||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||
fireEvent.change(input, { target: { files: [new File(['x'], 'b.pdf', { type: 'application/pdf' })] } });
|
||
await waitFor(() => expect(onFileUpload).toHaveBeenCalled());
|
||
expect(await screen.findByText('map.pdf')).toBeTruthy();
|
||
});
|
||
|
||
// ── Track stats edge cases ───────────────────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-066: a single-point track renders the colour row but no stats', () => {
|
||
render(<PlaceInspector {...defaultProps} place={{ ...place, route_geometry: '[[48.0,2.0]]' } as any} />);
|
||
expect(screen.getAllByText('Track color').length).toBeGreaterThan(0);
|
||
expect(screen.queryByText('Track')).toBeNull();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-067: unparsable geometry is swallowed instead of crashing the panel', () => {
|
||
render(<PlaceInspector {...defaultProps} place={{ ...place, route_geometry: 'not-json' } as any} />);
|
||
expect(screen.getByText('Eiffel Tower')).toBeTruthy();
|
||
expect(screen.getAllByText('Track color').length).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-068: a track with elevations reports distance, peaks and an elevation profile', () => {
|
||
const geom = JSON.stringify([[48.0, 2.0, 100], [48.01, 2.01, 180], [48.02, 2.02, 140]]);
|
||
render(<PlaceInspector {...defaultProps} place={{ ...place, route_geometry: geom } as any} />);
|
||
expect(screen.getByText('Track Stats')).toBeTruthy();
|
||
expect(screen.getByText(/↑/)).toBeTruthy();
|
||
expect(document.querySelector('svg path[stroke]')).toBeTruthy();
|
||
});
|
||
|
||
// ── Footer actions ───────────────────────────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-069: OpenStreetMap sits in the navigation menu, the website keeps its own button', async () => {
|
||
const user = userEvent.setup();
|
||
const open = vi.fn();
|
||
vi.stubGlobal('open', open);
|
||
const p = buildPlace({ id: 705, name: 'Linked Place', osm_id: 'node/12345', website: 'https://example.org' });
|
||
render(<PlaceInspector {...defaultProps} place={p} />);
|
||
|
||
// OSM moved in with the other map apps rather than standing beside them.
|
||
await user.click(screen.getAllByRole('button').find(b => b.textContent?.includes('Navigation'))!);
|
||
await user.click(await screen.findByRole('menuitem', { name: 'OpenStreetMap' }));
|
||
|
||
fireEvent.click(screen.getByText('Open Website').closest('button')!);
|
||
expect(open).toHaveBeenCalledTimes(2);
|
||
expect(open.mock.calls[1]).toEqual(['https://example.org', '_blank', 'noopener,noreferrer']);
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-070: the footer keeps the ways out on the left and delete + edit on the right', () => {
|
||
const p = buildPlace({ id: 712, name: 'Footer Place', lat: 48.1, lng: 2.1, website: 'https://example.org' });
|
||
render(<PlaceInspector {...defaultProps} place={p} selectedDayId={1} assignments={{ '1': [] }} />);
|
||
const footer = document.querySelector('footer') as HTMLElement;
|
||
const names = within(footer).getAllByRole('button').map(b => b.getAttribute('aria-label') || b.textContent);
|
||
expect(names).toEqual(['Add to Day', 'Navigation', 'Open Website', 'Delete', 'Edit']);
|
||
// Hover looks are classes on the tokens now, not inline styles swapped by handlers.
|
||
const edit = within(footer).getByRole('button', { name: 'Edit' });
|
||
expect(edit.getAttribute('style') ?? '').not.toMatch(/background/);
|
||
expect(edit.className).toContain('bg-accent');
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-071: the header close button is named and shows its tooltip', async () => {
|
||
render(<PlaceInspector {...defaultProps} />);
|
||
const close = screen.getByRole('button', { name: 'Close' });
|
||
expect(close.getAttribute('title')).toBeNull();
|
||
fireEvent.mouseEnter(close);
|
||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Close');
|
||
fireEvent.mouseLeave(close);
|
||
await waitFor(() => expect(screen.queryByRole('tooltip')).toBeNull());
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-072: rating a place forwards the vote', () => {
|
||
const onRate = vi.fn();
|
||
render(<PlaceInspector {...defaultProps} onRate={onRate} />);
|
||
const stars = screen.getAllByRole('radio');
|
||
expect(stars).toHaveLength(5);
|
||
fireEvent.click(stars[2]);
|
||
expect(onRate).toHaveBeenCalledWith(place.id, 3);
|
||
});
|
||
|
||
// ── Reservation summary ──────────────────────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-073: the linked reservation summarises flight, platform and check-in metadata', () => {
|
||
const res = buildReservation({
|
||
id: 1, title: 'Flight to Nice', status: 'pending', assignment_id: 9,
|
||
reservation_time: '2025-06-15T08:30', reservation_end_time: '2025-06-15T10:15',
|
||
confirmation_number: 'ABC999', notes: 'Aisle seat',
|
||
metadata: JSON.stringify({ airline: 'Air France', flight_number: 'AF123', departure_airport: 'CDG', arrival_airport: 'NCE', train_number: 'TGV1', platform: '7', check_in_time: '06:00', check_out_time: '12:00' }),
|
||
} as any);
|
||
render(<PlaceInspector {...defaultProps} selectedDayId={1} selectedAssignmentId={9}
|
||
assignments={{ '1': [{ id: 9, place, place_id: place.id, day_id: 1, order_index: 0, notes: null }] }}
|
||
reservations={[res]} />);
|
||
expect(screen.getByText('Flight to Nice')).toBeTruthy();
|
||
expect(screen.getByText('ABC999')).toBeTruthy();
|
||
expect(screen.getByText('Aisle seat')).toBeTruthy();
|
||
// Each fact of the metadata is its own pill, labelled in the user's language.
|
||
for (const fact of ['Air France AF123', 'CDG → NCE', 'TGV1', 'Platform 7', 'Check-in 06:00', 'Check-out 12:00']) {
|
||
expect(screen.getByText(fact)).toBeTruthy();
|
||
}
|
||
expect(screen.queryByText(/·/)).toBeNull();
|
||
// Date, time and code are labelled fields.
|
||
expect(screen.getByText('Date')).toBeTruthy();
|
||
expect(screen.getByText('Time')).toBeTruthy();
|
||
expect(screen.getByText('Booking Code')).toBeTruthy();
|
||
expect(screen.getByText('Pending')).toBeTruthy();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-074: a reservation whose metadata has no printable fields shows no meta line', () => {
|
||
const res = buildReservation({ id: 2, title: 'Plain booking', status: 'confirmed', assignment_id: 9, metadata: JSON.stringify({ seat: '4A' }) } as any);
|
||
render(<PlaceInspector {...defaultProps} selectedDayId={1} selectedAssignmentId={9}
|
||
assignments={{ '1': [{ id: 9, place, place_id: place.id, day_id: 1, order_index: 0, notes: null }] }}
|
||
reservations={[res]} />);
|
||
expect(screen.getByText('Plain booking')).toBeTruthy();
|
||
expect(screen.queryByText(/Platform|Gl\./)).toBeNull();
|
||
});
|
||
|
||
// ── Participants ─────────────────────────────────────────────────────────────
|
||
|
||
const members = [
|
||
{ id: 1, username: 'ada', avatar_url: null },
|
||
{ id: 2, username: 'bob', avatar_url: null },
|
||
{ id: 3, username: 'cleo', avatar_url: null, is_guest: true },
|
||
];
|
||
const participantProps = (participants: Array<{ user_id: number; username?: string }> = []) => ({
|
||
selectedDayId: 1,
|
||
selectedAssignmentId: 9,
|
||
tripMembers: members,
|
||
assignments: { '1': [{ id: 9, place, place_id: place.id, day_id: 1, order_index: 0, notes: null, participants }] } as unknown as AssignmentsMap,
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-075: with nobody explicitly set, every member counts as joined', () => {
|
||
render(<PlaceInspector {...defaultProps} {...participantProps()} />);
|
||
expect(screen.getByText('Participants')).toBeTruthy();
|
||
for (const m of members) expect(screen.getByText(m.username)).toBeTruthy();
|
||
// Everyone is in, so there is nothing left to add.
|
||
expect(screen.queryByText('+')).toBeNull();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-076: removing a member from the all-joined state sends the remaining ids', () => {
|
||
const onSetParticipants = vi.fn();
|
||
render(<PlaceInspector {...defaultProps} {...participantProps()} onSetParticipants={onSetParticipants} />);
|
||
fireEvent.click(screen.getByText('bob'));
|
||
expect(onSetParticipants).toHaveBeenCalledWith(9, 1, [1, 3]);
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-077: removing down to the full member list is stored as "everyone" again', () => {
|
||
const onSetParticipants = vi.fn();
|
||
render(<PlaceInspector {...defaultProps} {...participantProps([{ user_id: 1 }, { user_id: 2 }, { user_id: 3 }])} onSetParticipants={onSetParticipants} />);
|
||
// The chip is hovered first, which flags it removable.
|
||
fireEvent.mouseEnter(screen.getByText('ada').closest('div')!);
|
||
fireEvent.click(screen.getByText('cleo'));
|
||
expect(onSetParticipants).toHaveBeenCalledWith(9, 1, [1, 2]);
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-078: the last remaining participant cannot be removed', () => {
|
||
const onSetParticipants = vi.fn();
|
||
render(<PlaceInspector {...defaultProps} {...participantProps([{ user_id: 1 }])} onSetParticipants={onSetParticipants} />);
|
||
fireEvent.click(screen.getByText('ada'));
|
||
expect(onSetParticipants).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-079: the add menu lists the missing members and marks guests', () => {
|
||
const onSetParticipants = vi.fn();
|
||
render(<PlaceInspector {...defaultProps} {...participantProps([{ user_id: 1 }])} onSetParticipants={onSetParticipants} />);
|
||
fireEvent.click(screen.getByText('+'));
|
||
expect(screen.getByText('bob')).toBeTruthy();
|
||
expect(screen.getByText('Guest')).toBeTruthy();
|
||
fireEvent.click(screen.getByText('bob'));
|
||
expect(onSetParticipants).toHaveBeenCalledWith(9, 1, [1, 2]);
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-080: adding the final missing member stores "everyone" instead of a full list', () => {
|
||
const onSetParticipants = vi.fn();
|
||
render(<PlaceInspector {...defaultProps} {...participantProps([{ user_id: 1 }, { user_id: 2 }])} onSetParticipants={onSetParticipants} />);
|
||
fireEvent.click(screen.getByText('+'));
|
||
fireEvent.click(screen.getByText('cleo'));
|
||
expect(onSetParticipants).toHaveBeenCalledWith(9, 1, []);
|
||
});
|
||
|
||
// ── Save to collection ───────────────────────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-081: with collections enabled the footer offers to save the place', async () => {
|
||
seedStore(useAddonStore, { addons: [{ id: 'collections', name: 'Collections', type: 'addon', icon: 'bookmark', enabled: true }], loaded: true });
|
||
server.use(
|
||
http.get('/api/addons/collections/membership', () => HttpResponse.json({ saved: true, collections: [] })),
|
||
);
|
||
render(<PlaceInspector {...defaultProps} />);
|
||
expect(await screen.findByText('Saved')).toBeTruthy();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-082: clicking save hands the whole place to the collection picker', async () => {
|
||
seedStore(useAddonStore, { addons: [{ id: 'collections', name: 'Collections', type: 'addon', icon: 'bookmark', enabled: true }], loaded: true });
|
||
server.use(
|
||
http.get('/api/addons/collections/membership', () => HttpResponse.json({ saved: false, collections: [] })),
|
||
);
|
||
render(<PlaceInspector {...defaultProps} />);
|
||
fireEvent.click((await screen.findByText('Save to Collection')).closest('button')!);
|
||
expect(useSaveToCollectionStore.getState().target).toMatchObject({
|
||
name: 'Eiffel Tower', source_place_id: place.id, lat: 48.8584, lng: 2.2945,
|
||
});
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-083: a failing membership check leaves the unsaved label', async () => {
|
||
seedStore(useAddonStore, { addons: [{ id: 'collections', name: 'Collections', type: 'addon', icon: 'bookmark', enabled: true }], loaded: true });
|
||
server.use(
|
||
http.get('/api/addons/collections/membership', () => new HttpResponse(null, { status: 500 })),
|
||
);
|
||
render(<PlaceInspector {...defaultProps} />);
|
||
expect(await screen.findByText('Save to Collection')).toBeTruthy();
|
||
});
|
||
|
||
// ── Plugin contributions (#1429) ─────────────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-084: placeDetailProvider rows render as label/value and links', async () => {
|
||
server.use(
|
||
http.get('/api/place-details/1', () => HttpResponse.json({
|
||
providers: [
|
||
{ pluginId: 'tides', items: [{ label: 'High tide', value: '14:20' }, { label: 'Forecast', url: 'https://tides.example' }] },
|
||
// Empty providers are dropped before render.
|
||
{ pluginId: 'empty', items: [] },
|
||
],
|
||
})),
|
||
);
|
||
render(<PlaceInspector {...defaultProps} />);
|
||
expect(await screen.findByText('High tide')).toBeTruthy();
|
||
expect(screen.getByText('14:20')).toBeTruthy();
|
||
expect(screen.getByRole('link', { name: '↗' })).toHaveAttribute('href', 'https://tides.example');
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-085: a failing provider request adds no rows', async () => {
|
||
server.use(http.get('/api/place-details/1', () => new HttpResponse(null, { status: 500 })));
|
||
render(<PlaceInspector {...defaultProps} />);
|
||
await waitFor(() => expect(screen.getByText('Eiffel Tower')).toBeTruthy());
|
||
expect(screen.queryByText('High tide')).toBeNull();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-086: a place-detail widget plugin mounts a frame scoped to the place', async () => {
|
||
seedStore(usePluginStore, {
|
||
plugins: [
|
||
{ id: 'tide-widget', name: 'Tides', type: 'widget', icon: null, slot: 'place-detail' },
|
||
{ id: 'hero-widget', name: 'Hero', type: 'widget', icon: null, slot: 'hero' },
|
||
],
|
||
});
|
||
render(<PlaceInspector {...defaultProps} />);
|
||
await waitFor(() => expect(document.querySelector('iframe[src*="tide-widget"]')).toBeTruthy());
|
||
expect(document.querySelector('iframe[src*="hero-widget"]')).toBeNull();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-087: in collection mode neither provider rows nor plugin frames are fetched', async () => {
|
||
let called = false;
|
||
server.use(http.get('/api/place-details/1', () => { called = true; return HttpResponse.json({ providers: [] }); }));
|
||
seedStore(usePluginStore, {
|
||
plugins: [{ id: 'tide-widget', name: 'Tides', type: 'widget', icon: null, slot: 'place-detail' }],
|
||
});
|
||
render(<PlaceInspector {...defaultProps} mode="collection" onCopyToTrip={vi.fn()} onRemoveFromList={vi.fn()} />);
|
||
await waitFor(() => expect(screen.getByText('Copy to trip')).toBeTruthy());
|
||
expect(called).toBe(false);
|
||
expect(document.querySelector('iframe[src*="tide-widget"]')).toBeNull();
|
||
});
|
||
|
||
// ── Custom thumbnail callbacks (#1136) ───────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-088: removing the custom image clears image_url through onUpdatePlace', async () => {
|
||
const onUpdatePlace = vi.fn();
|
||
const onUploadImage = vi.fn(async () => {});
|
||
const withImage = buildPlace({ id: 706, name: 'Pictured', image_url: '/uploads/places/x.jpg' });
|
||
render(<PlaceInspector {...defaultProps} place={withImage} onUpdatePlace={onUpdatePlace} onUploadImage={onUploadImage} />);
|
||
fireEvent.click(screen.getByRole('button', { name: 'Remove image' }));
|
||
expect(onUpdatePlace).toHaveBeenCalledWith(706, { image_url: null });
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-090: opening hours are read for the selected day, not for today', async () => {
|
||
const withId = buildPlace({ id: 707, name: 'Day-aware', google_place_id: 'gp-707' });
|
||
vi.mocked(mapsApi.details).mockResolvedValue({
|
||
place: { opening_hours: ['Mon 08:00', 'Tue 09:00', 'Wed 10:00', 'Thu 11:00', 'Fri 12:00', 'Sat 13:00', 'Sun 14:00'] },
|
||
} as any);
|
||
// 2025-06-18 is a Wednesday → index 2.
|
||
render(<PlaceInspector {...defaultProps} place={withId} days={[{ id: 5, date: '2025-06-18' }] as any} selectedDayId={5} />);
|
||
expect(await screen.findByText('Wed 10:00')).toBeTruthy();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-091: without an onSetParticipants handler the chips are inert', () => {
|
||
render(<PlaceInspector {...defaultProps} {...participantProps([{ user_id: 1 }, { user_id: 2 }])} onSetParticipants={undefined} />);
|
||
fireEvent.click(screen.getByText('ada'));
|
||
fireEvent.click(screen.getByText('+'));
|
||
fireEvent.click(screen.getByText('cleo'));
|
||
// Nothing blew up and the box is still on screen.
|
||
expect(screen.getByText('Participants')).toBeTruthy();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-092: a stale participant id outside the member list collapses back to "everyone"', () => {
|
||
const onSetParticipants = vi.fn();
|
||
render(<PlaceInspector {...defaultProps} {...participantProps([{ user_id: 1 }, { user_id: 2 }, { user_id: 3 }, { user_id: 99 }])} onSetParticipants={onSetParticipants} />);
|
||
fireEvent.click(screen.getByText('ada'));
|
||
expect(onSetParticipants).toHaveBeenCalledWith(9, 1, []);
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-093: participant chips show the removal in the danger tokens, the add menu opens and closes', () => {
|
||
render(<PlaceInspector {...defaultProps} {...participantProps([{ user_id: 1 }, { user_id: 2 }])} />);
|
||
const chip = screen.getByText('ada').closest('button') as HTMLElement;
|
||
// A removable chip turns to the danger tokens on hover and strikes its name through.
|
||
expect(chip.className).toContain('enabled:hover:text-danger');
|
||
expect(chip.className).not.toMatch(/#[0-9a-f]{3,6}/i);
|
||
expect(screen.getByText('ada').className).toContain('group-hover/chip:line-through');
|
||
|
||
const add = screen.getByRole('button', { name: 'Add' });
|
||
expect(add).toHaveTextContent('+');
|
||
expect(add).toHaveAttribute('aria-expanded', 'false');
|
||
fireEvent.click(add);
|
||
expect(add).toHaveAttribute('aria-expanded', 'true');
|
||
const entry = screen.getByText('cleo').closest('button') as HTMLButtonElement;
|
||
expect(entry.className).toContain('hover:bg-surface-hover');
|
||
|
||
// Escape and a click elsewhere close the list again.
|
||
fireEvent.keyDown(document, { key: 'Escape' });
|
||
expect(screen.queryByText('cleo')).toBeNull();
|
||
fireEvent.click(add);
|
||
expect(screen.getByText('cleo')).toBeTruthy();
|
||
fireEvent.mouseDown(document.body);
|
||
expect(screen.queryByText('cleo')).toBeNull();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-094: a flight number without an airline is still summarised', () => {
|
||
const res = buildReservation({ id: 3, title: 'Numbered flight', status: 'confirmed', assignment_id: 9, metadata: JSON.stringify({ flight_number: 'LH400' }) } as any);
|
||
render(<PlaceInspector {...defaultProps} selectedDayId={1} selectedAssignmentId={9}
|
||
assignments={{ '1': [{ id: 9, place, place_id: place.id, day_id: 1, order_index: 0, notes: null }] }}
|
||
reservations={[res]} />);
|
||
expect(screen.getByText('LH400')).toBeTruthy();
|
||
});
|
||
|
||
// ── Open/closed ring (#1680) ─────────────────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-095: the ring follows the periods, not the cached open_now', async () => {
|
||
const seoul = buildPlace({ id: 708, name: 'Round the clock', google_place_id: 'gp-708', lat: 37.5665, lng: 126.978 });
|
||
vi.mocked(mapsApi.details).mockResolvedValue({
|
||
place: {
|
||
open_now: false,
|
||
// A period with no close is Google's round-the-clock place. The weekday lines
|
||
// arrive in the user's language and are shown, never parsed.
|
||
opening_hours: ['月曜日: 24 時間営業'],
|
||
opening_periods: [{ open: { day: 0, hour: 0, minute: 0 } }],
|
||
},
|
||
} as any);
|
||
render(<PlaceInspector {...defaultProps} place={seoul} />);
|
||
expect(await screen.findByText('Open')).toBeTruthy();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-096: the ring is read in the timezone of the place', async () => {
|
||
// Sunday 20:00 UTC — already Monday 05:00 in Seoul, still Sunday 22:00 in Paris.
|
||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||
vi.setSystemTime(new Date('2026-07-26T20:00:00Z'));
|
||
try {
|
||
const periods = [{ open: { day: 1, hour: 0, minute: 0 }, close: { day: 1, hour: 12, minute: 0 } }];
|
||
vi.mocked(mapsApi.details).mockResolvedValue({
|
||
place: { open_now: false, opening_periods: periods },
|
||
} as any);
|
||
|
||
const seoul = buildPlace({ id: 709, name: 'Seoul spot', google_place_id: 'gp-709', lat: 37.5665, lng: 126.978 });
|
||
const { unmount } = render(<PlaceInspector {...defaultProps} place={seoul} />);
|
||
expect(await screen.findByText('Open')).toBeTruthy();
|
||
unmount();
|
||
|
||
const paris = buildPlace({ id: 710, name: 'Paris spot', google_place_id: 'gp-710', lat: 48.8566, lng: 2.3522 });
|
||
render(<PlaceInspector {...defaultProps} place={paris} />);
|
||
expect(await screen.findByText('Closed')).toBeTruthy();
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-097: a holiday in the payload leaves the verdict to the server', async () => {
|
||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||
// Wednesday 12:00 in Seoul, inside the regular Wednesday period.
|
||
vi.setSystemTime(new Date('2026-07-29T03:00:00Z'));
|
||
try {
|
||
vi.mocked(mapsApi.details).mockResolvedValue({
|
||
place: {
|
||
open_now: false,
|
||
opening_periods: [{ open: { day: 3, hour: 9, minute: 0 }, close: { day: 3, hour: 18, minute: 0 } }],
|
||
opening_special_days: ['2026-07-29'],
|
||
},
|
||
} as any);
|
||
const seoul = buildPlace({ id: 711, name: 'Holiday spot', google_place_id: 'gp-711', lat: 37.5665, lng: 126.978 });
|
||
render(<PlaceInspector {...defaultProps} place={seoul} />);
|
||
expect(await screen.findByText('Closed')).toBeTruthy();
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-089: picking a file hands it to onUploadImage', async () => {
|
||
const onUploadImage = vi.fn(async () => {});
|
||
render(<PlaceInspector {...defaultProps} onUploadImage={onUploadImage} />);
|
||
const input = document.querySelector('input[accept*="image"]') as HTMLInputElement;
|
||
const file = new File(['x'], 'thumb.png', { type: 'image/png' });
|
||
fireEvent.change(input, { target: { files: [file] } });
|
||
await waitFor(() => expect(onUploadImage).toHaveBeenCalledWith(place.id, file));
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-098: deselecting and reselecting a place survives a rerender', async () => {
|
||
// The component bails out with `if (!place) return null`. Any hook below that
|
||
// line runs only while a place is selected, so clearing the selection changes
|
||
// the hook count and React tears the whole tree down.
|
||
const { rerender } = render(<PlaceInspector {...defaultProps} />);
|
||
expect(screen.getByText('Eiffel Tower')).toBeTruthy();
|
||
|
||
rerender(<PlaceInspector {...defaultProps} place={null} />);
|
||
expect(screen.queryByText('Eiffel Tower')).toBeNull();
|
||
|
||
rerender(<PlaceInspector {...defaultProps} />);
|
||
expect(screen.getByText('Eiffel Tower')).toBeTruthy();
|
||
});
|
||
|
||
// ── Day-specific assignment note (#2163) ─────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-099: the assignment note renders as markdown under its own eyebrow', () => {
|
||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: 'Book the **10:00** timed entry' }];
|
||
render(
|
||
<PlaceInspector
|
||
{...defaultProps}
|
||
selectedDayId={1}
|
||
selectedAssignmentId={99}
|
||
assignments={{ '1': assignmentInDay }}
|
||
/>
|
||
);
|
||
expect(screen.getByText('Notes for this day')).toBeTruthy();
|
||
const strong = Array.from(document.querySelectorAll('strong')).find(el => el.textContent === '10:00');
|
||
expect(strong).toBeTruthy();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-100: without a note (or without a day in context) the block stays away', () => {
|
||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||
const { rerender } = render(
|
||
<PlaceInspector {...defaultProps} selectedDayId={1} selectedAssignmentId={99} assignments={{ '1': assignmentInDay }} />
|
||
);
|
||
expect(screen.queryByText('Notes for this day')).toBeNull();
|
||
// A note exists but no day is selected — nothing to attribute it to, so no block.
|
||
rerender(
|
||
<PlaceInspector {...defaultProps} selectedDayId={null} assignments={{ '1': [{ ...assignmentInDay[0], notes: 'hidden' }] }} />
|
||
);
|
||
expect(screen.queryByText('Notes for this day')).toBeNull();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-101: the reservation strip opts out of the press-scale (#2158)', () => {
|
||
// jsdom cannot replay the browser mechanics: the :active scale on the
|
||
// composite strip slid the links inside out from under the pointer, so the
|
||
// click landed elsewhere. The data-no-press attribute is the pin.
|
||
const reservation = buildReservation({ title: 'Museum Ticket', status: 'confirmed', assignment_id: 99 } as any);
|
||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||
render(
|
||
<PlaceInspector
|
||
{...defaultProps}
|
||
selectedDayId={1}
|
||
selectedAssignmentId={99}
|
||
assignments={{ '1': assignmentInDay }}
|
||
reservations={[reservation]}
|
||
onEditReservation={vi.fn()}
|
||
/>
|
||
);
|
||
expect(screen.getByText('Museum Ticket').closest('[role="button"]')).toHaveAttribute('data-no-press');
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-102: a member without place_edit gets neither Edit nor Delete nor the inline rename (#2446)', () => {
|
||
// buildUser() is a plain member and buildTrip({ id: 1 }) belongs to somebody else.
|
||
usePermissionsStore.setState({ permissions: { place_edit: 'trip_owner' } });
|
||
const onEdit = vi.fn();
|
||
const onDelete = vi.fn();
|
||
const onUpdatePlace = vi.fn();
|
||
const member = render(<PlaceInspector {...defaultProps} onEdit={onEdit} onDelete={onDelete} onUpdatePlace={onUpdatePlace} />);
|
||
expect(screen.queryByText('Edit')).not.toBeInTheDocument();
|
||
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
|
||
fireEvent.doubleClick(screen.getByText(place.name));
|
||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||
member.unmount();
|
||
// the same trip owned by this member shows them again
|
||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: useAuthStore.getState().user!.id }) });
|
||
render(<PlaceInspector {...defaultProps} onEdit={onEdit} onDelete={onDelete} onUpdatePlace={onUpdatePlace} />);
|
||
expect(screen.getByText('Edit')).toBeInTheDocument();
|
||
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument();
|
||
});
|
||
|
||
|
||
it('FE-PLANNER-INSPECTOR-017b: a stop a booked night wrote is neither removed nor doubled here', () => {
|
||
// Taking it off the day would leave the booking behind with nothing on the drive and
|
||
// no way back short of saving it again; offering to add it would put a second copy
|
||
// of the same hotel beside it. The night is removed where it is made: the day's
|
||
// overnight block, or turning it back into a pause in road trip mode.
|
||
const onRemoveAssignment = vi.fn();
|
||
const booked = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null, accommodation_id: 4 }];
|
||
render(
|
||
<PlaceInspector
|
||
{...defaultProps}
|
||
selectedDayId={1}
|
||
assignments={{ '1': booked } as never}
|
||
onRemoveAssignment={onRemoveAssignment}
|
||
/>
|
||
);
|
||
|
||
expect(screen.queryByRole('button', { name: /remove from day/i })).toBeNull();
|
||
expect(screen.queryByRole('button', { name: /add to day/i })).toBeNull();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-017c: the booked stop stays off the buttons when the day list hides it', () => {
|
||
// The day view hands the inspector the day without the stop a booking wrote,
|
||
// while the store still holds it. Judged on the list alone the hotel looked
|
||
// unassigned, and "Add to day" put a second copy beside the booked night.
|
||
const booked = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null, accommodation_id: 4 }];
|
||
seedStore(useTripStore, { assignments: { '1': booked } as never });
|
||
render(<PlaceInspector {...defaultProps} selectedDayId={1} assignments={{ '1': [] }} />);
|
||
|
||
expect(screen.queryByText('Add to Day')).toBeNull();
|
||
expect(screen.queryByText('Remove from Day')).toBeNull();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-017d: a stop the traveller placed at the booked hotel can still be removed', () => {
|
||
// A second copy that did get onto the day is the traveller's own row and shows
|
||
// in the list. That one keeps its "Remove from day", which is the way back.
|
||
const onRemoveAssignment = vi.fn();
|
||
const booked = { id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null, accommodation_id: 4 };
|
||
const own = { id: 100, place, day_id: 1, place_id: place.id, order_index: 1, notes: null };
|
||
seedStore(useTripStore, { assignments: { '1': [booked, own] } as never });
|
||
render(
|
||
<PlaceInspector
|
||
{...defaultProps}
|
||
selectedDayId={1}
|
||
assignments={{ '1': [own] } as never}
|
||
onRemoveAssignment={onRemoveAssignment}
|
||
/>
|
||
);
|
||
|
||
fireEvent.click(screen.getByText('Remove from Day').closest('button')!);
|
||
expect(onRemoveAssignment).toHaveBeenCalledWith(1, 100);
|
||
expect(screen.queryByText('Add to Day')).toBeNull();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-017e: any booked stop at the place keeps the buttons off, whichever row the store lists first', () => {
|
||
// With the service stops switched off the day list can hide a hotel the traveller
|
||
// placed as well, so the store may list that row ahead of the booked one. The
|
||
// question is whether a booking holds this place on this day, not which row comes first.
|
||
const own = { id: 100, place, day_id: 1, place_id: place.id, order_index: 0, notes: null };
|
||
const booked = { id: 99, place, day_id: 1, place_id: place.id, order_index: 1, notes: null, accommodation_id: 4 };
|
||
seedStore(useTripStore, { assignments: { '1': [own, booked] } as never });
|
||
render(<PlaceInspector {...defaultProps} selectedDayId={1} assignments={{ '1': [] }} />);
|
||
|
||
expect(screen.queryByText('Add to Day')).toBeNull();
|
||
expect(screen.queryByText('Remove from Day')).toBeNull();
|
||
});
|
||
|
||
// ── Head band (booking-detail language) ──────────────────────────────────────
|
||
|
||
it('FE-PLANNER-INSPECTOR-105: the photo credit stays under the avatar next to the open/closed state (CC BY-SA)', async () => {
|
||
// It used to be dropped whenever the open/closed tag took its place under the
|
||
// avatar; the tag is a pill now, and the licence credit is always shown.
|
||
vi.mocked(mapsApi.placePhotoCredit).mockResolvedValue({ credit: 'Jane Doe, CC BY-SA 4.0' } as never);
|
||
vi.mocked(mapsApi.details).mockResolvedValue({ place: { open_now: true } } as never);
|
||
const p = buildPlace({ id: 713, name: 'Credited', google_place_id: 'gp-713', image_url: '/api/maps/place-photo/commons%3Aabc/bytes', lat: null, lng: null });
|
||
render(<PlaceInspector {...defaultProps} place={p} />);
|
||
expect(await screen.findByText('Open')).toBeTruthy();
|
||
const credit = await screen.findByText('Jane Doe, CC BY-SA 4.0');
|
||
expect(credit.getAttribute('title')).toBeNull();
|
||
expect(vi.mocked(mapsApi.placePhotoCredit)).toHaveBeenCalledWith('commons:abc');
|
||
// The full credit is one hover away when the line is cut short.
|
||
fireEvent.mouseEnter(credit);
|
||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Jane Doe, CC BY-SA 4.0');
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-106: the head band states its facts as pills and takes the category tint', async () => {
|
||
const coloured = { ...cat, color: '#10b981' };
|
||
const p: Place = { ...place, id: 714, category_id: cat.id, source: 'dawarich', phone: '+33 1 00', price: 12, currency: 'EUR' };
|
||
render(<PlaceInspector {...defaultProps} place={p} categories={[coloured]} />);
|
||
const header = document.querySelector('header') as HTMLElement;
|
||
expect(header.style.background).toContain('color-mix');
|
||
// Category as a pill with its colour dot.
|
||
const categoryPill = within(header).getByText('Landmark');
|
||
expect((categoryPill.querySelector('span') as HTMLElement).style.background).toBe('rgb(16, 185, 129)');
|
||
// The Dawarich mark keeps its tooltip and gets a name.
|
||
const mark = within(header).getByRole('img', { name: 'Added from your Dawarich recordings' });
|
||
fireEvent.mouseEnter(mark);
|
||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Added from your Dawarich recordings');
|
||
// Phone is a call link, the price a pill, the coordinates only from sm up.
|
||
expect(within(header).getByText('+33 1 00').closest('a')).toHaveAttribute('href', 'tel:+33 1 00');
|
||
expect(within(header).getByText(/12,00/)).toBeTruthy();
|
||
const coords = within(header).getByText(/48\.858400/);
|
||
expect(coords.className.split(/\s+/)).toEqual(expect.arrayContaining(['hidden', 'sm:inline-flex']));
|
||
// Nothing in the head band is separated with a middle dot any more.
|
||
expect(header.textContent).not.toContain('·');
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-107: the rating block is labelled and quotes the first usable review', async () => {
|
||
vi.mocked(mapsApi.details).mockResolvedValue({
|
||
place: { rating: 4.1, reviews: [{ text: 'meh' }, { text: 'Lovely staff and view' }] },
|
||
} as never);
|
||
const p = buildPlace({ id: 715, name: 'Reviewed', google_place_id: 'gp-715' });
|
||
render(<PlaceInspector {...defaultProps} place={p} onRate={vi.fn()} />);
|
||
expect(await screen.findByText(/Lovely staff and view/)).toBeTruthy();
|
||
const section = screen.getByText('Rating').closest('section') as HTMLElement;
|
||
expect(within(section).getAllByRole('radio')).toHaveLength(5);
|
||
expect(within(section).getByText(/Lovely staff and view/)).toBeTruthy();
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-108: every body block sits under its own label', () => {
|
||
const p: Place = { ...place, id: 716, notes: 'Bring cash', description: 'Iron lady' };
|
||
const assignmentInDay = [{ id: 99, place: p, day_id: 1, place_id: p.id, order_index: 0, notes: 'Early entry' }];
|
||
render(
|
||
<PlaceInspector
|
||
{...defaultProps}
|
||
place={p}
|
||
selectedDayId={1}
|
||
selectedAssignmentId={99}
|
||
assignments={{ '1': assignmentInDay }}
|
||
reservations={[buildReservation({ id: 610, title: 'Summit ticket', assignment_id: 99 })]}
|
||
tripMembers={[{ id: 1, username: 'ada' }, { id: 2, username: 'bob' }]}
|
||
/>
|
||
);
|
||
for (const [label, content] of [['Description', 'Iron lady'], ['Notes', 'Bring cash'], ['Notes for this day', 'Early entry'], ['Bookings', 'Summit ticket'], ['Participants', 'ada'], ['Files', '']] as const) {
|
||
const section = screen.getByText(label, { selector: 'div' }).closest('section') as HTMLElement;
|
||
expect(section).toBeTruthy();
|
||
if (content) expect(within(section).getByText(content)).toBeTruthy();
|
||
}
|
||
// Bookings and participants sit side by side from sm up.
|
||
const grid = screen.getByText('Bookings').closest('section')!.parentElement as HTMLElement;
|
||
expect(grid.className).toContain('sm:grid-cols-2');
|
||
});
|
||
})
|
||
|
||
// ── Blur booking codes on the linked-booking strip (#2457) ────────────────────
|
||
|
||
describe('PlaceInspector blur booking codes (#2457)', () => {
|
||
const renderWithBooking = (blur: boolean) => {
|
||
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius', blur_booking_codes: blur } });
|
||
const res = { ...buildReservation({ id: 3, title: 'Dinner at Jules Verne', status: 'confirmed', confirmation_number: 'TABLE-SECRET' }), assignment_id: 9 };
|
||
render(<PlaceInspector {...defaultProps} selectedDayId={1} selectedAssignmentId={9}
|
||
assignments={{ '1': [{ id: 9, place, place_id: place.id, day_id: 1, order_index: 0, notes: null }] }}
|
||
reservations={[res]} onEditReservation={vi.fn()} />);
|
||
};
|
||
|
||
it('FE-PLANNER-INSPECTOR-103: the booking code on the linked-booking strip is blurred while the setting is on', () => {
|
||
renderWithBooking(true);
|
||
expect(isBlurred(screen.getByText(/TABLE-SECRET/))).toBe(true);
|
||
});
|
||
|
||
it('FE-PLANNER-INSPECTOR-104: with the setting off the strip shows the code plainly', () => {
|
||
renderWithBooking(false);
|
||
expect(isBlurred(screen.getByText(/TABLE-SECRET/))).toBe(false);
|
||
});
|
||
});
|