Files
TREK/client/tests/unit/mobile/largeScreenTrip.test.tsx
T
Maurice 8955790f17 fix(mobile): the trip's screens on an iPad and an unfolded foldable, nothing cut or covered
- Top bar: the segmented control keeps to the room between the back button
  and the trailing button, draws its segments tighter and then scrolls with
  faded edges instead of covering the back button. On a wide window every
  list tab puts its primary pill in the middle and its round buttons at the
  trailing end, so the pill no longer jumps between tabs.
- Day strip: a chevron over the faded end pages the days along on a wide
  window, and the fade reaches further; the chips keep clear of it.
- Plan pane: the day card ends on the rail's bottom line beside the map (as
  tall as its day across the page), scrolls inside a frame and fades the
  rows at its edges. Addresses and the hotel chip end in an ellipsis instead
  of being cut mid-letter; the weather chip keeps its width.
- Map: in the dark theme the map beside the pane takes the dark basemap.
- Places: the All / Unplanned / Planned chips are never narrower than their
  names and scroll with a faded edge; on a short screen Places/Tours shares
  their row.
- Chat: the list fades out under the floating controls instead of cutting a
  bubble. Notes and to-do chip rows fade at their edges and New Note stays
  outside the scrolling chips.
- Costs: the balance amounts share a line, the settle-up action wraps to a
  line of its own, the totals stand under their labels.
- Bookings and transports: one column until two cards fit, titles take two
  lines with the type under them, the time cell is never cut and a range
  names its unit once.
- Files: the preview pane shows a type tile beside the name where no PDF can
  be drawn in place, its actions keep their labels on one line and wrap as a
  row, link labels are badges, and a tile that opens its file is no toggle.
- Polls get a fill that differs from card and track, packing filters are two
  segmented controls, empty checkboxes get an edge with 3:1 contrast, and the
  middle dots between values are gone.
2026-11-07 23:25:16 +01:00

274 lines
12 KiB
TypeScript

import { act, fireEvent, renderHook } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { openingView } from '../../../src/components/Map/openingView';
import MChipStrip from '../../../src/mobile/components/MChipStrip';
import { edgeFadeClass, useEdgeFade } from '../../../src/mobile/components/useEdgeFade';
import { DayRailSteps, GlassSegment, HeaderActions } from '../../../src/mobile/screens/trip/MTripShellParts';
import { FilePreviewPane, FileTile } from '../../../src/mobile/screens/trip/tabs/MFilesBoard';
import type { TripFile } from '../../../src/types';
import { buildPlanner } from '../../helpers/mobileTrip';
import { render, screen } from '../../helpers/render';
import { setSizeClass } from '../../helpers/sizeClass';
// FE-MOB-LSTRIP-001 onwards: the trip screen's pieces on an iPad or an unfolded foldable,
// after the large-screen review.
const t = (key: string) => key;
/** A box whose sizes the test sets, since jsdom lays nothing out. */
function box(sizes: Partial<Record<'scrollWidth' | 'clientWidth' | 'scrollHeight' | 'clientHeight', number>>) {
const el = document.createElement('div');
for (const [key, value] of Object.entries(sizes)) {
Object.defineProperty(el, key, { configurable: true, value });
}
return el;
}
describe('useEdgeFade down a list', () => {
it('FE-MOB-LSTRIP-001: a list that runs on fades at the bottom, then at both ends, then at the top', () => {
const el = box({ scrollHeight: 1200, clientHeight: 400 });
const { result } = renderHook(() => useEdgeFade(el, 'y'));
expect(edgeFadeClass(result.current, 'y')).toBe('m-fade-b');
act(() => {
el.scrollTop = 300;
el.dispatchEvent(new Event('scroll'));
});
expect(edgeFadeClass(result.current, 'y')).toBe('m-fade-tb');
act(() => {
el.scrollTop = 800;
el.dispatchEvent(new Event('scroll'));
});
expect(edgeFadeClass(result.current, 'y')).toBe('m-fade-t');
// The sideways classes stay the sideways ones.
expect(edgeFadeClass({ start: true, end: true })).toBe('m-fade-se');
});
it('FE-MOB-LSTRIP-002: new content past the edge is noticed without a scroll or a resize', async () => {
let height = 400;
const el = document.createElement('div');
Object.defineProperty(el, 'scrollHeight', { configurable: true, get: () => height });
Object.defineProperty(el, 'clientHeight', { configurable: true, value: 400 });
const { result } = renderHook(() => useEdgeFade(el, 'y'));
expect(result.current).toEqual({ start: false, end: false });
height = 900;
await act(async () => {
el.appendChild(document.createElement('p'));
await new Promise((resolve) => setTimeout(resolve, 40));
});
expect(result.current).toEqual({ start: false, end: true });
});
});
describe('MChipStrip', () => {
it('FE-MOB-LSTRIP-003: a chip row shrinks to its room and scrolls inside it, named where it is a set of filters', () => {
render(
<div className="flex">
<MChipStrip className="flex-1 gap-[6px]" label="Filters">
<button type="button">All</button>
</MChipStrip>
<button type="button">New note</button>
</div>
);
const strip = screen.getByRole('group', { name: 'Filters' });
expect(strip).toHaveClass('min-w-0', 'overflow-x-auto', 'flex-1');
});
});
describe('the top controls of a trip', () => {
afterEach(() => vi.restoreAllMocks());
it('FE-MOB-LSTRIP-004: a segmented control keeps to the room between the back and the trailing button', () => {
const { container } = render(
<GlassSegment activeKey="b">
<button type="button" aria-pressed={false}>
A
</button>
<button type="button" aria-pressed>
B
</button>
</GlassSegment>
);
const region = container.firstElementChild as HTMLElement;
expect(region).toHaveClass('start-12', 'end-12', 'pointer-events-none');
expect(region).not.toHaveClass('inset-x-0');
const track = container.querySelector('.m-seg') as HTMLElement;
expect(track).toHaveClass('overflow-x-auto', 'min-w-0');
expect(track.dataset.tight).toBe('false');
});
it('FE-MOB-LSTRIP-005: a control too wide for its room draws its segments tighter and shows the one that is on', () => {
vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) {
return this.classList.contains('m-seg') ? 320 : 0;
});
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(function (this: HTMLElement) {
return this.classList.contains('m-seg') ? 240 : 0;
});
const seen = vi.fn();
HTMLElement.prototype.scrollIntoView = seen;
const { container } = render(
<GlassSegment activeKey="notes">
<button type="button" aria-pressed={false}>
Chat
</button>
<button type="button" aria-pressed>
Notizen
</button>
</GlassSegment>
);
expect((container.querySelector('.m-seg') as HTMLElement).dataset.tight).toBe('true');
expect(seen).toHaveBeenCalledWith({ block: 'nearest', inline: 'nearest' });
expect(seen.mock.contexts[0]).toBe(screen.getByText('Notizen'));
});
it('FE-MOB-LSTRIP-006: on a wide window a list tab puts its pill in the middle and its buttons at the end, on every tab alike', () => {
setSizeClass('wide');
const { container } = render(
<HeaderActions phone="absolute inset-x-0 mx-auto w-fit" primary={<button type="button">Add</button>}>
<button type="button">Export</button>
</HeaderActions>
);
const bar = container.firstElementChild as HTMLElement;
expect(bar).toHaveClass('grid', 'grid-cols-[minmax(48px,1fr)_auto_minmax(max-content,1fr)]');
expect(screen.getByText('Add').parentElement).toHaveClass('col-start-2');
expect(screen.getByText('Export').parentElement).toHaveClass('col-start-3', 'justify-self-end');
});
it('FE-MOB-LSTRIP-007: on a phone the pill and its buttons stay one group where the tab puts it', () => {
const { container } = render(
<HeaderActions phone="absolute start-[52px] end-2 justify-center" primary={<button type="button">Add</button>}>
<button type="button">Export</button>
</HeaderActions>
);
const bar = container.firstElementChild as HTMLElement;
expect(bar).toHaveClass('absolute', 'start-[52px]', 'end-2', 'justify-center', 'flex', 'gap-[7px]');
expect(screen.getByText('Add').parentElement).toBe(bar);
expect(screen.getByText('Export').parentElement).toBe(bar);
});
});
describe('the day strip on a wide window', () => {
/** A strip 400px wide at x 100, with five 120px days laid out in reading order. */
function rail(rtl = false, scrolled = 0) {
const el = document.createElement('div');
if (rtl) el.style.direction = 'rtl';
el.getBoundingClientRect = () => ({ left: 100, right: 500, top: 0, bottom: 40, width: 400, height: 40 }) as DOMRect;
const chips = Array.from({ length: 5 }, (_, i) => {
const chip = document.createElement('button');
chip.textContent = `Day ${i + 1}`;
const from = rtl ? 500 - (i + 1) * 120 + scrolled : 100 + i * 120 - scrolled;
chip.getBoundingClientRect = () =>
({ left: from, right: from + 120, top: 0, bottom: 40, width: 120, height: 40 }) as DOMRect;
chip.scrollIntoView = vi.fn();
el.appendChild(chip);
return chip;
});
document.body.appendChild(el);
return { el, chips };
}
afterEach(() => {
document.body.innerHTML = '';
});
it('FE-MOB-LSTRIP-008: a chevron over the faded end brings the day the edge cuts to the start, for the eye and the finger only', () => {
setSizeClass('wide');
const { el, chips } = rail();
const { container } = render(<DayRailSteps rail={el} fade={{ start: false, end: true }} />);
const steps = container.querySelectorAll('button');
expect(steps).toHaveLength(1);
expect(steps[0]).toHaveAttribute('aria-hidden', 'true');
expect(steps[0]).toHaveAttribute('tabindex', '-1');
fireEvent.click(steps[0]);
// Day 4 runs from 460 to 580, past the end at 500.
expect(chips[3].scrollIntoView).toHaveBeenCalledWith(
expect.objectContaining({ inline: 'start', block: 'nearest' })
);
expect(chips[4].scrollIntoView).not.toHaveBeenCalled();
});
it('FE-MOB-LSTRIP-009: back towards the start, in reading order in a right-to-left strip; a phone keeps the fade alone', () => {
setSizeClass('wide');
// Scrolled by 200 towards the end: in a right-to-left strip days 1 and 2 lie past the right edge.
const { el, chips } = rail(true, 200);
const { container, unmount } = render(<DayRailSteps rail={el} fade={{ start: true, end: false }} />);
fireEvent.click(container.querySelector('button') as HTMLButtonElement);
expect(chips[1].scrollIntoView).toHaveBeenCalledWith(expect.objectContaining({ inline: 'end', block: 'nearest' }));
expect(chips[0].scrollIntoView).not.toHaveBeenCalled();
unmount();
setSizeClass('compact');
const phone = render(<DayRailSteps rail={el} fade={{ start: true, end: true }} />);
expect(phone.container.querySelector('button')).toBeNull();
});
});
describe('the files board', () => {
const pdf = {
id: 5,
trip_id: 1,
original_name: 'Rail pass.pdf',
mime_type: 'application/pdf',
file_size: 2048,
url: '/uploads/files/pass.pdf',
created_at: '2026-05-01T10:00:00Z',
} as unknown as TripFile;
it('FE-MOB-LSTRIP-010: a tile that opens its file is a plain button; beside the preview it says whether it is the picked one', () => {
const handlers = { canStar: false, onPick: vi.fn(), onStar: vi.fn(), onMenu: vi.fn(), t };
const { unmount } = render(<FileTile file={pdf} {...handlers} />);
expect(screen.getByRole('button', { name: /Rail pass/ })).not.toHaveAttribute('aria-pressed');
unmount();
render(<FileTile file={pdf} selected {...handlers} />);
expect(screen.getByRole('button', { name: /Rail pass/ })).toHaveAttribute('aria-pressed', 'true');
});
it('FE-MOB-LSTRIP-011: a PDF the engine cannot draw in the page gets its type tile beside its name, not a page-high box', () => {
const planner = buildPlanner();
const { container } = render(
<FilePreviewPane
file={pdf}
planner={planner}
onOpen={vi.fn()}
onStar={vi.fn()}
onLinks={vi.fn()}
onMenu={vi.fn()}
/>
);
expect(container.querySelector('object')).toBeNull();
expect(container.querySelector('.aspect-\\[4\\/5\\]')).toBeNull();
const heading = screen.getByRole('heading', { name: 'Rail pass.pdf' });
const tile = container.querySelector('.h-\\[92px\\]') as HTMLElement;
expect(tile.parentElement).toBe(heading.parentElement?.parentElement);
// The actions keep their labels on one line and wrap as a row instead.
const open = screen.getByRole('button', { name: /common.open/ });
expect(open).toHaveClass('whitespace-nowrap');
expect(open.parentElement).toHaveClass('flex-wrap');
});
});
describe('openingView', () => {
const points = [
{ lat: 34.9671, lng: 135.7727 },
{ lat: 35.005, lng: 135.7649 },
];
const options = {
padding: { top: 20, right: 40, bottom: 20, left: 40 },
lngFrame: null,
center: [0, 0] as [number, number],
zoom: 3,
};
it('FE-MOB-LSTRIP-012: beside a pane the map opens a whole level out and refits for its own size; elsewhere as it always has', () => {
const paned = openingView(points, { ...options, paned: true });
const plain = openingView(points, options);
expect(paned.zoom).toBe(Math.floor(plain.zoom));
expect(paned.refit).not.toBeNull();
expect(plain.refit).toBeNull();
expect(plain.framed).toBe(true);
expect(plain.center).toEqual(paned.center);
});
});