mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-07-09 15:05:59 +00:00
test(collections): client component tests + select in All saved, off the map (#1081)
- Add client tests for the new collections UI (80 tests): collectionsModel (incl. the undefined-entry guards that fix the white-screen regression), StatusBadge, CollectionFilterBar, CollectionList, CollectionPlaceDetail (permission gating), MoveToListModal. - Offer the select toggle in "All saved" too (server enforces per-place rights). - Drop the now-duplicate select button from the map controls (it lives in the filter row).
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
// FE-COMP-COLFILTERBAR-001 to FE-COMP-COLFILTERBAR-008
|
||||
import React from 'react';
|
||||
import { render, screen, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import { useTranslation } from '../../i18n/TranslationContext';
|
||||
import type { CategoryOption } from '../../pages/collections/collectionsModel';
|
||||
import type { StatusFilter } from '../../store/collectionStore';
|
||||
import CollectionFilterBar from './CollectionFilterBar';
|
||||
|
||||
// The component takes `t` as a prop; pull the REAL translation fn from context so
|
||||
// visible labels are actual English strings (All / Idea / Want to go / Visited / Select).
|
||||
type HarnessProps = Omit<React.ComponentProps<typeof CollectionFilterBar>, 't'>;
|
||||
|
||||
function Harness(props: HarnessProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
return <CollectionFilterBar {...props} t={t} />;
|
||||
}
|
||||
|
||||
const CATEGORY_OPTIONS: CategoryOption[] = [
|
||||
{ id: 1, name: 'Food', color: '#f00', icon: null, count: 2 },
|
||||
];
|
||||
|
||||
function makeProps(overrides: Partial<HarnessProps> = {}): HarnessProps {
|
||||
return {
|
||||
statusFilter: 'all' as StatusFilter,
|
||||
counts: { all: 3, idea: 1, want: 1, visited: 1 },
|
||||
categoryFilter: 'all',
|
||||
categoryOptions: CATEGORY_OPTIONS,
|
||||
onStatusFilter: vi.fn(),
|
||||
onCategoryFilter: vi.fn(),
|
||||
showSelect: true,
|
||||
selectMode: false,
|
||||
onToggleSelect: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('CollectionFilterBar', () => {
|
||||
it('FE-COMP-COLFILTERBAR-001: renders the status dropdown showing the current "All" filter', () => {
|
||||
render(<Harness {...makeProps()} />);
|
||||
// Both dropdown triggers currently read "All" (status=all, category=all).
|
||||
// With a category present there are exactly two "All" triggers: status + category.
|
||||
expect(screen.getAllByRole('button', { name: 'All' })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-002: opening the status dropdown reveals the status options', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness {...makeProps()} />);
|
||||
|
||||
// First "All" trigger is the status dropdown (rendered before the category one).
|
||||
const statusTrigger = screen.getAllByRole('button', { name: 'All' })[0];
|
||||
expect(statusTrigger).toHaveAttribute('aria-expanded', 'false');
|
||||
await user.click(statusTrigger);
|
||||
|
||||
const listbox = screen.getByRole('listbox');
|
||||
expect(within(listbox).getByRole('option', { name: /Idea/i })).toBeInTheDocument();
|
||||
expect(within(listbox).getByRole('option', { name: /Want to go/i })).toBeInTheDocument();
|
||||
expect(within(listbox).getByRole('option', { name: /Visited/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-003: clicking a status option calls onStatusFilter with that status', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onStatusFilter = vi.fn();
|
||||
render(<Harness {...makeProps({ onStatusFilter })} />);
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: 'All' })[0]);
|
||||
const listbox = screen.getByRole('listbox');
|
||||
await user.click(within(listbox).getByRole('option', { name: /Want to go/i }));
|
||||
|
||||
expect(onStatusFilter).toHaveBeenCalledTimes(1);
|
||||
expect(onStatusFilter).toHaveBeenCalledWith('want');
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-004: the category dropdown is present when categoryOptions is non-empty', () => {
|
||||
render(<Harness {...makeProps()} />);
|
||||
// Two dropdown triggers = status + category.
|
||||
const triggers = screen.getAllByRole('button', { name: 'All' });
|
||||
expect(triggers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-005: the category dropdown is hidden when categoryOptions is empty', () => {
|
||||
render(<Harness {...makeProps({ categoryOptions: [] })} />);
|
||||
// Only the status dropdown remains.
|
||||
expect(screen.getAllByRole('button', { name: 'All' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-006: clicking a category option calls onCategoryFilter with the category id', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCategoryFilter = vi.fn();
|
||||
render(<Harness {...makeProps({ onCategoryFilter })} />);
|
||||
|
||||
// Second "All" trigger is the category dropdown.
|
||||
await user.click(screen.getAllByRole('button', { name: 'All' })[1]);
|
||||
const listbox = screen.getByRole('listbox');
|
||||
await user.click(within(listbox).getByRole('option', { name: /Food/i }));
|
||||
|
||||
expect(onCategoryFilter).toHaveBeenCalledTimes(1);
|
||||
expect(onCategoryFilter).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-007: clicking the Select button calls onToggleSelect', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleSelect = vi.fn();
|
||||
render(<Harness {...makeProps({ onToggleSelect })} />);
|
||||
|
||||
const selectBtn = screen.getByRole('button', { name: 'Select' });
|
||||
expect(selectBtn).toHaveAttribute('aria-pressed', 'false');
|
||||
await user.click(selectBtn);
|
||||
|
||||
expect(onToggleSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-008: showSelect=false hides the Select button', () => {
|
||||
render(<Harness {...makeProps({ showSelect: false })} />);
|
||||
expect(screen.queryByRole('button', { name: 'Select' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
// FE-COMP-COLLIST-001 to FE-COMP-COLLIST-010
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { CollectionPlace } from '@trek/shared';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { useTranslation } from '../../i18n/TranslationContext';
|
||||
import CollectionList from './CollectionList';
|
||||
|
||||
// A saved-place row calls PlaceAvatar, which reads placesPhotosEnabled from the
|
||||
// auth store. Seeding it false (and leaving image_url unset) keeps the avatar a
|
||||
// plain category icon — no photoService fetch, no network in the test.
|
||||
|
||||
// Two inline CollectionPlace literals: one categorised (idea), one bare (want).
|
||||
const cafe = {
|
||||
id: 101,
|
||||
collection_id: 1,
|
||||
name: 'Blue Bottle Coffee',
|
||||
address: '123 Market St, San Francisco',
|
||||
status: 'idea',
|
||||
category: { id: 5, name: 'Cafe', color: '#f59e0b', icon: 'Coffee' },
|
||||
image_url: null,
|
||||
} as unknown as CollectionPlace;
|
||||
|
||||
const bridge = {
|
||||
id: 202,
|
||||
collection_id: 1,
|
||||
name: 'Golden Gate Bridge',
|
||||
address: 'Golden Gate, San Francisco',
|
||||
status: 'want',
|
||||
image_url: null,
|
||||
} as unknown as CollectionPlace;
|
||||
|
||||
const places = [cafe, bridge];
|
||||
|
||||
// Grab the real English translation fn so status labels match visible strings.
|
||||
function TFnProbe({ onReady }: { onReady: (t: ReturnType<typeof useTranslation>['t']) => void }) {
|
||||
const { t } = useTranslation();
|
||||
onReady(t);
|
||||
return null;
|
||||
}
|
||||
|
||||
let t: ReturnType<typeof useTranslation>['t'];
|
||||
render(<TFnProbe onReady={fn => { t = fn; }} />);
|
||||
|
||||
interface Handlers {
|
||||
onOpenPlace: ReturnType<typeof vi.fn>;
|
||||
onStatusChange: ReturnType<typeof vi.fn>;
|
||||
onToggleSelect: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function renderList(over: Partial<{
|
||||
selectMode: boolean;
|
||||
selectedIds: number[];
|
||||
selectedPlaceId: number | null;
|
||||
onStatusChange: ((placeId: number, status: string) => void) | undefined;
|
||||
}> = {}, handlers?: Handlers) {
|
||||
const h = handlers ?? {
|
||||
onOpenPlace: vi.fn(),
|
||||
onStatusChange: vi.fn(),
|
||||
onToggleSelect: vi.fn(),
|
||||
};
|
||||
const onStatusChange = 'onStatusChange' in over ? over.onStatusChange : h.onStatusChange;
|
||||
render(
|
||||
<CollectionList
|
||||
places={places}
|
||||
selectedPlaceId={over.selectedPlaceId ?? null}
|
||||
selectMode={over.selectMode ?? false}
|
||||
selectedIds={over.selectedIds ?? []}
|
||||
onOpenPlace={h.onOpenPlace as (id: number) => void}
|
||||
onStatusChange={onStatusChange as never}
|
||||
onToggleSelect={h.onToggleSelect as (id: number) => void}
|
||||
t={t}
|
||||
/>,
|
||||
);
|
||||
return h;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { user: buildUser(), placesPhotosEnabled: false });
|
||||
});
|
||||
|
||||
describe('CollectionList', () => {
|
||||
it('FE-COMP-COLLIST-001: renders both place names', () => {
|
||||
renderList();
|
||||
expect(screen.getByText('Blue Bottle Coffee')).toBeInTheDocument();
|
||||
expect(screen.getByText('Golden Gate Bridge')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIST-002: renders both place addresses', () => {
|
||||
renderList();
|
||||
expect(screen.getByText('123 Market St, San Francisco')).toBeInTheDocument();
|
||||
expect(screen.getByText('Golden Gate, San Francisco')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIST-003: renders the category name for the categorised place', () => {
|
||||
renderList();
|
||||
expect(screen.getByText('Cafe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIST-004: clicking a row calls onOpenPlace with the place id', async () => {
|
||||
const user = userEvent.setup();
|
||||
const h = renderList();
|
||||
const row = screen.getByText('Blue Bottle Coffee').closest('.col-lrow') as HTMLElement;
|
||||
await user.click(row);
|
||||
expect(h.onOpenPlace).toHaveBeenCalledWith(101);
|
||||
expect(h.onToggleSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIST-005: in select mode a row click calls onToggleSelect instead of onOpenPlace', async () => {
|
||||
const user = userEvent.setup();
|
||||
const h = renderList({ selectMode: true });
|
||||
const row = screen.getByText('Golden Gate Bridge').closest('.col-lrow') as HTMLElement;
|
||||
await user.click(row);
|
||||
expect(h.onToggleSelect).toHaveBeenCalledWith(202);
|
||||
expect(h.onOpenPlace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIST-006: the status badge is an interactive button when onStatusChange is provided', () => {
|
||||
renderList();
|
||||
// idea → label "Idea"; the badge is a role=button span with aria-label = label.
|
||||
expect(screen.getByRole('button', { name: 'Idea' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Want to go' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIST-007: clicking the interactive badge cycles the status without opening the place', async () => {
|
||||
const user = userEvent.setup();
|
||||
const h = renderList();
|
||||
await user.click(screen.getByRole('button', { name: 'Idea' }));
|
||||
// idea → want, and the badge stops propagation so the row does not open.
|
||||
expect(h.onStatusChange).toHaveBeenCalledWith(101, 'want');
|
||||
expect(h.onOpenPlace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIST-008: the status badge is read-only (not a button) when onStatusChange is undefined', () => {
|
||||
renderList({ onStatusChange: undefined });
|
||||
// Label text still renders...
|
||||
expect(screen.getByText('Idea')).toBeInTheDocument();
|
||||
// ...but there is no interactive status button for it.
|
||||
expect(screen.queryByRole('button', { name: 'Idea' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Want to go' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIST-009: in select mode the status badge is read-only even with onStatusChange provided', () => {
|
||||
renderList({ selectMode: true });
|
||||
expect(screen.getByText('Idea')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Idea' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIST-010: renders one clickable row per place', () => {
|
||||
renderList();
|
||||
// Each place contributes a role=button row; badges add their own buttons too,
|
||||
// but every place name must sit inside a .col-lrow row element.
|
||||
expect(screen.getByText('Blue Bottle Coffee').closest('.col-lrow')).toBeInTheDocument();
|
||||
expect(screen.getByText('Golden Gate Bridge').closest('.col-lrow')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { PanelLeftClose, PanelLeftOpen, Search, CheckSquare, Plus } from 'lucide-react'
|
||||
import { PanelLeftClose, PanelLeftOpen, Search, Plus } from 'lucide-react'
|
||||
import type { CollectionPlace } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import CollectionMap from './CollectionMap'
|
||||
@@ -15,10 +15,6 @@ interface CollectionMapPanelProps {
|
||||
/** 'list' = split (map can be expanded); 'map' = full (list collapsed). */
|
||||
view: 'list' | 'map'
|
||||
onToggleView: () => void
|
||||
/** Bulk-select toggle — hidden for the "All saved" view. */
|
||||
showSelect: boolean
|
||||
selectMode: boolean
|
||||
onToggleSelect: () => void
|
||||
/** Show a "+" to add a place to the current list (real lists only). */
|
||||
canAddPlace: boolean
|
||||
onAddPlace: () => void
|
||||
@@ -34,7 +30,7 @@ interface CollectionMapPanelProps {
|
||||
*/
|
||||
export default function CollectionMapPanel({
|
||||
places, selectedPlaceId, onSelect, onDeselect, dark, overlay, view, onToggleView,
|
||||
showSelect, selectMode, onToggleSelect, canAddPlace, onAddPlace, search, onSearch, t,
|
||||
canAddPlace, onAddPlace, search, onSearch, t,
|
||||
}: CollectionMapPanelProps): React.ReactElement {
|
||||
return (
|
||||
<div className="col-map-shell">
|
||||
@@ -57,18 +53,6 @@ export default function CollectionMapPanel({
|
||||
>
|
||||
{view === 'map' ? <PanelLeftOpen size={17} /> : <PanelLeftClose size={17} />}
|
||||
</button>
|
||||
{showSelect && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSelect}
|
||||
disabled={view === 'map'}
|
||||
className={`col-map-btn${selectMode ? ' on' : ''}`}
|
||||
aria-label={t('collections.selectMode')}
|
||||
title={t('collections.selectMode')}
|
||||
>
|
||||
<CheckSquare size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-map-group right">
|
||||
{canAddPlace && (
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// FE-COMP-COLDETAIL-001 to FE-COMP-COLDETAIL-010
|
||||
import React from 'react';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import type { CollectionPlace } from '@trek/shared';
|
||||
import { useTranslation } from '../../i18n/TranslationContext';
|
||||
import CollectionPlaceDetail from './CollectionPlaceDetail';
|
||||
|
||||
// The component takes `t` as a PROP (not from context), so wrap it in a tiny
|
||||
// consumer that feeds it the real English `t` from the TranslationProvider the
|
||||
// test render helper mounts. That way we can assert on visible English strings.
|
||||
type DetailProps = React.ComponentProps<typeof CollectionPlaceDetail>;
|
||||
function TranslatedDetail(props: Omit<DetailProps, 't'>): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
return <CollectionPlaceDetail {...props} t={t} />;
|
||||
}
|
||||
|
||||
// Place literal per spec: no image_url / lat / lng / provider id, so the cover
|
||||
// stays a gradient and status is 'idea'.
|
||||
const place: CollectionPlace = {
|
||||
id: 1,
|
||||
collection_id: 10,
|
||||
name: 'Test Cafe',
|
||||
status: 'idea',
|
||||
description: 'Nice spot',
|
||||
address: 'Somewhere',
|
||||
links: [{ url: 'https://x.com' }],
|
||||
category: { id: 1, name: 'Food', color: '#f00', icon: null },
|
||||
};
|
||||
|
||||
function renderDetail(overrides: Partial<Omit<DetailProps, 't'>> = {}) {
|
||||
const props = {
|
||||
place,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
categories: [],
|
||||
anchorRect: null,
|
||||
onClose: vi.fn(),
|
||||
onSetStatus: vi.fn(),
|
||||
onSave: vi.fn().mockResolvedValue(undefined),
|
||||
onCopyToTrip: vi.fn(),
|
||||
onRemove: vi.fn(),
|
||||
...overrides,
|
||||
} as Omit<DetailProps, 't'>;
|
||||
render(<TranslatedDetail {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { user: buildUser(), placesPhotosEnabled: false });
|
||||
// The detail sheet asks the maps provider for a cover photo on mount when a
|
||||
// place carries no image of its own — stub it so nothing hits the network.
|
||||
server.use(
|
||||
http.get('/api/maps/place-photo/:id', () =>
|
||||
HttpResponse.json({ photoUrl: null, attribution: null }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
describe('CollectionPlaceDetail', () => {
|
||||
it('FE-COMP-COLDETAIL-001: renders the place name, address and description', async () => {
|
||||
renderDetail();
|
||||
expect(await screen.findByRole('heading', { name: 'Test Cafe' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Somewhere')).toBeInTheDocument();
|
||||
expect(screen.getByText('Nice spot')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Editor / admin (canEdit + canDelete) ────────────────────────────────────
|
||||
it('FE-COMP-COLDETAIL-002: shows Edit and Remove buttons when canEdit && canDelete', async () => {
|
||||
renderDetail({ canEdit: true, canDelete: true });
|
||||
expect(await screen.findByRole('button', { name: 'Edit' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Remove from list' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-003: clicking a status option calls onSetStatus when canEdit', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderDetail({ canEdit: true, canDelete: true });
|
||||
const visited = await screen.findByRole('button', { name: 'Visited' });
|
||||
await user.click(visited);
|
||||
expect(props.onSetStatus).toHaveBeenCalledTimes(1);
|
||||
expect(props.onSetStatus).toHaveBeenCalledWith('visited');
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-004: current status option is pressed, others are not', async () => {
|
||||
renderDetail({ canEdit: true, canDelete: true });
|
||||
expect(await screen.findByRole('button', { name: 'Idea' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Want to go' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-005: entering edit mode reveals name input + Save', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDetail({ canEdit: true });
|
||||
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
||||
expect(screen.getByDisplayValue('Test Cafe')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Save/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Viewer (no edit / no delete) ────────────────────────────────────────────
|
||||
it('FE-COMP-COLDETAIL-006: hides Edit and Remove buttons when canEdit=false && canDelete=false', async () => {
|
||||
renderDetail({ canEdit: false, canDelete: false });
|
||||
// Wait for the async photo effect to settle before asserting absence.
|
||||
expect(await screen.findByRole('button', { name: 'Copy to trip' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Remove from list' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-007: clicking a status option does NOT call onSetStatus when canEdit=false', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderDetail({ canEdit: false, canDelete: false });
|
||||
const visited = await screen.findByRole('button', { name: 'Visited' });
|
||||
await user.click(visited);
|
||||
expect(props.onSetStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-008: status segment still renders (read-only) for viewers', async () => {
|
||||
renderDetail({ canEdit: false, canDelete: false });
|
||||
expect(await screen.findByRole('button', { name: 'Idea' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Visited' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Copy-to-trip is available in read mode regardless of permissions ─────────
|
||||
it('FE-COMP-COLDETAIL-009: Copy to trip button fires onCopyToTrip (editor)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderDetail({ canEdit: true, canDelete: true });
|
||||
await user.click(await screen.findByRole('button', { name: 'Copy to trip' }));
|
||||
expect(props.onCopyToTrip).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-010: Copy to trip button fires onCopyToTrip (viewer)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderDetail({ canEdit: false, canDelete: false });
|
||||
await user.click(await screen.findByRole('button', { name: 'Copy to trip' }));
|
||||
expect(props.onCopyToTrip).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
// FE-COMP-MOVETOLIST-001 to FE-COMP-MOVETOLIST-009
|
||||
import { render, screen, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { Collection } from '@trek/shared';
|
||||
import { useTranslation } from '../../i18n/TranslationContext';
|
||||
import MoveToListModal from './MoveToListModal';
|
||||
|
||||
// The modal receives `t` as a prop rather than reading context, so a tiny harness
|
||||
// pulls the REAL English translator from the provider and forwards it. That keeps
|
||||
// the assertions against real strings ("Move 2 to another list") instead of keys.
|
||||
function Harness(props: Omit<React.ComponentProps<typeof MoveToListModal>, 't'>) {
|
||||
const { t } = useTranslation();
|
||||
return <MoveToListModal {...props} t={t} />;
|
||||
}
|
||||
|
||||
const listA: Collection = { id: 11, owner_id: 1, name: 'Weekend in Rome', color: '#ef4444', place_count: 3 };
|
||||
const listB: Collection = { id: 22, owner_id: 1, name: 'Tokyo Food Tour', color: '#22c55e', place_count: 7 };
|
||||
|
||||
function renderModal(overrides: Partial<React.ComponentProps<typeof MoveToListModal>> = {}) {
|
||||
const props = {
|
||||
mode: 'move' as const,
|
||||
lists: [listA, listB],
|
||||
count: 2,
|
||||
onPick: vi.fn().mockResolvedValue(undefined),
|
||||
onClose: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
render(<Harness {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
describe('MoveToListModal', () => {
|
||||
it('FE-COMP-MOVETOLIST-001: renders every candidate list name', () => {
|
||||
renderModal();
|
||||
expect(screen.getByText('Weekend in Rome')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tokyo Food Tour')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOVETOLIST-002: title reflects the count in move mode', () => {
|
||||
renderModal({ mode: 'move', count: 2 });
|
||||
// collections.moveToListTitle = 'Move {count} to another list'
|
||||
expect(screen.getByRole('heading', { name: 'Move 2 to another list' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOVETOLIST-003: title reflects the count in copy mode', () => {
|
||||
renderModal({ mode: 'copy', count: 5 });
|
||||
// collections.duplicateToListTitle = 'Duplicate {count} to another list'
|
||||
expect(screen.getByRole('heading', { name: 'Duplicate 5 to another list' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOVETOLIST-004: shows the place count subtitle per list', () => {
|
||||
renderModal();
|
||||
// collections.placeCount = '{count} places'
|
||||
expect(screen.getByText('3 places')).toBeInTheDocument();
|
||||
expect(screen.getByText('7 places')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOVETOLIST-005: clicking a row calls onPick with that list id', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onPick } = renderModal();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Tokyo Food Tour/i }));
|
||||
|
||||
expect(onPick).toHaveBeenCalledTimes(1);
|
||||
expect(onPick).toHaveBeenCalledWith(22);
|
||||
});
|
||||
|
||||
it('FE-COMP-MOVETOLIST-006: empty lists shows the "no other lists" message and renders no rows', () => {
|
||||
renderModal({ lists: [] });
|
||||
// collections.noOtherLists = 'No other lists yet'
|
||||
expect(screen.getByText('No other lists yet')).toBeInTheDocument();
|
||||
// No selectable list rows exist (only the modal close button remains).
|
||||
expect(screen.queryByRole('button', { name: /Weekend in Rome|Tokyo Food Tour|places/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOVETOLIST-007: move mode renders a trailing arrow icon on each row', () => {
|
||||
renderModal({ mode: 'move' });
|
||||
const row = screen.getByRole('button', { name: /Weekend in Rome/i });
|
||||
expect(row.querySelector('.lucide-arrow-right')).toBeTruthy();
|
||||
expect(row.querySelector('.lucide-copy')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOVETOLIST-008: copy mode renders a trailing copy icon on each row', () => {
|
||||
renderModal({ mode: 'copy' });
|
||||
const row = screen.getByRole('button', { name: /Weekend in Rome/i });
|
||||
expect(row.querySelector('.lucide-copy')).toBeTruthy();
|
||||
expect(row.querySelector('.lucide-arrow-right')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('FE-COMP-MOVETOLIST-009: a second click while the first pick is pending is ignored', async () => {
|
||||
const user = userEvent.setup();
|
||||
// Never resolves, so the modal stays "busy" after the first click.
|
||||
const onPick = vi.fn().mockReturnValue(new Promise<void>(() => {}));
|
||||
renderModal({ onPick });
|
||||
|
||||
const rome = screen.getByRole('button', { name: /Weekend in Rome/i });
|
||||
const tokyo = screen.getByRole('button', { name: /Tokyo Food Tour/i });
|
||||
await user.click(rome);
|
||||
// Rows disable while busy; a click on another row must not fire a second pick.
|
||||
await user.click(tokyo);
|
||||
|
||||
expect(onPick).toHaveBeenCalledTimes(1);
|
||||
expect(onPick).toHaveBeenCalledWith(11);
|
||||
// Confirm the busy state actually disabled the rows.
|
||||
expect(within(tokyo).queryByText('Tokyo Food Tour')).toBeInTheDocument();
|
||||
expect(tokyo).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
// FE-COMP-STATUSBADGE-001 to FE-COMP-STATUSBADGE-013
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { CollectionStatus } from '@trek/shared';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import { useTranslation } from '../../i18n/TranslationContext';
|
||||
import StatusBadge from './StatusBadge';
|
||||
|
||||
// StatusBadge takes a `t` prop (TranslationFn) rather than reading the context
|
||||
// itself, so this harness pulls the real English `t` out of the provider that
|
||||
// the render helper wraps around us and forwards it. That way assertions run
|
||||
// against real translated strings, not i18n keys.
|
||||
type BadgeProps = {
|
||||
status: CollectionStatus;
|
||||
onChange?: (next: CollectionStatus) => void;
|
||||
showLabel?: boolean;
|
||||
onCover?: boolean;
|
||||
};
|
||||
|
||||
function Badge(props: BadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
return <StatusBadge {...props} t={t} />;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('StatusBadge', () => {
|
||||
// ── Current-status label ─────────────────────────────────────────────────────
|
||||
|
||||
it('FE-COMP-STATUSBADGE-001: renders the "Idea" label for the idea status', () => {
|
||||
render(<Badge status="idea" />);
|
||||
expect(screen.getByText('Idea')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-STATUSBADGE-002: renders the "Want to go" label for the want status', () => {
|
||||
render(<Badge status="want" />);
|
||||
expect(screen.getByText('Want to go')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-STATUSBADGE-003: renders the "Visited" label for the visited status', () => {
|
||||
render(<Badge status="visited" />);
|
||||
expect(screen.getByText('Visited')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-STATUSBADGE-004: hides the label when showLabel is false', () => {
|
||||
render(<Badge status="idea" showLabel={false} />);
|
||||
expect(screen.queryByText('Idea')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── One-tap cycle: idea → want → visited → idea ──────────────────────────────
|
||||
|
||||
it('FE-COMP-STATUSBADGE-005: exposes a button role when onChange is supplied', () => {
|
||||
render(<Badge status="idea" onChange={vi.fn()} />);
|
||||
expect(screen.getByRole('button', { name: 'Idea' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-STATUSBADGE-006: clicking an idea badge calls onChange with "want"', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<Badge status="idea" onChange={onChange} />);
|
||||
await user.click(screen.getByRole('button', { name: 'Idea' }));
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith('want');
|
||||
});
|
||||
|
||||
it('FE-COMP-STATUSBADGE-007: clicking a want badge calls onChange with "visited"', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<Badge status="want" onChange={onChange} />);
|
||||
await user.click(screen.getByRole('button', { name: 'Want to go' }));
|
||||
expect(onChange).toHaveBeenCalledWith('visited');
|
||||
});
|
||||
|
||||
it('FE-COMP-STATUSBADGE-008: clicking a visited badge wraps around to "idea"', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<Badge status="visited" onChange={onChange} />);
|
||||
await user.click(screen.getByRole('button', { name: 'Visited' }));
|
||||
expect(onChange).toHaveBeenCalledWith('idea');
|
||||
});
|
||||
|
||||
it('FE-COMP-STATUSBADGE-009: pressing Enter cycles the status', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<Badge status="idea" onChange={onChange} />);
|
||||
const badge = screen.getByRole('button', { name: 'Idea' });
|
||||
badge.focus();
|
||||
await user.keyboard('{Enter}');
|
||||
expect(onChange).toHaveBeenCalledWith('want');
|
||||
});
|
||||
|
||||
it('FE-COMP-STATUSBADGE-010: interactive badge advertises the cycle hint in its title', () => {
|
||||
render(<Badge status="idea" onChange={vi.fn()} />);
|
||||
// English: 'collections.status.cycleHint' = 'tap to change'
|
||||
expect(screen.getByRole('button', { name: 'Idea' })).toHaveAttribute(
|
||||
'title',
|
||||
'Idea — tap to change',
|
||||
);
|
||||
});
|
||||
|
||||
// ── Read-only (onChange omitted) ─────────────────────────────────────────────
|
||||
|
||||
it('FE-COMP-STATUSBADGE-011: renders as a static badge with no button role when onChange is omitted', () => {
|
||||
render(<Badge status="want" />);
|
||||
expect(screen.getByText('Want to go')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-STATUSBADGE-012: read-only badge uses the plain label as its title (no cycle hint)', () => {
|
||||
render(<Badge status="visited" />);
|
||||
const label = screen.getByText('Visited');
|
||||
const pill = label.parentElement as HTMLElement;
|
||||
expect(pill).toHaveAttribute('title', 'Visited');
|
||||
expect(pill.getAttribute('title')).not.toMatch(/tap to change/i);
|
||||
});
|
||||
|
||||
it('FE-COMP-STATUSBADGE-013: clicking a read-only badge does not throw', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Badge status="idea" />);
|
||||
// No interactive handler wired up — the click must be a harmless no-op.
|
||||
await user.click(screen.getByText('Idea'));
|
||||
expect(screen.getByText('Idea')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,7 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
|
||||
const hasPlaces = c.places.length > 0
|
||||
const noLists = !c.loading && c.collections.length === 0
|
||||
const showSelect = !c.isAllSaved && c.activeCollection != null
|
||||
const showSelect = c.isAllSaved || c.activeCollection != null
|
||||
|
||||
// Selecting a place toggles it, so clicking it again — or the map background —
|
||||
// clears it. Below the desktop breakpoint the list and map are separate views;
|
||||
@@ -85,9 +85,6 @@ export default function CollectionsPage(): React.ReactElement {
|
||||
overlay={overlay}
|
||||
view={c.view}
|
||||
onToggleView={toggleView}
|
||||
showSelect={showSelect}
|
||||
selectMode={c.selectMode}
|
||||
onToggleSelect={() => c.setSelectMode(!c.selectMode)}
|
||||
canAddPlace={canAddPlace}
|
||||
onAddPlace={() => c.setShowAddPlace(true)}
|
||||
search={c.search}
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
// FE-COLLECTIONS-MODEL-001 to FE-COLLECTIONS-MODEL-030
|
||||
// Pure-function tests for the Collections page data-shaping helpers. No React,
|
||||
// no network — plain vitest over collectionsModel.ts.
|
||||
import type { CollectionPlace, CollectionStatus } from '@trek/shared';
|
||||
import {
|
||||
filterPlaces,
|
||||
sortPlaces,
|
||||
statusCounts,
|
||||
presentCategories,
|
||||
mappablePlaces,
|
||||
normalizeLinkUrl,
|
||||
} from './collectionsModel';
|
||||
|
||||
// ── Inline CollectionPlace-ish builder ────────────────────────────────────────
|
||||
// Only the fields the helpers actually read are meaningful; the rest satisfy the
|
||||
// type. Callers override what a given case cares about.
|
||||
interface CatLike {
|
||||
id: number;
|
||||
name: string | null;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
}
|
||||
interface PlaceLike {
|
||||
id: number;
|
||||
name: string;
|
||||
status: CollectionStatus;
|
||||
category_id?: number | null;
|
||||
category?: CatLike | null;
|
||||
lat?: number | null;
|
||||
lng?: number | null;
|
||||
address?: string | null;
|
||||
notes?: string | null;
|
||||
sort_order?: number;
|
||||
created_at?: string;
|
||||
}
|
||||
function cp(overrides: PlaceLike): CollectionPlace {
|
||||
return {
|
||||
collection_id: 1,
|
||||
...overrides,
|
||||
} as unknown as CollectionPlace;
|
||||
}
|
||||
// A stray hole in the array (WS race / partial payload). Typed as CollectionPlace
|
||||
// so it can sit in the arrays the helpers receive.
|
||||
const HOLE = undefined as unknown as CollectionPlace;
|
||||
|
||||
describe('collectionsModel', () => {
|
||||
// ── filterPlaces ────────────────────────────────────────────────────────────
|
||||
describe('filterPlaces', () => {
|
||||
it('FE-COLLECTIONS-MODEL-001: status filter keeps only the matching status', () => {
|
||||
const places = [
|
||||
cp({ id: 1, name: 'A', status: 'idea' }),
|
||||
cp({ id: 2, name: 'B', status: 'want' }),
|
||||
cp({ id: 3, name: 'C', status: 'want' }),
|
||||
cp({ id: 4, name: 'D', status: 'visited' }),
|
||||
];
|
||||
const out = filterPlaces(places, 'want', '');
|
||||
expect(out.map(p => p.id)).toEqual([2, 3]);
|
||||
});
|
||||
|
||||
it("FE-COLLECTIONS-MODEL-002: statusFilter 'all' keeps every (defined) place", () => {
|
||||
const places = [
|
||||
cp({ id: 1, name: 'A', status: 'idea' }),
|
||||
cp({ id: 2, name: 'B', status: 'want' }),
|
||||
cp({ id: 3, name: 'C', status: 'visited' }),
|
||||
];
|
||||
expect(filterPlaces(places, 'all', '')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-003: category filter keeps only that category_id', () => {
|
||||
const places = [
|
||||
cp({ id: 1, name: 'A', status: 'idea', category_id: 5 }),
|
||||
cp({ id: 2, name: 'B', status: 'idea', category_id: 9 }),
|
||||
cp({ id: 3, name: 'C', status: 'idea', category_id: 5 }),
|
||||
cp({ id: 4, name: 'D', status: 'idea' }), // no category
|
||||
];
|
||||
const out = filterPlaces(places, 'all', '', 5);
|
||||
expect(out.map(p => p.id)).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-004: search matches place name (case-insensitive)', () => {
|
||||
const places = [
|
||||
cp({ id: 1, name: 'Eiffel Tower', status: 'idea' }),
|
||||
cp({ id: 2, name: 'Louvre', status: 'idea' }),
|
||||
];
|
||||
const out = filterPlaces(places, 'all', 'eiffel');
|
||||
expect(out.map(p => p.id)).toEqual([1]);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-005: search matches address', () => {
|
||||
const places = [
|
||||
cp({ id: 1, name: 'Nameless', status: 'idea', address: '10 Rue de Paris' }),
|
||||
cp({ id: 2, name: 'Other', status: 'idea', address: 'Berlin' }),
|
||||
];
|
||||
const out = filterPlaces(places, 'all', 'rue');
|
||||
expect(out.map(p => p.id)).toEqual([1]);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-006: search matches notes', () => {
|
||||
const places = [
|
||||
cp({ id: 1, name: 'X', status: 'idea', notes: 'sunset spot' }),
|
||||
cp({ id: 2, name: 'Y', status: 'idea', notes: 'breakfast' }),
|
||||
];
|
||||
const out = filterPlaces(places, 'all', 'SUNSET');
|
||||
expect(out.map(p => p.id)).toEqual([1]);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-007: blank/whitespace search returns all', () => {
|
||||
const places = [
|
||||
cp({ id: 1, name: 'A', status: 'idea' }),
|
||||
cp({ id: 2, name: 'B', status: 'want' }),
|
||||
];
|
||||
expect(filterPlaces(places, 'all', ' ')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-008: a stray undefined entry is skipped, not crashing', () => {
|
||||
const places = [
|
||||
cp({ id: 1, name: 'A', status: 'idea' }),
|
||||
HOLE,
|
||||
cp({ id: 2, name: 'B', status: 'want' }),
|
||||
];
|
||||
expect(() => filterPlaces(places, 'all', '')).not.toThrow();
|
||||
const out = filterPlaces(places, 'all', '');
|
||||
expect(out.map(p => p.id)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-009: status + category + search combine (AND)', () => {
|
||||
const places = [
|
||||
cp({ id: 1, name: 'Beach Bar', status: 'want', category_id: 3, notes: 'cocktails' }),
|
||||
cp({ id: 2, name: 'Beach Hut', status: 'idea', category_id: 3 }),
|
||||
cp({ id: 3, name: 'Beach Cafe', status: 'want', category_id: 9 }),
|
||||
];
|
||||
const out = filterPlaces(places, 'want', 'beach', 3);
|
||||
expect(out.map(p => p.id)).toEqual([1]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── sortPlaces ──────────────────────────────────────────────────────────────
|
||||
describe('sortPlaces', () => {
|
||||
it('FE-COLLECTIONS-MODEL-010: orders by sort_order ascending', () => {
|
||||
const places = [
|
||||
cp({ id: 1, name: 'A', status: 'idea', sort_order: 2 }),
|
||||
cp({ id: 2, name: 'B', status: 'idea', sort_order: 0 }),
|
||||
cp({ id: 3, name: 'C', status: 'idea', sort_order: 1 }),
|
||||
];
|
||||
expect(sortPlaces(places).map(p => p.id)).toEqual([2, 3, 1]);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-011: ties on sort_order fall back to created_at newest-first', () => {
|
||||
const places = [
|
||||
cp({ id: 1, name: 'A', status: 'idea', sort_order: 0, created_at: '2025-01-01T00:00:00Z' }),
|
||||
cp({ id: 2, name: 'B', status: 'idea', sort_order: 0, created_at: '2025-03-01T00:00:00Z' }),
|
||||
cp({ id: 3, name: 'C', status: 'idea', sort_order: 0, created_at: '2025-02-01T00:00:00Z' }),
|
||||
];
|
||||
// newest created_at first
|
||||
expect(sortPlaces(places).map(p => p.id)).toEqual([2, 3, 1]);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-012: missing sort_order is treated as 0', () => {
|
||||
const places = [
|
||||
cp({ id: 1, name: 'A', status: 'idea', sort_order: 5 }),
|
||||
cp({ id: 2, name: 'B', status: 'idea' }), // no sort_order -> 0
|
||||
];
|
||||
expect(sortPlaces(places).map(p => p.id)).toEqual([2, 1]);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-013: does not mutate the input array', () => {
|
||||
const places = [
|
||||
cp({ id: 1, name: 'A', status: 'idea', sort_order: 2 }),
|
||||
cp({ id: 2, name: 'B', status: 'idea', sort_order: 1 }),
|
||||
];
|
||||
const snapshot = places.map(p => p.id);
|
||||
sortPlaces(places);
|
||||
expect(places.map(p => p.id)).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
// ── statusCounts ────────────────────────────────────────────────────────────
|
||||
describe('statusCounts', () => {
|
||||
it('FE-COLLECTIONS-MODEL-014: counts per status plus a total under all', () => {
|
||||
const places = [
|
||||
cp({ id: 1, name: 'A', status: 'idea' }),
|
||||
cp({ id: 2, name: 'B', status: 'idea' }),
|
||||
cp({ id: 3, name: 'C', status: 'want' }),
|
||||
cp({ id: 4, name: 'D', status: 'visited' }),
|
||||
cp({ id: 5, name: 'E', status: 'visited' }),
|
||||
cp({ id: 6, name: 'F', status: 'visited' }),
|
||||
];
|
||||
expect(statusCounts(places)).toEqual({ all: 6, idea: 2, want: 1, visited: 3 });
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-015: empty input yields all zeros', () => {
|
||||
expect(statusCounts([])).toEqual({ all: 0, idea: 0, want: 0, visited: 0 });
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-016: undefined entries are skipped from every count', () => {
|
||||
const places = [
|
||||
cp({ id: 1, name: 'A', status: 'idea' }),
|
||||
HOLE,
|
||||
cp({ id: 2, name: 'B', status: 'want' }),
|
||||
];
|
||||
expect(() => statusCounts(places)).not.toThrow();
|
||||
expect(statusCounts(places)).toEqual({ all: 2, idea: 1, want: 1, visited: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── presentCategories ───────────────────────────────────────────────────────
|
||||
describe('presentCategories', () => {
|
||||
it('FE-COLLECTIONS-MODEL-017: distinct categories with per-category counts', () => {
|
||||
const museum = { id: 3, name: 'Museums', color: '#111', icon: 'landmark' };
|
||||
const food = { id: 7, name: 'Food', color: '#222', icon: 'utensils' };
|
||||
const places = [
|
||||
cp({ id: 1, name: 'A', status: 'idea', category_id: 3, category: museum }),
|
||||
cp({ id: 2, name: 'B', status: 'idea', category_id: 3, category: museum }),
|
||||
cp({ id: 3, name: 'C', status: 'idea', category_id: 7, category: food }),
|
||||
];
|
||||
const out = presentCategories(places);
|
||||
// sorted alphabetically by name: Food, Museums
|
||||
expect(out.map(c => c.name)).toEqual(['Food', 'Museums']);
|
||||
expect(out.find(c => c.id === 3)?.count).toBe(2);
|
||||
expect(out.find(c => c.id === 7)?.count).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-018: carries color and icon from the category', () => {
|
||||
const places = [
|
||||
cp({ id: 1, name: 'A', status: 'idea', category_id: 4, category: { id: 4, name: 'Parks', color: '#0f0', icon: 'tree' } }),
|
||||
];
|
||||
const [opt] = presentCategories(places);
|
||||
expect(opt).toMatchObject({ id: 4, name: 'Parks', color: '#0f0', icon: 'tree', count: 1 });
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-019: does NOT throw on an undefined entry (white-screen guard)', () => {
|
||||
const places = [
|
||||
HOLE,
|
||||
cp({ id: 1, name: 'A', status: 'idea', category_id: 3, category: { id: 3, name: 'Museums', color: null, icon: null } }),
|
||||
];
|
||||
let out: ReturnType<typeof presentCategories> = [];
|
||||
expect(() => { out = presentCategories(places); }).not.toThrow();
|
||||
expect(out.map(c => c.id)).toEqual([3]);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-020: place with category_id but no category object is skipped (does not throw)', () => {
|
||||
// Regression: presentCategories used to read undefined.category_id / .name here.
|
||||
const places = [
|
||||
cp({ id: 1, name: 'A', status: 'idea', category_id: 3 }), // category_id but no category
|
||||
cp({ id: 2, name: 'B', status: 'idea', category_id: 3, category: { id: 3, name: 'Museums', color: null, icon: null } }),
|
||||
];
|
||||
let out: ReturnType<typeof presentCategories> = [];
|
||||
expect(() => { out = presentCategories(places); }).not.toThrow();
|
||||
// Only the place that actually carries the joined category contributes.
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]).toMatchObject({ id: 3, name: 'Museums', count: 1 });
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-021: places with no category at all yield an empty list', () => {
|
||||
const places = [
|
||||
cp({ id: 1, name: 'A', status: 'idea' }),
|
||||
cp({ id: 2, name: 'B', status: 'want' }),
|
||||
];
|
||||
expect(presentCategories(places)).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-022: null color/icon on the category survive as null', () => {
|
||||
const places = [
|
||||
cp({ id: 1, name: 'A', status: 'idea', category_id: 8, category: { id: 8, name: 'Misc', color: null, icon: null } }),
|
||||
];
|
||||
expect(presentCategories(places)[0]).toMatchObject({ id: 8, name: 'Misc', color: null, icon: null, count: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── mappablePlaces ──────────────────────────────────────────────────────────
|
||||
describe('mappablePlaces', () => {
|
||||
it('FE-COLLECTIONS-MODEL-023: keeps only places with numeric lat and lng', () => {
|
||||
const places = [
|
||||
cp({ id: 1, name: 'A', status: 'idea', lat: 48.85, lng: 2.35 }),
|
||||
cp({ id: 2, name: 'B', status: 'idea', lat: null, lng: 2.35 }),
|
||||
cp({ id: 3, name: 'C', status: 'idea', lat: 40.0, lng: null }),
|
||||
cp({ id: 4, name: 'D', status: 'idea' }), // neither
|
||||
];
|
||||
expect(mappablePlaces(places).map(p => p.id)).toEqual([1]);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-024: keeps a place at the (0,0) origin (0 is numeric)', () => {
|
||||
const places = [cp({ id: 1, name: 'NullIsland', status: 'idea', lat: 0, lng: 0 })];
|
||||
expect(mappablePlaces(places).map(p => p.id)).toEqual([1]);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-025: skips a stray undefined entry without throwing', () => {
|
||||
const places = [
|
||||
HOLE,
|
||||
cp({ id: 1, name: 'A', status: 'idea', lat: 10, lng: 20 }),
|
||||
];
|
||||
expect(() => mappablePlaces(places)).not.toThrow();
|
||||
expect(mappablePlaces(places).map(p => p.id)).toEqual([1]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── normalizeLinkUrl ────────────────────────────────────────────────────────
|
||||
describe('normalizeLinkUrl', () => {
|
||||
it('FE-COLLECTIONS-MODEL-026: prepends https:// to a scheme-less host', () => {
|
||||
expect(normalizeLinkUrl('booking.com')).toBe('https://booking.com');
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-027: leaves an http:// URL unchanged', () => {
|
||||
expect(normalizeLinkUrl('http://example.com/path')).toBe('http://example.com/path');
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-028: leaves an https:// URL unchanged (case-insensitive scheme)', () => {
|
||||
expect(normalizeLinkUrl('https://example.com')).toBe('https://example.com');
|
||||
expect(normalizeLinkUrl('HTTPS://example.com')).toBe('HTTPS://example.com');
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-029: blank / whitespace-only input returns an empty string', () => {
|
||||
expect(normalizeLinkUrl('')).toBe('');
|
||||
expect(normalizeLinkUrl(' ')).toBe('');
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-030: trims and strips leading slashes before prefixing', () => {
|
||||
expect(normalizeLinkUrl(' booking.com ')).toBe('https://booking.com');
|
||||
expect(normalizeLinkUrl('//booking.com')).toBe('https://booking.com');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user