mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 08:11:03 +00:00
8955790f17
- 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.
274 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|