mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 23:31:02 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 796f39fe1f | |||
| ee9cbce23d | |||
| 82fcdcd0fc | |||
| 3b7cdf516a | |||
| 41752887dc | |||
| b9d0d3397e | |||
| 2a2dfec247 | |||
| 21e07e20a1 |
@@ -66,12 +66,12 @@ wait_ready() {
|
||||
|
||||
TOKEN=0
|
||||
# shot <udid> <file> <screen> <scenario> <time or -> <language>: one launch, retried
|
||||
# after 2, 5 and 10 seconds when the simulator refuses it.
|
||||
# after 2, 5, 10 and 20 seconds when the simulator refuses it, saying which step failed.
|
||||
shot() {
|
||||
local udid=$1 file=$2 screen=$3 scenario=$4 clock=$5 lang=$6
|
||||
local locale=${lang//-/_} attempt
|
||||
for attempt in 0 2 5 10; do
|
||||
[ "$attempt" -gt 0 ] && { echo "retrying $(basename "$file") in ${attempt}s"; sleep "$attempt"; }
|
||||
local locale=${lang//-/_} attempt failed=
|
||||
for attempt in 0 2 5 10 20; do
|
||||
[ "$attempt" -gt 0 ] && { echo "retrying $(basename "$file") in ${attempt}s ($failed)"; sleep "$attempt"; }
|
||||
TOKEN=$((TOKEN + 1))
|
||||
xcrun simctl terminate "$udid" "$BUNDLE" >/dev/null 2>&1 || true
|
||||
sleep 0.5
|
||||
@@ -79,11 +79,12 @@ shot() {
|
||||
[ "$clock" = - ] || extra=(-TrekNow "$clock")
|
||||
xcrun simctl launch "$udid" "$BUNDLE" \
|
||||
-AppleLanguages "($lang)" -AppleLocale "$locale" -TrekDemo "$screen" -TrekScenario "$scenario" \
|
||||
-TrekReady "$TOKEN" ${extra[@]+"${extra[@]}"} >/dev/null 2>&1 || continue
|
||||
wait_ready "$udid" "$TOKEN" || continue
|
||||
-TrekReady "$TOKEN" ${extra[@]+"${extra[@]}"} >/dev/null 2>&1 || { failed=launch; continue; }
|
||||
wait_ready "$udid" "$TOKEN" || { failed='not ready'; continue; }
|
||||
# The map's tiles come over the network after the screen is up.
|
||||
[ "$screen" = place ] && sleep 6
|
||||
xcrun simctl io "$udid" screenshot --type=png "$file" >/dev/null 2>&1 && return 0
|
||||
failed=screenshot
|
||||
done
|
||||
echo "gave up on $(basename "$file")"
|
||||
}
|
||||
|
||||
@@ -103,7 +103,10 @@ describe('localizedParams', () => {
|
||||
});
|
||||
|
||||
it('FE-NOTIF-MODEL-009: anything that is not a bare date passes through, and no params stay none', () => {
|
||||
expect(localizedParams({ trip: '2026-11-05 at noon', n: '3' }, 'de', NOW)).toEqual({ trip: '2026-11-05 at noon', n: '3' });
|
||||
expect(localizedParams({ trip: '2026-11-05 at noon', n: '3' }, 'de', NOW)).toEqual({
|
||||
trip: '2026-11-05 at noon',
|
||||
n: '3',
|
||||
});
|
||||
expect(localizedParams(undefined, 'de', NOW)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1290,7 +1290,6 @@ img[alt="TREK"] {
|
||||
|
||||
/* ── Dark-Mode: Tailwind-Klassen ─────────────── */
|
||||
.dark .bg-slate-50 { background-color: #131316 !important; }
|
||||
.dark .bg-slate-100 { background-color: #1c1c21 !important; }
|
||||
.dark .bg-white { background-color: #09090b !important; }
|
||||
.dark .bg-gray-50 { background-color: #131316 !important; }
|
||||
.dark .bg-gray-100 { background-color: #1c1c21 !important; }
|
||||
@@ -1307,10 +1306,7 @@ img[alt="TREK"] {
|
||||
.dark .bg-slate-100 { background-color: #27272a !important; }
|
||||
.dark .bg-slate-900 { background-color: #e4e4e7 !important; color: #09090b !important; }
|
||||
.dark .hover\:bg-slate-700:hover { background-color: #d4d4d8 !important; color: #09090b !important; }
|
||||
/* Not inside the touch shell (.m-root): its fields state their own fill, a
|
||||
transparent input inside a rounded wrapper above all, and this rule outweighs
|
||||
every utility class, so it painted a dark box inside each of them. The
|
||||
:where() keeps the desktop's weight exactly as it was. */
|
||||
/* Not in the touch shell (.m-root), whose fields set their own fill; :where() keeps the desktop's weight. */
|
||||
.dark input:not(:where(.m-root *)), .dark textarea:not(:where(.m-root *)), .dark select:not(:where(.m-root *)) {
|
||||
background-color: #1c1c21;
|
||||
color: #f4f4f5;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useLayoutEffect, useRef, type ReactNode, type RefObject } from 'react';
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from 'react';
|
||||
import { edgeFadeClass, useEdgeFade } from './useEdgeFade';
|
||||
|
||||
/**
|
||||
* The rail on the leading edge that takes the dock's place on a wide window
|
||||
@@ -27,6 +28,9 @@ export function MRail({
|
||||
}) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
useEvenLines(listRef);
|
||||
// On a short window the entries scroll; the cut edge fades, so the foot's entries read as more below.
|
||||
const [list, setList] = useState<HTMLDivElement | null>(null);
|
||||
const fade = useEdgeFade(list, 'y');
|
||||
return (
|
||||
<nav
|
||||
aria-label={label}
|
||||
@@ -34,8 +38,11 @@ export function MRail({
|
||||
>
|
||||
{head && <div className="mb-2 flex flex-none justify-center">{head}</div>}
|
||||
<div
|
||||
ref={listRef}
|
||||
className="flex min-h-0 w-full flex-1 flex-col items-center gap-1 overflow-y-auto px-1 [scrollbar-width:none]"
|
||||
ref={(node) => {
|
||||
listRef.current = node;
|
||||
setList(node);
|
||||
}}
|
||||
className={`flex min-h-0 w-full flex-1 flex-col items-center gap-1 overflow-y-auto px-1 [scrollbar-width:none] ${edgeFadeClass(fade, 'y')}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useIsSplit } from '../../../hooks/sizeClass'
|
||||
import {
|
||||
Archive, ArchiveRestore, ArrowRight, Bell, CalendarDays, CalendarPlus, Copy,
|
||||
LayoutGrid, List, MapPin, Pencil, Plus, RefreshCw, Sparkles, Trash2, Users,
|
||||
@@ -60,7 +59,6 @@ export default function MDashboard(): React.ReactElement {
|
||||
const unread = useInAppNotificationStore(s => s.unreadCount)
|
||||
const fetchUnreadCount = useInAppNotificationStore(s => s.fetchUnreadCount)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const split = useIsSplit()
|
||||
const { available: assistantAvailable, open: openAssistant } = useAssistantHomeLauncher()
|
||||
const [subOpen, setSubOpen] = useState(false)
|
||||
// The filter chips scroll when a language's words do not fit beside the two
|
||||
@@ -281,9 +279,12 @@ export default function MDashboard(): React.ReactElement {
|
||||
|
||||
{/* Beside each other from the split size class on, the way the desktop
|
||||
dashboard holds them: the trips in the main column, the widgets in a
|
||||
column of their own, each in the order the user set. */}
|
||||
<div className="split:grid split:grid-cols-[minmax(0,1fr)_minmax(300px,360px)] split:items-start split:gap-6">
|
||||
<div className="min-w-0">
|
||||
column of their own, each in the order the user set. Narrower, both
|
||||
columns dissolve (display: contents) into one stack ordered the same
|
||||
way, so crossing the split size never moves a widget to another
|
||||
parent and its state (the converter's amount, its rates) stays. */}
|
||||
<div className="flex flex-col split:grid split:grid-cols-[minmax(0,1fr)_minmax(300px,360px)] split:items-start split:gap-6">
|
||||
<div className="contents min-w-0 split:block">
|
||||
{spotlight && (
|
||||
<MSpotlightCard
|
||||
trip={spotlight}
|
||||
@@ -292,22 +293,18 @@ export default function MDashboard(): React.ReactElement {
|
||||
actions={actionsFor(spotlight, 'list')}
|
||||
/>
|
||||
)}
|
||||
{(split ? dashOrder.filter(id => id === 'trips') : dashOrder).map(id => (
|
||||
<React.Fragment key={id}>{renderBlock(id)}</React.Fragment>
|
||||
))}
|
||||
<div style={{ order: dashOrder.indexOf('trips') }}>{renderBlock('trips')}</div>
|
||||
</div>
|
||||
<div className="contents min-w-0 split:sticky split:top-[calc(var(--m-safe-top,12px)+82px)] split:-mt-[14px] split:block">
|
||||
{dashOrder.filter(id => id !== 'trips').map(id => (
|
||||
<div key={id} style={{ order: dashOrder.indexOf(id) }}>{renderBlock(id)}</div>
|
||||
))}
|
||||
{widgetPlugins.length > 0 && (
|
||||
<div className="mt-3 flex flex-col gap-3" style={{ order: dashOrder.length }}>
|
||||
<PluginWidgets plugins={widgetPlugins} tripId={spotlight ? String(spotlight.id) : null} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(split || widgetPlugins.length > 0) && (
|
||||
<div className="min-w-0 split:sticky split:top-[calc(var(--m-safe-top,12px)+82px)] split:-mt-[14px]">
|
||||
{split && dashOrder.filter(id => id !== 'trips').map(id => (
|
||||
<React.Fragment key={id}>{renderBlock(id)}</React.Fragment>
|
||||
))}
|
||||
{widgetPlugins.length > 0 && (
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
<PluginWidgets plugins={widgetPlugins} tripId={spotlight ? String(spotlight.id) : null} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -42,8 +42,12 @@ import MJourneyRecordingSheet from './MJourneyRecordingSheet'
|
||||
* (`embedded`): the trip's back button and controls sit on top, so the screen
|
||||
* drops its own back button and leaves the trip's URL alone.
|
||||
*/
|
||||
/** A phone on its side: too short for the regular strip of cards and the map above it. */
|
||||
const SHORT_WINDOW = '(max-height: 500px)'
|
||||
/**
|
||||
* A phone on its side, or a folded Duo on its side (about 540px high): too short
|
||||
* for the regular strip of cards and the map above it. Higher than the shell's
|
||||
* 500px for that Duo, whose regular cards left the map a band under the header.
|
||||
*/
|
||||
const SHORT_WINDOW = '(max-height: 600px)'
|
||||
function subscribeShort(onChange: () => void) {
|
||||
const mq = window.matchMedia(SHORT_WINDOW)
|
||||
mq.addEventListener('change', onChange)
|
||||
@@ -418,11 +422,12 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
{feed && (
|
||||
<div
|
||||
className="absolute inset-y-0 z-[6] overflow-y-auto overflow-x-hidden border-e border-[color:var(--m-rowbr)] bg-[color:var(--m-bg)] bg-[image:var(--m-scr)] px-4 pb-[calc(var(--bottom-nav-h,84px)+16px)] pt-[calc(var(--m-safe-top,12px)+60px)]"
|
||||
// Entries scrolled up fade out under the header's pills instead of showing through between them.
|
||||
// Entries scrolled up fade out under the header's pills instead of showing through between them:
|
||||
// gone down to the pills' lower edge (38px tall), whole again before the first day's head.
|
||||
style={{
|
||||
insetInlineStart: 'var(--m-rail-w, 0px)',
|
||||
width: 'var(--m-pane-aside-w)',
|
||||
maskImage: 'linear-gradient(180deg, transparent calc(var(--m-safe-top, 12px) + 22px), #000 calc(var(--m-safe-top, 12px) + 58px))',
|
||||
maskImage: 'linear-gradient(180deg, transparent calc(var(--m-safe-top, 12px) + 40px), #000 calc(var(--m-safe-top, 12px) + 60px))',
|
||||
}}
|
||||
>
|
||||
<MJourneyFeed
|
||||
@@ -589,7 +594,8 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
showMood={current.show_mood !== 0}
|
||||
showWeather={current.show_weather !== 0}
|
||||
tone="mobile"
|
||||
size={wide ? 'large' : short ? 'compact' : 'regular'}
|
||||
// Short before wide: a folded Duo on its side is wide (over 500px high) and short.
|
||||
size={short ? 'compact' : wide ? 'large' : 'regular'}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -137,12 +137,19 @@ function FeedCard({ entry, color, active, onTap, showMood, showWeather, t }: Fee
|
||||
) : (
|
||||
<MapPin size={11} strokeWidth={2.2} className="flex-none" />
|
||||
)}
|
||||
<span dir="auto" className="truncate font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] font-semibold">
|
||||
<span
|
||||
dir="auto"
|
||||
className="truncate font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] font-semibold"
|
||||
>
|
||||
{place}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{story && <span dir="auto" className="line-clamp-3 text-caption leading-[1.45] text-m-ink opacity-80">{story}</span>}
|
||||
{story && (
|
||||
<span dir="auto" className="line-clamp-3 text-caption leading-[1.45] text-m-ink opacity-80">
|
||||
{story}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useRef, useState, type ComponentType } from
|
||||
import { findFocusDayId, findTodayDayId } from '../../../components/Planner/today'
|
||||
import { takeResumeDay } from '../../../utils/resumeRoute'
|
||||
import {
|
||||
CalendarDays, ChevronDown, ChevronLeft, Download, FileDown, List, Map as MapIcon, MoreHorizontal, PanelLeftClose, PanelLeftOpen, Plane, Route, SlidersHorizontal, Trash2, Upload,
|
||||
CalendarDays, ChevronDown, ChevronLeft, Download, FileDown, List, Map as MapIcon, PanelLeftClose, PanelLeftOpen, Plane, Route, SlidersHorizontal, Trash2, Upload,
|
||||
} from 'lucide-react'
|
||||
import { useTripPlanner } from '../../../pages/tripPlanner/useTripPlanner'
|
||||
import { pickDockTabs } from './dockTabs'
|
||||
@@ -29,7 +29,7 @@ import { useDayPreviewStore } from '../../dayPreviewStore'
|
||||
import { useIsSplit, useIsWide } from '../../../hooks/sizeClass'
|
||||
import MTripRail from './MTripRail'
|
||||
import {
|
||||
CollabSegment, CompactToggle, DayRailSteps, DocSyncButton, GlassSegment, HeaderActions, PrimaryPill, ROW_AFTER_BACK, ROW_CENTRED,
|
||||
CollabSegment, CompactToggle, DayRailSteps, DockMoreButton, DocSyncButton, GlassSegment, HeaderActions, PrimaryPill, ROW_AFTER_BACK, ROW_CENTRED,
|
||||
} from './MTripShellParts'
|
||||
import { edgeFadeClass, useEdgeFade } from '../../components/useEdgeFade'
|
||||
import { useShortcuts } from '../../keyboard/useShortcuts'
|
||||
@@ -927,17 +927,12 @@ export default function MTripShell({
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openSheet('mehr')}
|
||||
aria-label={t('mobileTrip.more')}
|
||||
aria-expanded={sheet?.id === 'mehr'}
|
||||
className={`flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full ${
|
||||
sheet?.id === 'mehr' ? 'bg-m-act text-m-actfg' : 'text-m-muted'
|
||||
}`}
|
||||
>
|
||||
<MoreHorizontal size={19} strokeWidth={2.2} />
|
||||
</button>
|
||||
<DockMoreButton
|
||||
open={sheet?.id === 'mehr'}
|
||||
standsFor={!dockTabs.some(tab => tab.id === trTab)}
|
||||
onOpen={() => openSheet('mehr')}
|
||||
label={t('mobileTrip.more')}
|
||||
/>
|
||||
</nav>}
|
||||
|
||||
<Sheets planner={planner} shell={shell} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronLeft, ChevronRight, FolderSync, List, Plus, Rows3 } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, FolderSync, List, MoreHorizontal, Plus, Rows3 } from 'lucide-react';
|
||||
import { useCallback, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { canManageDocSync } from '../../../components/Files/docsync/useDocSync';
|
||||
import { useDocSyncOffered } from '../../../components/Files/docsync/useDocSyncOffered';
|
||||
@@ -284,3 +284,37 @@ export function CompactToggle({ active, onToggle, label }: { active: boolean; on
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The phone dock's More. It stands for its sections (chat, notes, files and the
|
||||
* rest) while one is on show, and for itself while open: marked as a ring, as
|
||||
* the main dock's More is, not filled like a tab it would then read as.
|
||||
*/
|
||||
export function DockMoreButton({
|
||||
open,
|
||||
standsFor,
|
||||
onOpen,
|
||||
label,
|
||||
}: {
|
||||
open: boolean;
|
||||
standsFor: boolean;
|
||||
onOpen: () => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
aria-label={label}
|
||||
aria-expanded={open}
|
||||
aria-current={!open && standsFor ? 'page' : undefined}
|
||||
className={`flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full ${
|
||||
open || standsFor
|
||||
? 'bg-[color:var(--m-ic)] text-m-ink ring-[1.5px] ring-inset ring-[color:var(--m-act)]'
|
||||
: 'text-m-muted'
|
||||
}`}
|
||||
>
|
||||
<MoreHorizontal size={19} strokeWidth={2.2} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -544,7 +544,8 @@ function TimelineHeader({ tl, dayLabel, openLabel, weatherLabel, onOpenDay, onOp
|
||||
className={`${GLASS_CHIP} min-w-[4.5rem] shrink gap-[5px] px-2.5`}
|
||||
>
|
||||
<HotelChipIcon variant={chip.variant} />
|
||||
<span className="min-w-0 truncate">{chip.name}</span>
|
||||
{/* Its own direction, so a Latin name in an Arabic header is cut at its end, not its start. */}
|
||||
<span dir="auto" className="min-w-0 truncate">{chip.name}</span>
|
||||
{chip.time && <span className="flex-none tabular-nums text-m-muted">{chip.time.slice(0, 5)}</span>}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -44,7 +44,10 @@ export function CardTitleButton({
|
||||
onClick={onOpen}
|
||||
className="flex min-w-0 flex-1 flex-wrap items-center gap-x-[7px] gap-y-1 text-start"
|
||||
>
|
||||
<span dir="auto" className="line-clamp-2 min-w-0 flex-[1_1_auto] break-words text-[0.78125rem] font-bold text-m-ink wide:basis-full">
|
||||
<span
|
||||
dir="auto"
|
||||
className="line-clamp-2 min-w-0 flex-[1_1_auto] break-words text-[0.78125rem] font-bold text-m-ink wide:basis-full"
|
||||
>
|
||||
{res.title}
|
||||
</span>
|
||||
<span className="inline-flex flex-none items-center gap-1 rounded-full border border-[color:var(--m-rowbr)] bg-m-card px-2 py-[2px] font-geist text-[0.5625rem] font-bold uppercase tracking-[.06em] text-m-muted">
|
||||
@@ -116,7 +119,9 @@ export function CardWhenAndCode({
|
||||
<Field label={t('reservations.date')} className="flex-[1.4]">
|
||||
{dayDate ? (
|
||||
<span className="flex min-w-0 items-center justify-center gap-1.5">
|
||||
<span dir="auto" className="min-w-0 truncate">{dayValue}</span>
|
||||
<span dir="auto" className="min-w-0 truncate">
|
||||
{dayValue}
|
||||
</span>
|
||||
<span className="flex-none font-medium text-m-faint">{dayDate}</span>
|
||||
</span>
|
||||
) : (
|
||||
@@ -178,7 +183,9 @@ export function CardFiles({ files, card, t }: { files: ReturnType<typeof filesFo
|
||||
className="flex items-center gap-[6px] rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card px-[10px] py-[7px]"
|
||||
>
|
||||
<FileText size={12} strokeWidth={2} className="flex-none text-m-muted" />
|
||||
<span dir="auto" className="truncate font-geist text-[0.65625rem] font-semibold text-m-muted">{f.original_name}</span>
|
||||
<span dir="auto" className="truncate font-geist text-[0.65625rem] font-semibold text-m-muted">
|
||||
{f.original_name}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -144,7 +144,7 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
|
||||
type="button"
|
||||
onClick={toggleStatus}
|
||||
aria-label={confirmed ? t('reservations.pending') : t('reservations.confirmed')}
|
||||
className="-m-1 flex-none p-1"
|
||||
className="-m-1 flex flex-none p-1"
|
||||
>
|
||||
<StatusDot color={dotColor} />
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { chromeFitPadding, chromePan } from './atlasChrome';
|
||||
|
||||
// FE-ATLAS-CHROME-001 onwards: the atlas camera beside the touch shell's rail and header.
|
||||
@@ -11,6 +11,14 @@ describe('atlas chrome insets', () => {
|
||||
expect(chromePan({ top: 62, start: 0 }, 83, false)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ATLAS-CHROME-005: the north edge is measured only where there is chrome to clear', () => {
|
||||
const measure = vi.fn(() => -77);
|
||||
expect(chromePan(undefined, measure, false)).toBeNull();
|
||||
expect(measure).not.toHaveBeenCalled();
|
||||
expect(chromePan({ top: 62, start: 0 }, measure, false)).toEqual([0, -139]);
|
||||
expect(measure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-ATLAS-CHROME-002: the world moves away from the rail, in either reading direction', () => {
|
||||
expect(chromePan({ top: 62, start: 100 }, 83, false)).toEqual([-50, 0]);
|
||||
expect(chromePan({ top: 62, start: 100 }, 83, true)).toEqual([50, 0]);
|
||||
|
||||
@@ -23,16 +23,17 @@ export const NORTH_EDGE_LAT = 75;
|
||||
* inhabited top (NORTH_EDGE_LAT, at `northEdgeY` in the map's own pixels) needs
|
||||
* to clear the header: a tall window shows it below the header anyway, a phone
|
||||
* had the stars of the far north under it. Leaflet pans the camera, so the
|
||||
* numbers point the other way from the world's move.
|
||||
* numbers point the other way from the world's move. `northEdgeY` may be a
|
||||
* measurement, taken only where there is chrome: the desktop atlas has none.
|
||||
*/
|
||||
export function chromePan(
|
||||
insets: AtlasChromeInsets | undefined,
|
||||
northEdgeY: number,
|
||||
northEdgeY: number | (() => number),
|
||||
rtl = documentRtl()
|
||||
): [number, number] | null {
|
||||
if (!insets) return null;
|
||||
const x = insets.start ? (rtl ? 1 : -1) * (insets.start / 2) : 0;
|
||||
const y = Math.min(0, northEdgeY - insets.top);
|
||||
const y = Math.min(0, (typeof northEdgeY === 'function' ? northEdgeY() : northEdgeY) - insets.top);
|
||||
return x || y ? [x, y] : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -373,10 +373,8 @@ export function useAtlas(options: { chromeInsets?: () => AtlasChromeInsets } = {
|
||||
map.getPane('regionPane')!.style.zIndex = '401'
|
||||
|
||||
mapInstance.current = map
|
||||
// The opening view centres the world beside the screen's chrome, not behind the rail and under the header.
|
||||
// Measured only where there is chrome to clear: the desktop atlas opens exactly as before.
|
||||
const insets = chromeInsetsRef.current?.()
|
||||
const pan = insets ? chromePan(insets, map.latLngToContainerPoint([NORTH_EDGE_LAT, 0]).y) : null
|
||||
// The opening view clears the touch shell's rail and header; the desktop atlas has none and opens as before.
|
||||
const pan = chromePan(chromeInsetsRef.current?.(), () => map.latLngToContainerPoint([NORTH_EDGE_LAT, 0]).y)
|
||||
if (pan) map.panBy(pan, { animate: false })
|
||||
// Both renderers live as long as the map does (see the note on the refs); map.remove()
|
||||
// disposes them along with every other layer it holds.
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
* Zero specificity, so an explicit text-center or text-end on the element wins.
|
||||
*/
|
||||
:where(
|
||||
:root[dir='rtl'] [dir='auto']:dir(ltr):is(.truncate, [class*='line-clamp-'], input, textarea),
|
||||
:root[dir='ltr'] [dir='auto']:dir(rtl):is(.truncate, [class*='line-clamp-'], input, textarea)
|
||||
) {
|
||||
:root[dir='rtl'] [dir='auto']:dir(ltr):is(.truncate, [class*='line-clamp-'], input, textarea),
|
||||
:root[dir='ltr'] [dir='auto']:dir(rtl):is(.truncate, [class*='line-clamp-'], input, textarea)
|
||||
) {
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
* own property, so it composes with a rotation the icon carries (an open
|
||||
* disclosure pointing down) instead of replacing it.
|
||||
*/
|
||||
:root[dir='rtl'] .m-root
|
||||
:root[dir='rtl']
|
||||
.m-root
|
||||
:is(
|
||||
.lucide-chevron-left,
|
||||
.lucide-chevron-right,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// FE-RTL-CSS-001 to FE-RTL-CSS-004
|
||||
//
|
||||
@@ -8,43 +8,56 @@ import { describe, expect, it } from 'vitest'
|
||||
// text that lines up with the UI in its own direction, arrows that turn round
|
||||
// in the touch shell, and Arabic's caption labels. jsdom applies no
|
||||
// stylesheet, so these read the real file.
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/styles/rtl.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/styles/rtl.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
|
||||
/** The declarations of the rule whose selector text contains every given part. */
|
||||
function ruleBody(...parts: string[]): string {
|
||||
for (const chunk of css.split('}')) {
|
||||
const open = chunk.indexOf('{')
|
||||
if (open < 0) continue
|
||||
const head = chunk.slice(0, open)
|
||||
if (parts.every(part => head.includes(part))) return chunk.slice(open + 1)
|
||||
const open = chunk.indexOf('{');
|
||||
if (open < 0) continue;
|
||||
// Prettier breaks a long selector over lines; compared with its white space folded.
|
||||
const head = chunk.slice(0, open).replace(/\s+/g, ' ');
|
||||
if (parts.every((part) => head.includes(part))) return chunk.slice(open + 1);
|
||||
}
|
||||
return ''
|
||||
return '';
|
||||
}
|
||||
|
||||
describe('rtl.css', () => {
|
||||
it('FE-RTL-CSS-001: the stylesheet is loaded with the app', () => {
|
||||
const main = readFileSync(resolve(process.cwd(), 'src/main.tsx'), 'utf8')
|
||||
expect(main).toMatch(/import '\.\/styles\/rtl\.css'/)
|
||||
})
|
||||
const main = readFileSync(resolve(process.cwd(), 'src/main.tsx'), 'utf8');
|
||||
expect(main).toMatch(/import '\.\/styles\/rtl\.css'/);
|
||||
});
|
||||
|
||||
it('FE-RTL-CSS-002: cut, clamped or typed user text in its own direction sits on the UI start edge', () => {
|
||||
const body = ruleBody(":root[dir='rtl'] [dir='auto']:dir(ltr)", '.truncate', "[class*='line-clamp-']", 'input', 'textarea')
|
||||
expect(body).toMatch(/text-align:\s*end/)
|
||||
const body = ruleBody(
|
||||
":root[dir='rtl'] [dir='auto']:dir(ltr)",
|
||||
'.truncate',
|
||||
"[class*='line-clamp-']",
|
||||
'input',
|
||||
'textarea'
|
||||
);
|
||||
expect(body).toMatch(/text-align:\s*end/);
|
||||
// The left-to-right UI gets the mirror case: Arabic text in an English row.
|
||||
expect(css).toContain(":root[dir='ltr'] [dir='auto']:dir(rtl)")
|
||||
expect(css).toContain(":root[dir='ltr'] [dir='auto']:dir(rtl)");
|
||||
// Zero specificity: an explicit alignment class on the element still wins.
|
||||
expect(css).toMatch(/:where\(\s*:root\[dir='rtl'\]/)
|
||||
})
|
||||
expect(css).toMatch(/:where\(\s*:root\[dir='rtl'\]/);
|
||||
});
|
||||
|
||||
it('FE-RTL-CSS-003: in the touch shell every back, forward and flow arrow turns round in a right-to-left language', () => {
|
||||
const body = ruleBody(":root[dir='rtl'] .m-root", '.lucide-chevron-left', '.lucide-chevron-right', '.lucide-arrow-left', '.lucide-arrow-right')
|
||||
expect(body).toMatch(/scale:\s*-1 1/)
|
||||
const body = ruleBody(
|
||||
":root[dir='rtl'] .m-root",
|
||||
'.lucide-chevron-left',
|
||||
'.lucide-chevron-right',
|
||||
'.lucide-arrow-left',
|
||||
'.lucide-arrow-right'
|
||||
);
|
||||
expect(body).toMatch(/scale:\s*-1 1/);
|
||||
// An icon that names its own treatment keeps it.
|
||||
expect(css).toContain(":not([class*='rtl:'])")
|
||||
})
|
||||
expect(css).toContain(":not([class*='rtl:'])");
|
||||
});
|
||||
|
||||
it('FE-RTL-CSS-004: Arabic and Hebrew caption labels grow, drop the tracking and take the muted ink', () => {
|
||||
expect(ruleBody(':lang(ar)', ':lang(he)', '.m-root .uppercase')).toMatch(/zoom:\s*1\.2[\s\S]*letter-spacing:\s*0/)
|
||||
expect(ruleBody('.uppercase.text-m-faint')).toMatch(/color:\s*var\(--m-muted\)/)
|
||||
})
|
||||
})
|
||||
expect(ruleBody(':lang(ar)', ':lang(he)', '.m-root .uppercase')).toMatch(/zoom:\s*1\.2[\s\S]*letter-spacing:\s*0/);
|
||||
expect(ruleBody('.uppercase.text-m-faint')).toMatch(/color:\s*var\(--m-muted\)/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,8 +7,8 @@ import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { flowText, isRtlDocument } from './flowText';
|
||||
|
||||
const FSI = '';
|
||||
const PDI = '';
|
||||
const FSI = '\u2068';
|
||||
const PDI = '\u2069';
|
||||
|
||||
describe('flowText', () => {
|
||||
afterEach(() => {
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
* script the names are in, and the arrow points left, from the first name to
|
||||
* the second. A left-to-right UI gets the plain string.
|
||||
*/
|
||||
const FSI = '';
|
||||
const PDI = '';
|
||||
const FSI = '\u2068';
|
||||
const PDI = '\u2069';
|
||||
|
||||
export function isRtlDocument(): boolean {
|
||||
return typeof document !== 'undefined' && document.documentElement.dir === 'rtl';
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useAddonStore } from '../../../src/store/addonStore';
|
||||
import { useAuthStore } from '../../../src/store/authStore';
|
||||
import { usePluginStore } from '../../../src/store/pluginStore';
|
||||
import { buildUser } from '../../helpers/factories';
|
||||
import { fireEvent, render, screen, within } from '../../helpers/render';
|
||||
import { act, fireEvent, render, screen, within } from '../../helpers/render';
|
||||
import { setSizeClass } from '../../helpers/sizeClass';
|
||||
|
||||
// FE-MOB-RAIL-001 onwards
|
||||
@@ -71,6 +71,37 @@ describe('MNavRail', () => {
|
||||
expect(within(rail()).getByRole('button', { name: 'Admin' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-RAIL-012: settings and administration, and their sub pages, mark their own entry in the rail', () => {
|
||||
useAuthStore.setState({ user: buildUser({ role: 'admin' }) });
|
||||
const { unmount } = render(<MNavRail />, { initialEntries: ['/settings'] });
|
||||
expect(within(rail()).getByRole('button', { name: 'Settings' })).toHaveAttribute('aria-current', 'page');
|
||||
expect(within(rail()).getByRole('button', { name: 'Admin' })).not.toHaveAttribute('aria-current');
|
||||
unmount();
|
||||
|
||||
render(<MNavRail />, { initialEntries: ['/admin/users'] });
|
||||
expect(within(rail()).getByRole('button', { name: 'Admin' })).toHaveAttribute('aria-current', 'page');
|
||||
expect(within(rail()).getByRole('button', { name: 'Settings' })).not.toHaveAttribute('aria-current');
|
||||
expect(within(rail()).getByRole('button', { name: 'My Trips' })).not.toHaveAttribute('aria-current');
|
||||
});
|
||||
|
||||
it('FE-MOB-RAIL-013: on a window too short for every entry the list fades at the edge that has more behind it', () => {
|
||||
useAuthStore.setState({ user: buildUser({ role: 'admin' }) });
|
||||
render(<MNavRail />, { initialEntries: ['/dashboard'] });
|
||||
const list = rail().querySelector('.overflow-y-auto') as HTMLElement;
|
||||
expect(list.className).not.toMatch(/m-fade-/);
|
||||
Object.defineProperty(list, 'scrollHeight', { configurable: true, value: 800 });
|
||||
Object.defineProperty(list, 'clientHeight', { configurable: true, value: 400 });
|
||||
act(() => {
|
||||
list.dispatchEvent(new Event('scroll'));
|
||||
});
|
||||
expect(list).toHaveClass('m-fade-b');
|
||||
list.scrollTop = 400;
|
||||
act(() => {
|
||||
list.dispatchEvent(new Event('scroll'));
|
||||
});
|
||||
expect(list).toHaveClass('m-fade-t');
|
||||
});
|
||||
|
||||
it("FE-MOB-RAIL-004: the create button keeps the dock's meaning and its exceptions", () => {
|
||||
const { unmount } = render(
|
||||
<>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useInAppNotificationStore } from '../../../../src/store/inAppNotificati
|
||||
import { usePluginStore } from '../../../../src/store/pluginStore';
|
||||
import type { TripCardBadge } from '../../../../src/api/client';
|
||||
import type { DashboardTrip } from '../../../../src/pages/dashboard/dashboardModel';
|
||||
import { setSizeClass } from '../../../helpers/sizeClass';
|
||||
|
||||
// FE-MOB-DASH-001 onwards
|
||||
|
||||
@@ -416,6 +417,21 @@ describe('MDashboard', () => {
|
||||
expect(screen.getByText('Upcoming reservations')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-041: crossing the split size keeps the widgets mounted, so the converter keeps its amount', () => {
|
||||
mocks.dash = buildDash();
|
||||
render(<MDashboard />);
|
||||
const amount = screen.getAllByRole('textbox', { name: 'From' })[0] as HTMLInputElement;
|
||||
fireEvent.change(amount, { target: { value: '250' } });
|
||||
|
||||
setSizeClass('split');
|
||||
expect(screen.getAllByRole('textbox', { name: 'From' })[0]).toBe(amount);
|
||||
expect(amount.value).toBe('250');
|
||||
|
||||
setSizeClass('compact');
|
||||
expect(screen.getAllByRole('textbox', { name: 'From' })[0]).toBe(amount);
|
||||
expect(amount.value).toBe('250');
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-027: a malformed date renders a dash instead of crashing', () => {
|
||||
mocks.dash = buildDash({ gridTrips: [buildTrip({ id: 14, start_date: 'not-a-date', end_date: iso(3) })] });
|
||||
render(<MDashboard />);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FE-MOB-JDET-001 to FE-MOB-JDET-048
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, act } from '../../../helpers/render';
|
||||
// FE-MOB-JDET-001 to FE-MOB-JDET-050
|
||||
import { describe, it, expect, beforeEach, afterEach, onTestFinished, vi } from 'vitest';
|
||||
import { setSizeClass } from '../../../helpers/sizeClass';
|
||||
import { render, screen, fireEvent, waitFor, act } from '../../../helpers/render';
|
||||
import MJourneyDetail from '../../../../src/mobile/screens/journey/MJourneyDetail';
|
||||
import { addonsApi, journeyApi, memoriesApi } from '../../../../src/api/client';
|
||||
import { useJourneyStore } from '../../../../src/store/journeyStore';
|
||||
@@ -246,6 +246,24 @@ afterEach(() => {
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('MJourneyDetail', () => {
|
||||
it('FE-MOB-JDET-049: a folded Duo on its side (540px high) gets the compact cards, so the map keeps its room', () => {
|
||||
const real = window.matchMedia;
|
||||
// Height queries answered for a window 540px high; the rest as the setup answers them.
|
||||
const tall = (query: string): MediaQueryList => {
|
||||
const max = /\(\s*max-height:\s*(\d+)px\s*\)/.exec(query);
|
||||
const list = real(query);
|
||||
return max ? ({ ...list, matches: 540 <= Number(max[1]), media: query } as MediaQueryList) : list;
|
||||
};
|
||||
window.matchMedia = tall;
|
||||
onTestFinished(() => { window.matchMedia = real; });
|
||||
// At 720 by 540 it is the wide size class too, whose large cards must not win.
|
||||
setSizeClass('wide');
|
||||
setup();
|
||||
expect(document.querySelector('.h-\\[112px\\]')).toBeInTheDocument();
|
||||
expect(document.querySelector('.h-\\[152px\\]')).not.toBeInTheDocument();
|
||||
expect(document.querySelector('.h-\\[196px\\]')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-001: shows a spinner while the journey is loading', () => {
|
||||
setup({ loading: true, current: null });
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
@@ -798,6 +816,15 @@ describe('MJourneyDetail', () => {
|
||||
expect(screen.getByRole('button', { name: /Kyoto/ })).toHaveAttribute('aria-current', 'true');
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-050: the feed hides what scrolls under the header pills down to their lower edge', () => {
|
||||
setSizeClass('split');
|
||||
setup();
|
||||
const feed = screen.getByRole('button', { name: /Kyoto/ }).closest('.overflow-y-auto') as HTMLElement;
|
||||
// Transparent through the 38px pills below the safe area, whole before the first day's head at +60px.
|
||||
expect(feed.style.maskImage).toMatch(/transparent calc\(var\(--m-safe-top, 12px\) \+ 40px\)/);
|
||||
expect(feed.style.maskImage).toMatch(/#000 calc\(var\(--m-safe-top, 12px\) \+ 60px\)/);
|
||||
});
|
||||
|
||||
it('FE-MOB-JDET-048: the gallery takes the page, feed or not', () => {
|
||||
setSizeClass('split');
|
||||
setup({ view: 'gallery' });
|
||||
|
||||
@@ -221,6 +221,8 @@ describe('MPlanTimeline', () => {
|
||||
// The name gives way with an ellipsis; the time after it stays, with no dot between.
|
||||
const sacher = screen.getByText('Hotel Sacher')
|
||||
expect(sacher).toHaveClass('truncate', 'min-w-0')
|
||||
// In an Arabic header a Latin name keeps its own direction, so the ellipsis lands at its end.
|
||||
expect(sacher).toHaveAttribute('dir', 'auto')
|
||||
expect(sacher.closest('button')).toHaveTextContent('Hotel Sacher11:00')
|
||||
expect(sacher.closest('button')).toHaveClass('shrink')
|
||||
expect(screen.getByText('Ryokan Kyoto').closest('button')).toHaveTextContent('Ryokan Kyoto15:00')
|
||||
|
||||
@@ -243,6 +243,14 @@ describe('MTransportsTab', () => {
|
||||
await waitFor(() => expect(p.tripActions.toggleReservationStatus).toHaveBeenCalledWith(7, 101))
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-029: the dot inside the status button is laid out as a box, so it shows', () => {
|
||||
renderTab(planner())
|
||||
const toggle = within(cardOf('HND to ITM')).getByRole('button', { name: 'reservations.pending' })
|
||||
// The dot is a bare span sized by width and height: only a flex (or block) parent gives it its box.
|
||||
expect(toggle).toHaveClass('flex')
|
||||
expect(toggle.querySelector('span')).toHaveClass('h-[7px]', 'w-[7px]')
|
||||
})
|
||||
|
||||
it('FE-MOB-TRTAB-017: toasts when the status toggle fails', async () => {
|
||||
const p = planner()
|
||||
vi.mocked(p.tripActions.toggleReservationStatus).mockRejectedValueOnce(new Error('offline'))
|
||||
|
||||
@@ -421,10 +421,20 @@ describe('MTripShell', () => {
|
||||
expect(more).toHaveAttribute('aria-expanded', 'false')
|
||||
fireEvent.click(more)
|
||||
expect(more).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(more).toHaveClass('bg-m-act')
|
||||
// A ring, as the main dock marks its More, not the fill of a tab.
|
||||
expect(more).toHaveClass('ring-[1.5px]')
|
||||
expect(more).not.toHaveClass('bg-m-act')
|
||||
expect(plan).not.toHaveAttribute('aria-current')
|
||||
act(() => { shellApi.closeSheet() })
|
||||
expect(plan).toHaveAttribute('aria-current', 'page')
|
||||
expect(more).not.toHaveClass('ring-[1.5px]')
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-098: a section reached through More marks More with the ring while it is on show', () => {
|
||||
renderShell({ activeTab: 'dateien' } as Partial<TripPlanner>)
|
||||
const more = screen.getByRole('button', { name: 'mobileTrip.more' })
|
||||
expect(more).toHaveAttribute('aria-current', 'page')
|
||||
expect(more).toHaveClass('ring-[1.5px]')
|
||||
expect(more).not.toHaveClass('bg-m-act')
|
||||
})
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { directionOf, pickLocale, translator } from '../../../native-shell/i18n'
|
||||
import { directionOf, pickLocale, translator } from '../../../native-shell/i18n';
|
||||
|
||||
// FE-NATIVE-SHELL-001 to FE-NATIVE-SHELL-006
|
||||
//
|
||||
@@ -12,83 +12,85 @@ import { directionOf, pickLocale, translator } from '../../../native-shell/i18n'
|
||||
|
||||
/** Every rule of a stylesheet as its selector list, its declarations and the media query around it, comments dropped. */
|
||||
function rules(css: string): { selectors: string[]; body: string; media: string }[] {
|
||||
const out: { selectors: string[]; body: string; media: string }[] = []
|
||||
const stack: string[] = []
|
||||
let buf = ''
|
||||
const out: { selectors: string[]; body: string; media: string }[] = [];
|
||||
const stack: string[] = [];
|
||||
let buf = '';
|
||||
for (const ch of css.replace(/\/\*[\s\S]*?\*\//g, '')) {
|
||||
if (ch === '{') {
|
||||
stack.push(buf.trim())
|
||||
buf = ''
|
||||
stack.push(buf.trim());
|
||||
buf = '';
|
||||
} else if (ch === '}') {
|
||||
const head = stack.pop() ?? ''
|
||||
const head = stack.pop() ?? '';
|
||||
if (!head.startsWith('@')) {
|
||||
const media = stack.filter(h => h.startsWith('@media')).join(' ')
|
||||
out.push({ selectors: head.split(',').map(sel => sel.trim()), body: buf, media })
|
||||
const media = stack.filter((h) => h.startsWith('@media')).join(' ');
|
||||
out.push({ selectors: head.split(',').map((sel) => sel.trim()), body: buf, media });
|
||||
}
|
||||
buf = ''
|
||||
buf = '';
|
||||
} else {
|
||||
buf += ch
|
||||
buf += ch;
|
||||
}
|
||||
}
|
||||
return out
|
||||
return out;
|
||||
}
|
||||
|
||||
const css = readFileSync(resolve(process.cwd(), 'native-shell/shell.css'), 'utf8')
|
||||
const all = rules(css)
|
||||
const css = readFileSync(resolve(process.cwd(), 'native-shell/shell.css'), 'utf8');
|
||||
const all = rules(css);
|
||||
const find = (selector: string, media?: RegExp) =>
|
||||
all.filter(r => r.selectors.includes(selector) && (!media || media.test(r.media)))
|
||||
all.filter((r) => r.selectors.includes(selector) && (!media || media.test(r.media)));
|
||||
|
||||
describe('native shell', () => {
|
||||
it('FE-NATIVE-SHELL-001: Arabic and Hebrew start right to left, every other language left to right', () => {
|
||||
expect(pickLocale(['ar-SA'])).toBe('ar')
|
||||
expect(pickLocale(['he-IL'])).toBe('he')
|
||||
expect(directionOf('ar')).toBe('rtl')
|
||||
expect(directionOf('he')).toBe('rtl')
|
||||
expect(directionOf('ru')).toBe('ltr')
|
||||
expect(directionOf('de')).toBe('ltr')
|
||||
})
|
||||
expect(pickLocale(['ar-SA'])).toBe('ar');
|
||||
expect(pickLocale(['he-IL'])).toBe('he');
|
||||
expect(directionOf('ar')).toBe('rtl');
|
||||
expect(directionOf('he')).toBe('rtl');
|
||||
expect(directionOf('ru')).toBe('ltr');
|
||||
expect(directionOf('de')).toBe('ltr');
|
||||
});
|
||||
|
||||
it('FE-NATIVE-SHELL-002: the first screens speak without dashes as punctuation', () => {
|
||||
for (const code of ['en', 'de']) {
|
||||
const t = translator(code)
|
||||
const t = translator(code);
|
||||
for (const key of ['native.welcome.sub', 'native.cloud.sub', 'native.cloud.f3']) {
|
||||
expect(t(key)).not.toMatch(/[\u2013\u2014]| - /)
|
||||
expect(t(key)).not.toMatch(/[\u2013\u2014]| - /);
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
it('FE-NATIVE-SHELL-003: Russian keeps TREK\'s faces through Cyrillic stand-ins under the same family names', () => {
|
||||
const faces = css.match(/@font-face\s*\{[^}]*\}/g) ?? []
|
||||
const cyrillic = faces.filter(f => /unicode-range:\s*U\+0301, U\+0400-045F/.test(f))
|
||||
expect(cyrillic.filter(f => /font-family:\s*'Poppins'/.test(f))).toHaveLength(3)
|
||||
expect(cyrillic.filter(f => /font-family:\s*'MuseoModerno'/.test(f))).toHaveLength(1)
|
||||
it("FE-NATIVE-SHELL-003: Russian keeps TREK's faces through Cyrillic stand-ins under the same family names", () => {
|
||||
const faces = css.match(/@font-face\s*\{[^}]*\}/g) ?? [];
|
||||
const cyrillic = faces.filter((f) => /unicode-range:\s*U\+0301, U\+0400-045F/.test(f));
|
||||
expect(cyrillic.filter((f) => /font-family:\s*'Poppins'/.test(f))).toHaveLength(3);
|
||||
expect(cyrillic.filter((f) => /font-family:\s*'MuseoModerno'/.test(f))).toHaveLength(1);
|
||||
for (const face of cyrillic) {
|
||||
const file = /url\('\.\/fonts\/([^']+)'\)/.exec(face)?.[1]
|
||||
expect(file).toBeTruthy()
|
||||
expect(() => readFileSync(resolve(process.cwd(), 'native-shell/fonts', file as string))).not.toThrow()
|
||||
const file = /url\('\.\/fonts\/([^']+)'\)/.exec(face)?.[1];
|
||||
expect(file).toBeTruthy();
|
||||
expect(() => readFileSync(resolve(process.cwd(), 'native-shell/fonts', file as string))).not.toThrow();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
it('FE-NATIVE-SHELL-004: a short screen on its side puts the two halves of a screen side by side', () => {
|
||||
const compact = /orientation: landscape\) and \(max-height: 500px\)/
|
||||
expect(find('.screen:has(> .col)', compact)[0]?.body).toMatch(/grid-template-columns:\s*minmax\(0, 1fr\) minmax\(0, 1fr\)/)
|
||||
expect(find('.col-about', compact)[0]?.body).toMatch(/position:\s*sticky/)
|
||||
const compact = /orientation: landscape\) and \(max-height: 500px\)/;
|
||||
expect(find('.screen:has(> .col)', compact)[0]?.body).toMatch(
|
||||
/grid-template-columns:\s*minmax\(0, 1fr\) minmax\(0, 1fr\)/
|
||||
);
|
||||
expect(find('.col-about', compact)[0]?.body).toMatch(/position:\s*sticky/);
|
||||
// Everywhere else the halves are no boxes of their own: one column, as before.
|
||||
expect(find('.col')[0]?.body).toMatch(/display:\s*contents/)
|
||||
})
|
||||
expect(find('.col')[0]?.body).toMatch(/display:\s*contents/);
|
||||
});
|
||||
|
||||
it('FE-NATIVE-SHELL-005: a screen taller than the window scrolls instead of overflowing both edges', () => {
|
||||
expect(find('.screen')[0]?.body).toMatch(/margin-block:\s*auto/)
|
||||
const shell = find('.shell')[0]?.body ?? ''
|
||||
expect(shell).toMatch(/overflow-x:\s*clip/)
|
||||
expect(shell).not.toMatch(/justify-content:\s*center/)
|
||||
})
|
||||
expect(find('.screen')[0]?.body).toMatch(/margin-block:\s*auto/);
|
||||
const shell = find('.shell')[0]?.body ?? '';
|
||||
expect(shell).toMatch(/overflow-x:\s*clip/);
|
||||
expect(shell).not.toMatch(/justify-content:\s*center/);
|
||||
});
|
||||
|
||||
it('FE-NATIVE-SHELL-006: in Arabic the chevrons sit at the far edge and point along the reading direction, the price stands apart', () => {
|
||||
expect(find('.btn .chev')[0]?.body).toMatch(/margin-inline-start:\s*auto/)
|
||||
expect(find(":root[dir='rtl'] .chev")[0]?.body).toMatch(/transform:\s*scaleX\(-1\)/)
|
||||
expect(find('.plan-price small')[0]?.body).toMatch(/margin-inline-start:\s*7px/)
|
||||
expect(find('.btn-row')[0]?.body).toMatch(/text-align:\s*start/)
|
||||
expect(css).not.toMatch(/margin-left|margin-right|padding-left|padding-right|text-align:\s*left/)
|
||||
})
|
||||
})
|
||||
expect(find('.btn .chev')[0]?.body).toMatch(/margin-inline-start:\s*auto/);
|
||||
expect(find(":root[dir='rtl'] .chev")[0]?.body).toMatch(/transform:\s*scaleX\(-1\)/);
|
||||
expect(find('.plan-price small')[0]?.body).toMatch(/margin-inline-start:\s*7px/);
|
||||
expect(find('.btn-row')[0]?.body).toMatch(/text-align:\s*start/);
|
||||
expect(css).not.toMatch(/margin-left|margin-right|padding-left|padding-right|text-align:\s*left/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user