// FE-PLANNER-PSHOOK-001 to FE-PLANNER-PSHOOK-048
import { http, HttpResponse } from 'msw';
import userEvent from '@testing-library/user-event';
import { render, screen, fireEvent, act, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildPlace, buildDay, buildAssignment } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { usePermissionsStore } from '../../store/permissionsStore';
import { useAddonStore } from '../../store/addonStore';
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore';
import { ContextMenu } from '../shared/ContextMenu';
import type { Place } from '../../types';
import { usePlacesSidebar, type PlacesSidebarProps, type SidebarState } from './usePlacesSidebar';
let S: SidebarState;
// The hook owns state that only makes sense against real DOM nodes (the scroll
// container, the per-row refs, the drag counter), so it is driven through a
// stripped-down host instead of renderHook.
function Host(props: PlacesSidebarProps) {
const state = usePlacesSidebar(props);
S = state;
return (
{state.sidebarDragOver &&
drop-active}
{state.filtered.map((p) => (
{ state.registerPlaceRow(p.id, el); }}
onContextMenu={(e) => state.openContextMenu(e, p)}
>
{p.name}
))}
);
}
function makeProps(overrides: Partial = {}): PlacesSidebarProps {
return {
tripId: 1,
places: [],
categories: [],
assignments: {},
selectedDayId: null,
selectedPlaceId: null,
onPlaceClick: vi.fn((_id: number | null) => {}),
onAddPlace: vi.fn(() => {}),
onAssignToDay: vi.fn((_placeId: number, _dayId: number) => {}),
onEditPlace: vi.fn((_place: Place) => {}),
onDeletePlace: vi.fn((_placeId: number) => {}),
days: [],
isMobile: false,
...overrides,
};
}
function names(): string[] {
return S.filtered.map((p) => p.name);
}
function enableCollections() {
seedStore(useAddonStore, {
addons: [{ id: 'collections', name: 'Collections', type: 'global', icon: '', enabled: true }],
loaded: true,
});
}
const addToast = vi.fn((_message: string, _type?: string, _duration?: number) => 0);
beforeEach(() => {
resetAllStores();
useSaveToCollectionStore.setState({ target: null });
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
addToast.mockClear();
window.__addToast = addToast;
});
afterEach(() => {
delete window.__addToast;
vi.unstubAllGlobals();
});
// ── Filtering, search and sorting ─────────────────────────────────────────────
describe('usePlacesSidebar filtering', () => {
it('FE-PLANNER-PSHOOK-001: passes every place through when nothing is filtered', () => {
const places = [buildPlace({ name: 'Alpha' }), buildPlace({ name: 'Beta' })];
render();
expect(names()).toEqual(['Alpha', 'Beta']);
expect(S.hasTracks).toBe(false);
});
it('FE-PLANNER-PSHOOK-002: search matches the name case-insensitively', () => {
const places = [buildPlace({ name: 'Museum of Art' }), buildPlace({ name: 'Central Park' })];
render();
act(() => { S.setSearch('MUSEUM'); });
expect(names()).toEqual(['Museum of Art']);
});
it('FE-PLANNER-PSHOOK-003: search also matches the address', () => {
const places = [
buildPlace({ name: 'UK Office', address: '10 Downing Street' }),
buildPlace({ name: 'Other', address: null }),
];
render();
act(() => { S.setSearch('downing'); });
expect(names()).toEqual(['UK Office']);
});
it('FE-PLANNER-PSHOOK-004: a search that matches nothing empties the list', () => {
const places = [buildPlace({ name: 'Alpha', address: 'Rue A' })];
render();
act(() => { S.setSearch('zzz'); });
expect(names()).toEqual([]);
});
it('FE-PLANNER-PSHOOK-005: the unplanned filter drops places assigned to a day', () => {
const planned = buildPlace({ id: 1, name: 'Planned' });
const loose = buildPlace({ id: 2, name: 'Loose' });
const assignments = { '3': [buildAssignment({ place: planned, day_id: 3 })] };
render();
act(() => { S.setFilter('unplanned'); });
expect(names()).toEqual(['Loose']);
expect([...S.plannedIds]).toEqual([1]);
});
it('FE-PLANNER-PSHOOK-006: the planned filter keeps only assigned places', () => {
const planned = buildPlace({ id: 1, name: 'Planned' });
const loose = buildPlace({ id: 2, name: 'Loose' });
const assignments = { '3': [buildAssignment({ place: planned, day_id: 3 })] };
render();
act(() => { S.setFilter('planned'); });
expect(names()).toEqual(['Planned']);
});
it('FE-PLANNER-PSHOOK-007: the tracks filter keeps only places carrying route geometry', () => {
const track = buildPlace({ name: 'GPX Track', route_geometry: '[[48,2],[49,3]]' });
const spot = buildPlace({ name: 'Plain Spot' });
render();
expect(S.hasTracks).toBe(true);
act(() => { S.setFilter('tracks'); });
expect(names()).toEqual(['GPX Track']);
});
it('FE-PLANNER-PSHOOK-008: a tracks filter without any track falls back to "all"', async () => {
seedStore(useTripStore, { placesFilter: 'tracks' });
render();
await waitFor(() => expect(useTripStore.getState().placesFilter).toBe('all'));
expect(names()).toEqual(['Plain Spot']);
});
it('FE-PLANNER-PSHOOK-009: a category filter keeps only that category', () => {
const tagged = buildPlace({ name: 'Tagged', category_id: 4 });
const other = buildPlace({ name: 'Other', category_id: 5 });
const none = buildPlace({ name: 'None', category_id: null });
render();
act(() => { S.setCategoryFilters(new Set(['4'])); });
expect(names()).toEqual(['Tagged']);
});
it('FE-PLANNER-PSHOOK-010: the "uncategorized" bucket keeps only places without a category', () => {
const tagged = buildPlace({ name: 'Tagged', category_id: 4 });
const none = buildPlace({ name: 'None', category_id: null });
render();
act(() => { S.setCategoryFilters(new Set(['uncategorized'])); });
expect(names()).toEqual(['None']);
});
it('FE-PLANNER-PSHOOK-011: toggleCategoryFilter adds and removes a category', () => {
render();
act(() => { S.toggleCategoryFilter('7'); });
expect([...useTripStore.getState().placesCategoryFilter]).toEqual(['7']);
act(() => { S.toggleCategoryFilter('7'); });
expect([...useTripStore.getState().placesCategoryFilter]).toEqual([]);
});
it('FE-PLANNER-PSHOOK-012: the star filter keeps everything at or above the floor', () => {
const places = [
buildPlace({ name: 'Unrated' }),
buildPlace({ name: 'Good', rating_avg: 4.2 }),
buildPlace({ name: 'Best', rating_avg: 4.9 }),
buildPlace({ name: 'Meh', rating_avg: 2.5 }),
];
render();
act(() => { S.setRatingFilter(4); });
// The list keeps its own order; the filter only decides who is on it.
expect(names()).toEqual(['Good', 'Best']);
});
it('FE-PLANNER-PSHOOK-013: an unrated place drops out as soon as a floor is set, and comes back with "all"', () => {
const places = [
buildPlace({ name: 'Unrated' }),
buildPlace({ name: 'Rated', rating_avg: 1 }),
];
render();
act(() => { S.setRatingFilter(1); });
expect(names()).toEqual(['Rated']);
act(() => { S.setRatingFilter('all'); });
expect(names()).toEqual(['Unrated', 'Rated']);
});
it('FE-PLANNER-PSHOOK-014: search and category filter combine', () => {
const places = [
buildPlace({ name: 'Cafe Nord', category_id: 4 }),
buildPlace({ name: 'Cafe Sud', category_id: 5 }),
buildPlace({ name: 'Bar Nord', category_id: 4 }),
];
render();
act(() => {
S.setCategoryFilters(new Set(['4']));
S.setSearch('cafe');
});
expect(names()).toEqual(['Cafe Nord']);
});
});
// ── Multi-select ──────────────────────────────────────────────────────────────
describe('usePlacesSidebar selection', () => {
it('FE-PLANNER-PSHOOK-015: toggleSelected adds and removes an id', () => {
render();
act(() => { S.toggleSelected(9); });
expect([...S.selectedIds]).toEqual([9]);
act(() => { S.toggleSelected(9); });
expect([...S.selectedIds]).toEqual([]);
});
it('FE-PLANNER-PSHOOK-016: exitSelectMode clears the mode and the selection', () => {
render();
act(() => { S.setSelectMode(true); S.toggleSelected(9); });
expect(S.selectMode).toBe(true);
act(() => { S.exitSelectMode(); });
expect(S.selectMode).toBe(false);
expect(S.selectedIds.size).toBe(0);
});
it('FE-PLANNER-PSHOOK-017: select mode ends by itself once every selected place is gone', () => {
const a = buildPlace({ id: 1, name: 'A' });
const b = buildPlace({ id: 2, name: 'B' });
const { rerender } = render();
act(() => { S.setSelectMode(true); S.toggleSelected(1); });
rerender();
expect(S.selectMode).toBe(false);
expect(S.selectedIds.size).toBe(0);
});
it('FE-PLANNER-PSHOOK-018: select mode survives while one selected place is still there', () => {
const a = buildPlace({ id: 1, name: 'A' });
const b = buildPlace({ id: 2, name: 'B' });
const { rerender } = render();
act(() => { S.setSelectMode(true); S.toggleSelected(1); S.toggleSelected(2); });
rerender();
expect(S.selectMode).toBe(true);
expect([...S.selectedIds].sort()).toEqual([1, 2]);
});
});
// ── Day assignment helpers ────────────────────────────────────────────────────
describe('usePlacesSidebar day helpers', () => {
it('FE-PLANNER-PSHOOK-019: isAssignedToSelectedDay and inDaySet reflect the selected day', () => {
const place = buildPlace({ id: 5, name: 'Assigned' });
const assignments = { '3': [buildAssignment({ place, day_id: 3 })] };
render();
expect(S.isAssignedToSelectedDay(5)).toBe(true);
expect(S.isAssignedToSelectedDay(99)).toBe(false);
expect([...S.inDaySet]).toEqual([5]);
});
it('FE-PLANNER-PSHOOK-020: without a selected day nothing counts as assigned', () => {
const place = buildPlace({ id: 5 });
const assignments = { '3': [buildAssignment({ place, day_id: 3 })] };
render();
expect(S.isAssignedToSelectedDay(5)).toBeFalsy();
expect(S.inDaySet.size).toBe(0);
});
it('FE-PLANNER-PSHOOK-021: a day without assignments yields an empty set', () => {
const place = buildPlace({ id: 5 });
render();
expect(S.inDaySet.size).toBe(0);
expect(S.isAssignedToSelectedDay(5)).toBe(false);
});
});
// ── Scroll restoration and row refs ───────────────────────────────────────────
describe('usePlacesSidebar scrolling', () => {
it('FE-PLANNER-PSHOOK-022: restores the previous scroll offset on mount', () => {
render();
expect(screen.getByTestId('sidebar').scrollTop).toBe(240);
});
it('FE-PLANNER-PSHOOK-023: a zero offset leaves the container untouched', () => {
render();
expect(screen.getByTestId('sidebar').scrollTop).toBe(0);
});
it('FE-PLANNER-PSHOOK-024: the selected row is scrolled into view exactly once', async () => {
const scrollIntoView = Element.prototype.scrollIntoView as unknown as ReturnType;
scrollIntoView.mockClear();
const places = [buildPlace({ id: 1, name: 'A' }), buildPlace({ id: 2, name: 'B' })];
const { rerender } = render();
await waitFor(() => expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' }));
scrollIntoView.mockClear();
// Re-rendering with the same selection must not scroll again.
rerender();
expect(scrollIntoView).not.toHaveBeenCalled();
});
it('FE-PLANNER-PSHOOK-025: clearing the selection resets the auto-scroll guard', async () => {
const scrollIntoView = Element.prototype.scrollIntoView as unknown as ReturnType;
const places = [buildPlace({ id: 1, name: 'A' }), buildPlace({ id: 2, name: 'B' })];
const { rerender } = render();
await waitFor(() => expect(scrollIntoView).toHaveBeenCalled());
rerender();
scrollIntoView.mockClear();
rerender();
await waitFor(() => expect(scrollIntoView).toHaveBeenCalledTimes(1));
});
it('FE-PLANNER-PSHOOK-026: a selected place filtered out of the list is not scrolled to', () => {
const scrollIntoView = Element.prototype.scrollIntoView as unknown as ReturnType;
const places = [buildPlace({ id: 1, name: 'Visible' }), buildPlace({ id: 2, name: 'Hidden' })];
const { rerender } = render();
act(() => { S.setSearch('Visible'); });
scrollIntoView.mockClear();
// The row is unmounted by the search, so registerPlaceRow has dropped its ref.
rerender();
expect(names()).toEqual(['Visible']);
expect(scrollIntoView).not.toHaveBeenCalled();
});
});
// ── Drag & drop import ────────────────────────────────────────────────────────
describe('usePlacesSidebar drag and drop', () => {
it('FE-PLANNER-PSHOOK-027: dragging over the sidebar raises the drop hint', () => {
render();
fireEvent.dragEnter(screen.getByTestId('sidebar'));
expect(screen.getByText('drop-active')).toBeInTheDocument();
fireEvent.dragOver(screen.getByTestId('sidebar'));
expect(screen.getByText('drop-active')).toBeInTheDocument();
});
it('FE-PLANNER-PSHOOK-028: leaving the last nested target lowers the hint again', () => {
render();
const sidebar = screen.getByTestId('sidebar');
fireEvent.dragEnter(sidebar);
fireEvent.dragEnter(sidebar);
fireEvent.dragLeave(sidebar);
expect(screen.getByText('drop-active')).toBeInTheDocument();
fireEvent.dragLeave(sidebar);
expect(screen.queryByText('drop-active')).not.toBeInTheDocument();
});
it('FE-PLANNER-PSHOOK-029: read-only members get no drop hint', () => {
seedStore(usePermissionsStore, { permissions: { place_edit: 'admin' } });
render();
fireEvent.dragEnter(screen.getByTestId('sidebar'));
fireEvent.dragOver(screen.getByTestId('sidebar'));
expect(screen.queryByText('drop-active')).not.toBeInTheDocument();
expect(S.canEditPlaces).toBe(false);
});
it('FE-PLANNER-PSHOOK-030: dropping a file arms the file-import modal', () => {
render();
const file = new File([''], 'route.gpx', { type: 'application/gpx+xml' });
fireEvent.drop(screen.getByTestId('sidebar'), { dataTransfer: { files: [file] } });
expect(S.fileImportOpen).toBe(true);
expect(S.sidebarDropFile?.name).toBe('route.gpx');
expect(screen.queryByText('drop-active')).not.toBeInTheDocument();
});
it('FE-PLANNER-PSHOOK-031: dropping nothing leaves the modal closed', () => {
render();
fireEvent.drop(screen.getByTestId('sidebar'), { dataTransfer: { files: [] } });
expect(S.fileImportOpen).toBe(false);
expect(S.sidebarDropFile).toBeNull();
});
it('FE-PLANNER-PSHOOK-032: a read-only member cannot drop a file either', () => {
seedStore(usePermissionsStore, { permissions: { place_edit: 'admin' } });
render();
const file = new File([''], 'route.gpx', { type: 'application/gpx+xml' });
fireEvent.drop(screen.getByTestId('sidebar'), { dataTransfer: { files: [file] } });
expect(S.fileImportOpen).toBe(false);
});
});
// ── List import ───────────────────────────────────────────────────────────────
describe('usePlacesSidebar list import', () => {
it('FE-PLANNER-PSHOOK-033: both providers are offered', () => {
render();
expect(S.availableListImportProviders).toEqual(['google', 'naver']);
expect(S.hasMultipleListImportProviders).toBe(true);
expect(S.listImportProvider).toBe('google');
});
it('FE-PLANNER-PSHOOK-034: an empty URL does not start an import', async () => {
let calls = 0;
server.use(http.post('/api/trips/1/places/import/google-list', () => {
calls++;
return HttpResponse.json({ count: 0, skipped: 0, places: [] });
}));
render();
act(() => { S.setListImportUrl(' '); });
await act(async () => { await S.handleListImport(); });
expect(calls).toBe(0);
expect(S.listImportLoading).toBe(false);
});
it('FE-PLANNER-PSHOOK-035: a successful Google import reloads the trip, toasts and closes', async () => {
const loadTrip = vi.fn().mockResolvedValue(undefined);
seedStore(useTripStore, { loadTrip });
let sentUrl: string | undefined;
let sentEnrich: boolean | undefined;
server.use(http.post('/api/trips/1/places/import/google-list', async ({ request }) => {
const body = await request.json() as { url: string; enrich?: boolean };
sentUrl = body.url;
sentEnrich = body.enrich;
return HttpResponse.json({ count: 2, skipped: 0, listName: 'Tokyo', places: [{ id: 20 }, { id: 21 }] });
}));
const pushUndo = vi.fn((_label: string, _fn: () => Promise | void) => {});
render();
act(() => { S.setListImportOpen(true); S.setListImportUrl(' https://maps.app.goo.gl/abc '); });
await act(async () => { await S.handleListImport(); });
expect(sentUrl).toBe('https://maps.app.goo.gl/abc');
expect(sentEnrich).toBe(false);
expect(loadTrip).toHaveBeenCalledWith(1);
expect(addToast).toHaveBeenCalledWith('2 places imported from "Tokyo"', 'success', undefined);
expect(S.listImportOpen).toBe(false);
expect(S.listImportUrl).toBe('');
expect(pushUndo).toHaveBeenCalledWith('Google Maps import', expect.any(Function));
});
it('FE-PLANNER-PSHOOK-036: the undo entry bulk-deletes the imported places', async () => {
const loadTrip = vi.fn().mockResolvedValue(undefined);
seedStore(useTripStore, { loadTrip });
server.use(http.post('/api/trips/1/places/import/google-list', () =>
HttpResponse.json({ count: 2, skipped: 0, listName: 'Tokyo', places: [{ id: 20 }, { id: 21 }] })));
let deletedIds: number[] = [];
server.use(http.post('/api/trips/1/places/bulk-delete', async ({ request }) => {
deletedIds = ((await request.json()) as { ids: number[] }).ids;
return HttpResponse.json({ deleted: deletedIds.length });
}));
let undoFn: (() => Promise | void) | undefined;
const pushUndo = vi.fn((_label: string, fn: () => Promise | void) => { undoFn = fn; });
render();
act(() => { S.setListImportUrl('https://maps.app.goo.gl/abc'); });
await act(async () => { await S.handleListImport(); });
await act(async () => { await undoFn?.(); });
expect(deletedIds).toEqual([20, 21]);
expect(loadTrip).toHaveBeenCalledTimes(2);
});
it('FE-PLANNER-PSHOOK-037: an import that skips everything warns instead of celebrating', async () => {
seedStore(useTripStore, { loadTrip: vi.fn().mockResolvedValue(undefined) });
server.use(http.post('/api/trips/1/places/import/google-list', () =>
HttpResponse.json({ count: 0, skipped: 4, listName: 'Tokyo', places: [] })));
render();
act(() => { S.setListImportUrl('https://maps.app.goo.gl/abc'); });
await act(async () => { await S.handleListImport(); });
expect(addToast).toHaveBeenCalledWith('All places were already in the trip.', 'warning', undefined);
});
it('FE-PLANNER-PSHOOK-038: the Naver provider hits the Naver endpoint and enriches when a maps key exists', async () => {
seedStore(useAuthStore, { hasMapsKey: true });
seedStore(useTripStore, { loadTrip: vi.fn().mockResolvedValue(undefined) });
let sentEnrich: boolean | undefined;
server.use(http.post('/api/trips/1/places/import/naver-list', async ({ request }) => {
sentEnrich = ((await request.json()) as { enrich?: boolean }).enrich;
return HttpResponse.json({ count: 1, skipped: 0, listName: 'Seoul', places: [{ id: 30 }] });
}));
const pushUndo = vi.fn((_label: string, _fn: () => Promise | void) => {});
render();
expect(S.canEnrichImport).toBe(true);
act(() => {
S.setListImportProvider('naver');
S.setListImportEnrich(true);
S.setListImportUrl('https://naver.me/xyz');
});
await act(async () => { await S.handleListImport(); });
expect(sentEnrich).toBe(true);
expect(addToast).toHaveBeenCalledWith('1 places imported from "Seoul"', 'success', undefined);
expect(pushUndo).toHaveBeenCalledWith('Naver Maps import', expect.any(Function));
});
it('FE-PLANNER-PSHOOK-039: enrichment stays off while the instance has no maps key', async () => {
seedStore(useTripStore, { loadTrip: vi.fn().mockResolvedValue(undefined) });
let sentEnrich: boolean | undefined;
server.use(http.post('/api/trips/1/places/import/google-list', async ({ request }) => {
sentEnrich = ((await request.json()) as { enrich?: boolean }).enrich;
return HttpResponse.json({ count: 1, skipped: 0, listName: 'Tokyo', places: [] });
}));
render();
expect(S.canEnrichImport).toBe(false);
act(() => { S.setListImportEnrich(true); S.setListImportUrl('https://maps.app.goo.gl/abc'); });
await act(async () => { await S.handleListImport(); });
expect(sentEnrich).toBe(false);
});
it('FE-PLANNER-PSHOOK-040: a failing import surfaces the server message', async () => {
server.use(http.post('/api/trips/1/places/import/google-list', () =>
HttpResponse.json({ error: 'List is private' }, { status: 400 })));
render();
act(() => { S.setListImportOpen(true); S.setListImportUrl('https://maps.app.goo.gl/abc'); });
await act(async () => { await S.handleListImport(); });
expect(addToast).toHaveBeenCalledWith('List is private', 'error', undefined);
// The dialog stays open so the URL can be corrected.
expect(S.listImportOpen).toBe(true);
expect(S.listImportLoading).toBe(false);
});
it('FE-PLANNER-PSHOOK-041: a failure without a server message falls back to the translated one', async () => {
server.use(http.post('/api/trips/1/places/import/naver-list', () => new HttpResponse(null, { status: 500 })));
render();
act(() => { S.setListImportProvider('naver'); S.setListImportUrl('https://naver.me/xyz'); });
await act(async () => { await S.handleListImport(); });
expect(addToast).toHaveBeenCalledWith('Failed to import Naver Maps list', 'error', undefined);
});
});
// ── Row context menu ──────────────────────────────────────────────────────────
describe('usePlacesSidebar context menu', () => {
it('FE-PLANNER-PSHOOK-042: a full-permission row offers edit, day, website, maps, collection and delete', () => {
enableCollections();
const place = buildPlace({ id: 3, name: 'Cafe', website: 'https://cafe.example', google_place_id: 'ChIJ1' });
render();
fireEvent.contextMenu(screen.getByTestId('row-3'));
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '+ Day' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Open Website' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Google Maps' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save to Collection' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument();
});
it('FE-PLANNER-PSHOOK-043: a read-only row without extras only offers the maps link', () => {
seedStore(usePermissionsStore, { permissions: { place_edit: 'admin' } });
const place = buildPlace({ id: 3, name: 'Cafe', website: null });
render();
fireEvent.contextMenu(screen.getByTestId('row-3'));
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '+ Day' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Open Website' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Save to Collection' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Google Maps' })).toBeInTheDocument();
});
it('FE-PLANNER-PSHOOK-044: Edit hands the place back to the planner', async () => {
const user = userEvent.setup();
const onEditPlace = vi.fn((_place: Place) => {});
const place = buildPlace({ id: 3, name: 'Cafe' });
render();
fireEvent.contextMenu(screen.getByTestId('row-3'));
await user.click(screen.getByRole('button', { name: 'Edit' }));
expect(onEditPlace).toHaveBeenCalledWith(expect.objectContaining({ id: 3 }));
});
it('FE-PLANNER-PSHOOK-045: "+ Day" uses the day that was selected when the menu opened', async () => {
const user = userEvent.setup();
const onAssignToDay = vi.fn((_placeId: number, _dayId: number) => {});
const place = buildPlace({ id: 3, name: 'Cafe' });
const days = [buildDay({ id: 6 })];
render();
fireEvent.contextMenu(screen.getByTestId('row-3'));
await user.click(screen.getByRole('button', { name: '+ Day' }));
expect(onAssignToDay).toHaveBeenCalledWith(3, 6);
});
it('FE-PLANNER-PSHOOK-046: Delete hands the id back to the planner', async () => {
const user = userEvent.setup();
const onDeletePlace = vi.fn((_placeId: number) => {});
const place = buildPlace({ id: 3, name: 'Cafe' });
render();
fireEvent.contextMenu(screen.getByTestId('row-3'));
await user.click(screen.getByRole('button', { name: 'Delete' }));
expect(onDeletePlace).toHaveBeenCalledWith(3);
});
it('FE-PLANNER-PSHOOK-047: Website and Google Maps open a new tab', async () => {
const user = userEvent.setup();
const open = vi.fn(() => null);
vi.stubGlobal('open', open);
const place = buildPlace({ id: 3, name: 'Cafe', website: 'https://cafe.example', lat: 48.8584, lng: 2.2945, google_place_id: null });
render();
fireEvent.contextMenu(screen.getByTestId('row-3'));
await user.click(screen.getByRole('button', { name: 'Open Website' }));
expect(open).toHaveBeenCalledWith('https://cafe.example', '_blank', 'noopener,noreferrer');
fireEvent.contextMenu(screen.getByTestId('row-3'));
await user.click(screen.getByRole('button', { name: 'Google Maps' }));
expect(open).toHaveBeenCalledWith('https://www.google.com/maps/search/?api=1&query=48.8584,2.2945', '_blank');
});
it('FE-PLANNER-PSHOOK-048: "Save to Collection" arms the picker with the place', async () => {
const user = userEvent.setup();
enableCollections();
const place = buildPlace({ id: 3, name: 'Cafe', address: 'Rue A' });
render();
fireEvent.contextMenu(screen.getByTestId('row-3'));
await user.click(screen.getByRole('button', { name: 'Save to Collection' }));
expect(useSaveToCollectionStore.getState().target).toMatchObject({
name: 'Cafe',
address: 'Rue A',
source_place_id: 3,
});
});
});