diff --git a/docs/location-simulation.md b/docs/location-simulation.md index f08d1fadd0..c42ac010e7 100644 --- a/docs/location-simulation.md +++ b/docs/location-simulation.md @@ -329,6 +329,12 @@ npm run gpx:replay -- --gpx assets/gpx/SampleTohokuShinkansen.gpx --serial { const i = argv.indexOf(`--${name}`); @@ -170,6 +174,35 @@ if (startSec * 1000 > totalMs) { ); } +// --- 行先 ----------------------------------------------------------------- +// 座標だけでは再生中にどこへ向かっているのか分からないので、終点の座標に +// いちばん近い駅を行先として表示する。生成した GPX の終点は駅の座標そのもの。 +// 行先が分からないまま再生しても検証に使えないので、引けなければ再生を始めない。 +async function fetchDestination() { + const last = points[points.length - 1]; + const res = await fetch(process.env.GQL_API_URL ?? DEFAULT_API_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: `query ReplayGpxDestination($latitude: Float!, $longitude: Float!) { + stationsNearby(latitude: $latitude, longitude: $longitude, limit: 1) { + name + } + }`, + variables: { latitude: last.lat, longitude: last.lon }, + }), + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = await res.json(); + if (json.errors) { + throw new Error(json.errors.map((e) => e.message).join(', ')); + } + const name = json.data?.stationsNearby?.[0]?.name; + if (!name) throw new Error('終点の座標の近くに駅が見つかりません'); + return name; +} + // --- テストプロバイダの準備 ---------------------------------------------- const loc = (...rest) => adb('shell', 'cmd', 'location', 'providers', ...rest); @@ -328,12 +361,21 @@ async function play(skipSec) { await push(p, acc); const elapsed = ((p.offset - originOffset) / 1000).toFixed(0); + // 開始時の行先表示はログに流されて見えなくなるので、進捗行にも載せる。 process.stdout.write( - `\r[${i + 1}/${points.length}] +${elapsed}s ${p.lat.toFixed(5)}, ${p.lon.toFixed(5)} acc=${acc}m 再登録${reregistrations}回 ` + `\r[${i + 1}/${points.length}] ${destination}行 +${elapsed}s ${p.lat.toFixed(5)}, ${p.lon.toFixed(5)} acc=${acc}m 再登録${reregistrations}回 ` ); } } +// テストプロバイダを有効にする前に引く。問い合わせを待つあいだ端末の測位が +// 止まったままにならず、失敗して終了するときに後始末も要らない。 +const destination = await fetchDestination().catch((e) => { + console.error(`行先を取得できませんでした: ${e.message}`); + process.exit(1); +}); +console.log(`行先: ${destination}`); + let exitCode = 0; try { await setup(); diff --git a/scripts/replay-location-gpx.test.mjs b/scripts/replay-location-gpx.test.mjs new file mode 100644 index 0000000000..273781c6a4 --- /dev/null +++ b/scripts/replay-location-gpx.test.mjs @@ -0,0 +1,128 @@ +// replay-location-gpx.mjs の行先の取得と表示に対する回帰テスト。 +// adb と StationAPI を偽物に差し替えて、端末もネットワークも無しで最後まで再生させる: +// node --test scripts/replay-location-gpx.test.mjs + +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { + chmodSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, before, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const script = fileURLToPath( + new URL('./replay-location-gpx.mjs', import.meta.url) +); +const dir = mkdtempSync(join(tmpdir(), 'replay-gpx-test-')); + +// devices には 1 台つながっていると答え、それ以外の呼び出しは何もせず成功する。 +writeFileSync( + join(dir, 'adb'), + [ + '#!/bin/sh', + `echo "$@" >> '${join(dir, 'adb.log')}'`, + 'if [ "$1" = "devices" ]; then', + ' printf "List of devices attached\\nemulator-5554\\tdevice\\n"', + 'fi', + '', + ].join('\n') +); +chmodSync(join(dir, 'adb'), 0o755); + +const adbLog = join(dir, 'adb.log'); +const gpxPath = join(dir, 'track.gpx'); +writeFileSync( + gpxPath, + [ + '', + '', + '', + '', + ].join('\n') +); + +let requests = []; +let respond = () => ({ status: 200, body: {} }); +const server = createServer((req, res) => { + let raw = ''; + req.on('data', (chunk) => { + raw += chunk; + }); + req.on('end', () => { + requests.push(JSON.parse(raw)); + const { status, body } = respond(); + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); + }); +}); + +before(() => new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))); +after(() => { + server.close(); + rmSync(dir, { recursive: true, force: true }); +}); + +// サーバと同じプロセスで待つので、execFileSync ではなく非同期で起動する。 +const replay = async () => { + requests = []; + const { stdout, stderr } = await execFileAsync( + process.execPath, + [script, '--gpx', gpxPath, '--speed', '1000'], + { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${dir}:${process.env.PATH}`, + GQL_API_URL: `http://127.0.0.1:${server.address().port}/`, + }, + } + ); + return { stdout, stderr }; +}; + +test('終点の座標で 1 回だけ問い合わせ、行先を開始時と進捗行に表示する', async () => { + respond = () => ({ + status: 200, + body: { data: { stationsNearby: [{ name: '京王八王子' }] } }, + }); + const { stdout } = await replay(); + assert.equal(requests.length, 1); + assert.deepEqual(requests[0].variables, { + latitude: 35.657416, + longitude: 139.343851, + }); + assert.match(stdout, /^行先: 京王八王子$/m); + assert.match(stdout, /\[2\/2\] 京王八王子行 /); +}); + +test('行先を引けなければ、テストプロバイダに触れずに異常終了する', async () => { + for (const [response, message] of [ + [{ status: 500, body: {} }, 'HTTP 500'], + [ + { status: 200, body: { data: { stationsNearby: [] } } }, + '終点の座標の近くに駅が見つかりません', + ], + [{ status: 200, body: { errors: [{ message: 'boom' }] } }, 'boom'], + ]) { + respond = () => response; + writeFileSync(adbLog, ''); + await assert.rejects(replay(), (error) => { + assert.equal(error.code, 1); + assert.match( + error.stderr, + new RegExp(`行先を取得できませんでした: ${message}`) + ); + return true; + }); + assert.equal(readFileSync(adbLog, 'utf8'), ''); + } +});