mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
d4bb8be86b
Adds ~45 new and updated test files covering Admin, Collab, Dashboard, Map, Memories, PDF, Photos, Planner, Settings, Vacay, Weather components, pages, stores, and a WebSocket integration test.
652 lines
29 KiB
TypeScript
652 lines
29 KiB
TypeScript
import { render, screen, waitFor, fireEvent, act } 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';
|
||
|
||
// ── 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 }) },
|
||
};
|
||
});
|
||
|
||
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} />);
|
||
// Find the X button — it's the close button with an X icon inside
|
||
const buttons = screen.getAllByRole('button');
|
||
// The close button is typically in the header, first button with X icon
|
||
const closeBtn = buttons.find(btn => btn.querySelector('svg'));
|
||
// Click the last-found header button that has no text label (the X)
|
||
// More reliable: find button by its position as close button
|
||
await user.click(buttons[0]); // first button is the close X
|
||
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();
|
||
const { container } = render(<PlaceInspector {...defaultProps} onEdit={onEdit} />);
|
||
// The edit button has Edit2 icon — find footer buttons
|
||
const allButtons = screen.getAllByRole('button');
|
||
// Edit button is second-to-last in footer (before delete)
|
||
const editBtn = allButtons[allButtons.length - 2];
|
||
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');
|
||
// Delete button is the last button in the footer
|
||
const deleteBtn = allButtons[allButtons.length - 1];
|
||
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: { id: place.id }, 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: { id: place.id }, 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 has "Remove" text (sm:hidden span)
|
||
const removeBtn = screen.getByText('Remove').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} />);
|
||
await screen.findByText(/4\.5/);
|
||
});
|
||
|
||
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 button text shows a day's hours line
|
||
const hoursBtn = await screen.findByText(/Show opening hours|Opening Hours|Mon:|9:00|09:00/i);
|
||
const btn = hoursBtn.closest('button')!;
|
||
await user.click(btn);
|
||
// After expand, one of the hours lines should be visible
|
||
await waitFor(() => {
|
||
expect(screen.getByText(/Mon:/)).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
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} />);
|
||
await screen.findByText(/open/i);
|
||
});
|
||
|
||
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);
|
||
await screen.findByText('photo.jpg');
|
||
} 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: { id: place.id }, 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();
|
||
});
|
||
|
||
// ── 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: { id: place.id }, 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} />);
|
||
expect(screen.getByText(/15 EUR/)).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();
|
||
});
|
||
|
||
// ── 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: { id: place.id }, day_id: 1, place_id: place.id, order_index: 0, notes: null,
|
||
participants: [{ user_id: 10 }],
|
||
}];
|
||
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: Google Maps lat/lng button visible when no google_maps_url', () => {
|
||
render(<PlaceInspector {...defaultProps} />);
|
||
// place has lat/lng so Google Maps button should appear with Navigation icon
|
||
const allButtons = screen.getAllByRole('button');
|
||
// Find button containing "Google Maps" text
|
||
const mapsBtn = allButtons.find(btn => btn.textContent?.includes('Google Maps'));
|
||
expect(mapsBtn).toBeTruthy();
|
||
});
|
||
|
||
// ── 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();
|
||
});
|
||
|
||
// ── 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: { id: place.id }, 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();
|
||
});
|
||
|
||
});
|
||
|