diff --git a/reactnative-for-template/.gitignore b/reactnative-for-template/.gitignore index f6db3ad..ba85fe2 100644 --- a/reactnative-for-template/.gitignore +++ b/reactnative-for-template/.gitignore @@ -1,3 +1,6 @@ build/ node_modules/ dist/ + +# node:test のテスト(とその補助)は手書きのソースなので追跡する(親の *.mjs 除外を打ち消す)。 +!test/**/*.mjs diff --git a/reactnative-for-template/package.json b/reactnative-for-template/package.json index 08f4b95..b6ebfa3 100644 --- a/reactnative-for-template/package.json +++ b/reactnative-for-template/package.json @@ -25,7 +25,7 @@ "build": "tsup src/index.ts --format cjs,esm --dts", "dev": "tsup src/index.ts --format cjs,esm --watch", "clean": "rm -rf dist", - "test": "echo 'No tests yet'" + "test": "node --test \"test/**/*.test.mjs\"" }, "keywords": [ "maps", diff --git a/reactnative-for-template/test/PublicSurface.test.mjs b/reactnative-for-template/test/PublicSurface.test.mjs new file mode 100644 index 0000000..51674fd --- /dev/null +++ b/reactnative-for-template/test/PublicSurface.test.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { after, test } from 'node:test'; +import { createGeoPoint, createMapCameraPosition, createMarkerState } from '@mapconductor/js-sdk-core'; +import * as webState from '@mapconductor/react-for-template'; +import { buildPackage } from './helpers/buildPackage.mjs'; + +// パッケージの入口が何を出すかと、そこから再輸出しているネイティブ向け変換の振る舞い。 +// 状態は web 版(react-for-template)と同一実体であること、雛形の地図は純 TS なので +// ルートから取っても react と js-sdk-core 以外を引き込まないことも確かめる。 +const { mod, cleanup, outDir, rnStubPath } = await buildPackage('surface-tests'); +after(cleanup); + +test('state comes from react-for-template, the same objects as the web package', () => { + assert.equal(mod.TemplateViewState, webState.TemplateViewState); + assert.equal(mod.useTemplateViewState, webState.useTemplateViewState); +}); + +test('the bundle reaches only react, js-sdk-core and react-for-template at runtime', () => { + const code = readdirSync(outDir) + .filter((f) => f.endsWith('.mjs')) + .map((f) => readFileSync(join(outDir, f), 'utf8')) + .join('\n'); + const imported = new Set([...code.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)].map((m) => m[1])); + imported.delete(rnStubPath); + assert.deepEqual([...imported].sort(), [ + '@mapconductor/js-sdk-core', + '@mapconductor/react-for-template', + 'react', + 'react/jsx-runtime', + ]); +}); + +test('toNativeCameraPosition flattens the camera and defaults altitude to 0', () => { + assert.equal(mod.toNativeCameraPosition(undefined), undefined); + const camera = createMapCameraPosition({ position: createGeoPoint({ latitude: 35.1, longitude: 139.2, altitude: null }), zoom: 11, bearing: 30, tilt: 45 }); + assert.deepEqual(mod.toNativeCameraPosition(camera), { + position: { latitude: 35.1, longitude: 139.2, altitude: 0 }, + zoom: 11, + bearing: 30, + tilt: 45, + }); +}); + +test('toNativeMarkerTilingOptions replaces the scale callback with a flag', () => { + assert.equal(mod.toNativeMarkerTilingOptions(undefined), undefined); + const base = { enabled: true, debugTileOverlay: true, minMarkerCount: 100, cacheSize: 2048, declutterPx: 0 }; + assert.deepEqual(mod.toNativeMarkerTilingOptions({ ...base, iconScaleCallback: () => 2 }), { + enabled: true, debugTileOverlay: true, minMarkerCount: 100, cacheSize: 2048, hasIconScaleCallback: true, + }); + assert.equal(mod.toNativeMarkerTilingOptions({ ...base, iconScaleCallback: null }).hasIconScaleCallback, false); +}); + +test('markerStateToNative sends a numeric zIndex and no icon when there is none', () => { + const state = createMarkerState({ id: 'm', position: createGeoPoint({ latitude: 1, longitude: 2 }), draggable: true }); + const payload = mod.markerStateToNative(state); + assert.equal(payload.id, 'm'); + assert.equal(payload.zIndex, 0); + assert.equal(payload.icon, null); + assert.equal(payload.draggable, true); + assert.equal(payload.position, state.position); +}); diff --git a/reactnative-for-template/test/TemplateMapView.test.mjs b/reactnative-for-template/test/TemplateMapView.test.mjs new file mode 100644 index 0000000..769256f --- /dev/null +++ b/reactnative-for-template/test/TemplateMapView.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { after, test } from 'node:test'; +import { createGeoPoint, createMapCameraPosition } from '@mapconductor/js-sdk-core'; +import { TemplateDesign } from '@mapconductor/react-for-template'; +import { buildPackage } from './helpers/buildPackage.mjs'; + +// TemplateMapView は共有の NativeMapViewHost に「どのネイティブビューか」「デザインをどう文字列化するか」を +// 渡すだけの層。React を描画せず関数として呼び、返ってきた要素の props で受け渡しを確かめる。 +// 雛形はデザインを id 1 本の文字列で渡し、分解はネイティブ側の責任とする。 +const { mod, cleanup } = await buildPackage('mapview-tests'); +after(cleanup); +const { TemplateMapView, TemplateViewState, TemplateViewController, NativeMapViewHost, ReactNativeBridgeMapViewController } = mod; + +test('the shared native host is rendered with the TemplateMapView native view', () => { + const element = TemplateMapView({ state: new TemplateViewState() }); + assert.equal(element.type, NativeMapViewHost); + // Android の REACT_CLASS / iOS の RCT_EXPORT_MODULE と揃っていないと + // 「View config not found」で落ちる名前。 + assert.deepEqual(element.props.nativeComponent, { nativeViewName: 'TemplateMapView' }); + // このプロバイダはネイティブ固有の prop(API キー等)を渡さない。 + assert.equal(element.props.nativeProps, undefined); +}); + +test('the design value sent to native follows the state design', () => { + const state = new TemplateViewState(); + assert.equal(TemplateMapView({ state }).props.mapDesignValue, 'standard'); + state.mapDesignType = TemplateDesign.Satellite; + assert.equal(TemplateMapView({ state }).props.mapDesignValue, 'satellite'); +}); + +test('app props pass through to the host untouched', () => { + const state = new TemplateViewState(); + const onMapClick = () => {}; + const props = TemplateMapView({ state, onMapClick, children: 'child' }).props; + assert.equal(props.state, state); + assert.equal(props.onMapClick, onMapClick); + assert.equal(props.children, 'child'); +}); + +test('createController builds a TemplateViewController bound to the native ref and initial camera', () => { + const view = { nodeHandle: 7 }; + const camera = createMapCameraPosition({ position: createGeoPoint({ latitude: 35.68, longitude: 139.76 }), zoom: 12 }); + const controller = TemplateMapView({ state: new TemplateViewState() }).props.createController({ current: view }, camera); + assert.ok(controller instanceof TemplateViewController); + assert.ok(controller instanceof ReactNativeBridgeMapViewController); + assert.equal(controller.getCameraPosition(), camera); + assert.equal(controller.holder.mapView, view); + controller.destroy(); +}); + +test('the id is sent, not the composite getValue() string', () => { + const design = TemplateDesign.Standard; + const value = TemplateMapView({ state: new TemplateViewState({ mapDesignType: design }) }).props.mapDesignValue; + assert.equal(value, design.id); + assert.notEqual(value, design.getValue()); +}); diff --git a/reactnative-for-template/test/TemplateMapViewHolder.test.mjs b/reactnative-for-template/test/TemplateMapViewHolder.test.mjs new file mode 100644 index 0000000..5d55960 --- /dev/null +++ b/reactnative-for-template/test/TemplateMapViewHolder.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import { after, test } from 'node:test'; +import { createGeoPoint } from '@mapconductor/js-sdk-core'; +import { buildPackage } from './helpers/buildPackage.mjs'; + +// TemplateMapViewHolder は投影を JS に持たない(投影とタップの当たり判定はネイティブ側にあり、 +// JS に二重に書くと食い違うため)。その約束と、ref の差し替えへの追従を確かめる。 +const { mod, cleanup } = await buildPackage('holder-tests'); +after(cleanup); +const { TemplateMapViewHolder, ReactNativeMapViewHolder } = mod; + +test('the holder exposes the current native view and no JS map', () => { + const ref = { current: null }; + const holder = new TemplateMapViewHolder(ref); + assert.ok(holder instanceof ReactNativeMapViewHolder); + assert.equal(holder.map, null); + assert.equal(holder.mapView, null); + const view = { nodeHandle: 1 }; + ref.current = view; + assert.equal(holder.mapView, view, 'follows the ref once the native view mounts'); +}); + +test('projection stays on the native side: both directions return null', () => { + const holder = new TemplateMapViewHolder({ current: { nodeHandle: 1 } }); + assert.equal(holder.toScreenOffset(createGeoPoint({ latitude: 35, longitude: 139 })), null); + assert.equal(holder.fromScreenOffsetSync({ x: 10, y: 20 }), null); +}); diff --git a/reactnative-for-template/test/TemplateViewController.test.mjs b/reactnative-for-template/test/TemplateViewController.test.mjs new file mode 100644 index 0000000..5b1afce --- /dev/null +++ b/reactnative-for-template/test/TemplateViewController.test.mjs @@ -0,0 +1,234 @@ +import assert from 'node:assert/strict'; +import { after, beforeEach, test } from 'node:test'; +import { + createCircleState, + createGeoPoint, + createGeoRectBounds, + createMapCameraPosition, + createMarkerState, + createPolygonState, + createPolylineState, +} from '@mapconductor/js-sdk-core'; +import { buildPackage } from './helpers/buildPackage.mjs'; + +// TemplateViewController をネイティブビューの代わりに react-native の代役(UIManager が +// コマンドを記録するだけ)へ繋いで動かす。地図の読み込み前に積んだオーバーレイが +// 読み込み時に流れること、ネイティブからのイベントが該当 state に戻ること、 +// マーカーの一括転送が ACK 単位で進むことを確かめる。 +const { mod, rn, cleanup } = await buildPackage('controller-tests'); +after(cleanup); +const { TemplateViewController } = mod; + +const NODE = 42; +const camera = createMapCameraPosition({ position: createGeoPoint({ latitude: 35.68, longitude: 139.76 }), zoom: 10 }); +const tick = () => new Promise((resolve) => setImmediate(resolve)); +const sent = () => rn.commands.map((c) => c.command); + +function newController({ mounted = true } = {}) { + const ref = { current: mounted ? { nodeHandle: NODE } : null }; + return { controller: new TemplateViewController(ref, camera), ref }; +} + +beforeEach(() => { + rn.commands.length = 0; +}); + +test('camera commands are dispatched to the native view node and remembered', async () => { + const { controller } = newController(); + const next = createMapCameraPosition({ position: createGeoPoint({ latitude: 34.7, longitude: 135.5 }), zoom: 14 }); + assert.equal(await controller.moveCamera(next), true); + assert.equal(await controller.animateCamera(camera, 500), true); + assert.deepEqual(rn.commands, [ + { node: NODE, command: 'moveCamera', args: [next] }, + { node: NODE, command: 'animateCamera', args: [camera, 500] }, + ]); + assert.equal(controller.getCameraPosition(), camera); + controller.destroy(); +}); + +test('nothing is dispatched while the native view is not mounted', async () => { + const { controller } = newController({ mounted: false }); + await controller.moveCamera(camera); + controller.applyUISettings({ scrollGesture: true, zoomGesture: true, rotateGesture: false, tiltGesture: false }); + assert.deepEqual(rn.commands, []); + controller.destroy(); +}); + +test('fitBounds sends the corners, and refuses empty bounds', async () => { + const { controller } = newController(); + assert.equal(await controller.fitBounds(createGeoRectBounds(), 10), false); + assert.deepEqual(rn.commands, []); + const southWest = createGeoPoint({ latitude: 35, longitude: 139 }); + const northEast = createGeoPoint({ latitude: 36, longitude: 140 }); + const bounds = createGeoRectBounds({ southWest, northEast }); + assert.equal(await controller.fitBounds(bounds, 24), true); + assert.equal(rn.commands.length, 1); + const [{ command, args }] = rn.commands; + assert.equal(command, 'fitBounds'); + assert.equal(args[1], 24); + assert.equal(args[0].southWest.latitude, 35); + assert.equal(args[0].northEast.longitude, 140); + controller.destroy(); +}); + +test('applyUISettings forwards only the four gesture flags', () => { + const { controller } = newController(); + controller.applyUISettings({ scrollGesture: false, zoomGesture: true, rotateGesture: false, tiltGesture: true, extra: 'ignored' }); + assert.deepEqual(rn.commands, [{ + node: NODE, + command: 'applyUISettings', + args: [{ scrollGesture: false, zoomGesture: true, rotateGesture: false, tiltGesture: true }], + }]); + controller.destroy(); +}); + +test('shapes composed before the map loads are queued and flushed on load', async () => { + const { controller } = newController(); + const circle = createCircleState({ id: 'c1', center: createGeoPoint({ latitude: 35, longitude: 139 }), radiusMeters: 100, strokeColor: '#112233' }); + const polyline = createPolylineState({ id: 'l1', points: [createGeoPoint({ latitude: 35, longitude: 139 }), createGeoPoint({ latitude: 36, longitude: 140 })] }); + const polygon = createPolygonState({ id: 'p1', points: [createGeoPoint({ latitude: 35, longitude: 139 }), createGeoPoint({ latitude: 36, longitude: 139 }), createGeoPoint({ latitude: 36, longitude: 140 })], fillColor: '#00ff0080' }); + await controller.compositionCircles([circle]); + await controller.compositionPolylines([polyline]); + await controller.compositionPolygons([polygon]); + assert.deepEqual(rn.commands, [], 'nothing reaches native before onNativeMapLoaded'); + + let initialized = 0; + controller.setMapInitializedListener(() => initialized++); + controller.onNativeMapLoaded(); + // 読み込み時の流し順は circle → groundImage → polygon → polyline → raster で固定。 + assert.deepEqual(sent(), ['compositionCircles', 'compositionPolygons', 'compositionPolylines']); + assert.equal(initialized, 1); + + const [circles] = rn.commands[0].args; + assert.equal(circles[0].id, 'c1'); + assert.equal(circles[0].radiusMeters, 100); + assert.deepEqual(circles[0].strokeColor, { color: '#112233' }); + const [polygons] = rn.commands[1].args; + assert.deepEqual(polygons[0].fillColor, { color: '#00ff0080' }); + assert.equal(polygons[0].points.length, 3); + + // 読み込み後は即時に送る。 + rn.commands.length = 0; + await controller.updateCircle(circle.copy({ radiusMeters: 250 })); + assert.deepEqual(sent(), ['updateCircle']); + assert.equal(rn.commands[0].args[0].radiusMeters, 250); + + // 読み込み済みなら、あとから付けた初期化リスナーにも即時に知らせる。 + controller.setMapInitializedListener(() => initialized++); + assert.equal(initialized, 2); + controller.destroy(); +}); + +test('an update before load replaces the queued composition instead of being sent', async () => { + const { controller } = newController(); + const center = createGeoPoint({ latitude: 35, longitude: 139 }); + await controller.compositionCircles([createCircleState({ id: 'a', center, radiusMeters: 10 })]); + await controller.updateCircle(createCircleState({ id: 'b', center, radiusMeters: 20 })); + assert.deepEqual(rn.commands, []); + controller.onNativeMapLoaded(); + const [payload] = rn.commands.find((c) => c.command === 'compositionCircles').args; + assert.deepEqual(payload.map((c) => c.id), ['a', 'b']); + controller.destroy(); +}); + +test('native click and drag events come back to the matching state and listener', async () => { + const { controller } = newController(); + controller.onNativeMapLoaded(); + const calls = []; + const marker = createMarkerState({ + id: 'm1', + position: createGeoPoint({ latitude: 35, longitude: 139 }), + draggable: true, + onClick: (s) => calls.push(['state-click', s.id]), + onDragEnd: (s) => calls.push(['state-drag-end', s.position.latitude]), + }); + // 読み込み済みなら一括転送がすぐ始まり、ネイティブの ACK を待つ。ACK を返して完了させる。 + const composed = controller.compositionMarkers([marker]); + const begin = rn.commands.find((c) => c.command === 'beginMarkerComposition'); + controller.onNativeMarkerCompositionBatchProcessed(begin.args[0], 0); + await composed; + assert.ok(sent().includes('commitMarkerComposition')); + controller.setOnMarkerClickListener((s) => calls.push(['listener-click', s.id])); + controller.onNativeMarkerClick('m1'); + controller.onNativeMarkerClick('unknown'); + controller.onNativeMarkerDragEnd('m1', createGeoPoint({ latitude: 36.5, longitude: 139 })); + assert.deepEqual(calls, [['state-click', 'm1'], ['listener-click', 'm1'], ['state-drag-end', 36.5]]); + assert.equal(marker.position.latitude, 36.5, 'drag writes the new position back to the state'); + + const polygonClicks = []; + const polygon = createPolygonState({ + id: 'p1', + points: [createGeoPoint({ latitude: 0, longitude: 0 }), createGeoPoint({ latitude: 1, longitude: 0 }), createGeoPoint({ latitude: 1, longitude: 1 })], + onClick: (e) => polygonClicks.push(e), + }); + await controller.compositionPolygons([polygon]); + controller.onNativePolygonClick('p1', createGeoPoint({ latitude: 0.5, longitude: 0.2 })); + assert.equal(polygonClicks.length, 1); + assert.equal(polygonClicks[0].state, polygon); + assert.equal(polygonClicks[0].clicked.latitude, 0.5); + controller.destroy(); +}); + +test('native camera events update the remembered camera and notify listeners', () => { + const { controller } = newController(); + const seen = []; + controller.setCameraMoveEndListener((c) => seen.push(c.zoom)); + const moved = createMapCameraPosition({ position: createGeoPoint({ latitude: 1, longitude: 2 }), zoom: 5 }); + controller.onNativeCameraMoveEnd(moved); + assert.equal(controller.getCameraPosition(), moved); + assert.deepEqual(seen, [5]); + controller.destroy(); +}); + +test('marker composition is streamed in acknowledged batches; updates wait for the commit', async () => { + const { controller } = newController(); + const markers = Array.from({ length: 3 }, (_, i) => + createMarkerState({ id: `m${i}`, position: createGeoPoint({ latitude: i, longitude: i * 2 }), zIndex: i === 0 ? null : i }) + ); + await controller.compositionMarkers(markers); + assert.deepEqual(rn.commands, [], 'markers wait for the map to load'); + + controller.onNativeMapLoaded(); + assert.deepEqual(sent(), ['beginMarkerComposition', 'appendMarkerComposition']); + const generation = rn.commands[0].args[0]; + const [, sequence, payload] = rn.commands[1].args; + assert.equal(sequence, 0); + assert.deepEqual(payload.ids, ['m0', 'm1', 'm2']); + assert.deepEqual(payload.positions, [0, 0, 0, 1, 2, 0, 2, 4, 0]); + assert.deepEqual(payload.zIndex, [0, 1, 2], 'a null zIndex goes over as 0'); + + // コミット前の個別更新は保留され、コミット後にまとめて送られる。 + await controller.updateMarker(markers[1]); + assert.ok(!sent().includes('updateMarker')); + + // 世代違い・連番違いの ACK は無視される。 + controller.onNativeMarkerCompositionBatchProcessed(generation + 1, 0); + controller.onNativeMarkerCompositionBatchProcessed(generation, 1); + await tick(); + assert.ok(!sent().includes('commitMarkerComposition')); + + controller.onNativeMarkerCompositionBatchProcessed(generation, 0); + await tick(); + assert.deepEqual(sent().slice(2), ['commitMarkerComposition', 'updateMarker']); + assert.deepEqual(rn.commands[2].args, [generation]); + assert.equal(rn.commands[3].args[0].id, 'm1'); + + // コミット後は即時に送る。 + rn.commands.length = 0; + await controller.updateMarker(markers[2]); + assert.deepEqual(sent(), ['updateMarker']); + controller.destroy(); +}); + +test('clearOverlays drops tracked overlays and tells native to clear', async () => { + const { controller } = newController(); + controller.onNativeMapLoaded(); + const circle = createCircleState({ id: 'c', center: createGeoPoint({ latitude: 0, longitude: 0 }), radiusMeters: 1 }); + await controller.compositionCircles([circle]); + assert.equal(controller.hasCircle(circle), true); + rn.commands.length = 0; + await controller.clearOverlays(); + assert.deepEqual(sent(), ['clearOverlays']); + assert.equal(controller.hasCircle(circle), false); + controller.destroy(); +}); diff --git a/reactnative-for-template/test/helpers/buildPackage.mjs b/reactnative-for-template/test/helpers/buildPackage.mjs new file mode 100644 index 0000000..5553376 --- /dev/null +++ b/reactnative-for-template/test/helpers/buildPackage.mjs @@ -0,0 +1,44 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { build } from 'tsup'; + +// src/index.ts を node で読める形に束ねる。 +// - react-native は test/helpers/react-native-stub.mjs に差し替える(外部参照のまま残すので、 +// テストからも同じモジュール実体を import してコマンドを覗ける) +// - js-sdk-react は `react-native` 条件で解決して束ねる(RN 用の共有ブリッジを使うため) +// - js-sdk-core / react / react-for-*/state は普通に node から解決する +const stubPath = fileURLToPath(new URL('./react-native-stub.mjs', import.meta.url)); + +export async function buildPackage(prefix) { + const buildRoot = new URL('../../build/', import.meta.url); + mkdirSync(buildRoot, { recursive: true }); + const outDir = mkdtempSync(fileURLToPath(new URL(`${prefix}-`, buildRoot))); + const entry = resolve(outDir, 'entry.ts'); + writeFileSync( + entry, + [ + `export * from ${JSON.stringify(fileURLToPath(new URL('../../src/index.ts', import.meta.url)))};`, + `export { NativeMapViewHost, ReactNativeBridgeMapViewController, ReactNativeMapViewHolder } from '@mapconductor/js-sdk-react/internal';`, + ].join('\n') + ); + await build({ + entry: { index: entry }, + outDir, format: ['esm'], platform: 'node', splitting: false, + dts: false, sourcemap: false, config: false, silent: true, + noExternal: ['react-native', /^@mapconductor\/js-sdk-react/], + esbuildOptions(options) { + options.conditions = ['react-native']; + options.jsx = 'automatic'; + }, + esbuildPlugins: [{ + name: 'react-native-stub', + setup(b) { + b.onResolve({ filter: /^react-native$/ }, () => ({ path: stubPath, external: true })); + }, + }], + }); + const mod = await import(pathToFileURL(resolve(outDir, 'index.mjs'))); + const rn = await import(pathToFileURL(stubPath)); + return { mod, rn, outDir, rnStubPath: stubPath, cleanup: () => rmSync(outDir, { recursive: true, force: true }) }; +} diff --git a/reactnative-for-template/test/helpers/react-native-stub.mjs b/reactnative-for-template/test/helpers/react-native-stub.mjs new file mode 100644 index 0000000..80537a5 --- /dev/null +++ b/reactnative-for-template/test/helpers/react-native-stub.mjs @@ -0,0 +1,29 @@ +// react-native の代役。node では本物(Flow 構文)を読めないので、このパッケージの +// JS が触る API だけを最小限に置く。ネイティブへ送ったコマンドは `commands` に残る。 +export const commands = []; + +export function requireNativeComponent(name) { + return { nativeViewName: name }; +} + +export function findNodeHandle(ref) { + return ref?.nodeHandle ?? null; +} + +export const UIManager = { + dispatchViewManagerCommand(node, command, args) { + commands.push({ node, command, args }); + }, +}; + +// 本物は数値 (ARGB) を返す。ここでは何が渡ったかが見えるよう包むだけにする。 +export function processColor(color) { + return color == null ? null : { color }; +} + +export const StyleSheet = { create: (styles) => styles, absoluteFill: {}, absoluteFillObject: {} }; +export const View = 'View'; +export const Text = 'Text'; +export const Linking = { openURL: async () => {} }; +export const NativeModules = {}; +export const Platform = { OS: 'ios', select: (o) => o.ios ?? o.default };