mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
390135f3bc
The old popup was a narrow white card with its own copy of the texts in eight languages, hex colours and a minute counter on the browser clock. The new DemoWelcome is a wide dialog on DialogShell with theme tokens only: what TREK is in one line, the hourly reset with a live countdown in the server's time zone, the 5.0 highlights to try right away (trip planner with bookings and costs, Tours with 3D terrain, TREK Studio photo books, live collaboration, Journey, Atlas, Vacay, Collections, offline use), what the demo leaves out and what the full version adds, and the link to the source. Its strings are real i18n keys in a new demo domain file, translated into all 27 locales. Both dashboards open it instead of the old DemoBanner, which leaves the theme and format baselines with its file and the test non-null assertions it held.
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 opens the demo welcome', () => {
|
|
mocks.dash = buildDash({ demoMode: true });
|
|
render(<MDashboard />);
|
|
|
|
expect(screen.getByRole('dialog')).toHaveTextContent('Welcome to TREK');
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|