mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
c204a25a9c
The app shows the touch shell at every size now, an iPad's and an unfolded foldable's included, instead of switching to the desktop pages from 768px. One route tree survives rotation, Split View, Stage Manager and folding, and the app's own features (Wallet, sharing into a trip, the edge swipe back) are there on the big screen too. Two size classes drive the layout (hooks/sizeClass.ts, Tailwind variants wide: and split:): - wide (600pt and taller than a phone on its side): a glass navigation rail on the leading edge replaces the dock and holds every destination with its name; sheets become centred form sheets; the trip's More menu is a popover beside the rail; lists keep a readable measure; dashboard, journey and vacay get columns; full-bleed maps run under the floating rail. - split (900pt): the trip's plan, places or road trip chain sit in a pane beside the map, which follows the plan's day; the list/map switch hides and shows the pane like a sidebar; on a foldable that reports its fold the pane ends at the fold. Settings show their sections beside the page and the dashboard keeps its widgets in a column of their own. A hardware keyboard gets the shortcuts an iPad app has (⌘1 to ⌘9 for the sections, ⌘N, ⌘, , ⌥←/⌥→ through the days, ⌃⌘S for the pane, ⌘[ back), answered by the page or, with a binary that has the keyCommands feature, handed to it as native key commands. The day strip fades at an edge that runs on, journey cards grow on a wide window and a card without a photo takes the day's colour, and the Leaflet map measures itself again when its box changes size.
159 lines
6.9 KiB
TypeScript
159 lines
6.9 KiB
TypeScript
import '@testing-library/jest-dom/vitest';
|
|
import 'fake-indexeddb/auto';
|
|
import { cleanup, configure } from '@testing-library/react';
|
|
import { afterAll, afterEach, beforeAll, vi } from 'vitest';
|
|
import { resetSizeClass, sizeClassMatches } from './helpers/sizeClass';
|
|
import { server } from './helpers/msw/server';
|
|
|
|
// waitFor/findBy* default to 1s, which is enough on a dev machine but not on a
|
|
// 4-core CI runner running the whole suite in parallel forks — a multipart POST
|
|
// through MSW plus an IndexedDB write can exceed it. Still well inside the 15s
|
|
// testTimeout, so a genuinely broken assertion fails, it just takes longer.
|
|
configure({ asyncUtilTimeout: 5000 });
|
|
|
|
// Mock the websocket module so stores don't try to open real connections
|
|
vi.mock('../src/api/websocket', () => ({
|
|
connect: vi.fn(),
|
|
disconnect: vi.fn(),
|
|
getSocketId: vi.fn(() => null),
|
|
setRefetchCallback: vi.fn(),
|
|
setPreReconnectHook: vi.fn(),
|
|
reconnectNow: vi.fn(),
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
}));
|
|
|
|
// MSW lifecycle. A cross-origin request nobody mocked is the dangerous kind: 'warn'
|
|
// lets it through, so the runner really talks to frankfurter or a tile server and settles
|
|
// a promise after the test environment is gone (see handlers/external.ts). Those fail now.
|
|
// An unhandled same-origin call stays a warning: that is a missing handler, not egress.
|
|
beforeAll(() => server.listen({
|
|
onUnhandledRequest: (request, print) => {
|
|
if (new URL(request.url).origin === location.origin) print.warning();
|
|
else print.error();
|
|
},
|
|
}));
|
|
afterEach(() => {
|
|
server.resetHandlers();
|
|
cleanup();
|
|
resetSizeClass();
|
|
localStorage.clear();
|
|
sessionStorage.clear();
|
|
});
|
|
afterAll(() => server.close());
|
|
|
|
// ── jsdom stubs ────────────────────────────────────────────────────────────────
|
|
|
|
// Force en-US locale for toLocaleDateString so tests are deterministic on
|
|
// non-US dev machines (Windows-de-DE returns "Sonntag" instead of "Sunday").
|
|
// Only affects calls without an explicit locale — callers that pass a locale
|
|
// keep their behavior.
|
|
const _origToLocaleDateString = Date.prototype.toLocaleDateString
|
|
Date.prototype.toLocaleDateString = function (locales?: Intl.LocalesArgument, options?: Intl.DateTimeFormatOptions) {
|
|
return _origToLocaleDateString.call(this, locales ?? 'en-US', options)
|
|
}
|
|
|
|
// window.matchMedia — used by dark mode / responsive components.
|
|
// Width queries are answered from window.innerWidth, which is what a test sets
|
|
// when it wants a phone viewport, and the lists re-evaluate on a resize event,
|
|
// so a component that follows the breakpoint can be driven from a test the same
|
|
// way the browser drives it. Every other query keeps the old constant false.
|
|
const mediaLists = new Set<{ media: string; matches: boolean; listeners: Set<(e: MediaQueryListEvent) => void> }>()
|
|
|
|
function widthMatches(query: string): boolean {
|
|
// The touch shell's size classes come from tests/helpers/sizeClass.ts, compact
|
|
// by default: jsdom's 1024px window would otherwise read as an iPad.
|
|
const sizeClass = sizeClassMatches(query)
|
|
if (sizeClass != null) return sizeClass
|
|
const max = /\(\s*max-width:\s*(\d+)px\s*\)/.exec(query)
|
|
if (max) return window.innerWidth <= Number(max[1])
|
|
const min = /\(\s*min-width:\s*(\d+)px\s*\)/.exec(query)
|
|
if (min) return window.innerWidth >= Number(min[1])
|
|
return false
|
|
}
|
|
|
|
Object.defineProperty(window, 'matchMedia', {
|
|
writable: true,
|
|
value: vi.fn().mockImplementation((query: string) => {
|
|
const entry = { media: query, matches: widthMatches(query), listeners: new Set<(e: MediaQueryListEvent) => void>() }
|
|
mediaLists.add(entry)
|
|
return {
|
|
get matches() { return entry.matches },
|
|
media: query,
|
|
onchange: null,
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
addEventListener: vi.fn((_type: string, fn: (e: MediaQueryListEvent) => void) => entry.listeners.add(fn)),
|
|
removeEventListener: vi.fn((_type: string, fn: (e: MediaQueryListEvent) => void) => entry.listeners.delete(fn)),
|
|
dispatchEvent: vi.fn(),
|
|
}
|
|
}),
|
|
});
|
|
|
|
window.addEventListener('resize', () => {
|
|
for (const entry of mediaLists) {
|
|
const matches = widthMatches(entry.media)
|
|
if (matches === entry.matches) continue
|
|
entry.matches = matches
|
|
for (const fn of entry.listeners) fn({ matches, media: entry.media } as MediaQueryListEvent)
|
|
}
|
|
})
|
|
|
|
// IntersectionObserver — used by lazy loading
|
|
// Must use a class or regular function (not arrow function) so 'new IntersectionObserver()' works
|
|
class _MockIntersectionObserver {
|
|
observe = vi.fn()
|
|
unobserve = vi.fn()
|
|
disconnect = vi.fn()
|
|
root = null
|
|
rootMargin = ''
|
|
thresholds: ReadonlyArray<number> = []
|
|
takeRecords = vi.fn(() => [])
|
|
constructor(_callback: IntersectionObserverCallback, _options?: IntersectionObserverInit) {}
|
|
}
|
|
globalThis.IntersectionObserver = _MockIntersectionObserver as unknown as typeof IntersectionObserver;
|
|
|
|
// ResizeObserver — used by resizable panels
|
|
class _MockResizeObserver {
|
|
observe = vi.fn()
|
|
unobserve = vi.fn()
|
|
disconnect = vi.fn()
|
|
constructor(_callback: ResizeObserverCallback) {}
|
|
}
|
|
globalThis.ResizeObserver = _MockResizeObserver as unknown as typeof ResizeObserver;
|
|
|
|
// URL.createObjectURL / revokeObjectURL — Node 22 URL.createObjectURL requires
|
|
// a native node:buffer Blob; passing a jsdom Blob throws ERR_INVALID_ARG_TYPE.
|
|
// Tests that need blob URLs should mock fetch to return node:buffer Blobs so
|
|
// the real URL.createObjectURL works. For tests that only need the method to
|
|
// exist without returning a real URL, stub it here as a vi.fn fallback.
|
|
if (typeof URL.createObjectURL === 'undefined') {
|
|
Object.defineProperty(URL, 'createObjectURL', { writable: true, configurable: true, value: vi.fn(() => 'blob:mock') });
|
|
Object.defineProperty(URL, 'revokeObjectURL', { writable: true, configurable: true, value: vi.fn() });
|
|
}
|
|
|
|
// Element.prototype.scrollIntoView — jsdom doesn't implement it
|
|
Element.prototype.scrollIntoView = vi.fn();
|
|
|
|
// maplibre-gl-leaflet — the vector basemap every Leaflet map draws since the move
|
|
// off CARTO. jsdom has no WebGL, so the real layer can never work here; more to
|
|
// the point, several map tests hand react-leaflet a partial `useMap()` stub, and
|
|
// the real layer's addTo() reaches for map.addLayer and rejects into the void.
|
|
// Stubbed globally for the same reason ResizeObserver is: the capability does not
|
|
// exist in this environment. Tests that assert on the layer register their own
|
|
// mock, which takes precedence over this one.
|
|
vi.mock('@maplibre/maplibre-gl-leaflet', () => ({
|
|
maplibreGL: vi.fn(() => {
|
|
const gl = {
|
|
setStyle: vi.fn(),
|
|
on: vi.fn(),
|
|
isStyleLoaded: vi.fn(() => false),
|
|
getStyle: vi.fn(() => ({ layers: [] })),
|
|
setLayoutProperty: vi.fn(),
|
|
};
|
|
const layer: Record<string, unknown> = { remove: vi.fn(), getMaplibreMap: vi.fn(() => gl) };
|
|
layer.addTo = vi.fn(() => layer);
|
|
return layer;
|
|
}),
|
|
}));
|