mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
b644235eb6
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.
448 lines
18 KiB
TypeScript
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')).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();
|
|
});
|
|
});
|