mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +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.
251 lines
10 KiB
TypeScript
251 lines
10 KiB
TypeScript
// FE-MOB-AUSERS-001 to FE-MOB-AUSERS-016
|
||
import React from 'react';
|
||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||
import userEvent from '@testing-library/user-event';
|
||
import { fireEvent, render, screen } from '../../../helpers/render';
|
||
import { resetAllStores } from '../../../helpers/store';
|
||
import { buildAdminHook, buildAdminUser, type AdminHook } from '../../../helpers/mobileAdmin';
|
||
import { useTranslation } from '../../../../src/i18n';
|
||
import MAdminUsersSection from '../../../../src/mobile/screens/admin/MAdminUsersSection';
|
||
|
||
// The permissions matrix self-loads and has its own suite — stub it out here.
|
||
vi.mock('../../../../src/mobile/screens/admin/MAdminPermissionsPanel', () => ({
|
||
default: () => <div data-testid="permissions-panel" />,
|
||
}));
|
||
|
||
function Harness({ admin }: { admin: AdminHook }) {
|
||
const { t, locale } = useTranslation();
|
||
return <MAdminUsersSection admin={admin} t={t} locale={locale} />;
|
||
}
|
||
|
||
function renderSection(overrides: Record<string, unknown> = {}) {
|
||
const admin = buildAdminHook(overrides);
|
||
render(<Harness admin={admin} />);
|
||
return admin as unknown as Record<string, ReturnType<typeof vi.fn>>;
|
||
}
|
||
|
||
interface InviteForm {
|
||
max_uses: number;
|
||
expires_in_days: number | '';
|
||
trip_id: number | '';
|
||
}
|
||
|
||
/**
|
||
* The invite form controls read e.target.value inside the state updater, so the
|
||
* spy fixture can't be inspected after the fact — the sheet needs real state to
|
||
* show what a chip or the trip select actually writes.
|
||
*/
|
||
function InviteFormHarness({ overrides }: { overrides: Record<string, unknown> }) {
|
||
const { t, locale } = useTranslation();
|
||
const [inviteForm, setInviteForm] = React.useState<InviteForm>({
|
||
max_uses: 1,
|
||
expires_in_days: 7,
|
||
trip_id: '',
|
||
});
|
||
const admin = buildAdminHook({ showCreateInvite: true, ...overrides, inviteForm, setInviteForm });
|
||
return <MAdminUsersSection admin={admin} t={t} locale={locale} />;
|
||
}
|
||
|
||
function isActiveChip(el: HTMLElement): boolean {
|
||
return el.className.includes('bg-m-act');
|
||
}
|
||
|
||
const ME = buildAdminUser({ id: 1, username: 'admin', email: 'admin@example.com', role: 'admin' });
|
||
const ALICE = buildAdminUser({ id: 2, username: 'alice', email: 'alice@example.com', role: 'user' });
|
||
|
||
function buildInvite(overrides: Record<string, unknown> = {}) {
|
||
return {
|
||
id: 1,
|
||
token: 'abcdefghijklmnop',
|
||
max_uses: 3,
|
||
used_count: 1,
|
||
expires_at: null,
|
||
trip_title: null,
|
||
created_by_name: 'admin',
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
beforeEach(() => {
|
||
resetAllStores();
|
||
});
|
||
|
||
describe('MAdminUsersSection', () => {
|
||
it('FE-MOB-AUSERS-001: lists users with role badges, the "you" marker and the count', () => {
|
||
renderSection({ users: [ME, ALICE], currentUser: ME });
|
||
|
||
expect(screen.getByText('2 Users')).toBeInTheDocument();
|
||
expect(screen.getByText('admin')).toBeInTheDocument();
|
||
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
|
||
expect(screen.getByText('Administrator')).toBeInTheDocument();
|
||
expect(screen.getByText('User')).toBeInTheDocument();
|
||
expect(screen.getByText('(You)')).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-MOB-AUSERS-002: users without an avatar get an initial, users with one get the image', () => {
|
||
renderSection({ users: [ALICE, buildAdminUser({ id: 3, username: 'bob', avatar_url: '/uploads/bob.png' })] });
|
||
|
||
expect(screen.getByText('A')).toBeInTheDocument();
|
||
expect(screen.getByRole('presentation')).toHaveAttribute('src', '/uploads/bob.png');
|
||
});
|
||
|
||
it('FE-MOB-AUSERS-003: the spinner replaces the list while users are loading', () => {
|
||
renderSection({ users: [ALICE], isLoading: true });
|
||
|
||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||
expect(screen.queryByText('alice')).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-MOB-AUSERS-004: tapping a row opens the edit sheet for that user', async () => {
|
||
const user = userEvent.setup();
|
||
const admin = renderSection({ users: [ME, ALICE], currentUser: ME });
|
||
|
||
await user.click(screen.getByText('alice'));
|
||
|
||
expect(admin.handleEditUser).toHaveBeenCalledWith(ALICE);
|
||
});
|
||
|
||
it('FE-MOB-AUSERS-005: the New user button opens the create sheet', async () => {
|
||
const user = userEvent.setup();
|
||
const admin = renderSection();
|
||
|
||
await user.click(screen.getByRole('button', { name: 'New user' }));
|
||
|
||
expect(admin.setShowCreateUser).toHaveBeenCalledWith(true);
|
||
});
|
||
|
||
it('FE-MOB-AUSERS-006: the invite card shows its empty state without invites', () => {
|
||
renderSection();
|
||
|
||
expect(screen.getByText('Invite Links')).toBeInTheDocument();
|
||
expect(screen.getByText('No invite links created yet')).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-MOB-AUSERS-007: an active invite shows the truncated token, usage and copy action', () => {
|
||
renderSection({ invites: [buildInvite()] });
|
||
|
||
expect(screen.getByText('abcdefghijkl…')).toBeInTheDocument();
|
||
expect(screen.getByText('Active')).toBeInTheDocument();
|
||
expect(screen.getByText(/1\/3 used/)).toBeInTheDocument();
|
||
expect(screen.getByText(/by admin/)).toBeInTheDocument();
|
||
expect(screen.getByTitle('Copy link')).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-MOB-AUSERS-008: unlimited uses render as ∞ and an expiry/trip line is appended', () => {
|
||
renderSection({
|
||
invites: [
|
||
buildInvite({ max_uses: 0, used_count: 4, expires_at: '2099-01-02T00:00:00Z', trip_title: 'Japan' }),
|
||
],
|
||
});
|
||
|
||
expect(screen.getByText(/4\/∞ used/)).toBeInTheDocument();
|
||
expect(screen.getByText(/expires 1\/2\/2099/)).toBeInTheDocument();
|
||
expect(screen.getByText(/adds to Japan/)).toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-MOB-AUSERS-009: expired and used-up invites lose the copy action', () => {
|
||
renderSection({
|
||
invites: [
|
||
buildInvite({ id: 1, token: 'expired-token-xx', expires_at: '2020-01-01T00:00:00Z' }),
|
||
buildInvite({ id: 2, token: 'usedup-token-xxx', max_uses: 2, used_count: 2 }),
|
||
],
|
||
});
|
||
|
||
expect(screen.getByText('Expired')).toBeInTheDocument();
|
||
expect(screen.getByText('Used up')).toBeInTheDocument();
|
||
expect(screen.queryByTitle('Copy link')).not.toBeInTheDocument();
|
||
expect(screen.getAllByTitle('Delete')).toHaveLength(2);
|
||
});
|
||
|
||
it('FE-MOB-AUSERS-010: copy and delete hit the handlers with token and id', async () => {
|
||
const user = userEvent.setup();
|
||
const admin = renderSection({ invites: [buildInvite({ id: 7, token: 'abcdefghijklmnop' })] });
|
||
|
||
await user.click(screen.getByTitle('Copy link'));
|
||
expect(admin.copyInviteLink).toHaveBeenCalledWith('abcdefghijklmnop');
|
||
|
||
await user.click(screen.getByTitle('Delete'));
|
||
expect(admin.handleDeleteInvite).toHaveBeenCalledWith(7);
|
||
});
|
||
|
||
it('FE-MOB-AUSERS-011: the create-invite sheet is closed until showCreateInvite flips', async () => {
|
||
const user = userEvent.setup();
|
||
const admin = renderSection();
|
||
|
||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||
await user.click(screen.getByRole('button', { name: 'Create Link' }));
|
||
expect(admin.setShowCreateInvite).toHaveBeenCalledWith(true);
|
||
});
|
||
|
||
it('FE-MOB-AUSERS-012: the open sheet marks the active max-uses and expiry chips', () => {
|
||
renderSection({ showCreateInvite: true, inviteForm: { max_uses: 3, expires_in_days: '', trip_id: '' } });
|
||
|
||
expect(screen.getByRole('dialog', { name: 'Create Link' })).toBeInTheDocument();
|
||
expect(screen.getByRole('button', { name: '3×' }).className).toContain('bg-m-act');
|
||
expect(screen.getByRole('button', { name: '1×' }).className).not.toContain('bg-m-act');
|
||
// Both chip rows end in ∞ — max uses (unlimited) and expiry (never)
|
||
const [unlimitedUses, neverExpires] = screen.getAllByRole('button', { name: '∞' });
|
||
expect(unlimitedUses.className).not.toContain('bg-m-act');
|
||
expect(neverExpires.className).toContain('bg-m-act');
|
||
});
|
||
|
||
it('FE-MOB-AUSERS-013: chips patch only their own field of the invite form', async () => {
|
||
const user = userEvent.setup();
|
||
render(<InviteFormHarness overrides={{}} />);
|
||
|
||
await user.click(screen.getByRole('button', { name: '5×' }));
|
||
expect(isActiveChip(screen.getByRole('button', { name: '5×' }))).toBe(true);
|
||
expect(isActiveChip(screen.getByRole('button', { name: '1×' }))).toBe(false);
|
||
// The expiry row is untouched: 7d is still the active chip
|
||
expect(isActiveChip(screen.getByRole('button', { name: '7d' }))).toBe(true);
|
||
|
||
await user.click(screen.getByRole('button', { name: '14d' }));
|
||
expect(isActiveChip(screen.getByRole('button', { name: '14d' }))).toBe(true);
|
||
expect(isActiveChip(screen.getByRole('button', { name: '7d' }))).toBe(false);
|
||
expect(isActiveChip(screen.getByRole('button', { name: '5×' }))).toBe(true);
|
||
});
|
||
|
||
it('FE-MOB-AUSERS-014: the trip select is hidden without trips', () => {
|
||
renderSection({ showCreateInvite: true });
|
||
|
||
expect(screen.getByRole('dialog', { name: 'Create Link' })).toBeInTheDocument();
|
||
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
|
||
expect(screen.queryByText('Add to trip (optional)')).not.toBeInTheDocument();
|
||
});
|
||
|
||
it('FE-MOB-AUSERS-015: the trip select stores the numeric id and maps "No trip" back to ""', async () => {
|
||
const user = userEvent.setup();
|
||
const handleCreateInvite = vi.fn();
|
||
render(
|
||
<InviteFormHarness
|
||
overrides={{ inviteTrips: [{ id: 4, title: 'Japan 2026' }], handleCreateInvite }}
|
||
/>,
|
||
);
|
||
|
||
const select = screen.getByRole('combobox') as HTMLSelectElement;
|
||
expect(screen.getByRole('option', { name: 'No trip' })).toBeInTheDocument();
|
||
expect(select.value).toBe('');
|
||
|
||
fireEvent.change(select, { target: { value: '4' } });
|
||
expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe('4');
|
||
|
||
fireEvent.change(select, { target: { value: '' } });
|
||
expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe('');
|
||
|
||
await user.click(screen.getByRole('button', { name: 'Create & Copy' }));
|
||
expect(handleCreateInvite).toHaveBeenCalled();
|
||
});
|
||
|
||
it('FE-MOB-AUSERS-016: cancel, close and Escape all dismiss the sheet', async () => {
|
||
const user = userEvent.setup();
|
||
const admin = renderSection({ showCreateInvite: true });
|
||
|
||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||
expect(admin.setShowCreateInvite).toHaveBeenCalledWith(false);
|
||
|
||
await user.click(screen.getByRole('button', { name: 'Close' }));
|
||
fireEvent.keyDown(document, { key: 'Escape' });
|
||
expect(admin.setShowCreateInvite).toHaveBeenCalledTimes(3);
|
||
});
|
||
});
|