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 @@ -87,6 +87,12 @@ npm run gpx:generate -- \
ごとの速度を実ダイヤで較正した到着時間推定から出る。アプリのオートモード
(`useSimulationMode`) も同じ見込みで走るので、GPX・オートモード・ETA の時間がそろう。

オートモードがこの見込みで走るのは、Worker の `/config/remote` が
`auto_mode_estimated_enabled: true` を配信しているときだけ。`false`・未配信・取得失敗の
ときは、`model` を渡さない従来の値 (`Legacy`) で区間の最高速度を出して走る。値は Worker の
KV `config:remote` に入れる。`true` でも StationAPI が `model` を知らずにエラーを返したときは、
`Legacy` に戻して走る。

- 停車駅から次の停車駅までの時間は「次の停車駅の到着 − この駅の出発」。推定では停まるが
GPX では通過する駅 (平日運転の駅を休日に走る、`--skip` など) は、その駅の停車時間を
走行から引く。
Expand Down
11 changes: 11 additions & 0 deletions src/hooks/useAutoModeEstimatedEnabled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useSyncExternalStore } from 'react';
import {
isAutoModeEstimatedEnabled,
subscribeRemoteConfig,
} from '~/lib/remoteConfig';

// オートモードの走らせ方(auto_mode_estimated_enabled)のリアクティブ版。
// setupRemoteConfig は起動時に非同期で完了するため、同期読みだけではコールドスタート時の
// キャッシュ更新に追従できない。useAIAgentFeatureEnabled と同じくキャッシュ更新を購読する。
export const useAutoModeEstimatedEnabled = (): boolean =>
useSyncExternalStore(subscribeRemoteConfig, isAutoModeEstimatedEnabled);
74 changes: 74 additions & 0 deletions src/hooks/useSimulationMode.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
StopCondition,
} from '~/@types/graphql';
import { YAMANOTE_LINE_ID } from '~/constants';
import { useAutoModeEstimatedEnabled } from '~/hooks/useAutoModeEstimatedEnabled';
import * as useCurrentTrainTypeModule from '~/hooks/useCurrentTrainType';
import { useGraphQLQuery } from '~/hooks/useGraphQLQuery';
import { useLoopLine } from '~/hooks/useLoopLine';
Expand Down Expand Up @@ -61,6 +62,12 @@ jest.mock('~/store/atoms/location', () => ({
locationAtom: { toString: () => 'locationAtom' },
}));

// オートモードの走らせ方のフラグ(auto_mode_estimated_enabled)。各テストの既定は
// 到着時間推定の見込みで走る側(true)で、従来の値で走る側は個別に false にする
jest.mock('~/hooks/useAutoModeEstimatedEnabled', () => ({
useAutoModeEstimatedEnabled: jest.fn(() => true),
}));

jest.mock('~/hooks/useLoopLine', () => ({
useLoopLine: jest.fn(() => ({
isLoopLine: false,
Expand Down Expand Up @@ -258,6 +265,7 @@ describe('useSimulationMode', () => {
// 各テストは非ループ線を前提とする。ループ線テストで上書きした実装が
// 後続テストへ漏れないよう毎回明示的にリセットする。
(useLoopLine as jest.Mock).mockReturnValue({ isLoopLine: false });
(useAutoModeEstimatedEnabled as jest.Mock).mockReturnValue(true);

jest
.spyOn(useCurrentTrainTypeModule, 'useCurrentTrainType')
Expand Down Expand Up @@ -1805,6 +1813,72 @@ describe('useSimulationMode', () => {
);
});

it('auto_mode_estimated_enabled が false なら、model: Estimated を引かずに従来の値で走る', () => {
(useAutoModeEstimatedEnabled as jest.Mock).mockReturnValue(false);
const stations = timedStations();
setupAtomMocks(
{ station: stations[0], stations, selectedDirection: 'INBOUND' },
{ autoModeEnabled: true }
);
// 従来の問い合わせは見込みのフィールドを選ばないので、区間の最高速度だけが返る
mockTrainRoute(stations, { accel: 0.7, decel: 0.9 });
const generateSpy = jest.spyOn(
trainSpeedModule,
'generateTrainSpeedProfile'
);

renderHook(() => useSimulationMode(), {
wrapper: ({ children }) => <Provider>{children}</Provider>,
});

expect(useGraphQLQuery).toHaveBeenCalledWith(
GET_ESTIMATED_TRAIN_ROUTE,
expect.objectContaining({ skip: true })
);
expect(useGraphQLQuery).toHaveBeenCalledWith(
GET_TRAIN_ROUTE,
expect.objectContaining({ skip: false })
);
// 見込みではなく区間の最高速度で速度プロファイルを作る
expect(generateSpy).toHaveBeenCalledWith(
expect.objectContaining({ maxSpeed: 30 })
);
});

it('auto_mode_estimated_enabled が false なら、乗換経路も従来の問い合わせで引く', () => {
(useAutoModeEstimatedEnabled as jest.Mock).mockReturnValue(false);
const withTrainType = (station: Station, groupId: number): Station => ({
...station,
trainType: { groupId } as Station['trainType'],
});
const stations = [
withTrainType(mockStation(9930138, 9930138, 35.76, 139.63, 99301), 7),
withTrainType(mockStation(9930128, 1130208, 35.69, 139.7, 99301), 7),
withTrainType(mockStation(1132104, 1130208, 35.69, 139.7, 11321), 170),
withTrainType(
mockStation(1132103, 1130205, 35.658, 139.701, 11321),
170
),
];
setupAtomMocks(
{ station: stations[0], stations, selectedDirection: 'INBOUND' },
{ autoModeEnabled: true }
);

renderHook(() => useSimulationMode(), {
wrapper: ({ children }) => <Provider>{children}</Provider>,
});

expect(useGraphQLQuery).toHaveBeenCalledWith(
GET_ESTIMATED_CONNECTED_TRAIN_ROUTE,
expect.objectContaining({ skip: true })
);
expect(useGraphQLQuery).toHaveBeenCalledWith(
GET_CONNECTED_TRAIN_ROUTE,
expect.objectContaining({ skip: false })
);
});

it('model: Estimated の問い合わせがエラーなら、従来の問い合わせの値で走る', () => {
const stations = timedStations();
setupAtomMocks(
Expand Down
36 changes: 24 additions & 12 deletions src/hooks/useSimulationMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
} from '../store/atoms/station';
import dropEitherJunctionStation from '../utils/dropJunctionStation';
import getIsPass from '../utils/isPass';
import { useAutoModeEstimatedEnabled } from './useAutoModeEstimatedEnabled';
import { useCurrentTrainType } from './useCurrentTrainType';
import { useGraphQLQuery } from './useGraphQLQuery';
import { useLoopLine } from './useLoopLine';
Expand Down Expand Up @@ -180,9 +181,10 @@ export const useSimulationMode = (): void => {
toStationId != null &&
fromStationId !== toStationId;

// 区間の値は到着時間推定のモデル(model: Estimated)で取り、ETA と同じ時間で走らせる。
// model を知らない StationAPI ではクエリ全体がエラーになるので、そのときだけ
// 従来の問い合わせ(Legacy)に戻す
// auto_mode_estimated_enabled が true なら、区間の値を到着時間推定のモデル
// (model: Estimated)で取り、ETA と同じ時間で走らせる。model を知らない StationAPI では
// クエリ全体がエラーになるので、そのときだけ従来の問い合わせ(Legacy)に戻す。
// false・未配信なら最初から Legacy で引く
const singleVariables = {
fromStationId: fromStationId ?? 0,
toStationId: toStationId ?? 0,
Expand All @@ -195,42 +197,52 @@ export const useSimulationMode = (): void => {
};
const skipSingle = !canFetchTrainRoute || !!routeLegs;
const skipConnected = !canFetchTrainRoute || !routeLegs;
const estimatedEnabled = useAutoModeEstimatedEnabled();

const { data: estimatedSingleData, error: estimatedSingleError } =
useGraphQLQuery<TrainRouteData, GetTrainRouteQueryVariables>(
GET_ESTIMATED_TRAIN_ROUTE,
{ variables: singleVariables, skip: skipSingle }
{ variables: singleVariables, skip: skipSingle || !estimatedEnabled }
);
const { data: legacySingleData, error: legacySingleError } = useGraphQLQuery<
TrainRouteData,
GetTrainRouteQueryVariables
>(GET_TRAIN_ROUTE, {
variables: singleVariables,
skip: skipSingle || !estimatedSingleError,
skip: skipSingle || (estimatedEnabled && !estimatedSingleError),
});
const { data: estimatedConnectedData, error: estimatedConnectedError } =
useGraphQLQuery<TrainRouteData, ConnectedTrainRouteVariables>(
GET_ESTIMATED_CONNECTED_TRAIN_ROUTE,
{ variables: connectedVariables, skip: skipConnected }
{
variables: connectedVariables,
skip: skipConnected || !estimatedEnabled,
}
);
const { data: legacyConnectedData, error: legacyConnectedError } =
useGraphQLQuery<TrainRouteData, ConnectedTrainRouteVariables>(
GET_CONNECTED_TRAIN_ROUTE,
{
variables: connectedVariables,
skip: skipConnected || !estimatedConnectedError,
skip: skipConnected || (estimatedEnabled && !estimatedConnectedError),
}
);

const singleTrainRouteData: TrainRouteData | undefined = estimatedSingleError
// skip していても同じ queryKey のキャッシュがあれば data が返るので、どちらを使うかは
// フラグとエラーで決める
const useLegacySingle = !estimatedEnabled || !!estimatedSingleError;
const useLegacyConnected = !estimatedEnabled || !!estimatedConnectedError;
const singleTrainRouteData: TrainRouteData | undefined = useLegacySingle
? legacySingleData
: estimatedSingleData;
const connectedTrainRouteData = estimatedConnectedError
const connectedTrainRouteData = useLegacyConnected
? legacyConnectedData
: estimatedConnectedData;
const estimatedTrainRouteError = routeLegs
? estimatedConnectedError
: estimatedSingleError;
const estimatedTrainRouteError = !estimatedEnabled
? undefined
: routeLegs
? estimatedConnectedError
: estimatedSingleError;
const legacyTrainRouteError = routeLegs
? legacyConnectedError
: legacySingleError;
Expand Down
33 changes: 33 additions & 0 deletions src/lib/remoteConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getVoicevoxTTSManifestUrl,
getVoicevoxTTSStyleId,
isAIAgentFeatureEnabled,
isAutoModeEstimatedEnabled,
isEtaAssistEnabled,
isRemoteTTSEnabled,
isTTSFeatureEnabled,
Expand Down Expand Up @@ -596,6 +597,38 @@ describe('isAIAgentFeatureEnabled(サーバー側キルスイッチ)', () =>
});
});

describe('isAutoModeEstimatedEnabled(オートモードの走らせ方)', () => {
it('取得前は従来の値(false)', () => {
expect(isAutoModeEstimatedEnabled()).toBe(false);
});

it('true が配信されたら到着時間推定の見込みで走る', async () => {
mockRemoteConfig({ auto_mode_estimated_enabled: true });
await setupRemoteConfig();
expect(isAutoModeEstimatedEnabled()).toBe(true);
});

it('false が配信されたら従来の値で走る', async () => {
mockRemoteConfig({ auto_mode_estimated_enabled: false });
await setupRemoteConfig();
expect(isAutoModeEstimatedEnabled()).toBe(false);
});

it('真偽値でなければ従来の値に倒す', async () => {
mockRemoteConfig({ auto_mode_estimated_enabled: 'true' });
await setupRemoteConfig();
expect(isAutoModeEstimatedEnabled()).toBe(false);
});

it('取得に失敗したら従来の値に倒す', async () => {
mockRemoteConfig({}, false);
await expect(setupRemoteConfig()).rejects.toThrow(
'remote config fetch failed: 503'
);
expect(isAutoModeEstimatedEnabled()).toBe(false);
});
});

describe('subscribeRemoteConfig(キャッシュ更新の購読)', () => {
it('setupRemoteConfig 完了時にリスナーへ通知される', async () => {
const listener = jest.fn();
Expand Down
23 changes: 23 additions & 0 deletions src/lib/remoteConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ export const REMOTE_CONFIG_KEYS = {
// AIエージェント(行き先相談)機能の有効/無効。障害・コスト超過時にサーバー側から
// エントリポイントごと機能を止められるようにするキルスイッチ。
AI_AGENT_ENABLED: 'ai_agent_enabled',
// オートモードを到着時間推定の見込み(trainRoute の model: Estimated)で走らせるかどうか。
// true なら ETA と同じ時間で駅に着いて停まり、false・未配信なら従来の値(Legacy)で走る。
AUTO_MODE_ESTIMATED_ENABLED: 'auto_mode_estimated_enabled',
} as const;

type RemoteConfigResponse = {
Expand All @@ -75,6 +78,7 @@ type RemoteConfigResponse = {
vits_tts_enabled_ios?: boolean;
vits_tts_manifest_url_ios?: string;
ai_agent_enabled?: boolean;
auto_mode_estimated_enabled?: boolean;
};

// ETAフォールバック機能自体のフォールバック既定値。安全側に倒し、既定では無効とする。
Expand All @@ -99,6 +103,10 @@ const REMOTE_TTS_ENABLED_ANDROID_FALLBACK = false;
// 未配信・取得失敗時は安全側に倒して無効とする。
const AI_AGENT_ENABLED_FALLBACK = false;

// オートモードの走らせ方のフォールバック既定値。未配信・取得失敗時は従来の値(Legacy)で
// 走らせ、配信で true にしたときだけ到着時間推定の見込みに切り替える。
const AUTO_MODE_ESTIMATED_ENABLED_FALLBACK = false;

// VOICEVOX フォールバックのフォールバック既定値。約 180MB の辞書・音声モデルを
// 端末へ取得する機能のため、Remote Config で明示的に有効化されたときだけ動かす。
const VOICEVOX_TTS_ENABLED_IOS_FALLBACK = false;
Expand Down Expand Up @@ -149,6 +157,7 @@ let cachedTTSEnabledAndroid: boolean | null = null;
let cachedRemoteTTSEnabledIOS: boolean | null = null;
let cachedRemoteTTSEnabledAndroid: boolean | null = null;
let cachedAIAgentEnabled: boolean | null = null;
let cachedAutoModeEstimatedEnabled: boolean | null = null;
let cachedVoicevoxTTSEnabledIOS: boolean | null = null;
let cachedVoicevoxTTSManifestUrlIOS: string | null = null;
let cachedVoicevoxTTSStyleIdIOS: number | null = null;
Expand Down Expand Up @@ -185,6 +194,7 @@ export const resetRemoteConfigCache = (): void => {
cachedRemoteTTSEnabledIOS = null;
cachedRemoteTTSEnabledAndroid = null;
cachedAIAgentEnabled = null;
cachedAutoModeEstimatedEnabled = null;
cachedVoicevoxTTSEnabledIOS = null;
cachedVoicevoxTTSManifestUrlIOS = null;
cachedVoicevoxTTSStyleIdIOS = null;
Expand Down Expand Up @@ -239,6 +249,9 @@ export const setupRemoteConfig = async (): Promise<void> => {
if (typeof data.ai_agent_enabled === 'boolean') {
cachedAIAgentEnabled = data.ai_agent_enabled;
}
if (typeof data.auto_mode_estimated_enabled === 'boolean') {
cachedAutoModeEstimatedEnabled = data.auto_mode_estimated_enabled;
}
if (typeof data.voicevox_tts_enabled_ios === 'boolean') {
cachedVoicevoxTTSEnabledIOS = data.voicevox_tts_enabled_ios;
}
Expand Down Expand Up @@ -358,6 +371,16 @@ export const isAIAgentFeatureEnabled = (): boolean => {
return AI_AGENT_ENABLED_FALLBACK;
};

// オートモードを到着時間推定の見込み(trainRoute の model: Estimated)で走らせるかどうかを
// 同期的に取得する。setupRemoteConfig 完了後は取得済みのリモート値を、未設定・取得失敗時は
// フォールバック(false=従来の値で走る)を返す。
export const isAutoModeEstimatedEnabled = (): boolean => {
if (cachedAutoModeEstimatedEnabled != null) {
return cachedAutoModeEstimatedEnabled;
}
return AUTO_MODE_ESTIMATED_ENABLED_FALLBACK;
};

/**
* iOS でリモート TTS が使えない回の日本語フォールバックを VOICEVOX (端末内合成) で
* 読み上げるかどうかを同期的に取得する。isRemoteTTSEnabled とは独立しており、
Expand Down
Loading