import { afterEach, describe, expect, it, vi } from 'vitest' import { getNavigationTargets, navigationAppChoices, navigationTargetLabel, openNavigationTarget, preferredNavAppOptions, showsAppleMaps, showsGeoUri, withPreferredApp } from './placeNavigation' import { useSettingsStore } from '../../store/settingsStore' import type { Place } from '../../types' // FE-PLANNER-NAV-001 to FE-PLANNER-NAV-008 function place(overrides: Partial = {}): Place { return { name: 'Stephansdom', lat: 48.2038, lng: 16.3616, google_place_id: null, google_ftid: null, ...overrides, } as unknown as Place } /** * jsdom reports its own agent, so Apple has to be simulated. userAgent lives on * the prototype as a getter: shadowing it on the instance and deleting that * again afterwards restores the real one without having to reconstruct it. */ function withUserAgent(ua: string) { Object.defineProperty(navigator, 'userAgent', { value: ua, configurable: true }) return () => { delete (navigator as unknown as Record).userAgent } } afterEach(() => { vi.restoreAllMocks() }) describe('getNavigationTargets', () => { it('FE-PLANNER-NAV-001: offers every app that can resolve a place with coordinates', () => { const targets = getNavigationTargets(place()) expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm', 'comaps']) expect(targets[0].label).toBe('Google Maps') }) it('FE-PLANNER-NAV-002: Waze gets the name as well as the position, and navigates', () => { // Coordinates alone would show a driver a number instead of a destination; // the name alone would be a guess. Waze documents taking both. const waze = getNavigationTargets(place()).find(t => t.id === 'waze')! expect(waze.url).toBe('https://waze.com/ul?q=Stephansdom&ll=48.2038,16.3616&navigate=yes') }) it('FE-PLANNER-NAV-003: Google keeps its precise link when the place has an ftid', () => { const targets = getNavigationTargets(place({ google_ftid: '0x882b:0x8591' })) // The whole point of the ftid: it lands on the entry, not on the roof. expect(targets[0].url).toContain('ftid=0x882b:0x8591') expect(targets[0].url).not.toContain('query=48.2038') }) it('FE-PLANNER-NAV-004: a Windows desktop gets Apple Maps too, through its web version', () => { const restore = withUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64)') try { expect(getNavigationTargets(place()).map(t => t.id)).toContain('apple') } finally { restore() } }) it('FE-PLANNER-NAV-004b: an Android phone does not, because nobody there wants it', () => { const restore = withUserAgent('Mozilla/5.0 (Linux; Android 15; Pixel 9)') try { expect(getNavigationTargets(place()).map(t => t.id)).toEqual(['google', 'waze', 'osm', 'comaps', 'geo']) } finally { restore() } }) it('FE-PLANNER-NAV-005: an iPhone gets Apple Maps with a navigation target', () => { const restore = withUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)') try { const targets = getNavigationTargets(place()) expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm', 'comaps']) // q next to ll labels the pin rather than searching blindly. expect(targets[2].url).toBe('https://maps.apple.com/?q=Stephansdom&ll=48.2038,16.3616') } finally { restore() } }) it('FE-PLANNER-NAV-006: iPadOS reports itself as a Mac, and both deserve the entry', () => { const restore = withUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)') try { expect(showsAppleMaps()).toBe(true) } finally { restore() } }) it('FE-PLANNER-NAV-007: a place without coordinates keeps only what Google can resolve', () => { // Waze and Apple Maps take coordinates and nothing else, so they cannot // offer anything here — Google still can, through the place id. // OpenStreetMap still manages a name search, the two driving apps do not. const targets = getNavigationTargets(place({ lat: null, lng: null, google_place_id: 'ChIJabc' })) expect(targets.map(t => t.id)).toEqual(['google', 'osm']) }) it('FE-PLANNER-NAV-007b: CoMaps needs the position, so a place without one loses it', () => { // CoMaps drops a pin from `ll` and only labels it with `n` — a name alone // has nothing to attach to, the same reason Waze and Apple Maps drop out. const targets = getNavigationTargets(place({ lat: null, lng: null, name: 'Stephansdom' })) expect(targets.map(t => t.id)).not.toContain('comaps') }) it('FE-PLANNER-NAV-104: Android gets a geo: link for any installed map app, labeled in the UI language (#1406)', () => { const restore = withUserAgent('Mozilla/5.0 (Linux; Android 15; Pixel 9)') try { expect(showsGeoUri()).toBe(true) const geo = getNavigationTargets(place()).find(t => t.id === 'geo')! expect(geo.url).toBe('geo:48.2038,16.3616?q=48.2038,16.3616(Stephansdom)') expect(navigationTargetLabel(geo, key => `t:${key}`)).toBe('t:inspector.otherMapApp') } finally { restore() } }) it('FE-PLANNER-NAV-105: no geo: entry where nothing would answer it', () => { const restore = withUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)') try { expect(getNavigationTargets(place()).map(t => t.id)).not.toContain('geo') const google = getNavigationTargets(place())[0] expect(navigationTargetLabel(google, key => `t:${key}`)).toBe('Google Maps') } finally { restore() } }) it('FE-PLANNER-NAV-008: no place at all yields nothing', () => { expect(getNavigationTargets(null)).toEqual([]) expect(getNavigationTargets(place({ lat: null, lng: null, name: '' }))).toEqual([]) }) it('FE-PLANNER-NAV-009: a nameless place still reaches every app, just without a label', () => { const targets = getNavigationTargets(place({ name: '' })) expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm', 'comaps']) expect(targets.find(t => t.id === 'waze')!.url).toBe('https://waze.com/ul?ll=48.2038,16.3616&navigate=yes') }) }) describe('openNavigationTarget', () => { const target = { id: 'google', label: 'Google Maps', url: 'https://maps.google.com/?q=1,2' } as const afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() }) function stubDisplayMode(installed: boolean, iosStandalone = false) { const open = vi.fn() const assigned: string[] = [] vi.stubGlobal('window', { matchMedia: (q: string) => ({ matches: installed && q.includes('display-mode') }), navigator: { standalone: iosStandalone }, open, get location() { return { get href() { return '' }, set href(v: string) { assigned.push(v) } } }, }) return { open, assigned } } it('FE-PLANNER-NAV-101: a browser tab keeps opening a tab', () => { const { open, assigned } = stubDisplayMode(false) openNavigationTarget(target) expect(open).toHaveBeenCalledWith(target.url, '_blank', 'noopener,noreferrer') expect(assigned).toEqual([]) }) it('FE-PLANNER-NAV-102: an installed app hands over from the current context, leaving no blank window (#2218)', () => { const { open, assigned } = stubDisplayMode(true) openNavigationTarget(target) expect(open).not.toHaveBeenCalled() expect(assigned).toEqual([target.url]) }) it('FE-PLANNER-NAV-103: an iOS home-screen app is recognised without the display-mode query', () => { const { open, assigned } = stubDisplayMode(false, true) openNavigationTarget(target) expect(open).not.toHaveBeenCalled() expect(assigned).toEqual([target.url]) }) }) describe('preferred map app (#2423)', () => { afterEach(() => { useSettingsStore.setState(s => ({ settings: { ...s.settings, preferred_nav_app: '' } })) }) it('FE-PLANNER-NAV-2423-1: unset keeps every app on offer', () => { expect(getNavigationTargets(place()).length).toBeGreaterThan(1) }) it('FE-PLANNER-NAV-2423-2: a picked app is the only target, so the button opens it straight away', () => { useSettingsStore.setState(s => ({ settings: { ...s.settings, preferred_nav_app: 'waze' } })) const targets = getNavigationTargets(place()) expect(targets.map(t => t.id)).toEqual(['waze']) }) it('FE-PLANNER-NAV-2423-3: an app this place cannot be opened in falls back to the full list', () => { const targets = [{ id: 'google' as const, label: 'Google Maps', url: 'g' }, { id: 'osm' as const, label: 'OpenStreetMap', url: 'o' }] expect(withPreferredApp(targets, 'amap')).toEqual(targets) expect(withPreferredApp(targets, null)).toEqual(targets) expect(withPreferredApp(targets, 'osm')).toEqual([targets[1]]) }) it('FE-PLANNER-NAV-2423-4: the settings options start with asking and name every app', () => { const options = preferredNavAppOptions(k => k) expect(options[0]).toEqual({ value: '', label: 'settings.preferredNavAppAsk' }) expect(options.slice(1).map(o => o.value)).toEqual(navigationAppChoices().map(a => a.id)) expect(options.map(o => o.value)).toContain('google') }) })