mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
test: close the remaining gaps and raise the thresholds
Pushes lines from 95.2% to 98.1% and branches from 85.2% to 89.9%, mostly in the release-notes modal, the reservation overlay, the journey entry sheet and the trip dialogs.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// FE-PLANNER-DAYDETAIL-001 to FE-PLANNER-DAYDETAIL-079
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor, within, act } from '../../../tests/helpers/render';
|
||||
import { fireEvent, render, screen, waitFor, within, act } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
@@ -1436,3 +1436,397 @@ describe('DayDetailPanel', () => {
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// FE-W5DDP-001 to FE-W5DDP-013 — the remaining formatting, reservation-row and
|
||||
// hotel-picker branches of the day panel.
|
||||
describe('DayDetailPanel remaining branches', () => {
|
||||
const hotel = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 1, place_id: 5, place_name: 'Grand Hotel', place_address: 'Paris',
|
||||
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: '11:00', confirmation: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('FE-W5DDP-001: reservation times follow the 12h preference around midnight and noon', async () => {
|
||||
seedStore(useSettingsStore, { settings: { time_format: '12h', temperature_unit: 'celsius', blur_booking_codes: false } });
|
||||
render(
|
||||
<DayDetailPanel
|
||||
{...defaultProps}
|
||||
reservations={[
|
||||
buildReservation({ id: 9, type: 'event', title: 'Night Show', day_id: 1, reservation_time: '00:15', reservation_end_time: '12:45', status: 'confirmed' }),
|
||||
buildReservation({ id: 10, type: 'tour', title: 'Afternoon Tour', day_id: 1, reservation_time: '14:30', reservation_end_time: null, status: 'pending' }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('12:15 AM – 12:45 PM')).toBeInTheDocument();
|
||||
expect(screen.getByText('2:30 PM')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5DDP-002: a reservation without any time shows no time span', async () => {
|
||||
render(
|
||||
<DayDetailPanel
|
||||
{...defaultProps}
|
||||
reservations={[buildReservation({ id: 9, type: 'event', title: 'Open Ticket', day_id: 1, reservation_time: null, reservation_end_time: null })]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const row = (await screen.findByText('Open Ticket')).closest('div[style*="border-radius: 8px"]') as HTMLElement;
|
||||
expect(row.textContent).toBe('Open Ticket');
|
||||
});
|
||||
|
||||
it('FE-W5DDP-003: an unknown reservation type falls back to the generic icon and names its place', async () => {
|
||||
const place = buildPlace({ id: 10, name: 'Opera House' });
|
||||
render(
|
||||
<DayDetailPanel
|
||||
{...defaultProps}
|
||||
assignments={{ '1': [{ id: 77, day_id: 1, place }] as never }}
|
||||
reservations={[
|
||||
buildReservation({ id: 9, type: 'submarine', title: 'Deep Dive', assignment_id: 77, day_id: null }),
|
||||
buildReservation({ id: 11, type: 'hotel', title: 'Hidden Hotel', day_id: 1 }),
|
||||
buildReservation({ id: 12, type: 'flight', title: 'Other Day Flight', day_id: 2 }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const row = (await screen.findByText('Deep Dive')).closest('div[style*="border-radius: 8px"]') as HTMLElement;
|
||||
expect(row.querySelector('svg')?.getAttribute('class')).toMatch(/file-text/);
|
||||
expect(row).toHaveTextContent('Opera House');
|
||||
// hotels are shown in the accommodation block, other days are not shown at all
|
||||
expect(screen.queryByText('Hidden Hotel')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Other Day Flight')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5DDP-004: a hotel photo, a check-in window and a confirmation code all render', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/accommodations', () =>
|
||||
HttpResponse.json({ accommodations: [hotel({ place_image: '/uploads/places/hotel.jpg', check_in_end: '18:00', confirmation: 'ABC123' })] }),
|
||||
),
|
||||
);
|
||||
render(<DayDetailPanel {...defaultProps} />);
|
||||
|
||||
expect(await screen.findByText('14:00 – 18:00')).toBeInTheDocument();
|
||||
expect(screen.getByText('ABC123')).toBeInTheDocument();
|
||||
expect(document.querySelector('img[src="/uploads/places/hotel.jpg"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-W5DDP-005: a blurred booking code unblurs on hover and toggles on click', async () => {
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius', blur_booking_codes: true } });
|
||||
server.use(
|
||||
http.get('/api/trips/1/accommodations', () => HttpResponse.json({ accommodations: [hotel({ accommodation_id: 1 })] })),
|
||||
);
|
||||
render(
|
||||
<DayDetailPanel
|
||||
{...defaultProps}
|
||||
reservations={[buildReservation({ id: 40, type: 'hotel', title: 'Grand Hotel Booking', accommodation_id: 1, status: 'confirmed', confirmation_number: 'XY99' })]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const code = await screen.findByText('#XY99');
|
||||
expect(code.style.filter).toBe('blur(4px)');
|
||||
|
||||
fireEvent.mouseEnter(code);
|
||||
expect(code.style.filter).toBe('none');
|
||||
fireEvent.mouseLeave(code);
|
||||
expect(code.style.filter).toBe('blur(4px)');
|
||||
|
||||
fireEvent.click(code);
|
||||
expect(code.style.filter).toBe('none');
|
||||
fireEvent.click(code);
|
||||
expect(code.style.filter).toBe('blur(4px)');
|
||||
});
|
||||
|
||||
it('FE-W5DDP-006: saving the edit picker updates the accommodation and reloads the list', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAccommodationChange = vi.fn();
|
||||
let updateBody: Record<string, unknown> | null = null;
|
||||
let listCalls = 0;
|
||||
server.use(
|
||||
http.get('/api/trips/1/accommodations', () => {
|
||||
listCalls += 1;
|
||||
return HttpResponse.json({
|
||||
accommodations: [hotel(listCalls > 1 ? { place_name: 'Reloaded Hotel' } : {})],
|
||||
});
|
||||
}),
|
||||
http.put('/api/trips/1/accommodations/1', async ({ request }) => {
|
||||
updateBody = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ accommodation: hotel() });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<DayDetailPanel {...defaultProps} places={[buildPlace({ id: 5, name: 'Grand Hotel' })]} onAccommodationChange={onAccommodationChange} />);
|
||||
await screen.findByText('Grand Hotel');
|
||||
await user.click(screen.getAllByRole('button').find(b => b.querySelector('svg')?.getAttribute('class')?.includes('pencil')) as HTMLElement);
|
||||
await user.click(await screen.findByText(/^Save$/i));
|
||||
|
||||
await waitFor(() => expect(updateBody).not.toBeNull());
|
||||
expect(updateBody).toMatchObject({ place_id: 5, start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: '11:00', confirmation: null });
|
||||
await waitFor(() => expect(screen.getByText('Reloaded Hotel')).toBeInTheDocument());
|
||||
expect(onAccommodationChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-W5DDP-007: the hotel picker filters the place list by category', async () => {
|
||||
const user = userEvent.setup();
|
||||
const museums = { id: 3, name: 'Museums', color: '#ff0000' };
|
||||
render(
|
||||
<DayDetailPanel
|
||||
{...defaultProps}
|
||||
categories={[museums] as never}
|
||||
places={[
|
||||
buildPlace({ id: 10, name: 'Maison Blanche', category_id: 3 }),
|
||||
buildPlace({ id: 11, name: 'Chez Nous', category_id: 4 }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
await user.click(await screen.findByText(/Add accommodation/i));
|
||||
|
||||
expect(screen.getByRole('button', { name: /Chez Nous/ })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Museums' }));
|
||||
expect(screen.queryByRole('button', { name: /Chez Nous/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Maison Blanche/ })).toBeInTheDocument();
|
||||
|
||||
const filterRow = screen.getByRole('button', { name: 'Museums' }).parentElement as HTMLElement;
|
||||
await user.click(within(filterRow).getByRole('button', { name: /^All$/ }));
|
||||
expect(screen.getByRole('button', { name: /Chez Nous/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5DDP-008: the day-range pickers badge dated days, titled days and nothing else', async () => {
|
||||
const user = userEvent.setup();
|
||||
const dated = buildDay({ id: 1, trip_id: 1, date: '2025-06-15', title: 'Day in Paris' });
|
||||
const titled = buildDay({ id: 2, trip_id: 1, date: null, title: 'Free Day' });
|
||||
const bare = buildDay({ id: 3, trip_id: 1, date: null, title: null });
|
||||
|
||||
render(<DayDetailPanel {...defaultProps} day={dated} days={[dated, titled, bare]} places={[buildPlace({ id: 10, name: 'Maison Blanche' })]} />);
|
||||
await user.click(await screen.findByText(/Add accommodation/i));
|
||||
|
||||
// open the "from" select — the option rows carry the badges
|
||||
const range = screen.getByText('Apply to days').parentElement as HTMLElement;
|
||||
await user.click(within(range).getAllByRole('button')[0]);
|
||||
|
||||
expect(screen.getAllByText('Jun 15').length).toBeGreaterThanOrEqual(1);
|
||||
const freeDay = screen.getAllByRole('button', { name: /Free Day/ });
|
||||
expect(freeDay.some(b => b.textContent?.includes('Day 2'))).toBe(true);
|
||||
expect(screen.getAllByRole('button', { name: /^Day 3$/ }).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('FE-W5DDP-009: a day without a title falls back to its position and hides the pencil', () => {
|
||||
const bare = buildDay({ id: 2, trip_id: 1, date: null, title: null });
|
||||
render(<DayDetailPanel {...defaultProps} day={bare} days={[defaultProps.day, bare]} />);
|
||||
|
||||
expect(screen.getByText('Day 2')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Edit')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5DDP-010: renaming a day that is not in the list still commits the draft', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUpdateDayTitle = vi.fn();
|
||||
const orphan = buildDay({ id: 9, trip_id: 1, date: null, title: null });
|
||||
render(<DayDetailPanel {...defaultProps} day={orphan} days={[]} onUpdateDayTitle={onUpdateDayTitle} />);
|
||||
|
||||
expect(screen.getByText('Day ?')).toBeInTheDocument();
|
||||
await user.click(screen.getByLabelText('Edit'));
|
||||
const input = await screen.findByPlaceholderText('Day ?');
|
||||
await user.type(input, 'Named Later');
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(onUpdateDayTitle).toHaveBeenCalledWith(9, 'Named Later');
|
||||
});
|
||||
|
||||
it('FE-W5DDP-011: an unmapped weather condition falls back to the cloud icon', async () => {
|
||||
server.use(
|
||||
http.get('/api/weather/detailed', () =>
|
||||
HttpResponse.json({
|
||||
main: 'Sandstorm', temp: 30, description: 'blowing sand', type: 'forecast',
|
||||
hourly: [{ hour: 9, main: 'Sandstorm', temp: 28, precipitation_probability: 0 }],
|
||||
}),
|
||||
),
|
||||
);
|
||||
render(<DayDetailPanel {...defaultProps} lat={48.85} lng={2.35} />);
|
||||
|
||||
expect(await screen.findByText('blowing sand')).toBeInTheDocument();
|
||||
expect(document.querySelectorAll('svg[class*="lucide-cloud"]').length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('FE-W5DDP-012: an ISO check-in timestamp is rendered in local time', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/accommodations', () =>
|
||||
HttpResponse.json({ accommodations: [hotel({ check_in: '2025-06-15T14:00', check_out: null })] }),
|
||||
),
|
||||
);
|
||||
render(<DayDetailPanel {...defaultProps} />);
|
||||
|
||||
expect(await screen.findByText('14:00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5DDP-013: the collapsed header keeps the title and the date on one line', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleCollapse = vi.fn();
|
||||
const { rerender } = render(<DayDetailPanel {...defaultProps} collapsed={false} onToggleCollapse={onToggleCollapse} />);
|
||||
|
||||
await user.click(screen.getByTitle('Collapse'));
|
||||
expect(onToggleCollapse).toHaveBeenCalled();
|
||||
|
||||
rerender(<DayDetailPanel {...defaultProps} collapsed onToggleCollapse={onToggleCollapse} />);
|
||||
const header = screen.getByText('Day in Paris').closest('div') as HTMLElement;
|
||||
expect(header).toHaveTextContent(/Sunday, June 15/);
|
||||
expect(screen.getByTitle('Expand')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// FE-W5DDP-014 to FE-W5DDP-020
|
||||
describe('DayDetailPanel remaining branches, part two', () => {
|
||||
const hotel = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 1, place_id: 5, place_name: 'Grand Hotel', place_address: 'Paris',
|
||||
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: '11:00', confirmation: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('FE-W5DDP-014: an unblurred booking code stays readable and pending bookings look different', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/accommodations', () => HttpResponse.json({ accommodations: [hotel()] })),
|
||||
);
|
||||
render(
|
||||
<DayDetailPanel
|
||||
{...defaultProps}
|
||||
reservations={[buildReservation({ id: 40, type: 'hotel', title: 'Grand Hotel Booking', accommodation_id: 1, status: 'pending', confirmation_number: 'XY99' })]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const code = await screen.findByText('#XY99');
|
||||
expect(code.style.filter).toBe('none');
|
||||
expect(code.style.cursor).toBe('default');
|
||||
|
||||
fireEvent.mouseEnter(code);
|
||||
fireEvent.click(code);
|
||||
fireEvent.mouseLeave(code);
|
||||
expect(code.style.filter).toBe('none');
|
||||
expect(screen.getByText('Pending')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5DDP-015: mobile mode lifts the panel and works without a ResizeObserver', () => {
|
||||
vi.stubGlobal('ResizeObserver', undefined);
|
||||
try {
|
||||
const { container } = render(<DayDetailPanel {...defaultProps} mobile collapsed />);
|
||||
const panel = container.firstElementChild as HTMLElement;
|
||||
|
||||
expect(panel.style.zIndex).toBe('10000');
|
||||
expect(document.documentElement.style.getPropertyValue('--day-panel-h')).not.toBe('');
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-W5DDP-016: with coordinates the reservations block gets its own divider', async () => {
|
||||
render(
|
||||
<DayDetailPanel
|
||||
{...defaultProps}
|
||||
lat={48.85}
|
||||
lng={2.35}
|
||||
reservations={[buildReservation({ id: 9, type: 'event', title: 'Late Entry', day_id: 1, reservation_time: null, reservation_end_time: '22:30' })]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Late Entry')).toBeInTheDocument();
|
||||
expect(screen.getByText('– 22:30')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5DDP-017: moving the start day past the end day drags the end day with it', async () => {
|
||||
const user = userEvent.setup();
|
||||
const d1 = buildDay({ id: 1, trip_id: 1, date: '2025-06-15', title: 'Day in Paris' });
|
||||
const d2 = buildDay({ id: 2, trip_id: 1, date: '2025-06-16', title: 'Day Two' });
|
||||
render(<DayDetailPanel {...defaultProps} day={d1} days={[d1, d2]} places={[buildPlace({ id: 10, name: 'Maison Blanche' })]} />);
|
||||
await user.click(await screen.findByText(/Add accommodation/i));
|
||||
|
||||
const range = screen.getByText('Apply to days').parentElement as HTMLElement;
|
||||
const [fromSelect, toSelect] = within(range).getAllByRole('button');
|
||||
|
||||
await user.click(fromSelect);
|
||||
await user.click(screen.getAllByRole('button', { name: /Day Two/ })[0]);
|
||||
expect(within(range).getAllByRole('button')[1]).toHaveTextContent('Day Two');
|
||||
|
||||
await user.click(within(range).getAllByRole('button')[1] === toSelect ? toSelect : within(range).getAllByRole('button')[1]);
|
||||
await user.click(screen.getAllByRole('button', { name: /Day in Paris/ })[0]);
|
||||
expect(within(range).getAllByRole('button')[0]).toHaveTextContent('Day in Paris');
|
||||
|
||||
// moving each end back inside the range leaves the other one alone
|
||||
await user.click(within(range).getAllByRole('button')[1]);
|
||||
await user.click(screen.getAllByRole('button', { name: /Day Two/ })[0]);
|
||||
expect(within(range).getAllByRole('button')[0]).toHaveTextContent('Day in Paris');
|
||||
|
||||
await user.click(within(range).getAllByRole('button')[0]);
|
||||
await user.click(screen.getAllByRole('button', { name: /Day in Paris/ })[0]);
|
||||
expect(within(range).getAllByRole('button')[1]).toHaveTextContent('Day Two');
|
||||
});
|
||||
|
||||
it('FE-W5DDP-018: the picked place keeps its highlight on hover and shows its photo', async () => {
|
||||
const user = userEvent.setup();
|
||||
const withPhoto = buildPlace({ id: 10, name: 'Maison Blanche', image_url: '/uploads/places/mb.jpg', address: null });
|
||||
const other = buildPlace({ id: 11, name: 'Chez Nous' });
|
||||
render(<DayDetailPanel {...defaultProps} places={[withPhoto, other]} />);
|
||||
await user.click(await screen.findByText(/Add accommodation/i));
|
||||
|
||||
const picked = screen.getByRole('button', { name: /Maison Blanche/ });
|
||||
await user.click(picked);
|
||||
expect(picked.style.background).toBe('var(--bg-hover)');
|
||||
|
||||
fireEvent.mouseEnter(picked);
|
||||
fireEvent.mouseLeave(picked);
|
||||
expect(picked.style.background).toBe('var(--bg-hover)');
|
||||
expect(document.querySelector('img[src="/uploads/places/mb.jpg"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-W5DDP-019: a category without a colour falls back to the default filter highlight', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DayDetailPanel
|
||||
{...defaultProps}
|
||||
categories={[{ id: 3, name: 'Uncoloured', color: null }] as never}
|
||||
places={[buildPlace({ id: 10, name: 'Maison Blanche', category_id: 3 })]}
|
||||
/>,
|
||||
);
|
||||
await user.click(await screen.findByText(/Add accommodation/i));
|
||||
|
||||
const chip = screen.getByRole('button', { name: 'Uncoloured' });
|
||||
await user.click(chip);
|
||||
expect(chip.style.background).toBe('var(--text-primary)');
|
||||
});
|
||||
|
||||
it('FE-W5DDP-022: a plugin column for this day is appended above the reservations', async () => {
|
||||
server.use(
|
||||
http.get('/api/view-contributions/day/1', () =>
|
||||
HttpResponse.json({ contributions: [{ kind: 'column', pluginId: 'sun', entityId: 1, label: 'Daylight', value: '15h 20m', tone: 'default' }] }),
|
||||
),
|
||||
);
|
||||
render(<DayDetailPanel {...defaultProps} />);
|
||||
|
||||
expect(await screen.findByText('15h 20m')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5DDP-021: a collapsed untitled day falls back to its position, or to ? when it has none', () => {
|
||||
const bare = buildDay({ id: 2, trip_id: 1, date: null, title: null });
|
||||
const { unmount } = render(<DayDetailPanel {...defaultProps} day={bare} days={[defaultProps.day, bare]} collapsed />);
|
||||
expect(screen.getByText('Day 2')).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
render(<DayDetailPanel {...defaultProps} day={bare} days={[]} collapsed />);
|
||||
expect(screen.getByText('Day ?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5DDP-020: an empty reload after the edit-save clears the accommodation', async () => {
|
||||
const user = userEvent.setup();
|
||||
let listCalls = 0;
|
||||
server.use(
|
||||
http.get('/api/trips/1/accommodations', () => {
|
||||
listCalls += 1;
|
||||
return listCalls === 1 ? HttpResponse.json({ accommodations: [hotel()] }) : HttpResponse.json({});
|
||||
}),
|
||||
http.put('/api/trips/1/accommodations/1', () => HttpResponse.json({ accommodation: hotel() })),
|
||||
);
|
||||
|
||||
render(<DayDetailPanel {...defaultProps} places={[buildPlace({ id: 5, name: 'Grand Hotel' })]} />);
|
||||
await screen.findByText('Grand Hotel');
|
||||
await user.click(screen.getAllByRole('button').find(b => b.querySelector('svg')?.getAttribute('class')?.includes('pencil')) as HTMLElement);
|
||||
await user.click(await screen.findByText(/^Save$/i));
|
||||
|
||||
expect(await screen.findByText(/Add accommodation/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3667,3 +3667,83 @@ describe('DayPlanSidebar', () => {
|
||||
expect(row.style.opacity).toBe('1')
|
||||
})
|
||||
})
|
||||
|
||||
// FE-W5DPS-001 to FE-W5DPS-006 — booking subtitles, the collections entry in the
|
||||
// place menu and the accommodation ordering on a hotel-change day.
|
||||
describe('DayPlanSidebar remaining branches', () => {
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
|
||||
const renderWith = (reservations: Reservation[], overrides = {}) => {
|
||||
seedStore(useTripStore, { reservations })
|
||||
return render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations, ...overrides })} />)
|
||||
}
|
||||
|
||||
it('FE-W5DPS-001: a flight without an airline still shows its flight number', async () => {
|
||||
renderWith([buildReservation({
|
||||
id: 601, type: 'flight', title: 'Hop', day_id: 10,
|
||||
reservation_time: '2025-06-01T09:00:00', metadata: JSON.stringify({ flight_number: 'AF900' }),
|
||||
}) as Reservation])
|
||||
|
||||
expect(await screen.findByText('AF900')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5DPS-002: a train shows its train number', async () => {
|
||||
renderWith([buildReservation({
|
||||
id: 602, type: 'train', title: 'ICE', day_id: 10,
|
||||
reservation_time: '2025-06-01T09:00:00', metadata: JSON.stringify({ train_number: 'ICE 599' }),
|
||||
}) as Reservation])
|
||||
|
||||
expect(await screen.findByText('ICE 599')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5DPS-003: a booking without usable metadata gets no subtitle', async () => {
|
||||
renderWith([buildReservation({
|
||||
id: 603, type: 'bus', title: 'Shuttle', day_id: 10,
|
||||
reservation_time: '2025-06-01T09:00:00', metadata: JSON.stringify({ seat: '4B' }),
|
||||
}) as Reservation])
|
||||
|
||||
const row = cardRow(await screen.findByText('Shuttle'))
|
||||
expect(row).not.toHaveTextContent('4B')
|
||||
})
|
||||
|
||||
it('FE-W5DPS-005: the place menu offers Save to collection only with the addon on', async () => {
|
||||
const user = userEvent.setup()
|
||||
const place = buildPlace({ id: 1, name: 'Louvre' })
|
||||
const assignments = { '10': [buildAssignment({ id: 11, day_id: 10, order_index: 0, place })] }
|
||||
const { unmount } = render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments })} />)
|
||||
|
||||
fireEvent.contextMenu(dragRow(screen.getByText('Louvre')))
|
||||
expect(contextMenu().queryByText(/save to/i)).not.toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
const { useAddonStore } = await import('../../store/addonStore')
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'collections', name: 'Collections', type: 'trip', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
})
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments })} />)
|
||||
|
||||
fireEvent.contextMenu(dragRow(screen.getByText('Louvre')))
|
||||
await user.click(contextMenu().getByText(/save to/i))
|
||||
|
||||
const { useSaveToCollectionStore } = await import('../../store/saveToCollectionStore')
|
||||
expect(useSaveToCollectionStore.getState().target).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5DPS-006: a hotel-change day lists the departing stay before the arriving one', async () => {
|
||||
const dayBefore = buildDay({ id: 9, date: '2025-05-31', title: 'Day 0' })
|
||||
const dayAfter = buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' })
|
||||
const accommodations = [
|
||||
{ id: 1, place_id: 5, place_name: 'Arriving Inn', start_day_id: 10, end_day_id: 11 },
|
||||
{ id: 2, place_id: 6, place_name: 'Departing Inn', start_day_id: 9, end_day_id: 10 },
|
||||
] as unknown as Accommodation[]
|
||||
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [dayBefore, day, dayAfter], accommodations, selectedDayId: 10,
|
||||
})} />)
|
||||
|
||||
const body = document.body.textContent ?? ''
|
||||
expect(body.indexOf('Departing Inn')).toBeGreaterThan(-1)
|
||||
expect(body.indexOf('Departing Inn')).toBeLessThan(body.indexOf('Arriving Inn'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1115,3 +1115,218 @@ describe('PlaceFormModal', () => {
|
||||
expect(warning).not.toHaveTextContent('Timeless Event');
|
||||
});
|
||||
});
|
||||
|
||||
// FE-W5PFM-001 to FE-W5PFM-014 — the remaining prefill, response-shape and
|
||||
// error branches of the place form.
|
||||
describe('PlaceFormModal remaining branches', () => {
|
||||
it('FE-W5PFM-001: an existing place with empty fields opens on the defaults', () => {
|
||||
const place = buildPlace({
|
||||
name: null, description: null, address: null, lat: null, lng: null,
|
||||
category_id: null, notes: null, transport_mode: null, website: null,
|
||||
});
|
||||
render(<PlaceFormModal {...defaultProps} place={place} />);
|
||||
|
||||
expect(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i)).toHaveValue('');
|
||||
expect(screen.getByPlaceholderText(/Latitude/i)).toHaveValue('');
|
||||
expect(screen.getByPlaceholderText(/Longitude/i)).toHaveValue('');
|
||||
expect(screen.getByPlaceholderText('https://...')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('FE-W5PFM-002: prefilled coordinates without a name only fill the coordinates', () => {
|
||||
render(<PlaceFormModal {...defaultProps} prefillCoords={{ lat: 48.8584, lng: 2.2945 }} />);
|
||||
|
||||
expect(screen.getByPlaceholderText(/Latitude/i)).toHaveValue('48.8584');
|
||||
expect(screen.getByPlaceholderText(/Longitude/i)).toHaveValue('2.2945');
|
||||
expect(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i)).toHaveValue('');
|
||||
expect(screen.getByPlaceholderText(/Street, City, Country/i)).toHaveValue('');
|
||||
});
|
||||
|
||||
it('FE-W5PFM-003: an autocomplete response without a suggestions array shows no dropdown', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.post('/api/maps/autocomplete', () => HttpResponse.json({})));
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
|
||||
expect(screen.queryByText('Eiffel Tower, Paris')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5PFM-004: an aborted autocomplete is swallowed without logging', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const user = userEvent.setup();
|
||||
const gate: { release?: () => void } = {};
|
||||
let seen = 0;
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', async () => {
|
||||
seen += 1;
|
||||
if (seen === 1) await new Promise<void>(res => { gate.release = res; });
|
||||
return HttpResponse.json({
|
||||
suggestions: [{ placeId: 'node:1', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
const searchInput = screen.getByPlaceholderText('Search places...');
|
||||
await user.type(searchInput, 'Eiff');
|
||||
await waitFor(() => expect(seen).toBe(1));
|
||||
// The second query aborts the still-open first one
|
||||
await user.type(searchInput, 'el tower');
|
||||
await waitFor(() => expect(seen).toBe(2));
|
||||
gate.release?.();
|
||||
|
||||
expect(await screen.findByText('Paris, France')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Eiffel Tower')).toHaveLength(1);
|
||||
expect(consoleError).not.toHaveBeenCalledWith('Autocomplete failed:', expect.anything());
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it('FE-W5PFM-005: a resolved URL without a name or address keeps the form values', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/maps/resolve-url', () => HttpResponse.json({ lat: 48.853, lng: 2.3499 })),
|
||||
);
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Kept Name');
|
||||
await user.type(screen.getByPlaceholderText('Search places...'), 'https://maps.google.com/maps?q=x');
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expect(await screen.findByDisplayValue('48.853')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i)).toHaveValue('Kept Name');
|
||||
});
|
||||
|
||||
it('FE-W5PFM-006: a search response without a places array renders no results', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.post('/api/maps/search', () => HttpResponse.json({})));
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
await user.type(screen.getByPlaceholderText('Search places...'), 'nowhere');
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('Search places...')).toHaveValue('nowhere'));
|
||||
expect(screen.queryByRole('button', { name: /nowhere/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5PFM-007: picking a bare search result leaves the typed fields alone', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Bare Result' }] })),
|
||||
);
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
await user.type(screen.getByPlaceholderText(/Street, City, Country/i), 'Rue de Rivoli');
|
||||
await user.type(screen.getByPlaceholderText('Search places...'), 'bare');
|
||||
await user.keyboard('{Enter}');
|
||||
await user.click(await screen.findByText('Bare Result'));
|
||||
|
||||
expect(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i)).toHaveValue('Bare Result');
|
||||
expect(screen.getByPlaceholderText(/Street, City, Country/i)).toHaveValue('Rue de Rivoli');
|
||||
expect(screen.getByPlaceholderText(/Latitude/i)).toHaveValue('');
|
||||
});
|
||||
|
||||
it('FE-W5PFM-008: a place sharing only the name is flagged as a duplicate', async () => {
|
||||
const addToast = vi.fn();
|
||||
window.__addToast = addToast;
|
||||
const user = userEvent.setup();
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1 }), places: [buildPlace({ name: 'Louvre', lat: null, lng: null })] });
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} onSave={onSave} />);
|
||||
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'louvre');
|
||||
await user.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(addToast).toHaveBeenCalledWith(expect.stringContaining('Louvre'), 'warning', undefined);
|
||||
|
||||
// The second click is the explicit confirmation
|
||||
await user.click(screen.getByRole('button', { name: /Add anyway/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
it('FE-W5PFM-009: a place sharing only the coordinates is flagged as a duplicate', async () => {
|
||||
const addToast = vi.fn();
|
||||
window.__addToast = addToast;
|
||||
const user = userEvent.setup();
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1 }), places: [buildPlace({ name: null, lat: 48.8584, lng: 2.2945 })] });
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} onSave={onSave} />);
|
||||
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Tour Eiffel');
|
||||
await user.type(screen.getByPlaceholderText(/Latitude/i), '48.85841');
|
||||
await user.type(screen.getByPlaceholderText(/Longitude/i), '2.29451');
|
||||
await user.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
// the existing place has no name, so the warning falls back to the typed one
|
||||
expect(addToast).toHaveBeenCalledWith(expect.stringContaining('Tour Eiffel'), 'warning', undefined);
|
||||
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
it('FE-W5PFM-010: a rejected save without an Error surfaces the generic message', async () => {
|
||||
const addToast = vi.fn();
|
||||
window.__addToast = addToast;
|
||||
const user = userEvent.setup();
|
||||
const onSave = vi.fn().mockRejectedValue('nope');
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} onSave={onSave} onClose={onClose} />);
|
||||
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Louvre');
|
||||
await user.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to save', 'error', undefined));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
it('FE-W5PFM-011: pasting a coordinate pair into latitude fills both fields', () => {
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
const lat = screen.getByPlaceholderText(/Latitude/i);
|
||||
|
||||
fireEvent.paste(lat, { clipboardData: { getData: () => ' 48.8584, 2.2945 ' } });
|
||||
expect(screen.getByPlaceholderText(/Latitude/i)).toHaveValue('48.8584');
|
||||
expect(screen.getByPlaceholderText(/Longitude/i)).toHaveValue('2.2945');
|
||||
});
|
||||
|
||||
it('FE-W5PFM-012: pasting anything else into latitude is left to the input', () => {
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
const lat = screen.getByPlaceholderText(/Latitude/i);
|
||||
|
||||
fireEvent.paste(lat, { clipboardData: { getData: () => 'not a coordinate' } });
|
||||
expect(screen.getByPlaceholderText(/Longitude/i)).toHaveValue('');
|
||||
});
|
||||
|
||||
it('FE-W5PFM-013: a missing category list still renders the no-category option', () => {
|
||||
render(<PlaceFormModal {...defaultProps} categories={null} />);
|
||||
|
||||
expect(screen.getByText(/No category/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5PFM-014: an open-ended assignment collapses to a point in time', () => {
|
||||
const current = buildPlace({ name: 'My Event', place_time: '11:30', end_time: '' });
|
||||
const spanning = buildPlace({ name: 'Spanning Event', place_time: '11:00', end_time: '12:00' });
|
||||
const earlier = buildPlace({ name: 'Earlier Event', place_time: '11:00', end_time: '' });
|
||||
|
||||
render(
|
||||
<PlaceFormModal
|
||||
{...defaultProps}
|
||||
place={current}
|
||||
assignmentId={10}
|
||||
dayAssignments={[
|
||||
buildAssignment({ id: 10, day_id: 5, place: current }),
|
||||
buildAssignment({ id: 20, day_id: 5, place: spanning }),
|
||||
buildAssignment({ id: 30, day_id: 5, place: earlier }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const warning = screen.getByText(/Time overlap with:/i).closest('div') as HTMLElement;
|
||||
expect(warning).toHaveTextContent('Spanning Event');
|
||||
expect(warning).not.toHaveTextContent('Earlier Event');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user