diff --git a/android/app/build.gradle b/android/app/build.gradle index c349207777..4e5bdb4a44 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -170,12 +170,12 @@ android { dimension "environment" applicationId "me.tinykitten.trainlcd.dev" versionNameSuffix "-dev" - versionCode 100000833 + versionCode 100000835 versionName "10.17.0" } prod { dimension "environment" - versionCode 100000833 + versionCode 100000835 versionName "10.17.0" } // ストア配信する dev (Canary) と applicationId を分けた、ローカル検証専用のフレーバー。 @@ -186,7 +186,7 @@ android { dimension "environment" applicationId "me.tinykitten.trainlcd.local" versionNameSuffix "-local" - versionCode 100000833 + versionCode 100000835 versionName "10.17.0" } } diff --git a/android/wearable/build.gradle.kts b/android/wearable/build.gradle.kts index 03baf4fda2..4ce670ec93 100644 --- a/android/wearable/build.gradle.kts +++ b/android/wearable/build.gradle.kts @@ -85,12 +85,12 @@ android { dimension = "environment" applicationIdSuffix = ".dev" versionNameSuffix = "-dev" - versionCode = 100000834 + versionCode = 100000836 versionName = "10.17.0" } create("prod") { dimension = "environment" - versionCode = 100000834 + versionCode = 100000836 versionName = "10.17.0" } // :app の local フレーバーと対になるローカル検証専用フレーバー。Wear の Data Layer は @@ -100,7 +100,7 @@ android { dimension = "environment" applicationIdSuffix = ".local" versionNameSuffix = "-local" - versionCode = 100000834 + versionCode = 100000836 versionName = "10.17.0" } } diff --git a/app.config.ts b/app.config.ts index 95bfa7e484..0886eb3a08 100644 --- a/app.config.ts +++ b/app.config.ts @@ -64,7 +64,7 @@ export default { userInterfaceStyle: 'automatic', // Expo SDK 57 の各モジュール(expo / expo-modules-core ほか)は podspec で iOS 16.4 以上を要求する deploymentTarget: '16.4', - buildNumber: '2946', + buildNumber: '2947', scheme: IS_DEV ? 'CanaryTrainLCD' : 'ProdTrainLCD', bundleIdentifier: IS_DEV ? 'me.tinykitten.trainlcd.dev' @@ -78,7 +78,7 @@ export default { ? 'me.tinykitten.trainlcd.dev' : 'me.tinykitten.trainlcd', permissions: [], - versionCode: 100000833, + versionCode: 100000835, }, owner: 'trainlcd', experiments: { diff --git a/docs/location-simulation.md b/docs/location-simulation.md index 9ff2e72c3c..66c8034cf7 100644 --- a/docs/location-simulation.md +++ b/docs/location-simulation.md @@ -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` など) は、その駅の停車時間を 走行から引く。 diff --git a/ios/TrainLCD.xcodeproj/project.pbxproj b/ios/TrainLCD.xcodeproj/project.pbxproj index f23b05ca47..16ea1cc7d2 100644 --- a/ios/TrainLCD.xcodeproj/project.pbxproj +++ b/ios/TrainLCD.xcodeproj/project.pbxproj @@ -2562,7 +2562,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2946; + CURRENT_PROJECT_VERSION = 2947; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; @@ -2602,7 +2602,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2946; + CURRENT_PROJECT_VERSION = 2947; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TrainLCD; @@ -2661,7 +2661,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2946; + CURRENT_PROJECT_VERSION = 2947; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -2768,7 +2768,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = YES; - CURRENT_PROJECT_VERSION = 2946; + CURRENT_PROJECT_VERSION = 2947; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -2849,7 +2849,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2946; + CURRENT_PROJECT_VERSION = 2947; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2889,7 +2889,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2946; + CURRENT_PROJECT_VERSION = 2947; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -3101,7 +3101,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/CanaryRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2946; + CURRENT_PROJECT_VERSION = 2947; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3153,7 +3153,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2946; + CURRENT_PROJECT_VERSION = 2947; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3204,7 +3204,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/ProdWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2946; + CURRENT_PROJECT_VERSION = 2947; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3262,7 +3262,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2946; + CURRENT_PROJECT_VERSION = 2947; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3313,7 +3313,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/CanaryWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2946; + CURRENT_PROJECT_VERSION = 2947; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3370,7 +3370,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2946; + CURRENT_PROJECT_VERSION = 2947; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3419,7 +3419,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/ProdRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2946; + CURRENT_PROJECT_VERSION = 2947; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3471,7 +3471,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2946; + CURRENT_PROJECT_VERSION = 2947; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3690,7 +3690,7 @@ CODE_SIGN_ENTITLEMENTS = ProdAppClip/ProdAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2946; + CURRENT_PROJECT_VERSION = 2947; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3746,7 +3746,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2946; + CURRENT_PROJECT_VERSION = 2947; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3796,7 +3796,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryAppClip/CanaryAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2946; + CURRENT_PROJECT_VERSION = 2947; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3854,7 +3854,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2946; + CURRENT_PROJECT_VERSION = 2947; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; diff --git a/src/hooks/useAutoModeEstimatedEnabled.ts b/src/hooks/useAutoModeEstimatedEnabled.ts new file mode 100644 index 0000000000..a9d5a7c6b1 --- /dev/null +++ b/src/hooks/useAutoModeEstimatedEnabled.ts @@ -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); diff --git a/src/hooks/useSimulationMode.test.tsx b/src/hooks/useSimulationMode.test.tsx index c56a66c369..56c78c9ba7 100644 --- a/src/hooks/useSimulationMode.test.tsx +++ b/src/hooks/useSimulationMode.test.tsx @@ -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'; @@ -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, @@ -258,6 +265,7 @@ describe('useSimulationMode', () => { // 各テストは非ループ線を前提とする。ループ線テストで上書きした実装が // 後続テストへ漏れないよう毎回明示的にリセットする。 (useLoopLine as jest.Mock).mockReturnValue({ isLoopLine: false }); + (useAutoModeEstimatedEnabled as jest.Mock).mockReturnValue(true); jest .spyOn(useCurrentTrainTypeModule, 'useCurrentTrainType') @@ -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 }) => {children}, + }); + + 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 }) => {children}, + }); + + 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( diff --git a/src/hooks/useSimulationMode.ts b/src/hooks/useSimulationMode.ts index 9f0866845a..c7a2d776ff 100644 --- a/src/hooks/useSimulationMode.ts +++ b/src/hooks/useSimulationMode.ts @@ -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'; @@ -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, @@ -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( 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( GET_ESTIMATED_CONNECTED_TRAIN_ROUTE, - { variables: connectedVariables, skip: skipConnected } + { + variables: connectedVariables, + skip: skipConnected || !estimatedEnabled, + } ); const { data: legacyConnectedData, error: legacyConnectedError } = useGraphQLQuery( 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; diff --git a/src/lib/remoteConfig.test.ts b/src/lib/remoteConfig.test.ts index ca1b122a53..0287766ada 100644 --- a/src/lib/remoteConfig.test.ts +++ b/src/lib/remoteConfig.test.ts @@ -7,6 +7,7 @@ import { getVoicevoxTTSManifestUrl, getVoicevoxTTSStyleId, isAIAgentFeatureEnabled, + isAutoModeEstimatedEnabled, isEtaAssistEnabled, isRemoteTTSEnabled, isTTSFeatureEnabled, @@ -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(); diff --git a/src/lib/remoteConfig.ts b/src/lib/remoteConfig.ts index 40e1c91e7b..d7ca5921aa 100644 --- a/src/lib/remoteConfig.ts +++ b/src/lib/remoteConfig.ts @@ -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 = { @@ -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フォールバック機能自体のフォールバック既定値。安全側に倒し、既定では無効とする。 @@ -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; @@ -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; @@ -185,6 +194,7 @@ export const resetRemoteConfigCache = (): void => { cachedRemoteTTSEnabledIOS = null; cachedRemoteTTSEnabledAndroid = null; cachedAIAgentEnabled = null; + cachedAutoModeEstimatedEnabled = null; cachedVoicevoxTTSEnabledIOS = null; cachedVoicevoxTTSManifestUrlIOS = null; cachedVoicevoxTTSStyleIdIOS = null; @@ -239,6 +249,9 @@ export const setupRemoteConfig = async (): Promise => { 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; } @@ -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 とは独立しており、