mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user