Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/location-simulation.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,12 @@ npm run gpx:replay -- --gpx assets/gpx/SampleTohokuShinkansen.gpx --serial <seri
| `--start` | GPX 先頭からのスキップ秒数 |
| `--provider` | テストプロバイダ名。既定 `gps,network,fused` |

再生を始める前に、GPX の終点の座標でステージングの StationAPI
(`https://gql-stg.trainlcd.app/`) に 1 回だけ問い合わせ、いちばん近い駅を行先として
表示する。行先は進捗行にも `京王八王子行` のように出る。環境変数 `GQL_API_URL` が
あればそちらへ問い合わせる。行先を引けなかったときは、テストプロバイダを有効にする前に
異常終了する。

Xcode と違い精度を指定できるので、`--accuracy 100,300` のように渡せば
`getSmoothingAlpha` の低精度分岐も実機で観測できる。一方で `coords.speed` を
渡す手段が無いため、DEV OVERLAY の `CURRENT SPEED` は常に 0km/h を表示する。
Expand Down
44 changes: 43 additions & 1 deletion scripts/replay-location-gpx.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { promisify } from 'node:util';

const execFileAsync = promisify(execFile);

// 開発用のスクリプトなので、既定ではステージングの StationAPI に問い合わせる。
// GQL_API_URL があればそちらを使う。
const DEFAULT_API_URL = 'https://gql-stg.trainlcd.app/';

const argv = process.argv.slice(2);
const opt = (name, fallback) => {
const i = argv.indexOf(`--${name}`);
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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();
Expand Down
128 changes: 128 additions & 0 deletions scripts/replay-location-gpx.test.mjs
Original file line number Diff line number Diff line change
@@ -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,
[
'<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">',
'<wpt lat="35.0" lon="139.0"><time>2026-01-01T00:00:00Z</time></wpt>',
'<wpt lat="35.6574160" lon="139.3438510"><time>2026-01-01T00:00:01Z</time></wpt>',
'</gpx>',
].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'), '');
}
});
Loading