mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
bee500880e
Finishes the plural work the previous wip commit started. t() picks a count string's form by the language's own Intl.PluralRules, and every locale now spells out the forms its language reaches with whole numbers, about 160 count strings in all: one, few and many in Russian, Ukrainian and Polish, one and few in Czech and Slovak, five forms in Arabic, one in the others, none in Japanese, Korean, Chinese, Thai, Vietnamese and Indonesian. Each language was translated and then reviewed on its own. English no longer reads "1 files", "1 updates available" or "photo(s)". Count labels shown next to a number now take that number too: the atlas and admin statistics, the trip cards, journey and shared trip pills, packing templates, receipts, storage usage, the Studio export note. A heading that shared its key with a counted label keeps a key of its own (the boarding pass heading, the Bookings tab), so languages that put a noun after a number in the singular keep a plural heading. The plugin hosts chip passed its count through .replace() after t() and never got a form; three toasts sent the count as text. Mail and push texts for shared photos and suppressed storage failures pick their form in code through pluralForm. A whole number sent as text counts too, which is how in-app notification params arrive, and the plural rule follows the language of the strings on screen while another one still loads. The parity check knows which forms each language needs and refuses missing and impossible ones. It also reads keys that contain a colon now: it skipped them, which hid 58 missing plugin permission texts in Catalan and 35 in Vietnamese, both translated. The client parity test covers every language of the registry instead of a hand-kept list of 19, and the placeholder spec lets a form spell its number out only where its category is that one number. Brazilian Portuguese error screens were in Breton, and an Arabic dashboard label held Hebrew text.
213 lines
9.5 KiB
TypeScript
213 lines
9.5 KiB
TypeScript
// FE-COMP-SAVETRIPPL-001 to FE-COMP-SAVETRIPPL-015
|
|
import React from 'react';
|
|
import { afterEach, beforeEach } from 'vitest';
|
|
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render';
|
|
import userEvent from '@testing-library/user-event';
|
|
import type { Collection, CollectionListResponse } from '@trek/shared';
|
|
import { collectionsApi } from '../../api/collections';
|
|
import SaveTripPlacesToListModal from './SaveTripPlacesToListModal';
|
|
|
|
type ModalProps = React.ComponentProps<typeof SaveTripPlacesToListModal>;
|
|
type SaveManyResult = { copied: number; skipped: { id: number; name: string }[] };
|
|
|
|
function list(over: Partial<Collection>): Collection {
|
|
return { id: 1, owner_id: 7, name: 'List', color: null, place_count: 0, ...over } as Collection;
|
|
}
|
|
|
|
const FAVORITES = list({ id: 1, name: 'Favorites', color: '#ef4444', place_count: 4 });
|
|
const WISHLIST = list({ id: 2, name: 'Wishlist' });
|
|
|
|
const listResponse = (collections: Collection[]): CollectionListResponse => ({ collections, incomingInvites: [] });
|
|
|
|
let addToast: ReturnType<typeof vi.fn>;
|
|
|
|
function renderModal(overrides: Partial<ModalProps> = {}) {
|
|
const props: ModalProps = {
|
|
isOpen: true,
|
|
tripId: 5,
|
|
placeIds: [11, 12],
|
|
onClose: vi.fn(),
|
|
onDone: vi.fn(),
|
|
...overrides,
|
|
};
|
|
render(<SaveTripPlacesToListModal {...props} />);
|
|
return props;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
addToast = vi.fn();
|
|
window.__addToast = addToast as unknown as typeof window.__addToast;
|
|
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES, WISHLIST]));
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
delete window.__addToast;
|
|
});
|
|
|
|
describe('SaveTripPlacesToListModal', () => {
|
|
it('FE-COMP-SAVETRIPPL-001: titles the modal with the selection count and lists the writable lists', async () => {
|
|
renderModal({ placeIds: [11, 12, 13] });
|
|
expect(await screen.findByText('Favorites')).toBeInTheDocument();
|
|
expect(screen.getByRole('heading', { name: 'Save 3 to a list' })).toBeInTheDocument();
|
|
expect(screen.getByText('4 places')).toBeInTheDocument();
|
|
expect(screen.getByText('0 places')).toBeInTheDocument();
|
|
expect(screen.getByText('Duplicates are skipped automatically')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-SAVETRIPPL-002: lists shared with the user are dropped — they cannot be written to', async () => {
|
|
vi.spyOn(collectionsApi, 'list').mockResolvedValue(
|
|
listResponse([FAVORITES, list({ id: 3, name: 'Team ideas', is_owner: false })]),
|
|
);
|
|
renderModal();
|
|
expect(await screen.findByText('Favorites')).toBeInTheDocument();
|
|
expect(screen.queryByText('Team ideas')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-SAVETRIPPL-003: shows the spinner until the lists arrive', async () => {
|
|
let resolve!: (v: CollectionListResponse) => void;
|
|
vi.spyOn(collectionsApi, 'list').mockReturnValue(new Promise(r => { resolve = r; }));
|
|
renderModal();
|
|
expect(document.querySelector('.animate-spin')).not.toBeNull();
|
|
expect(screen.queryByText('You have no lists yet')).not.toBeInTheDocument();
|
|
|
|
resolve(listResponse([FAVORITES]));
|
|
expect(await screen.findByText('Favorites')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-SAVETRIPPL-004: an absent or failing collections response falls back to the empty copy', async () => {
|
|
vi.spyOn(collectionsApi, 'list').mockResolvedValue({ incomingInvites: [] } as unknown as CollectionListResponse);
|
|
const { unmount } = render(<SaveTripPlacesToListModal isOpen tripId={5} placeIds={[11]} onClose={vi.fn()} onDone={vi.fn()} />);
|
|
expect(await screen.findByText('You have no lists yet')).toBeInTheDocument();
|
|
unmount();
|
|
|
|
vi.spyOn(collectionsApi, 'list').mockRejectedValue(new Error('offline'));
|
|
renderModal();
|
|
expect(await screen.findByText('You have no lists yet')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-SAVETRIPPL-005: requests nothing and renders nothing while closed', () => {
|
|
const spy = vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES]));
|
|
renderModal({ isOpen: false });
|
|
expect(spy).not.toHaveBeenCalled();
|
|
expect(screen.queryByRole('heading', { name: /Save/ })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-SAVETRIPPL-006: the search box appears above five lists and filters by name', async () => {
|
|
const many = [1, 2, 3, 4, 5, 6].map(id => list({ id, name: `List ${id}` }));
|
|
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse(many));
|
|
renderModal();
|
|
|
|
const search = await screen.findByPlaceholderText('Search lists');
|
|
fireEvent.change(search, { target: { value: 'list 4' } });
|
|
expect(screen.getByText('List 4')).toBeInTheDocument();
|
|
expect(screen.queryByText('List 5')).not.toBeInTheDocument();
|
|
|
|
fireEvent.change(search, { target: { value: 'nope' } });
|
|
expect(screen.getByText('You have no lists yet')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-SAVETRIPPL-007: five lists or fewer need no search box', async () => {
|
|
renderModal();
|
|
await screen.findByText('Favorites');
|
|
expect(screen.queryByPlaceholderText('Search lists')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-SAVETRIPPL-008: picking a list copies every selected place, reports it and closes', async () => {
|
|
const save = vi.spyOn(collectionsApi, 'saveFromTripMany').mockResolvedValue({ copied: 2, skipped: [] });
|
|
const props = renderModal();
|
|
|
|
fireEvent.click(await screen.findByText('Favorites'));
|
|
|
|
await waitFor(() => expect(props.onDone).toHaveBeenCalled());
|
|
expect(save).toHaveBeenCalledWith(1, 5, [11, 12]);
|
|
expect(addToast).toHaveBeenCalledWith('Saved 2 to Favorites', 'success', undefined);
|
|
expect(props.onClose).toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-COMP-SAVETRIPPL-009: server-side duplicates are reported separately', async () => {
|
|
vi.spyOn(collectionsApi, 'saveFromTripMany').mockResolvedValue({ copied: 1, skipped: [{ id: 12, name: 'Louvre' }] });
|
|
renderModal();
|
|
|
|
fireEvent.click(await screen.findByText('Favorites'));
|
|
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Skipped 1 duplicate', 'info', undefined));
|
|
expect(addToast).toHaveBeenCalledWith('Saved 1 to Favorites', 'success', undefined);
|
|
});
|
|
|
|
it('FE-COMP-SAVETRIPPL-010: a no-op save says so instead of staying silent', async () => {
|
|
vi.spyOn(collectionsApi, 'saveFromTripMany').mockResolvedValue({ copied: 0, skipped: [] });
|
|
renderModal();
|
|
|
|
fireEvent.click(await screen.findByText('Favorites'));
|
|
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Nothing to copy', 'info', undefined));
|
|
expect(addToast).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('FE-COMP-SAVETRIPPL-011: a failed save surfaces the server message and keeps the modal open', async () => {
|
|
vi.spyOn(collectionsApi, 'saveFromTripMany').mockRejectedValue({ response: { data: { error: 'List is full' } } });
|
|
const props = renderModal();
|
|
|
|
fireEvent.click(await screen.findByText('Favorites'));
|
|
await waitFor(() => expect(addToast).toHaveBeenCalledWith('List is full', 'error', undefined));
|
|
expect(props.onDone).not.toHaveBeenCalled();
|
|
expect(props.onClose).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-COMP-SAVETRIPPL-012: every row locks while a save runs, and an empty selection never reaches the server', async () => {
|
|
let resolve!: (v: SaveManyResult) => void;
|
|
const save = vi.spyOn(collectionsApi, 'saveFromTripMany').mockReturnValue(new Promise(r => { resolve = r; }));
|
|
const props = renderModal();
|
|
|
|
const favorites = await screen.findByRole('button', { name: /Favorites/ });
|
|
fireEvent.click(favorites);
|
|
const wishlist = screen.getByRole('button', { name: /Wishlist/ });
|
|
expect(wishlist).toBeDisabled();
|
|
expect(within(favorites).queryByText('4 places')).toBeInTheDocument();
|
|
fireEvent.click(wishlist);
|
|
expect(save).toHaveBeenCalledTimes(1);
|
|
|
|
resolve({ copied: 1, skipped: [] });
|
|
await waitFor(() => expect(props.onDone).toHaveBeenCalled());
|
|
});
|
|
|
|
it('FE-COMP-SAVETRIPPL-014: a list response landing after the modal closed is dropped', async () => {
|
|
const closed = <SaveTripPlacesToListModal isOpen tripId={5} placeIds={[11]} onClose={vi.fn()} onDone={vi.fn()} />;
|
|
|
|
let resolve!: (v: CollectionListResponse) => void;
|
|
const spy = vi.spyOn(collectionsApi, 'list').mockReturnValue(new Promise(r => { resolve = r; }));
|
|
render(closed).unmount();
|
|
resolve(listResponse([FAVORITES]));
|
|
await Promise.resolve();
|
|
expect(screen.queryByText('Favorites')).not.toBeInTheDocument();
|
|
|
|
let reject!: (e: Error) => void;
|
|
spy.mockReturnValue(new Promise((_, r) => { reject = r; }));
|
|
render(closed).unmount();
|
|
reject(new Error('offline'));
|
|
await Promise.resolve();
|
|
expect(screen.queryByText('You have no lists yet')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-SAVETRIPPL-015: a list the server sent without a count still renders a countable row', async () => {
|
|
vi.spyOn(collectionsApi, 'list').mockResolvedValue(
|
|
listResponse([{ id: 9, owner_id: 7, name: 'Untracked' } as Collection]),
|
|
);
|
|
renderModal();
|
|
|
|
const untracked = await screen.findByRole('button', { name: /Untracked/ });
|
|
expect(within(untracked).getByText('0 places')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-COMP-SAVETRIPPL-013: an empty selection is a no-op, and Escape hands back to the caller', async () => {
|
|
const user = userEvent.setup();
|
|
const save = vi.spyOn(collectionsApi, 'saveFromTripMany');
|
|
const props = renderModal({ placeIds: [] });
|
|
|
|
fireEvent.click(await screen.findByText('Favorites'));
|
|
expect(save).not.toHaveBeenCalled();
|
|
|
|
await user.keyboard('{Escape}');
|
|
expect(props.onClose).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|