Files
TREK/client/tests/unit/mobile/largeScreenPanes.test.tsx
T
Maurice b644235eb6 fix(mobile): level card columns, the day framed beside the plan, the reader's place kept across a fold
From a screen by screen pass over the iPad and foldable sizes:

- Card lists (transports, bookings, tasks, polls) flow down their columns
  instead of filling rows, so the columns start level and a short card no
  longer ends above its neighbour in a ragged row. The cards' own top
  margin gives way to an even gap under each, so every column starts on
  one line.
- A booking's or transport's title may take two lines in a column of a wide
  window instead of being cut after a few words.
- The map beside the plan opened too close: the opening frame was worked
  out for the window, not the narrower map, and Leaflet rounded its zoom up
  to the next whole level, so the day ran off the top and bottom. The map
  now opens at the whole level that fits and, once it knows its own size,
  works the frame out again for it (OpeningFrameFit). On a wide window the
  touch shell frames a day clear of its floating toolbar and corner
  buttons.
- Costs and to-dos in two panes are one element tree with the stacked
  arrangement, so a rotation or a fold between them keeps what is typed or
  open in either pane, and the row at the top of the list stays at the top.
- The place sheet's category pill has room for its name on a wide window.
2026-11-07 23:25:13 +01:00

448 lines
18 KiB
TypeScript

import userEvent from '@testing-library/user-event';
import { Settings2, Users } from 'lucide-react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MRailButton } from '../../../src/mobile/components/MRail';
import MSectionNav from '../../../src/mobile/components/MSectionNav';
import MCollListNav from '../../../src/mobile/screens/collections/MCollListNav';
import MJourneyFeed from '../../../src/mobile/screens/journey/MJourneyFeed';
import { storyExcerpt } from '../../../src/mobile/screens/journey/mobileJourneyMeta';
import { CollabSegment } from '../../../src/mobile/screens/trip/MTripShellParts';
import { collabBoardFor, collabBoards, collabSideBySide } from '../../../src/mobile/screens/trip/tabs/collabPanes';
import { FilePreviewPane, FileTile } from '../../../src/mobile/screens/trip/tabs/MFilesBoard';
import MTodoSidebar from '../../../src/mobile/screens/trip/tabs/MTodoSidebar';
import { CardGrid, TabPanes } from '../../../src/mobile/screens/trip/tabs/tabChrome';
import { fileRepo } from '../../../src/repo/fileRepo';
import { ALL_SAVED } from '../../../src/store/collectionStore';
import type { JourneyEntry } from '../../../src/store/journeyStore';
import type { Reservation, TripFile } from '../../../src/types';
import { buildPlanner } from '../../helpers/mobileTrip';
import { fireEvent, render, screen, waitFor, within } from '../../helpers/render';
import { setSizeClass } from '../../helpers/sizeClass';
// FE-MOB-PANES-001 onwards: the panes, grids and labels the touch shell uses on a wide window.
const echo = (key: string, params?: Record<string, string | number>) =>
params ? `${key}:${Object.values(params).join(',')}` : key;
describe('rail labels that fit', () => {
// jsdom lays nothing out, so the label measures what these getters say: a word
// `width` px wide at full size in a 64px box, narrower once it may hyphenate.
function measureLike(width: number, hyphenated: number) {
const proto = HTMLSpanElement.prototype;
vi.spyOn(proto, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) {
const fit = Number(this.style.getPropertyValue('--fit') || 1);
return (this.style.hyphens === 'auto' ? hyphenated : width) * fit;
});
vi.spyOn(proto, 'clientWidth', 'get').mockReturnValue(64);
}
afterEach(() => vi.restoreAllMocks());
function label() {
return screen.getByText('Einstellungen');
}
it('FE-MOB-PANES-001: a name that fits keeps its size and its words whole', () => {
measureLike(60, 60);
render(
<MRailButton label="Einstellungen" active={false} onClick={() => {}}>
x
</MRailButton>
);
expect(label().style.getPropertyValue('--fit')).toBe('1');
expect(label().style.hyphens).toBe('manual');
expect(label().style.overflowWrap).toBe('normal');
});
it('FE-MOB-PANES-002: a word a little too wide shrinks the name instead of splitting it', () => {
measureLike(72, 40);
render(
<MRailButton label="Einstellungen" active={false} onClick={() => {}}>
x
</MRailButton>
);
const fit = Number(label().style.getPropertyValue('--fit'));
expect(fit).toBeLessThan(1);
expect(72 * fit).toBeLessThanOrEqual(64.5);
expect(label().style.hyphens).toBe('manual');
});
it('FE-MOB-PANES-003: a word too long even when small is hyphenated, and broken only past that', () => {
measureLike(140, 60);
const { unmount } = render(
<MRailButton label="Einstellungen" active={false} onClick={() => {}}>
x
</MRailButton>
);
expect(label().style.hyphens).toBe('auto');
expect(label().style.overflowWrap).toBe('normal');
unmount();
vi.restoreAllMocks();
measureLike(140, 140);
render(
<MRailButton label="Einstellungen" active={false} onClick={() => {}}>
x
</MRailButton>
);
expect(label().style.overflowWrap).toBe('anywhere');
});
});
describe('collab panes', () => {
const on = { chat: true, notes: true, links: true, polls: true };
it('FE-MOB-PANES-004: the boards beside the chat follow the trip switches, in header order', () => {
expect(collabBoards(on)).toEqual(['notes', 'links', 'polls']);
expect(collabBoards({ ...on, notes: false, links: false })).toEqual(['polls']);
expect(collabSideBySide(true, on)).toBe(true);
expect(collabSideBySide(false, on)).toBe(false);
expect(collabSideBySide(true, { ...on, chat: false })).toBe(false);
expect(collabSideBySide(true, { chat: true, notes: false, links: false, polls: false })).toBe(false);
});
it('FE-MOB-PANES-005: a tab still set to the chat shows the first board beside it', () => {
expect(collabBoardFor('chat', on)).toBe('notes');
expect(collabBoardFor('polls', on)).toBe('polls');
expect(collabBoardFor('notes', { ...on, notes: false })).toBe('links');
});
it('FE-MOB-PANES-006: the header switches the board alone and names the chat over its pane', () => {
const onPick = vi.fn();
const { rerender } = render(<CollabSegment active="chat" onPick={onPick} features={on} split={false} t={echo} />);
expect(screen.getAllByRole('button').map((b) => b.textContent)).toEqual([
'collab.tabs.chat',
'collab.tabs.notes',
'collab.tabs.links',
'collab.tabs.polls',
]);
rerender(<CollabSegment active="chat" onPick={onPick} features={on} split t={echo} />);
expect(screen.getAllByRole('button').map((b) => b.textContent)).toEqual([
'collab.tabs.notes',
'collab.tabs.links',
'collab.tabs.polls',
]);
expect(screen.getByRole('button', { name: 'collab.tabs.notes' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByText('collab.tabs.chat').tagName).toBe('SPAN');
fireEvent.click(screen.getByRole('button', { name: 'collab.tabs.polls' }));
expect(onPick).toHaveBeenCalledWith('polls');
});
});
describe('section lists beside the page', () => {
const sections = [
{ id: 'users', label: 'Users', icon: Users },
{ id: 'settings', label: 'Settings', icon: Settings2 },
];
it('FE-MOB-PANES-007: names the screen, leads back and marks the section on show', () => {
const onBack = vi.fn();
const onPick = vi.fn();
render(
<MSectionNav
title="Administration"
backLabel="Back"
onBack={onBack}
sections={sections}
activeId="settings"
onPick={onPick}
action={<button type="button">New user</button>}
/>
);
const nav = screen.getByRole('navigation', { name: 'Administration' });
expect(within(nav).getByRole('button', { name: 'Settings' })).toHaveAttribute('aria-current', 'page');
expect(within(nav).getByRole('button', { name: 'Users' })).not.toHaveAttribute('aria-current');
fireEvent.click(within(nav).getByRole('button', { name: 'Back' }));
fireEvent.click(within(nav).getByRole('button', { name: 'Users' }));
expect(onBack).toHaveBeenCalled();
expect(onPick).toHaveBeenCalledWith('users');
expect(within(nav).getByRole('button', { name: 'New user' })).toBeInTheDocument();
});
it("FE-MOB-PANES-008: the collections' lists, with their counts, the one on show and a new one", () => {
const onSelect = vi.fn();
const onNewList = vi.fn();
render(
<MCollListNav
lists={[
{ id: 1, name: 'Coffee in Kyoto', color: '#a16207', place_count: 4 },
{ id: 2, name: 'Tokyo next time', color: '#2563eb', place_count: 5 },
]}
allSaved={false}
activeId={2}
onSelect={onSelect}
onNewList={onNewList}
t={echo}
/>
);
const tokyo = screen.getByRole('button', { name: /Tokyo next time/ });
expect(tokyo).toHaveAttribute('aria-current', 'page');
expect(tokyo).toHaveTextContent('5');
fireEvent.click(screen.getByRole('button', { name: /collections.allSaved/ }));
fireEvent.click(screen.getByRole('button', { name: /Coffee in Kyoto/ }));
fireEvent.click(screen.getByRole('button', { name: /collections.newList/ }));
expect(onSelect).toHaveBeenNthCalledWith(1, ALL_SAVED);
expect(onSelect).toHaveBeenNthCalledWith(2, 1);
expect(onNewList).toHaveBeenCalled();
});
});
describe('list tabs on a wide window', () => {
it('FE-MOB-PANES-009: cards flow down as many columns of a phone width as the page holds, starting level', () => {
const { container } = render(
<CardGrid>
<div>a</div>
</CardGrid>
);
const grid = container.firstChild as HTMLElement;
expect(grid.className).toContain('wide:columns-[340px]');
// No card splits across two columns, and none keeps a top margin that
// would start its column lower than the next.
expect(grid.className).toContain('wide:[&>*]:break-inside-avoid');
expect(grid.className).toContain('wide:[&>*]:mt-0');
expect(grid.className).not.toContain('wide:grid');
});
it('FE-MOB-PANES-010: the summary stacks above the list, and stands beside it with room for two panes', () => {
const { rerender } = render(
<TabPanes aside={<p>summary</p>} asideLabel="Budget">
<p>list</p>
</TabPanes>
);
expect(screen.queryByRole('complementary')).not.toBeInTheDocument();
// Stacked: one scroll body holds both, the summary first.
const scroller = screen.getByText('summary').closest('.overflow-y-auto');
expect(scroller).not.toBeNull();
expect(scroller).toBe(screen.getByText('list').closest('.overflow-y-auto'));
setSizeClass('split');
rerender(
<TabPanes aside={<p>summary</p>} asideLabel="Budget">
<p>list</p>
</TabPanes>
);
const aside = screen.getByRole('complementary', { name: 'Budget' });
expect(aside).toHaveClass('m-pane-aside');
expect(within(aside).getByText('summary')).toBeInTheDocument();
expect(within(aside).queryByText('list')).not.toBeInTheDocument();
});
it('FE-MOB-PANES-016: turning between stacked and two panes keeps what is typed in either pane', async () => {
const user = userEvent.setup();
const panes = () => (
<TabPanes aside={<input aria-label="budget note" />} asideLabel="Budget">
<input aria-label="search" />
</TabPanes>
);
const { rerender } = render(panes());
await user.type(screen.getByLabelText('search'), 'ramen');
await user.type(screen.getByLabelText('budget note'), 'cap');
setSizeClass('split');
rerender(panes());
expect(screen.getByLabelText('search')).toHaveValue('ramen');
expect(screen.getByLabelText('budget note')).toHaveValue('cap');
setSizeClass('wide');
rerender(panes());
expect(screen.getByLabelText('search')).toHaveValue('ramen');
expect(screen.queryByRole('complementary')).not.toBeInTheDocument();
});
it('FE-MOB-PANES-011: the to-do filters as tiles and lists, each one a filter to pick', () => {
const onPick = vi.fn();
render(
<MTodoSidebar
active={{ kind: 'smart', id: 'all' }}
onPick={onPick}
counts={{ total: 6, open: 4, done: 2, overdue: 1, my: 3 }}
categories={['Tickets', 'Money']}
catCount={(name) => (name === 'Tickets' ? 2 : 1)}
smartLabel={(id) => `filter.${id}`}
listsLabel="Lists"
/>
);
const all = screen.getByRole('button', { name: /filter\.all/ });
expect(all).toHaveAttribute('aria-pressed', 'true');
expect(all).toHaveTextContent('4');
expect(screen.getByRole('button', { name: /filter\.overdue/ })).toHaveTextContent('1');
fireEvent.click(screen.getByRole('button', { name: /filter\.done/ }));
fireEvent.click(screen.getByRole('button', { name: /Tickets/ }));
expect(onPick).toHaveBeenNthCalledWith(1, { kind: 'smart', id: 'done' });
expect(onPick).toHaveBeenNthCalledWith(2, { kind: 'category', name: 'Tickets' });
});
});
describe('the journey as a feed beside its map', () => {
function entry(id: number, date: string, overrides: Partial<JourneyEntry> = {}): JourneyEntry {
return {
id,
journey_id: 1,
type: 'entry',
entry_date: date,
title: `Entry ${id}`,
photos: [],
...overrides,
} as JourneyEntry;
}
const days = [
{ date: '2026-11-05', color: '#6366f1' },
{ date: '2026-11-06', color: '#f97316' },
];
it('FE-MOB-PANES-012: the start of a story reads as plain text', () => {
expect(storyExcerpt('# Day one\n\nThe **bamboo** grove, see [the map](https://x.y).\n![img](a.jpg)')).toBe(
'Day one The bamboo grove, see the map.'
);
expect(storyExcerpt(null)).toBe('');
});
it('FE-MOB-PANES-013: entries under their day, the active one marked, a tap handed back with its place', () => {
const onCardTap = vi.fn();
const cardRefs = { current: new Map<number, HTMLDivElement>() };
const entries = [
entry(1, '2026-11-05', { story: 'Landed **late**', mood: 'amazing' }),
entry(2, '2026-11-05'),
entry(3, '2026-11-06', { type: 'skeleton' }),
];
render(
<MJourneyFeed
entries={entries}
days={days}
activeIndex={1}
cardRefs={cardRefs}
onCardTap={onCardTap}
showMood
showWeather
t={echo}
locale="en-US"
/>
);
expect(screen.getAllByText(/journey\.detail\.day:/).map((n) => n.textContent)).toEqual([
'journey.detail.day:1',
'journey.detail.day:2',
]);
expect(screen.getByText('Landed late')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Entry 2/ })).toHaveAttribute('aria-current', 'true');
fireEvent.click(screen.getByRole('button', { name: /Entry 3/ }));
expect(onCardTap).toHaveBeenCalledWith(entries[2], 2);
expect(cardRefs.current.size).toBe(3);
});
});
describe('files as tiles and a preview', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
// The setup's observer never reports; a tile asks for its picture once it is near the screen.
class InView {
constructor(private readonly report: IntersectionObserverCallback) {}
observe(target: Element) {
this.report(
[{ isIntersecting: true, target } as IntersectionObserverEntry],
this as unknown as IntersectionObserver
);
}
disconnect() {}
unobserve() {}
takeRecords() {
return [];
}
}
const photo = {
id: 6,
trip_id: 1,
original_name: 'Fushimi Inari gates.jpg',
mime_type: 'image/jpeg',
file_size: 402200,
created_at: '2026-11-07T10:00:00Z',
url: '/api/trips/1/files/6/download',
} as unknown as TripFile;
const pass = {
id: 1,
trip_id: 1,
original_name: 'Boarding pass LH 740.pdf',
mime_type: 'application/pdf',
file_size: 46100,
created_at: '2026-11-07T10:00:00Z',
url: '/api/trips/1/files/1/download',
reservation_id: 6,
uploaded_by_name: 'Alex',
} as unknown as TripFile;
it('FE-MOB-PANES-014: a tile shows its picture, and picks, stars and opens its menu', async () => {
vi.stubGlobal('IntersectionObserver', InView);
const preview = vi.spyOn(fileRepo, 'previewUrl').mockResolvedValue('/signed/6');
const onPick = vi.fn();
const onStar = vi.fn();
const onMenu = vi.fn();
const { container } = render(
<FileTile file={photo} selected canStar onPick={onPick} onStar={onStar} onMenu={onMenu} t={echo} />
);
await waitFor(() => expect(container.querySelector('img')).toHaveAttribute('src', '/signed/6'));
expect(preview).toHaveBeenCalledWith('/api/trips/1/files/6/download');
fireEvent.click(screen.getByRole('button', { name: /Fushimi Inari gates\.jpg/ }));
fireEvent.click(screen.getByRole('button', { name: 'files.star' }));
fireEvent.click(screen.getByRole('button', { name: 'files.menu' }));
expect(onPick).toHaveBeenCalled();
expect(onStar).toHaveBeenCalled();
expect(onMenu).toHaveBeenCalled();
expect(screen.getByRole('button', { name: /Fushimi Inari gates\.jpg/ })).toHaveAttribute('aria-pressed', 'true');
});
it('FE-MOB-PANES-015: the preview names the file, what it belongs to, and what to do with it', () => {
vi.spyOn(fileRepo, 'previewUrl').mockResolvedValue('/signed/1');
const reservations = [{ id: 6, title: 'LH 740', type: 'flight' }] as unknown as Reservation[];
const planner = buildPlanner({ reservations, TRANSPORT_TYPES: new Set(['flight']) } as never);
const onOpen = vi.fn();
const onStar = vi.fn();
const onLinks = vi.fn();
const onMenu = vi.fn();
render(
<FilePreviewPane
file={pass}
planner={planner}
onOpen={onOpen}
onStar={onStar}
onLinks={onLinks}
onMenu={onMenu}
/>
);
expect(screen.getByRole('heading', { name: 'Boarding pass LH 740.pdf' })).toBeInTheDocument();
expect(screen.getByText('files.sourceTransport')).toBeInTheDocument();
expect(screen.getByText('LH 740')).toBeInTheDocument();
expect(screen.getByText('Alex')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /common\.open/ }));
fireEvent.click(screen.getByRole('button', { name: /files\.star/ }));
fireEvent.click(screen.getByRole('button', { name: /files\.link/ }));
fireEvent.click(screen.getByRole('button', { name: 'files.menu' }));
expect(onOpen).toHaveBeenCalled();
expect(onStar).toHaveBeenCalled();
expect(onLinks).toHaveBeenCalled();
expect(onMenu).toHaveBeenCalled();
});
it('FE-MOB-PANES-016: without the right to edit files the preview only opens', () => {
vi.spyOn(fileRepo, 'previewUrl').mockResolvedValue('/signed/1');
const planner = buildPlanner({ can: vi.fn(() => false) } as never);
render(
<FilePreviewPane
file={pass}
planner={planner}
onOpen={vi.fn()}
onStar={vi.fn()}
onLinks={vi.fn()}
onMenu={vi.fn()}
/>
);
expect(screen.getByRole('button', { name: /common\.open/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /files\.star/ })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /files\.link/ })).not.toBeInTheDocument();
});
});