Files
TREK/client/tests/unit/mobile/MobileShell.test.tsx
T
Maurice 6906f80ff8 feat(native): Android tablets get the shortcuts, two panes meet at a foldable's fold
The Android shell now reports keyCommands and windowLayout, so the large
screen layout no longer assumes an iPad:

- The keyboard host hands the shortcuts to the app whenever the shell has
  the keyCommands feature, whatever the platform. Before it also required
  iOS, so an Android tablet with a keyboard answered the keys in the page
  and never listed them in the system's shortcuts.
- useWindowFold follows the layout the Android shell reports (feature
  windowLayout). When a vertical fold separates the window, a foldable half
  open like a book, MobileShell marks it with data-fold and its place, and
  the leading pane (the trip's plan, a tab's summary, a list beside its
  detail) ends at the fold while the trailing one starts there, on either
  reading direction. A flat fold that does not separate stays one screen.
- The split size class starts at 840pt, Android's expanded width, instead
  of 900pt, so a foldable unfolded on its side (about 841dp) gets two panes
  like an iPad does. An 11" iPad upright (834pt) stays a single pane. The
  address screen of the native shell follows the same width.
- TrekShell exports NativeWindowLayout for it.

Also: the sheet's width and anchor types and the collab board type stay
private to their modules, which nothing else imports (knip).
2026-11-07 23:25:13 +01:00

141 lines
5.9 KiB
TypeScript

import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, act } from '../../helpers/render';
import MobileShell from '../../../src/mobile/MobileShell';
import { useAddonStore } from '../../../src/store/addonStore';
import { usePluginStore } from '../../../src/store/pluginStore';
// FE-MOB-MSHELL-001 onwards
const fold = vi.hoisted(() => ({ current: null as null | { start: number; end: number } }));
vi.mock('../../../src/native/windowFold', () => ({ useWindowFold: () => fold.current }));
beforeEach(() => {
fold.current = null;
useAddonStore.setState({ addons: [], loaded: true });
usePluginStore.setState({ plugins: [], loaded: true });
});
describe('MobileShell', () => {
it('FE-MOB-MSHELL-001: keeps the legacy wrapper above the phone breakpoint', () => {
const { container } = render(
<MobileShell isPhone={false}><p>page body</p></MobileShell>,
{ initialEntries: ['/dashboard'] },
);
expect(screen.getByText('page body')).toBeInTheDocument();
expect(container.querySelector('.m-root')).toBeNull();
expect(document.getElementById('m-sheet-root')).toBeNull();
});
it('FE-MOB-MSHELL-002: scopes the mobile tokens and mounts the dock plus sheet portal', () => {
const { container } = render(
<MobileShell isPhone><p>page body</p></MobileShell>,
{ initialEntries: ['/dashboard'] },
);
expect(container.querySelector('.m-root')).toBeInTheDocument();
expect(document.getElementById('m-sheet-root')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'My Trips' })).toBeInTheDocument();
});
it('FE-MOB-MSHELL-003: hides the global dock inside the trip planner', () => {
render(
<MobileShell isPhone><p>page body</p></MobileShell>,
{ initialEntries: ['/trips/7'] },
);
expect(screen.queryByRole('button', { name: 'My Trips' })).not.toBeInTheDocument();
expect(screen.getByText('page body')).toBeInTheDocument();
});
it('FE-MOB-MSHELL-004: the mobile toast presenter takes over the global bridge', () => {
const previous = vi.fn(() => 0);
window.__addToast = previous;
render(<MobileShell isPhone><p>page body</p></MobileShell>, { initialEntries: ['/dashboard'] });
expect(window.__addToast).not.toBe(previous);
act(() => { window.__addToast?.('Saved', 'success'); });
expect(screen.getByText('Saved')).toBeInTheDocument();
delete window.__addToast;
});
// #1809: iOS Safari only minimises its address bar when the document scrolls,
// so the phone shell must not sit a scroll container between the two.
it('FE-MOB-MSHELL-005: the phone shell owns no scroll container and grows with the document', () => {
const { container } = render(
<MobileShell isPhone><p>page body</p></MobileShell>,
{ initialEntries: ['/dashboard'] },
);
const root = container.querySelector('.m-root') as HTMLElement;
// svh, not dvh: dvh grows while the toolbar retracts.
expect(root.className).toContain('min-h-svh');
expect(root.className).not.toContain('h-dvh');
expect(root.querySelector(':scope > .overflow-y-auto')).toBeNull();
const content = screen.getByText('page body').parentElement as HTMLElement;
expect(content.className).toContain('flex-1');
expect(content.className).not.toContain('overflow');
});
it('FE-MOB-MSHELL-006: paints background and screen gradient on a viewport-sized layer', () => {
const { container } = render(
<MobileShell isPhone><p>page body</p></MobileShell>,
{ initialEntries: ['/dashboard'] },
);
const root = container.querySelector('.m-root') as HTMLElement;
const layer = root.querySelector('[aria-hidden="true"]') as HTMLElement;
expect(layer).toBeInTheDocument();
expect(layer.className).toContain('fixed');
expect(layer.className).toContain('inset-0');
expect(layer.className).toContain('var(--m-scr)');
expect(layer.className).toContain('var(--m-bg)');
expect(root.className).not.toContain('var(--m-bg)');
expect(layer.className).toContain('-z-10');
});
it('FE-MOB-MSHELL-006b: the content wrapper is not a stacking context', () => {
// A positioned wrapper with a z-index traps every fixed overlay a screen
// renders inside it: the vacay view/edit FAB (z-50) went underneath the dock
// (z-40) when this carried `relative z-10`. The screens rely on their own
// z-index competing with the chrome, so this has to stay unpositioned.
render(<MobileShell isPhone><p>page body</p></MobileShell>, { initialEntries: ['/dashboard'] });
const content = screen.getByText('page body').parentElement as HTMLElement;
expect(content.className).toContain('flex-1');
expect(content.className).not.toMatch(/(^|\s)(relative|absolute|fixed|sticky)(\s|$)/);
expect(content.className).not.toMatch(/z-\[?\d/);
});
it('FE-MOB-MSHELL-007: the desktop branch keeps its own scroller', () => {
render(
<MobileShell isPhone={false}><p>page body</p></MobileShell>,
{ initialEntries: ['/dashboard'] },
);
const content = screen.getByText('page body').parentElement as HTMLElement;
expect(content.className).toContain('overflow-y-auto');
expect(content.className).toContain('md:overflow-visible');
});
it('FE-MOB-MSHELL-008: a fold the Android app reports is where the panes meet', () => {
const { container, rerender } = render(
<MobileShell isPhone><p>page body</p></MobileShell>,
{ initialEntries: ['/dashboard'] },
);
const root = () => container.querySelector('.m-root') as HTMLElement;
expect(root().dataset.fold).toBeUndefined();
expect(root().style.getPropertyValue('--m-fold-start')).toBe('');
fold.current = { start: 420, end: 421 };
rerender(<MobileShell isPhone><p>page body</p></MobileShell>);
expect(root().dataset.fold).toBe('vertical');
expect(root().style.getPropertyValue('--m-fold-start')).toBe('420px');
expect(root().style.getPropertyValue('--m-fold-end')).toBe('421px');
});
});