mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
ba70ad28b8
Arabic runs the app right to left, but margins, paddings, insets, text alignment, borders and corner radii were written for the left side, so icons sat against the wrong edge of their labels, indents and badges pointed the wrong way and amount columns aligned left. They now use the logical forms (ms-, ps-, start-, text-start, marginInlineStart, insetInlineEnd and their CSS equivalents), which are the very same sides in a left to right language. Geometry stays physical on purpose: maps, positions measured from the pointer or a bounding box, time axes, sliders, transforms and print output. Amounts drawn as several spans keep their order in a right to left row, the time picker keeps hours before minutes, and two switches whose thumb slid out of its track in Arabic now move the right way. lint:rtl counts what is still physical per file against a baseline that may only shrink (1512 uses in 370 files before, 319 in 113 now), and CI runs it, so new code reaches for the logical forms.
573 lines
23 KiB
TypeScript
573 lines
23 KiB
TypeScript
import React from 'react';
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { render, screen, fireEvent, waitFor, within } from '../../../helpers/render';
|
|
import MDashboard from '../../../../src/mobile/screens/dashboard/MDashboard';
|
|
import { tripsApi } from '../../../../src/api/client';
|
|
import { useAuthStore } from '../../../../src/store/authStore';
|
|
import { useInAppNotificationStore } from '../../../../src/store/inAppNotificationStore';
|
|
import { usePluginStore } from '../../../../src/store/pluginStore';
|
|
import type { TripCardBadge } from '../../../../src/api/client';
|
|
import type { DashboardTrip } from '../../../../src/pages/dashboard/dashboardModel';
|
|
|
|
// FE-MOB-DASH-001 onwards
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
dash: {} as Record<string, unknown>,
|
|
badges: {} as Record<number, unknown[]>,
|
|
}));
|
|
|
|
vi.mock('../../../../src/pages/dashboard/useDashboard', () => ({
|
|
useDashboard: () => mocks.dash,
|
|
}));
|
|
|
|
// The badge lookup is a network hook of its own; the screen only cares what it returns.
|
|
vi.mock('../../../../src/components/Plugins/TripCardBadges', () => ({
|
|
useTripCardBadges: () => (tripId: number) => mocks.badges[tripId] ?? [],
|
|
}));
|
|
|
|
// This screen only ever renders on a phone. Shared dialogs it opens (the calendar
|
|
// subscription) draw their phone look there and their desktop look above 768px.
|
|
vi.mock('../../../../src/mobile/useIsPhone', () => ({ useIsPhone: () => true }));
|
|
|
|
// Local-calendar date string — NOT toISOString(), which is the UTC date and
|
|
// disagrees with the badge logic's wall-clock classification between local
|
|
// midnight and the UTC rollover (these tests flaked in exactly that window).
|
|
const iso = (offsetDays: number): string => {
|
|
const d = new Date();
|
|
d.setDate(d.getDate() + offsetDays);
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
};
|
|
|
|
function buildTrip(over: Partial<DashboardTrip> = {}): DashboardTrip {
|
|
return {
|
|
id: 1, user_id: 1, title: 'Japan 2026', currency: 'EUR', is_archived: 0,
|
|
reminder_days: 0, start_date: iso(-3), end_date: iso(10),
|
|
day_count: 14, place_count: 32, shared_count: 2,
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function buildDash(over: Record<string, unknown> = {}): Record<string, unknown> {
|
|
return {
|
|
locale: 'en',
|
|
t: (key: string) => key,
|
|
navigate: vi.fn(),
|
|
spotlight: null,
|
|
upcoming: [],
|
|
gridTrips: [],
|
|
isLoading: false,
|
|
loadError: false,
|
|
retryLoad: vi.fn(),
|
|
tripFilter: 'planned',
|
|
setTripFilter: vi.fn(),
|
|
viewMode: 'grid',
|
|
toggleViewMode: vi.fn(),
|
|
showForm: false,
|
|
setShowForm: vi.fn(),
|
|
editingTrip: null,
|
|
setEditingTrip: vi.fn(),
|
|
deleteTrip: null,
|
|
setDeleteTrip: vi.fn(),
|
|
copyTrip: null,
|
|
setCopyTrip: vi.fn(),
|
|
applyCoverUpdate: vi.fn(),
|
|
handleCreate: vi.fn(),
|
|
handleUpdate: vi.fn(),
|
|
confirmDelete: vi.fn(),
|
|
handleArchive: vi.fn(),
|
|
handleUnarchive: vi.fn(),
|
|
confirmCopy: vi.fn(),
|
|
...over,
|
|
};
|
|
}
|
|
|
|
describe('MDashboard', () => {
|
|
beforeEach(() => {
|
|
mocks.dash = buildDash();
|
|
mocks.badges = {};
|
|
usePluginStore.setState({ plugins: [], loaded: true });
|
|
useAuthStore.setState({
|
|
isAuthenticated: true,
|
|
user: { id: 1, username: 'Maurice', email: 'maurice@trek.app', role: 'user', avatar_url: '' } as never,
|
|
});
|
|
useInAppNotificationStore.setState({ unreadCount: 0, fetchUnreadCount: async () => {} });
|
|
});
|
|
|
|
it('FE-MOB-DASH-001: renders the ongoing spotlight with badge, progress and grid cards', () => {
|
|
mocks.dash = buildDash({
|
|
spotlight: buildTrip(),
|
|
gridTrips: [buildTrip({ id: 2, title: 'Lisbon', start_date: iso(30), end_date: iso(40) })],
|
|
});
|
|
render(<MDashboard />);
|
|
|
|
expect(screen.getByText('Japan 2026')).toBeInTheDocument();
|
|
expect(screen.getByText('dashboard.status.ongoing')).toBeInTheDocument();
|
|
expect(screen.getByText('dashboard.mobile.spotlightDayOf')).toBeInTheDocument();
|
|
// Stat pills reuse the desktop hero keys (no mobile-only duplicates).
|
|
expect(screen.getByText('dashboard.hero.destinations')).toBeInTheDocument();
|
|
expect(screen.getByText('dashboard.hero.travelers')).toBeInTheDocument();
|
|
expect(screen.getByText('Lisbon')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-DASH-002: archived filter swaps card actions to restore + permanent delete', () => {
|
|
const handleUnarchive = vi.fn();
|
|
mocks.dash = buildDash({
|
|
tripFilter: 'archive',
|
|
handleUnarchive,
|
|
gridTrips: [buildTrip({ id: 3, title: 'Iceland', is_archived: 1 })],
|
|
});
|
|
render(<MDashboard />);
|
|
|
|
expect(screen.queryByRole('button', { name: 'common.edit' })).not.toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole('button', { name: 'dashboard.restore' }));
|
|
expect(handleUnarchive).toHaveBeenCalledWith(3);
|
|
expect(screen.getByRole('button', { name: 'common.delete' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-DASH-003: the view toggle persists through the shared hook action', () => {
|
|
const toggleViewMode = vi.fn();
|
|
mocks.dash = buildDash({ toggleViewMode, gridTrips: [buildTrip()] });
|
|
render(<MDashboard />);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'dashboard.aria.toggleView' }));
|
|
expect(toggleViewMode).toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-MOB-DASH-004: an upcoming reservation opens the trip on its bookings tab', () => {
|
|
mocks.dash = buildDash({
|
|
upcoming: [{ id: 9, trip_id: 7, title: 'teamLab Planets', type: 'ticket', reservation_time: null, day_date: iso(2) }],
|
|
});
|
|
render(<MDashboard />);
|
|
|
|
fireEvent.click(screen.getByText('teamLab Planets'));
|
|
expect(sessionStorage.getItem('trip-tab-7')).toBe('buchungen');
|
|
});
|
|
|
|
it('FE-MOB-DASH-005: a failed load shows the retry banner', () => {
|
|
const retryLoad = vi.fn();
|
|
mocks.dash = buildDash({ loadError: true, retryLoad });
|
|
render(<MDashboard />);
|
|
|
|
expect(screen.getByRole('alert')).toHaveTextContent('dashboard.loadErrorBanner');
|
|
fireEvent.click(screen.getByRole('button', { name: 'dashboard.retry' }));
|
|
expect(retryLoad).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('FE-MOB-DASH-006: the empty state opens a blank create sheet', () => {
|
|
const setShowForm = vi.fn();
|
|
const setEditingTrip = vi.fn();
|
|
mocks.dash = buildDash({ setShowForm, setEditingTrip });
|
|
render(<MDashboard />);
|
|
|
|
expect(screen.getByText('dashboard.emptyTitle')).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole('button', { name: 'dashboard.emptyButton' }));
|
|
|
|
expect(setEditingTrip).toHaveBeenCalledWith(null);
|
|
expect(setShowForm).toHaveBeenCalledWith(true);
|
|
});
|
|
|
|
it('FE-MOB-DASH-007: a still-loading dashboard shows no empty state', () => {
|
|
mocks.dash = buildDash({ isLoading: true });
|
|
render(<MDashboard />);
|
|
|
|
expect(screen.queryByText('dashboard.emptyTitle')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-DASH-008: demo mode adds the banner', () => {
|
|
mocks.dash = buildDash({ demoMode: true });
|
|
render(<MDashboard />);
|
|
|
|
expect(screen.getByText(/demo/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-DASH-009: unread notifications mark the bell and it opens the list', () => {
|
|
const navigate = vi.fn();
|
|
mocks.dash = buildDash({ navigate });
|
|
useInAppNotificationStore.setState({ unreadCount: 3, fetchUnreadCount: async () => {} });
|
|
render(<MDashboard />);
|
|
|
|
const bell = screen.getByRole('button', { name: 'notifications.title' });
|
|
expect(bell.querySelector('span[aria-hidden]')).toBeInTheDocument();
|
|
|
|
fireEvent.click(bell);
|
|
expect(navigate).toHaveBeenCalledWith('/notifications');
|
|
});
|
|
|
|
it('FE-MOB-DASH-010: the avatar button toggles the user menu', () => {
|
|
render(<MDashboard />);
|
|
const avatar = screen.getByRole('button', { name: 'nav.profile' });
|
|
|
|
expect(avatar).toHaveTextContent('M');
|
|
expect(avatar).toHaveAttribute('aria-expanded', 'false');
|
|
|
|
fireEvent.click(avatar);
|
|
expect(avatar).toHaveAttribute('aria-expanded', 'true');
|
|
});
|
|
|
|
it('FE-MOB-DASH-011: a stored avatar replaces the initial', () => {
|
|
useAuthStore.setState({
|
|
isAuthenticated: true,
|
|
user: { id: 1, username: 'Maurice', email: 'm@trek.app', role: 'user', avatar_url: '/uploads/avatars/m.jpg' } as never,
|
|
});
|
|
render(<MDashboard />);
|
|
|
|
const avatar = screen.getByRole('button', { name: 'nav.profile' });
|
|
expect(avatar.querySelector('img')).toHaveAttribute('src', '/uploads/avatars/m.jpg');
|
|
});
|
|
|
|
it('FE-MOB-DASH-012: the calendar action opens the all-trips subscribe dialog', () => {
|
|
render(<MDashboard />);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'dashboard.subscribeAllTrips' }));
|
|
|
|
expect(screen.getByText('dashboard.subscribeAllTripsDesc')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-DASH-013: the delete sheet names the trip and runs the confirm action', () => {
|
|
const confirmDelete = vi.fn();
|
|
const setDeleteTrip = vi.fn();
|
|
mocks.dash = buildDash({ deleteTrip: buildTrip({ title: 'Iceland' }), confirmDelete, setDeleteTrip });
|
|
render(<MDashboard />);
|
|
|
|
const sheet = screen.getByRole('dialog', { name: 'common.delete' });
|
|
expect(within(sheet).getByText('dashboard.confirm.delete')).toBeInTheDocument();
|
|
|
|
// The sheet's own chrome uses the real translations, the props the mocked t.
|
|
fireEvent.click(within(sheet).getByText('Cancel'));
|
|
expect(setDeleteTrip).toHaveBeenCalledWith(null);
|
|
|
|
fireEvent.click(within(sheet).getAllByText('common.delete')[1]);
|
|
expect(confirmDelete).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('FE-MOB-DASH-014: the copy sheet confirms the duplicate', () => {
|
|
const confirmCopy = vi.fn();
|
|
mocks.dash = buildDash({ copyTrip: buildTrip({ title: 'Iceland' }), confirmCopy });
|
|
render(<MDashboard />);
|
|
|
|
const sheet = screen.getByRole('dialog', { name: 'dashboard.confirm.copy.title' });
|
|
expect(within(sheet).getByText('Iceland')).toBeInTheDocument();
|
|
|
|
fireEvent.click(within(sheet).getByText('dashboard.confirm.copy.confirm'));
|
|
expect(confirmCopy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('FE-MOB-DASH-015: the list layout adds an archive action and the day/place stats', () => {
|
|
const handleArchive = vi.fn();
|
|
mocks.dash = buildDash({
|
|
viewMode: 'list',
|
|
handleArchive,
|
|
gridTrips: [buildTrip({ id: 4, title: 'Lisbon', day_count: 12, place_count: 30, shared_count: 1 })],
|
|
});
|
|
render(<MDashboard />);
|
|
|
|
expect(screen.getByText('12')).toBeInTheDocument();
|
|
expect(screen.getByText('30')).toBeInTheDocument();
|
|
expect(screen.getByText('dashboard.card.buddies')).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'dashboard.archive' }));
|
|
expect(handleArchive).toHaveBeenCalledWith(4);
|
|
});
|
|
|
|
it('FE-MOB-DASH-016: an undated trip renders dashes instead of a range', () => {
|
|
mocks.dash = buildDash({ gridTrips: [buildTrip({ id: 5, start_date: null, end_date: null })] });
|
|
render(<MDashboard />);
|
|
|
|
const card = screen.getByText('Japan 2026').closest('[role="button"]') as HTMLElement;
|
|
expect(within(card).getAllByText('—')).toHaveLength(2);
|
|
expect(within(card).getByText('dashboard.card.idea')).toBeInTheDocument();
|
|
});
|
|
|
|
it.each([
|
|
[{ is_archived: 1 }, 'dashboard.archived'],
|
|
[{ start_date: iso(0), end_date: null }, 'dashboard.status.today'],
|
|
[{ start_date: iso(1), end_date: null }, 'dashboard.status.tomorrow'],
|
|
[{ start_date: iso(10), end_date: null }, 'dashboard.mobile.inDays'],
|
|
[{ start_date: iso(200), end_date: null }, 'dashboard.mobile.inMonths'],
|
|
[{ start_date: iso(-20), end_date: iso(-10) }, 'dashboard.mobile.completed'],
|
|
] as Array<[Partial<DashboardTrip>, string]>)(
|
|
'FE-MOB-DASH-017: the card badge follows the trip state (%#)',
|
|
(over, label) => {
|
|
mocks.dash = buildDash({ gridTrips: [buildTrip({ id: 6, ...over })] });
|
|
render(<MDashboard />);
|
|
|
|
// The filter segments reuse some of these labels, so assert on the card itself.
|
|
const card = screen.getByText('Japan 2026').closest('[role="button"]') as HTMLElement;
|
|
expect(within(card).getByText(label)).toBeInTheDocument();
|
|
},
|
|
);
|
|
|
|
it('FE-MOB-DASH-018: plugin badges render as chips, linked ones as anchors', () => {
|
|
mocks.badges = {
|
|
8: [
|
|
{ pluginId: 'p', id: 'a', label: 'Weather', value: '21°', tone: 'success' },
|
|
{ pluginId: 'p', id: 'b', label: 'Docs', value: '', tone: 'warn', url: 'https://example.com' },
|
|
] as TripCardBadge[],
|
|
};
|
|
mocks.dash = buildDash({ gridTrips: [buildTrip({ id: 8, title: 'Oslo' })] });
|
|
render(<MDashboard />);
|
|
|
|
expect(screen.getByText('21°')).toBeInTheDocument();
|
|
const link = screen.getByText('Docs').closest('a') as HTMLAnchorElement;
|
|
expect(link).toHaveAttribute('href', 'https://example.com');
|
|
// The chip must not open the card underneath it.
|
|
fireEvent.click(link);
|
|
expect((mocks.dash.navigate as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-MOB-DASH-019: dashboard widget plugins render below the blocks', () => {
|
|
usePluginStore.setState({
|
|
plugins: [
|
|
{ id: 'w1', name: 'Packing helper', type: 'widget', icon: null },
|
|
{ id: 'h1', name: 'Hero widget', type: 'widget', icon: null, slot: 'hero' },
|
|
] as never,
|
|
loaded: true,
|
|
});
|
|
mocks.dash = buildDash({ spotlight: buildTrip() });
|
|
render(<MDashboard />);
|
|
|
|
expect(screen.getByText('Packing helper')).toBeInTheDocument();
|
|
expect(screen.queryByText('Hero widget')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-DASH-020: a card action does not open the trip underneath it', () => {
|
|
const navigate = vi.fn();
|
|
const setCopyTrip = vi.fn();
|
|
mocks.dash = buildDash({ navigate, setCopyTrip, gridTrips: [buildTrip({ id: 9, title: 'Oslo' })] });
|
|
render(<MDashboard />);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'dashboard.aria.duplicate' }));
|
|
|
|
expect(setCopyTrip).toHaveBeenCalled();
|
|
expect(navigate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-MOB-DASH-021: pressing Enter on a card opens the trip', () => {
|
|
const navigate = vi.fn();
|
|
mocks.dash = buildDash({ navigate, gridTrips: [buildTrip({ id: 9, title: 'Oslo' })] });
|
|
render(<MDashboard />);
|
|
|
|
fireEvent.keyDown(screen.getByText('Oslo').closest('[role="button"]') as HTMLElement, { key: 'Enter' });
|
|
|
|
expect(navigate).toHaveBeenCalledWith('/trips/9');
|
|
});
|
|
|
|
it('FE-MOB-DASH-022: an upcoming spotlight counts down instead of showing progress', () => {
|
|
mocks.dash = buildDash({
|
|
spotlight: buildTrip({ start_date: iso(4), end_date: iso(9), cover_image: '/uploads/covers/x.jpg' }),
|
|
});
|
|
const { container } = render(<MDashboard />);
|
|
|
|
expect(screen.getByText('dashboard.hero.badgeNext')).toBeInTheDocument();
|
|
expect(screen.getByText('dashboard.mobile.inDays')).toBeInTheDocument();
|
|
expect(container.querySelector('img[src="/uploads/covers/x.jpg"]')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-DASH-023: a finished spotlight is labelled as recent', () => {
|
|
mocks.dash = buildDash({ spotlight: buildTrip({ start_date: iso(-20), end_date: iso(-10), day_count: 1 }) });
|
|
render(<MDashboard />);
|
|
|
|
expect(screen.getByText('dashboard.hero.badgeRecent')).toBeInTheDocument();
|
|
expect(screen.getByText('dashboard.mobile.spotlightDays')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-DASH-024: the spotlight opens on Enter as well as on click', () => {
|
|
const navigate = vi.fn();
|
|
mocks.dash = buildDash({ navigate, spotlight: buildTrip({ id: 12 }) });
|
|
render(<MDashboard />);
|
|
|
|
fireEvent.keyDown(screen.getByText('Japan 2026').closest('[role="button"]') as HTMLElement, { key: 'Enter' });
|
|
|
|
expect(navigate).toHaveBeenCalledWith('/trips/12');
|
|
});
|
|
|
|
it('FE-MOB-DASH-025: editing a card hands the trip to the create/edit sheet', () => {
|
|
const setEditingTrip = vi.fn();
|
|
const setShowForm = vi.fn();
|
|
const trip = buildTrip({ id: 13, title: 'Oslo' });
|
|
mocks.dash = buildDash({ setEditingTrip, setShowForm, gridTrips: [trip] });
|
|
render(<MDashboard />);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.edit' }));
|
|
|
|
expect(setEditingTrip).toHaveBeenCalledWith(trip);
|
|
expect(setShowForm).toHaveBeenCalledWith(true);
|
|
});
|
|
|
|
it('FE-MOB-DASH-026: the mobile widgets render in the configured block order', () => {
|
|
mocks.dash = buildDash();
|
|
render(<MDashboard />);
|
|
|
|
// The widget panels bring their own translations rather than the mocked hook's t.
|
|
expect(screen.getByText('Currency')).toBeInTheDocument();
|
|
expect(screen.getByText('Timezones')).toBeInTheDocument();
|
|
expect(screen.getByText('Upcoming reservations')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-DASH-027: a malformed date renders a dash instead of crashing', () => {
|
|
mocks.dash = buildDash({ gridTrips: [buildTrip({ id: 14, start_date: 'not-a-date', end_date: iso(3) })] });
|
|
render(<MDashboard />);
|
|
|
|
const card = screen.getByText('Japan 2026').closest('[role="button"]') as HTMLElement;
|
|
expect(within(card).getAllByText('—')).toHaveLength(1);
|
|
});
|
|
|
|
it('FE-MOB-DASH-028: the archived card duplicates and deletes through the hook', () => {
|
|
const setCopyTrip = vi.fn();
|
|
const setDeleteTrip = vi.fn();
|
|
const trip = buildTrip({ id: 15, title: 'Iceland', is_archived: 1 });
|
|
mocks.dash = buildDash({ tripFilter: 'archive', setCopyTrip, setDeleteTrip, gridTrips: [trip] });
|
|
render(<MDashboard />);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'dashboard.aria.duplicate' }));
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }));
|
|
|
|
expect(setCopyTrip).toHaveBeenCalledWith(trip);
|
|
expect(setDeleteTrip).toHaveBeenCalledWith(trip);
|
|
});
|
|
|
|
it('FE-MOB-DASH-029: a grid card deletes through the hook', () => {
|
|
const setDeleteTrip = vi.fn();
|
|
const trip = buildTrip({ id: 16, title: 'Oslo' });
|
|
mocks.dash = buildDash({ setDeleteTrip, gridTrips: [trip] });
|
|
render(<MDashboard />);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }));
|
|
|
|
expect(setDeleteTrip).toHaveBeenCalledWith(trip);
|
|
});
|
|
|
|
it('FE-MOB-DASH-030: a list card opens its trip by click and by Enter', () => {
|
|
const navigate = vi.fn();
|
|
mocks.dash = buildDash({ viewMode: 'list', navigate, gridTrips: [buildTrip({ id: 17, title: 'Oslo' })] });
|
|
render(<MDashboard />);
|
|
const card = screen.getByText('Oslo').closest('[role="button"]') as HTMLElement;
|
|
|
|
fireEvent.click(card);
|
|
fireEvent.keyDown(card, { key: 'Enter' });
|
|
|
|
expect(navigate).toHaveBeenNthCalledWith(1, '/trips/17');
|
|
expect(navigate).toHaveBeenNthCalledWith(2, '/trips/17');
|
|
});
|
|
|
|
it('FE-MOB-DASH-031: the brand tile scrolls the page back to the top', () => {
|
|
// Since #1809 the document itself is the scroller on a phone, so there is no
|
|
// shell container left to walk up to.
|
|
const scrollTo = vi.fn();
|
|
const original = window.scrollTo;
|
|
window.scrollTo = scrollTo as unknown as typeof window.scrollTo;
|
|
render(<MDashboard />);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'TREK' }));
|
|
|
|
expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
|
|
window.scrollTo = original;
|
|
});
|
|
|
|
it('FE-MOB-DASH-032: the user menu backdrop closes it again', () => {
|
|
const { container } = render(<MDashboard />);
|
|
const avatar = screen.getByRole('button', { name: 'nav.profile' });
|
|
|
|
fireEvent.click(avatar);
|
|
fireEvent.click(container.querySelector('.fixed.inset-0') as HTMLElement);
|
|
|
|
expect(avatar).toHaveAttribute('aria-expanded', 'false');
|
|
});
|
|
|
|
it('FE-MOB-DASH-033: closing the trip sheet resets the form state', () => {
|
|
const setShowForm = vi.fn();
|
|
const setEditingTrip = vi.fn();
|
|
mocks.dash = buildDash({ showForm: true, setShowForm, setEditingTrip });
|
|
render(<MDashboard />);
|
|
|
|
fireEvent.click(within(screen.getByRole('dialog')).getByText('Cancel'));
|
|
|
|
expect(setShowForm).toHaveBeenCalledWith(false);
|
|
expect(setEditingTrip).toHaveBeenCalledWith(null);
|
|
});
|
|
|
|
it('FE-MOB-DASH-034: archiving from the edit sheet runs the matching hook action', () => {
|
|
const handleArchive = vi.fn();
|
|
const handleUnarchive = vi.fn();
|
|
mocks.dash = buildDash({
|
|
showForm: true, editingTrip: buildTrip({ id: 18 }), handleArchive, handleUnarchive,
|
|
});
|
|
render(<MDashboard />);
|
|
|
|
fireEvent.click(screen.getByText('Archive'));
|
|
expect(handleArchive).toHaveBeenCalledWith(18);
|
|
|
|
mocks.dash = buildDash({
|
|
showForm: true, editingTrip: buildTrip({ id: 18, is_archived: 1 }), handleArchive, handleUnarchive,
|
|
});
|
|
render(<MDashboard />);
|
|
|
|
fireEvent.click(screen.getByText('Restore'));
|
|
expect(handleUnarchive).toHaveBeenCalledWith(18);
|
|
});
|
|
|
|
it('FE-MOB-DASH-035: a cover uploaded in the sheet is patched into the trip list', async () => {
|
|
const applyCoverUpdate = vi.fn();
|
|
vi.spyOn(tripsApi, 'uploadCover').mockResolvedValue({ cover_image: '/uploads/covers/n.jpg' });
|
|
mocks.dash = buildDash({ showForm: true, editingTrip: buildTrip({ id: 19 }), applyCoverUpdate });
|
|
render(<MDashboard />);
|
|
|
|
fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, {
|
|
target: { files: [new File(['x'], 'c.png', { type: 'image/png' })] },
|
|
});
|
|
|
|
await waitFor(() => expect(applyCoverUpdate).toHaveBeenCalledWith(19, '/uploads/covers/n.jpg'));
|
|
});
|
|
|
|
it('FE-MOB-DASH-036: dismissing the copy sheet clears the pending trip', () => {
|
|
const setCopyTrip = vi.fn();
|
|
mocks.dash = buildDash({ copyTrip: buildTrip({ title: 'Iceland' }), setCopyTrip });
|
|
render(<MDashboard />);
|
|
|
|
const sheet = screen.getByRole('dialog', { name: 'dashboard.confirm.copy.title' });
|
|
fireEvent.click(within(sheet).getByText('Cancel'));
|
|
|
|
expect(setCopyTrip).toHaveBeenCalledWith(null);
|
|
});
|
|
|
|
it('FE-MOB-DASH-037: the subscribe dialog can be closed again', async () => {
|
|
render(<MDashboard />);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'dashboard.subscribeAllTrips' }));
|
|
expect(screen.getByText('dashboard.subscribeAllTripsDesc')).toBeInTheDocument();
|
|
|
|
// The dialog's close control is icon-only; it is the first button of the card.
|
|
const card = screen.getByText('dashboard.subscribeAllTripsDesc').parentElement as HTMLElement;
|
|
fireEvent.click(card.querySelector('button') as HTMLElement);
|
|
|
|
await waitFor(() =>
|
|
expect(screen.queryByText('dashboard.subscribeAllTripsDesc')).not.toBeInTheDocument());
|
|
});
|
|
|
|
it('FE-MOB-DASH-038: the filter row cannot widen the document on narrow phones (#discord S26)', async () => {
|
|
render(<MDashboard />);
|
|
|
|
// The chip track scrolls inside its own flexible box; without this the row
|
|
// was the widest element on the page and dragged the fixed bars off-screen
|
|
// under Android's forced zoom.
|
|
const chip = await screen.findByText('dashboard.filter.planned');
|
|
const track = chip.closest('button')!.parentElement as HTMLElement;
|
|
expect(track.className).toContain('min-w-max');
|
|
const wrapper = track.parentElement as HTMLElement;
|
|
expect(wrapper.className).toContain('m-hscroll');
|
|
expect(wrapper.className).toContain('min-w-0');
|
|
});
|
|
|
|
it('FE-MOB-DASH-039: the filter pill hugs its chips instead of filling the row', async () => {
|
|
render(<MDashboard />);
|
|
|
|
// flex-1 on the scroll box stretched the grey pill track all the way to the
|
|
// calendar icon on phones from ~400px up; the icons ride on ms-auto instead.
|
|
const chip = await screen.findByText('dashboard.filter.planned');
|
|
const wrapper = chip.closest('button')!.parentElement!.parentElement as HTMLElement;
|
|
expect(wrapper.className).not.toContain('flex-1');
|
|
expect(screen.getByRole('button', { name: 'dashboard.subscribeAllTrips' }).className)
|
|
.toContain('ms-auto');
|
|
});
|
|
});
|