mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
4dfccaa2a4
From the large-screen review (code findings H1, M1, M2, M3, M4, M7, L1, L3, L4, L6, L7): - The size class is one value in JavaScript (hooks/sizeClass.ts) and the stylesheet follows it: MobileShell writes it to the shell as data-size, the wide:/split: variants and mobile.css key on that instead of media queries of their own. A window that only grows shorter at the same width while a field has focus is Android's soft keyboard, not a new window, so the rail, the form sheets and the panes no longer fold away (and take the focused field with them) while one types. - The large-screen layout is the app's only. In a browser the touch shell is the phone's below 768px and stays compact there, as before this branch. - Shortcuts are the app's only, so a browser keeps Ctrl+1 and Alt+Left. The command key is the platform's own (Cmd on Apple, Ctrl elsewhere), digits are matched by the key's place as well (AZERTY), and nothing runs while a sheet or another modal dialog is open: the page listens to nothing and the app is handed no key commands, so Cmd+[ can no longer drop an unsaved form. - Inside a trip Cmd+N belongs to the trip shell and creates what the tab on show creates, named after it. - Inside the touch shell the desktop navbar of the few PageShell routes is hidden and --nav-h is 0. - The rail comes before the page in the DOM, so a keyboard reaches it first. - The phone's deep day colour for a journey card without a photo stays the phone's; the desktop and shared timelines keep their faint wash. Its card is opaque, a suggestion included, so the map's names do not read through. - The trip widget's iPad mini layout keys on the iPad mini's width alone. - The trip dock gets the scroll-edge strip and the denser dock glass the global dock has. - Duplicate test id FE-MOB-PANES-016 renumbered.
236 lines
11 KiB
TypeScript
236 lines
11 KiB
TypeScript
import { http, HttpResponse } from 'msw';
|
|
import { useEffect } from 'react';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import MKeyboardHost from '../../../../src/mobile/keyboard/MKeyboardHost';
|
|
import { useShortcutRegistry } from '../../../../src/mobile/keyboard/shortcuts';
|
|
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell';
|
|
import { useAuthStore } from '../../../../src/store/authStore';
|
|
import { useDocSyncOfferStore } from '../../../../src/store/docSyncOfferStore';
|
|
import type { Day } from '../../../../src/types';
|
|
import { buildPlanner } from '../../../helpers/mobileTrip';
|
|
import { server } from '../../../helpers/msw/server';
|
|
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render';
|
|
import { setSizeClass } from '../../../helpers/sizeClass';
|
|
import { seedStore } from '../../../helpers/store';
|
|
|
|
// FE-MOB-SHELLW-001 onwards: the trip screen on a wide window (iPad, foldable unfolded).
|
|
|
|
const mocks = vi.hoisted(() => ({ planner: {} as TripPlanner }));
|
|
|
|
// The shortcuts are the app's (MKeyboardHost): an iPad without native key commands.
|
|
vi.mock('../../../../src/native/platform', () => ({
|
|
isNativeApp: () => true,
|
|
isNativeIos: () => true,
|
|
isNativeAndroid: () => false,
|
|
nativeServerHost: () => null,
|
|
}));
|
|
vi.mock('../../../../src/pages/tripPlanner/useTripPlanner', () => ({
|
|
useTripPlanner: () => mocks.planner,
|
|
}));
|
|
vi.mock('../../../../src/mobile/screens/trip/plan/MPlanTimeline', () => ({ default: () => null }));
|
|
vi.mock('../../../../src/mobile/screens/trip/map/MMapArea', () => ({ default: () => null }));
|
|
vi.mock('../../../../src/mobile/screens/trip/places/MPlacesBrowser', () => ({ default: () => null }));
|
|
vi.mock('../../../../src/mobile/screens/trip/tabs/MTripTabPanel', () => ({ default: () => null }));
|
|
vi.mock('../../../../src/mobile/screens/trip/sheets/MTripSheets', () => ({ default: () => null }));
|
|
|
|
import MTripShell from '../../../../src/mobile/screens/trip/MTripShell';
|
|
|
|
// Recorded through spies, not module variables a render would write to.
|
|
const seen = vi.fn<(shell: MTripShellApi) => void>();
|
|
const mapMounted = vi.fn();
|
|
const shellApi = () => seen.mock.lastCall?.[0] as MTripShellApi;
|
|
|
|
function slot(testId: string) {
|
|
return function Slot({ shell }: { planner: TripPlanner; shell: MTripShellApi }) {
|
|
seen(shell);
|
|
return <div data-testid={testId} />;
|
|
};
|
|
}
|
|
const PlanTimelineSlot = slot('plan-timeline');
|
|
const PlacesBrowserSlot = slot('places-browser');
|
|
const SheetsSlot = slot('sheets');
|
|
function MapAreaSlot({ shell }: { planner: TripPlanner; shell: MTripShellApi }) {
|
|
seen(shell);
|
|
useEffect(() => {
|
|
mapMounted();
|
|
}, []);
|
|
return <div data-testid="map-area" />;
|
|
}
|
|
function TabSlot({ shell, tab }: { planner: TripPlanner; shell: MTripShellApi; tab: string }) {
|
|
seen(shell);
|
|
return <div data-testid="tab-panel">{tab}</div>;
|
|
}
|
|
|
|
const TRIP_TABS = [
|
|
{ id: 'plan', label: 'Plan' },
|
|
{ id: 'transports', label: 'Transport' },
|
|
{ id: 'buchungen', label: 'Bookings' },
|
|
{ id: 'finanzplan', label: 'Budget' },
|
|
{ id: 'listen', label: 'Lists' },
|
|
{ id: 'dateien', label: 'Files' },
|
|
{ id: 'collab', label: 'Collaboration' },
|
|
];
|
|
|
|
const DAYS = [
|
|
{ id: 11, trip_id: 1, day_number: 1, date: '2026-05-02', title: null },
|
|
{ id: 12, trip_id: 1, day_number: 2, date: '2026-05-03', title: null },
|
|
{ id: 13, trip_id: 1, day_number: 3, date: '2026-05-04', title: null },
|
|
] as unknown as Day[];
|
|
|
|
function renderShell(overrides: Partial<TripPlanner> = {}) {
|
|
mocks.planner = buildPlanner({ days: DAYS, selectedDayId: 12, TRIP_TABS, ...overrides } as Partial<TripPlanner>);
|
|
return render(
|
|
<>
|
|
<MKeyboardHost />
|
|
<MTripShell
|
|
PlanTimeline={PlanTimelineSlot}
|
|
MapArea={MapAreaSlot}
|
|
PlacesBrowser={PlacesBrowserSlot}
|
|
TabPanel={TabSlot}
|
|
Sheets={SheetsSlot}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const tripRail = () => screen.getByRole('navigation', { name: 'Trip sections' });
|
|
const root = (container: HTMLElement) => container.querySelector('.m-trip') as HTMLElement;
|
|
|
|
describe('MTripShell on a wide window', () => {
|
|
beforeEach(() => {
|
|
sessionStorage.clear();
|
|
seen.mockClear();
|
|
mapMounted.mockClear();
|
|
useShortcutRegistry.setState({ lists: [] });
|
|
useDocSyncOfferStore.setState({ bound: {}, providers: null });
|
|
seedStore(useAuthStore, { user: null, isAuthenticated: false });
|
|
server.use(
|
|
http.get('/api/trips/:tripId/docsync/providers', () => HttpResponse.json([])),
|
|
http.get('/api/trips/:tripId/docsync/links', () => HttpResponse.json([]))
|
|
);
|
|
});
|
|
|
|
it('FE-MOB-SHELLW-001: the sections stand in a rail, every one of them, instead of the dock', () => {
|
|
setSizeClass('wide');
|
|
renderShell();
|
|
const labels = within(tripRail())
|
|
.getAllByRole('button')
|
|
.map((b) => b.getAttribute('title'));
|
|
expect(labels).toEqual(['Plan', 'Transport', 'Bookings', 'Budget', 'Lists', 'Files', 'Collaboration', 'More']);
|
|
fireEvent.click(within(tripRail()).getByRole('button', { name: 'Files' }));
|
|
expect(mocks.planner.handleTabChange).toHaveBeenCalledWith('dateien');
|
|
fireEvent.click(within(tripRail()).getByRole('button', { name: 'More' }));
|
|
expect(shellApi().sheet).toEqual({ id: 'mehr', payload: undefined });
|
|
});
|
|
|
|
it('FE-MOB-SHELLW-002: with room for two panes the plan sits beside the map, which follows its day', () => {
|
|
setSizeClass('split');
|
|
const { container } = renderShell();
|
|
expect(screen.getByTestId('plan-timeline')).toBeInTheDocument();
|
|
expect(shellApi().paneOpen).toBe(true);
|
|
// The map is on screen too, so its chrome is up and "show on map" has nowhere to go.
|
|
expect(shellApi().mapFront).toBe(true);
|
|
expect(root(container).style.getPropertyValue('--trip-pane')).toBe('var(--trip-pane-open)');
|
|
expect(mocks.planner.autoShowRoute).toHaveBeenCalled();
|
|
expect(mocks.planner.setExpandedDayIds).toHaveBeenCalledWith(new Set([12]));
|
|
});
|
|
|
|
it('FE-MOB-SHELLW-003: the switch hides and shows the pane like a sidebar, the map runs under the rail meanwhile', () => {
|
|
setSizeClass('split');
|
|
const { container } = renderShell();
|
|
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.hidePane' }));
|
|
expect(shellApi().paneOpen).toBe(false);
|
|
expect(screen.queryByTestId('plan-timeline')).not.toBeInTheDocument();
|
|
expect(root(container).style.getPropertyValue('--trip-map-start')).toBe('0px');
|
|
expect(root(container).style.getPropertyValue('--trip-map-inset')).toBe('var(--m-rail-w)');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.showPane' }));
|
|
expect(shellApi().paneOpen).toBe(true);
|
|
expect(screen.getByTestId('plan-timeline')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-SHELLW-007: the map frames the day beside the pane, and again once its box has changed', async () => {
|
|
setSizeClass('split');
|
|
renderShell();
|
|
// On arrival, as the map toggle frames it: the day, without the list flag.
|
|
expect(mocks.planner.handleSelectDay).toHaveBeenCalledWith(12, false);
|
|
vi.mocked(mocks.planner.handleSelectDay).mockClear();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.hidePane' }));
|
|
vi.mocked(mocks.planner.handleSelectDay).mockClear();
|
|
await waitFor(() => expect(mocks.planner.handleSelectDay).toHaveBeenCalledWith(12, false), { timeout: 1500 });
|
|
});
|
|
|
|
it('FE-MOB-SHELLW-004: folding to compact mid-plan keeps the map and the day, and brings the dock back', () => {
|
|
setSizeClass('split');
|
|
renderShell();
|
|
const mountsBefore = mapMounted.mock.calls.length;
|
|
setSizeClass('compact');
|
|
expect(screen.queryByRole('navigation', { name: 'Trip sections' })).not.toBeInTheDocument();
|
|
// The dock's More, named by the planner's t like the rest of the dock.
|
|
expect(screen.getByRole('button', { name: 'mobileTrip.more' })).toBeInTheDocument();
|
|
expect(shellApi().paneOpen).toBe(false);
|
|
expect(shellApi().view).toBe('plan');
|
|
expect(screen.getByTestId('plan-timeline')).toBeInTheDocument();
|
|
// The same map instance, not a new one: a remount reloads every tile.
|
|
expect(mapMounted).toHaveBeenCalledTimes(mountsBefore);
|
|
});
|
|
|
|
it('FE-MOB-SHELLW-005: a hardware keyboard switches sections, steps through days and toggles the pane', () => {
|
|
setSizeClass('split');
|
|
renderShell();
|
|
fireEvent.keyDown(document.body, { key: '3', metaKey: true });
|
|
expect(mocks.planner.handleTabChange).toHaveBeenCalledWith('buchungen');
|
|
|
|
fireEvent.keyDown(document.body, { key: 'ArrowRight', altKey: true });
|
|
expect(mocks.planner.handleSelectDay).toHaveBeenCalledWith(13, true);
|
|
fireEvent.keyDown(document.body, { key: 'ArrowLeft', altKey: true });
|
|
expect(mocks.planner.handleSelectDay).toHaveBeenCalledWith(11, true);
|
|
|
|
fireEvent.keyDown(document.body, { key: 's', metaKey: true, ctrlKey: true });
|
|
expect(shellApi().paneOpen).toBe(false);
|
|
|
|
fireEvent.keyDown(document.body, { key: '[', metaKey: true });
|
|
expect(mocks.planner.navigate).toHaveBeenCalledWith('/dashboard');
|
|
});
|
|
|
|
it('FE-MOB-SHELLW-010: ⌘N creates what the tab on show creates, and says so', () => {
|
|
setSizeClass('split');
|
|
renderShell({ activeTab: 'finanzplan' } as Partial<TripPlanner>);
|
|
const trip = useShortcutRegistry.getState().lists.find((l) => l.owner === 'trip');
|
|
expect(trip?.shortcuts.find((s) => s.id === 'create')?.title).toBe('costs.addExpense');
|
|
fireEvent.keyDown(document.body, { key: 'n', metaKey: true });
|
|
expect(mocks.planner.navigate).toHaveBeenCalledWith('/trips/1?create=expense');
|
|
});
|
|
|
|
it('FE-MOB-SHELLW-006: the phone keeps its dock and its full-screen plan', () => {
|
|
// Pinned: in the app jsdom's 1024x768 window would read as a tablet.
|
|
setSizeClass('compact');
|
|
renderShell();
|
|
expect(screen.queryByRole('navigation', { name: 'Trip sections' })).not.toBeInTheDocument();
|
|
expect(shellApi().paneOpen).toBe(false);
|
|
expect(shellApi().mapFront).toBe(false);
|
|
expect(screen.getByRole('button', { name: 'mobileTrip.mapView' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-SHELLW-008: beside the plan there is no all-days switch to empty it, on the bare map and beside the places there is', () => {
|
|
setSizeClass('split');
|
|
renderShell();
|
|
expect(screen.queryByRole('button', { name: 'mobileTrip.allDays' })).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'trip.mobilePlaces' }));
|
|
expect(screen.getByRole('button', { name: 'mobileTrip.allDays' })).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.hidePane' }));
|
|
expect(screen.getByRole('button', { name: 'mobileTrip.allDays' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-SHELLW-009: leaving the places for the plan after an all-days deselect brings a day back', () => {
|
|
setSizeClass('split');
|
|
renderShell({ selectedDayId: null });
|
|
fireEvent.click(screen.getByRole('button', { name: 'trip.mobilePlaces' }));
|
|
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.travel' }));
|
|
expect(mocks.planner.handleSelectDay).toHaveBeenCalledWith(expect.any(Number), true);
|
|
});
|
|
});
|