diff --git a/.claude/skills/finalize-release/SKILL.md b/.claude/skills/finalize-release/SKILL.md index 7d25f74b9c..9c0d9bbb31 100644 --- a/.claude/skills/finalize-release/SKILL.md +++ b/.claude/skills/finalize-release/SKILL.md @@ -126,6 +126,8 @@ sync 側の走行は **必須**。master→dev 差分が 0 件の場合のみ自 sync が **スキップ**(差分 0 件)された場合はこの手順を飛ばす。新規作成・流用いずれかで dev<-master PR が存在する場合のみ実行する。**緩和→マージ→復元は 1 つの bash 呼び出しにまとめ、`set +e` でマージ失敗を握って復元を無条件実行する**(途中で関数が分かれて復元が飛ぶ事故を防ぐ)。 + **マージ前にコンフリクトを確認する。** `gh pr view --json mergeable,mergeStateStatus` が `CONFLICTING` を返す場合、`master` から特定コミットだけを cherry-pick したリリース(`create-release-pr` の「この変更だけ」指定など)の後に起きる **版数ファイルのねじれ** が典型。このときは Ruleset 緩和より先に `sync-dev-from-master` の「版数ファイルのコンフリクト解決」に従って版数を解決し(本番版数の判断はユーザー承認を取る)、PR を `MERGEABLE` にしてから 6-3 以降へ進む。`mergeable` はプッシュ直後に非同期で古い値を返すことがあるため、`git merge-base --is-ancestor origin/dev origin/chore/dev-from-master` で構造的な包含も確認するとよい。 + 1. マージ対象 PR 番号を確定(手順 5 で新規作成 or 流用した PR)。ローカルが `chore/dev-from-master` に居るとブランチ削除で支障が出るため `git switch dev`(または安全な枝)へ退避する。 2. `OWNER_REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)` を解決。プレフライトの「マージ方式制約」記録を使う: - dev が `merge` を許可済み → **緩和不要**。そのまま手順 6-5 のマージへ。 diff --git a/.claude/skills/sync-dev-from-master/SKILL.md b/.claude/skills/sync-dev-from-master/SKILL.md index 8d3d55105a..cf8febf601 100644 --- a/.claude/skills/sync-dev-from-master/SKILL.md +++ b/.claude/skills/sync-dev-from-master/SKILL.md @@ -75,6 +75,7 @@ description: Open a dev<-master merge PR that syncs master back into dev after a - 何もコミットは積まない(master 先端そのまま)。biome 等のフォーマッタは走らせない(新規コミット無し)。 - push 前に、対象 SHA(`git rev-parse origin/master`)・取り込まれるコミット件数・本文に入れる version をユーザーに提示して承認を取る。 + - **例外**: この PR が版数ファイルで衝突する場合(`master` から特定 PR だけ cherry-pick したリリースの後に起きる。後述の「版数ファイルのコンフリクト解決」を参照)は、この枝に `origin/dev` をマージして解決コミットを 1 つだけ積む。それ以外は master 先端そのまま。 5. **PR 本文を組み立て(テンプレ厳守・全節を実内容で埋める)** @@ -160,6 +161,56 @@ description: Open a dev<-master merge PR that syncs master back into dev after a - テスト欄のチェック状態(全 OFF + 説明文あり) - 使用した `release_version`(どこから取ったか) +## 版数ファイルのコンフリクト解決(cherry-pick / hotfix リリース後) + +通常の「dev から丸ごと」リリースでは `master` が `dev` の完全な祖先になるため、この同期 PR は衝突しない(手順 4 のとおり master 先端そのままで済む)。しかし **リリースブランチを `master` から切って特定 PR だけ cherry-pick したリリース**(`create-release-pr` に「この変更だけ」と指定したホットフィックス型など)では、`dev` を `master` に取り込んでいないため、`master` のリリース版数と `dev` の canary bump 版数が **ねじれたまま** 残り、この同期 PR が版数ファイルで衝突する。 + +衝突するのは版数ファイルのみで、アプリコードは衝突しない: + +- `android/app/build.gradle`(`versionCode` / `versionName`) +- `app.config.ts`(`version` / `buildNumber` / `versionCode`) +- `ios/TrainLCD.xcodeproj/project.pbxproj`(`MARKETING_VERSION` / `CURRENT_PROJECT_VERSION`) + +### 解決方針: semver はリリース版、ビルド番号は最大値 + +| 種別 | 採用する側 | 理由 | +| ---- | ---- | ---- | +| semver(`version` / `versionName` / `MARKETING_VERSION`) | `master`(リリース版数) | `dev` をリリース済みバージョンへ前進させる。過去 PR #6396 も semver 競合をリリース版で解決している | +| ビルド番号(`versionCode` / `CURRENT_PROJECT_VERSION` / `buildNumber`) | `max(dev, master)`(通常は `dev` 側が大きい) | ストアはビルド番号の単調増加を要求する。canary で既発行の番号より下げると次回 bump で衝突する | + +### 解決手順 + +1. `chore/dev-from-master`(= master 先端)に居る状態で `origin/dev` をマージする(手順 4 の「コミットを積まない」原則の唯一の例外): + + ```bash + git switch chore/dev-from-master + git merge --no-ff --no-commit origin/dev + ``` + +2. 衝突した版数 3 ファイルを master 側(`--ours`)で確定してから、ビルド番号だけ `dev` 側の最大値へ引き上げる(下は master=530/2743・dev=531/2744 の例): + + ```bash + git checkout --ours android/app/build.gradle app.config.ts ios/TrainLCD.xcodeproj/project.pbxproj + sed -i 's/versionCode 100000530/versionCode 100000531/g' android/app/build.gradle + sed -i "s/buildNumber: '2743'/buildNumber: '2744'/g; s/versionCode: 100000530/versionCode: 100000531/g" app.config.ts + sed -i 's/CURRENT_PROJECT_VERSION = 2743;/CURRENT_PROJECT_VERSION = 2744;/g' ios/TrainLCD.xcodeproj/project.pbxproj + ``` + + これらの版数ファイルは master↔dev で数値以外の差分が無い(`git diff origin/dev origin/master -- ` で確認できる)ため、`--ours` で master を採ってもコンテンツは失われない。 + +3. **`dev` への正味の変化が semver だけ**(ビルド番号は据え置き)であることを確認してからマージコミットを作成し push する: + + ```bash + git add android/app/build.gradle app.config.ts ios/TrainLCD.xcodeproj/project.pbxproj + git diff origin/dev HEAD # semver(例 10.9.0 -> 10.9.1)のみが出るのが正 + git commit -m "origin/dev をマージし版数競合を解決(semver=、ビルド番号=)" + git push origin chore/dev-from-master + ``` + +4. 以降は通常どおり merge commit でマージする(`finalize-release` が Ruleset 一時緩和つきで実行する)。マージ後は dev HEAD が 2 親の merge commit になり、`git rev-list --count origin/dev..origin/master` が `0`(dev が master を完全包含)になることを検証する。 + +**semver をリリース版数へ更新する判断とビルド番号の採用値は本番の版数に関わるため、自動で確定せずユーザーに確認する。** + ## 注意事項 - **Squash merge 禁止**。これがこのスキルの存在理由の半分。実行時と完了報告で二重に明示する。 diff --git a/android/app/build.gradle b/android/app/build.gradle index 310de5ad6d..b5b350855e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -143,13 +143,13 @@ android { dimension "environment" applicationId "me.tinykitten.trainlcd.dev" versionNameSuffix "-dev" - versionCode 100000531 - versionName "10.9.0" + versionCode 100000532 + versionName "10.9.1" } prod { dimension "environment" - versionCode 100000531 - versionName "10.9.0" + versionCode 100000532 + versionName "10.9.1" } } } diff --git a/app.config.ts b/app.config.ts index fa38763765..b0afef2268 100644 --- a/app.config.ts +++ b/app.config.ts @@ -3,7 +3,7 @@ const IS_DEV = process.env.APP_VARIANT === 'dev'; export default { name: 'TrainLCD', slug: 'trainlcd', - version: '10.9.0', + version: '10.9.1', plugins: [ 'expo-image', 'expo-font', @@ -52,7 +52,7 @@ export default { }, }, ios: { - buildNumber: '2744', + buildNumber: '2745', scheme: IS_DEV ? 'CanaryTrainLCD' : 'ProdTrainLCD', bundleIdentifier: IS_DEV ? 'me.tinykitten.trainlcd.dev' : 'me.tinykitten.trainlcd', supportsTablet: true, @@ -60,7 +60,7 @@ export default { android: { package: IS_DEV ? 'me.tinykitten.trainlcd.dev' : 'me.tinykitten.trainlcd', permissions: [], - versionCode: 100000531, + versionCode: 100000532, }, owner: 'trainlcd', experiments: { diff --git a/assets/translations/en.json b/assets/translations/en.json index 724cd4d2d0..d0b693f0cc 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -331,6 +331,7 @@ "telemetryDescription": "Your device's geographic coordinates are sent to our analytics server. The information is used only for analytics.", "etaAssistTitle": "Improve arrival detection", "etaAssistDescription": "When enabled, in sections where GPS accuracy drops such as subways, the app uses the server's estimated arrival times (ETA) to assist arrival detection. GPS remains the source of truth for arrival and current location; ETA is only an aid.", + "batterySettings": "Battery", "powerSavingLocationTitle": "Power-saving location mode", "powerSavingLocationDescription": "When enabled, location accuracy is lowered to a battery-friendly level and iOS pauses tracking automatically while you are stopped, further reducing battery drain and heat on long rides. The lower accuracy may delay or shift station detection and arrival announcements. The relaxed update frequency is already part of the default settings. It also turns on automatically while your device is in low-power mode.", "passStationLabel": "Pass" diff --git a/assets/translations/ja.json b/assets/translations/ja.json index 58d7f61851..507dfc8cca 100644 --- a/assets/translations/ja.json +++ b/assets/translations/ja.json @@ -332,6 +332,7 @@ "telemetryDescription": "お使いの端末の地理的座標を解析用サーバに送信します。送信された情報は解析以外に使用されません。", "etaAssistTitle": "到着判定の改善", "etaAssistDescription": "有効にすると、地下鉄などGPSの精度が落ちる区間で、サーバーの到着予測(ETA)を使って到着判定を補助します。到着や現在地はGPSが基準で、ETAはあくまで補助です。", + "batterySettings": "バッテリー", "powerSavingLocationTitle": "省電力測位モード", "powerSavingLocationDescription": "有効にすると、測位精度を電池優先まで下げ、停車中はiOSが測位を自動休止して、長時間の乗車での電池消費と発熱をさらに減らします。精度の低下により駅の判定や到着案内が遅れたりずれたりする場合があります。位置情報の更新頻度の緩和は標準設定に組み込まれています。端末の省電力モード中は自動的に有効になります。", "passStationLabel": "通過" diff --git a/ios/TrainLCD.xcodeproj/project.pbxproj b/ios/TrainLCD.xcodeproj/project.pbxproj index 2931213599..8ca5a29654 100644 --- a/ios/TrainLCD.xcodeproj/project.pbxproj +++ b/ios/TrainLCD.xcodeproj/project.pbxproj @@ -2407,7 +2407,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; @@ -2446,7 +2446,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TrainLCD; @@ -2505,7 +2505,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -2561,7 +2561,7 @@ "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", "\"$(inherited)\"", ); - MARKETING_VERSION = 10.9.0; + MARKETING_VERSION = 10.9.1; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; @@ -2611,7 +2611,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = YES; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -2663,7 +2663,7 @@ "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", "\"$(inherited)\"", ); - MARKETING_VERSION = 10.9.0; + MARKETING_VERSION = 10.9.1; MTL_ENABLE_DEBUG_INFO = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(inherited)"; @@ -2690,7 +2690,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2729,7 +2729,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2940,7 +2940,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/CanaryRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -2991,7 +2991,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3042,7 +3042,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/ProdWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3100,7 +3100,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3151,7 +3151,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/CanaryWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3208,7 +3208,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3256,7 +3256,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/ProdRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3307,7 +3307,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3526,7 +3526,7 @@ CODE_SIGN_ENTITLEMENTS = ProdAppClip/ProdAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3582,7 +3582,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3632,7 +3632,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryAppClip/CanaryAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3656,7 +3656,7 @@ "@executable_path/Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 10.9.0; + MARKETING_VERSION = 10.9.1; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; @@ -3690,7 +3690,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3710,7 +3710,7 @@ "@executable_path/Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 10.9.0; + MARKETING_VERSION = 10.9.1; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PODS_ROOT = "${SRCROOT}/Pods"; diff --git a/package-lock.json b/package-lock.json index b4c648762f..47e24c03df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "trainlcd", - "version": "10.9.0", + "version": "10.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trainlcd", - "version": "10.9.0", + "version": "10.9.1", "hasInstallScript": true, "dependencies": { "@expo-google-fonts/roboto": "^0.2.3", diff --git a/package.json b/package.json index b5be3bfe90..358e43c49c 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,9 @@ "web": "expo start --web", "lint": "biome check ./src", "format": "biome format ./src --write", - "test": "TZ=UTC jest", + "test": "cross-env TZ=UTC jest", "typecheck": "tsc --noEmit", - "watch:test": "TZ=UTC jest --watch", + "watch:test": "cross-env TZ=UTC jest --watch", "gql:codegen": "graphql-codegen --config utils/codegen.ts", "version:bump": "node scripts/bump-version.js", "postinstall": "patch-package" @@ -167,5 +167,5 @@ } }, "name": "trainlcd", - "version": "10.9.0" + "version": "10.9.1" } diff --git a/src/components/Permitted.tsx b/src/components/Permitted.tsx index ad316d742e..dcc82fd75b 100644 --- a/src/components/Permitted.tsx +++ b/src/components/Permitted.tsx @@ -445,7 +445,7 @@ const PermittedLayout: React.FC = ({ children }: Props) => { ); // NOTE: powerSavingLocationEnabledAtom はここでは復元しない。effect復元だと // 継続測位がデフォルト精度で一度起動してから再起動されるため、 - // atom定義側(store/atoms/experimental.ts)でMMKVから同期的に初期値を確定している。 + // atom定義側(store/atoms/battery.ts)でMMKVから同期的に初期値を確定している。 if (themePreferenceKey) { setThemePreference(themePreferenceKey as ThemePreference); diff --git a/src/hooks/useSimulationMode.test.tsx b/src/hooks/useSimulationMode.test.tsx index 00815f62c9..41d7d33016 100644 --- a/src/hooks/useSimulationMode.test.tsx +++ b/src/hooks/useSimulationMode.test.tsx @@ -11,6 +11,7 @@ import { import { YAMANOTE_LINE_ID } from '~/constants'; import * as useCurrentTrainTypeModule from '~/hooks/useCurrentTrainType'; import { useGraphQLQuery } from '~/hooks/useGraphQLQuery'; +import { useLoopLine } from '~/hooks/useLoopLine'; import { useSimulationMode } from '~/hooks/useSimulationMode'; import { GET_TRAIN_ROUTE } from '~/lib/graphql/queries'; import { store } from '~/store'; @@ -28,6 +29,7 @@ jest.mock('~/store/atoms/station', () => ({ stationAtom: { toString: () => 'stationAtom' }, stationsAtom: { toString: () => 'stationsAtom' }, selectedDirectionAtom: { toString: () => 'selectedDirectionAtom' }, + selectedBoundAtom: { toString: () => 'selectedBoundAtom' }, })); jest.mock('~/store/atoms/navigation', () => ({ @@ -36,6 +38,12 @@ jest.mock('~/store/atoms/navigation', () => ({ autoModeEnabledAtom: { toString: () => 'autoModeEnabledAtom' }, })); +jest.mock('~/store/atoms/speech', () => ({ + __esModule: true, + default: { toString: () => 'speechState' }, + resetFirstSpeechAtom: { toString: () => 'resetFirstSpeechAtom' }, +})); + jest.mock('~/store', () => ({ store: { get: jest.fn(() => null), @@ -199,6 +207,10 @@ describe('useSimulationMode', () => { jest.useFakeTimers(); jest.setSystemTime(new Date(100000)); + // 各テストは非ループ線を前提とする。ループ線テストで上書きした実装が + // 後続テストへ漏れないよう毎回明示的にリセットする。 + (useLoopLine as jest.Mock).mockReturnValue({ isLoopLine: false }); + jest .spyOn(useCurrentTrainTypeModule, 'useCurrentTrainType') .mockReturnValue(null); @@ -575,8 +587,8 @@ describe('useSimulationMode', () => { ); }); - it('終端駅到達後、先頭に戻ったときに速度プロファイルを最初から再生する', () => { - // 駅が1つだけ → nextStopStationがない → 即座に先頭リセット + it('終点到達後は即座に折り返さず終点で停車し続ける', () => { + // 駅が1つだけ → nextStopStationがない → 即座に終点扱い const stations = [mockStation(1, 1, 35.681, 139.767)]; setupAtomMocks( @@ -588,37 +600,168 @@ describe('useSimulationMode', () => { { autoModeEnabled: true } ); - // step内でstore.getが呼ばれる + // dwell処理内でstore.getが呼ばれる (store.get as jest.Mock).mockReturnValue( mockLocationObject(35.681, 139.767) ); // 速度プロファイルは空(駅が1つで次の駅がない)なので // interval tick 1: speeds=[], i(0)>=0 → dwellPending=true - // interval tick 2: dwell handler → nextSegment=-1 → 先頭に戻る - // interval tick 3: speeds=[] again → dwellPending=true - // 先頭に戻る際にchildIndexがリセットされていれば、 - // 毎回i=0から開始される(リセットされていないとiが進み続ける) + // interval tick 2以降: dwell handler → nextSegment=-1 → 終点で停車し + // TERMINAL_DWELL_TICKS に達するまで方面逆転せず待機し続ける。 + // 待機中は始発駅へワープせず、終点座標で speed=0 のまま留まる。 renderHook(() => useSimulationMode(), { wrapper: ({ children }) => {children}, }); - // 6秒分進める(複数回のリセットサイクルを経る) + // 6秒分進める(待機継続中) jest.advanceTimersByTime(6000); - // 先頭駅の位置が繰り返しセットされることを確認(リセットが正しく機能している) + // 終点座標で speed=0 の位置更新が繰り返しセットされることを確認 const locationSetCalls = (store.set as jest.Mock).mock.calls .filter((call) => call[0] === locationAtom) .map((call) => call[1]); - const resetCalls = locationSetCalls.filter( + const dwellCalls = locationSetCalls.filter( (loc) => loc?.coords?.latitude === stations[0].latitude && loc?.coords?.longitude === stations[0].longitude && loc?.coords?.speed === 0 ); - // 初期化 + dwellハンドラでの複数回リセット - expect(resetCalls.length).toBeGreaterThanOrEqual(2); + // 終点停車中の複数回の位置更新 + expect(dwellCalls.length).toBeGreaterThanOrEqual(2); + + // 待機時間(60ティック)未満では方面逆転(selectedDirection書き込み)は起きない + const directionSetCalls = (store.set as jest.Mock).mock.calls.filter( + (call) => call[0]?.toString?.() === 'selectedDirectionAtom' + ); + expect(directionSetCalls).toHaveLength(0); + }); + + it('終点で約1分停車したのち方面を逆転して折り返す', () => { + const stations = [ + mockStation(1, 1, 35.681, 139.767), + mockStation(2, 2, 35.691, 139.777), + ]; + + setupAtomMocks( + { + station: stations[0], + stations, + selectedDirection: 'INBOUND', + }, + { autoModeEnabled: true } + ); + + mockTrainRoute(stations); + + // 1駅1ティックで終点まで到達させる + jest + .spyOn(trainSpeedModule, 'generateTrainSpeedProfile') + .mockReturnValue([2000]); + + // resetFirstSpeechAtom は非ゼロの数値、それ以外(locationAtom)は位置オブジェクトを返す。 + // 非ゼロ(3)にすることで「現在値 + 1」を読んでいることを検証できる(固定値1だと通ってしまう)。 + (store.get as jest.Mock).mockImplementation((atom) => + atom?.toString?.() === 'resetFirstSpeechAtom' + ? 3 + : mockLocationObject(35.691, 139.777) + ); + + renderHook(() => useSimulationMode(), { + wrapper: ({ children }) => {children}, + }); + + // 終点到達 + 30秒程度の停車。まだ待機時間(約60秒)に満たないので折り返さない + jest.advanceTimersByTime(30000); + + let directionSetCalls = (store.set as jest.Mock).mock.calls.filter( + (call) => call[0]?.toString?.() === 'selectedDirectionAtom' + ); + expect(directionSetCalls).toHaveLength(0); + // 折り返す前は初回放送の再発火も起きない + expect( + (store.set as jest.Mock).mock.calls.filter( + (call) => call[0]?.toString?.() === 'resetFirstSpeechAtom' + ) + ).toHaveLength(0); + + // さらに進めて待機時間を超過させると方面(selectedDirection/selectedBound)が逆転する + jest.advanceTimersByTime(40000); + + directionSetCalls = (store.set as jest.Mock).mock.calls.filter( + (call) => call[0]?.toString?.() === 'selectedDirectionAtom' + ); + expect(directionSetCalls.length).toBeGreaterThanOrEqual(1); + // INBOUND → OUTBOUND へ逆転 + expect(directionSetCalls[0][1]).toBe('OUTBOUND'); + + // 折り返し後の行き先(selectedBound)も更新される + const boundSetCalls = (store.set as jest.Mock).mock.calls.filter( + (call) => call[0]?.toString?.() === 'selectedBoundAtom' + ); + expect(boundSetCalls.length).toBeGreaterThanOrEqual(1); + + // 折り返し時に初回放送(firstSpeech)が再発火する(resetFirstSpeechをインクリメント)。 + // 現在値3 + 1 = 4 が設定され、二重発火せず1回だけ呼ばれることを検証する。 + const resetFirstSpeechCalls = (store.set as jest.Mock).mock.calls.filter( + (call) => call[0]?.toString?.() === 'resetFirstSpeechAtom' + ); + expect(resetFirstSpeechCalls).toHaveLength(1); + expect(resetFirstSpeechCalls[0][1]).toBe(4); + }); + + it('ループ線では終点でも方面を逆転せず先頭に戻って周回を続ける', () => { + (useLoopLine as jest.Mock).mockReturnValue({ isLoopLine: true }); + + const stations = [ + mockStation(1, 1, 35.681, 139.767), + mockStation(2, 2, 35.691, 139.777), + ]; + + setupAtomMocks( + { + station: stations[1], + stations, + selectedDirection: 'INBOUND', + }, + { autoModeEnabled: true } + ); + + // ループ線 + INBOUND では進行順が reverse される([s2, s1]) + mockTrainRoute([stations[1], stations[0]]); + + jest + .spyOn(trainSpeedModule, 'generateTrainSpeedProfile') + .mockReturnValue([2000]); + + (store.get as jest.Mock).mockReturnValue( + mockLocationObject(35.691, 139.777) + ); + + renderHook(() => useSimulationMode(), { + wrapper: ({ children }) => {children}, + }); + + // 非ループ線なら折り返す待機時間を超過しても、ループ線では方面を逆転しない + jest.advanceTimersByTime(70000); + + const directionSetCalls = (store.set as jest.Mock).mock.calls.filter( + (call) => call[0]?.toString?.() === 'selectedDirectionAtom' + ); + expect(directionSetCalls).toHaveLength(0); + + // 先頭駅(周回の始点)へ戻る位置更新が行われている + const locationSetCalls = (store.set as jest.Mock).mock.calls + .filter((call) => call[0] === locationAtom) + .map((call) => call[1]); + const backToStartCalls = locationSetCalls.filter( + (loc) => + loc?.coords?.latitude === stations[1].latitude && + loc?.coords?.longitude === stations[1].longitude && + loc?.coords?.speed === 0 + ); + expect(backToStartCalls.length).toBeGreaterThanOrEqual(1); }); it('速度プロファイルの終端に達したら次のセグメントに移動する', () => { diff --git a/src/hooks/useSimulationMode.ts b/src/hooks/useSimulationMode.ts index 18d7c8b8c8..9ee5ca796b 100644 --- a/src/hooks/useSimulationMode.ts +++ b/src/hooks/useSimulationMode.ts @@ -11,8 +11,10 @@ import { GET_TRAIN_ROUTE } from '~/lib/graphql/queries'; import { store } from '~/store'; import { locationAtom } from '~/store/atoms/location'; import { autoModeEnabledAtom } from '~/store/atoms/navigation'; +import { resetFirstSpeechAtom } from '~/store/atoms/speech'; import { generateTrainSpeedProfile } from '~/utils/trainSpeed'; import { + selectedBoundAtom, selectedDirectionAtom, stationAtom, stationsAtom, @@ -23,6 +25,10 @@ import { useCurrentTrainType } from './useCurrentTrainType'; import { useGraphQLQuery } from './useGraphQLQuery'; import { useLoopLine } from './useLoopLine'; +// 終点到着後、方面を逆転して折り返すまでの待機時間。 +// step のインターバルが1秒間隔のため、ティック数(≒秒数)としてそのまま扱う。 +const TERMINAL_DWELL_TICKS = 60; + export const useSimulationMode = (): void => { const currentStation = useAtomValue(stationAtom); const rawStations = useAtomValue(stationsAtom); @@ -40,6 +46,11 @@ export const useSimulationMode = (): void => { const speedProfilesRef = useRef([]); const segmentProgressDistanceRef = useRef(0); const dwellPendingRef = useRef(false); + // 終点到着後、方面を逆転して折り返すまで終点で停車し続けたティック数 + const terminalDwellCountRef = useRef(0); + // 方面逆転中フラグ。新しい進行方向の速度プロファイルが再生成されるまで + // 終点で停車したまま待機し、旧方向のプロファイル/ジオメトリでの誤stepを防ぐ。 + const reversingRef = useRef(false); // 区間ごとの (waypoints, cumulativeDistances) キャッシュ。 // step() は毎秒呼ばれる。区間が変わらない限り cumulativeDistances は同じなので、 // waypoints 毎の getDistance / reduce を毎ティック走らせる必要は無い。 @@ -282,6 +293,9 @@ export const useSimulationMode = (): void => { childIndexRef.current = 0; segmentProgressDistanceRef.current = 0; dwellPendingRef.current = false; + terminalDwellCountRef.current = 0; + // 新しい方向の速度プロファイルが揃ったので折り返し待機を解除する + reversingRef.current = false; }, [maybeRevsersedStations, trainRouteData, resolveStartIndex]); const step = useCallback( @@ -427,6 +441,23 @@ export const useSimulationMode = (): void => { const speeds = speedProfilesRef.current[segmentIndexRef.current] ?? []; + // 方面逆転中は新方向の速度プロファイルが再生成されるまで終点で停車して待つ。 + // 旧方向のプロファイル/ジオメトリで step すると位置が飛ぶため、ここで待機する。 + if (reversingRef.current) { + const prev = store.get(locationAtom); + if (prev) { + store.set(locationAtom, { + timestamp: Date.now(), + coords: { + ...prev.coords, + speed: 0, + heading: null, + }, + }); + } + return; + } + if (dwellPendingRef.current) { const prev = store.get(locationAtom); if (prev) { @@ -442,27 +473,62 @@ export const useSimulationMode = (): void => { const nextSegmentIndex = speedProfilesRef.current.findIndex( (seg, idx) => seg.length > 0 && idx > segmentIndexRef.current ); + if (nextSegmentIndex === -1) { - const firstStation = maybeRevsersedStations[0]; - if ( - prev && - firstStation?.latitude != null && - firstStation?.longitude != null - ) { - store.set(locationAtom, { - timestamp: Date.now(), - coords: { - ...prev.coords, - latitude: firstStation.latitude, - longitude: firstStation.longitude, - speed: 0, - heading: null, - }, - }); + if (isLoopLine) { + // ループ線には折り返す終点が無いため、従来どおり先頭に戻って + // 同一方向のまま周回を続ける。 + const firstStation = maybeRevsersedStations[0]; + if ( + prev && + firstStation?.latitude != null && + firstStation?.longitude != null + ) { + store.set(locationAtom, { + timestamp: Date.now(), + coords: { + ...prev.coords, + latitude: firstStation.latitude, + longitude: firstStation.longitude, + speed: 0, + heading: null, + }, + }); + } + segmentIndexRef.current = 0; + childIndexRef.current = 0; + segmentProgressDistanceRef.current = 0; + dwellPendingRef.current = false; + return; + } + + // 終点に到達。すぐには折り返さず、約1分間そのまま停車してから + // 方面(selectedDirection / selectedBound)を逆転させて折り返す。 + if (terminalDwellCountRef.current < TERMINAL_DWELL_TICKS) { + terminalDwellCountRef.current += 1; + return; } + + // 待機完了 → 方面を逆転する。maybeRevsersedStations と trainRoute は + // どちらも selectedDirection に依存して再計算されるため、方向を反転すれば + // 終点始発の折り返し運転として自然にシミュレーションが継続する。 + terminalDwellCountRef.current = 0; + dwellPendingRef.current = false; + reversingRef.current = true; + const reversedDirection = + selectedDirection === 'INBOUND' ? 'OUTBOUND' : 'INBOUND'; + // 反転後の進行方向は現在の maybeRevsersedStations の逆順になるため、 + // 折り返し後の行き先(selectedBound)は現在の始発駅にあたる先頭要素。 + store.set(selectedBoundAtom, maybeRevsersedStations[0] ?? null); + store.set(selectedDirectionAtom, reversedDirection); + // 折り返し後は新しい行き先として初回放送(この電車は〜行きです)を再発火させる。 + // resetFirstSpeechAtom を進めると useTTS 側で firstSpeech が true に戻り、 + // 行き先変更 + 発車後(arrived=false)に初回TTSが改めて再生される。 + store.set(resetFirstSpeechAtom, store.get(resetFirstSpeechAtom) + 1); + return; } - segmentIndexRef.current = - nextSegmentIndex === -1 ? 0 : nextSegmentIndex; + + segmentIndexRef.current = nextSegmentIndex; childIndexRef.current = 0; segmentProgressDistanceRef.current = 0; dwellPendingRef.current = false; @@ -483,5 +549,5 @@ export const useSimulationMode = (): void => { return () => { clearInterval(intervalId); }; - }, [enabled, maybeRevsersedStations, selectedDirection, step]); + }, [enabled, isLoopLine, maybeRevsersedStations, selectedDirection, step]); }; diff --git a/src/hooks/useStartBackgroundLocationUpdates.test.tsx b/src/hooks/useStartBackgroundLocationUpdates.test.tsx index 56dfd23ebe..e35df4d673 100644 --- a/src/hooks/useStartBackgroundLocationUpdates.test.tsx +++ b/src/hooks/useStartBackgroundLocationUpdates.test.tsx @@ -13,7 +13,6 @@ import { useStartBackgroundLocationUpdates } from './useStartBackgroundLocationU let mockNeedsJobSchedulerBypass = false; let mockSystemLowPowerMode = false; -let mockIsDevApp = false; jest.mock('../constants/native', () => ({ get NEEDS_JOBSCHEDULER_BYPASS() { return mockNeedsJobSchedulerBypass; @@ -23,11 +22,6 @@ jest.mock('../constants/native', () => ({ jest.mock('expo-battery', () => ({ useLowPowerMode: () => mockSystemLowPowerMode, })); -jest.mock('~/utils/isDevApp', () => ({ - get isDevApp() { - return mockIsDevApp; - }, -})); jest.mock('expo-location'); jest.mock('./useLocationPermissionsGranted'); jest.mock('~/store', () => ({ @@ -44,7 +38,7 @@ jest.mock('~/store/atoms/navigation', () => ({ default: {}, autoModeEnabledAtom: { toString: () => 'autoModeEnabledAtom' }, })); -jest.mock('~/store/atoms/experimental', () => ({ +jest.mock('~/store/atoms/battery', () => ({ powerSavingLocationEnabledAtom: { toString: () => 'powerSavingLocationEnabledAtom', }, @@ -92,7 +86,6 @@ describe('useStartBackgroundLocationUpdates', () => { mockAutoModeEnabled = false; mockPowerSavingLocationEnabled = false; mockSystemLowPowerMode = false; - mockIsDevApp = false; mockNeedsJobSchedulerBypass = false; mockStartLocationUpdatesAsync.mockResolvedValue(undefined); mockStopLocationUpdatesAsync.mockResolvedValue(undefined); @@ -391,7 +384,6 @@ describe('useStartBackgroundLocationUpdates', () => { describe('power saving location mode', () => { test('should lower accuracy and allow automatic pauses for background updates when enabled', async () => { mockPowerSavingLocationEnabled = true; - mockIsDevApp = true; mockUseLocationPermissionsGranted.mockReturnValue(true); renderHook(() => useStartBackgroundLocationUpdates()); @@ -429,10 +421,9 @@ describe('useStartBackgroundLocationUpdates', () => { ); }); - test('should enable the power-saving profile in the dev app while the system low-power mode is active', async () => { + test('should enable the power-saving profile while the system low-power mode is active', async () => { mockPowerSavingLocationEnabled = false; mockSystemLowPowerMode = true; - mockIsDevApp = true; mockUseLocationPermissionsGranted.mockReturnValue(true); renderHook(() => useStartBackgroundLocationUpdates()); @@ -449,50 +440,8 @@ describe('useStartBackgroundLocationUpdates', () => { ); }); - test('should keep the default profile in production while the system low-power mode is active', async () => { - mockPowerSavingLocationEnabled = false; - mockSystemLowPowerMode = true; - mockIsDevApp = false; - mockUseLocationPermissionsGranted.mockReturnValue(true); - - renderHook(() => useStartBackgroundLocationUpdates()); - - await new Promise(process.nextTick); - - expect(mockStartLocationUpdatesAsync).toHaveBeenCalledWith( - LOCATION_TASK_NAME, - expect.objectContaining({ - ...LOCATION_TASK_OPTIONS, - accuracy: Location.Accuracy.High, - pausesUpdatesAutomatically: false, - foregroundService: expect.objectContaining({ - killServiceOnDestroy: true, - }), - }) - ); - }); - - test('should ignore the experimental setting outside the dev app', async () => { - mockPowerSavingLocationEnabled = true; - mockIsDevApp = false; - mockUseLocationPermissionsGranted.mockReturnValue(true); - - renderHook(() => useStartBackgroundLocationUpdates()); - - await new Promise(process.nextTick); - - expect(mockStartLocationUpdatesAsync).toHaveBeenCalledWith( - LOCATION_TASK_NAME, - expect.objectContaining({ - accuracy: Location.Accuracy.High, - pausesUpdatesAutomatically: false, - }) - ); - }); - test('should apply the power-saving accuracy to foreground watchPositionAsync when enabled', async () => { mockPowerSavingLocationEnabled = true; - mockIsDevApp = true; mockUseLocationPermissionsGranted.mockReturnValue(false); renderHook(() => useStartBackgroundLocationUpdates()); diff --git a/src/hooks/useStartBackgroundLocationUpdates.ts b/src/hooks/useStartBackgroundLocationUpdates.ts index 90af2ed21f..6b4b6e6e91 100644 --- a/src/hooks/useStartBackgroundLocationUpdates.ts +++ b/src/hooks/useStartBackgroundLocationUpdates.ts @@ -3,11 +3,10 @@ import * as Location from 'expo-location'; import { useAtomValue } from 'jotai'; import { useEffect } from 'react'; import { store } from '~/store'; -import { powerSavingLocationEnabledAtom } from '~/store/atoms/experimental'; +import { powerSavingLocationEnabledAtom } from '~/store/atoms/battery'; import { backgroundLocationTrackingAtom } from '~/store/atoms/location'; import { autoModeEnabledAtom } from '~/store/atoms/navigation'; import { handleTrackingLocation } from '~/utils/handleTrackingLocation'; -import { isDevApp } from '~/utils/isDevApp'; import { LOCATION_START_MAX_RETRIES, LOCATION_START_RETRY_BASE_DELAY_MS, @@ -30,16 +29,15 @@ export const useStartBackgroundLocationUpdates = () => { const bgPermGranted = useLocationPermissionsGranted(); const autoModeEnabled = useAtomValue(autoModeEnabledAtom); const systemLowPowerMode = Battery.useLowPowerMode(); - // 省電力測位モード(実験的機能)。精度をBalancedへ下げ、停車中の測位自動休止 - // (iOSのみ)を許可する。旧プロファイルのHigh精度・更新間隔の緩和は実車検証を - // 経て既定値へ昇格済み(constants/location.ts)。 + // 省電力測位モード。精度をBalancedへ下げ、停車中の測位自動休止(iOSのみ)を + // 許可する。旧プロファイルのHigh精度・更新間隔の緩和は実車検証を経て既定値へ + // 昇格済み(constants/location.ts)。 const powerSavingSettingEnabled = useAtomValue( powerSavingLocationEnabledAtom ); - // 試験用機能のためdevアプリだけで有効化する。手動設定に加えて、 - // 端末の省電力モード中も自動的に同じプロファイルへ切り替える。 - const powerSavingEnabled = - isDevApp && (powerSavingSettingEnabled || systemLowPowerMode); + // 「バッテリー」設定でONにしたときに加え、端末の省電力モード中も自動的に + // 同じプロファイルへ切り替える。 + const powerSavingEnabled = powerSavingSettingEnabled || systemLowPowerMode; // 選択するオブジェクトはモジュール定数なので、effect依存でも参照が安定する。 const watchOptions = powerSavingEnabled ? LOCATION_WATCH_OPTIONS_POWER_SAVING diff --git a/src/screens/AppSettings.tsx b/src/screens/AppSettings.tsx index 822d2357fe..e2112d7109 100644 --- a/src/screens/AppSettings.tsx +++ b/src/screens/AppSettings.tsx @@ -40,6 +40,7 @@ const SETTING_ITEM_ID_MAP = { personalize_tts: 'personalize_tts', personalize_languages: 'personalize_languages', personalize_notifications: 'personalize_notifications', + personalize_battery: 'personalize_battery', personalize_experimental: 'personalize_experimental', personalize_android: 'personalize_android', about_app_licenses: 'about_app_licenses', @@ -110,6 +111,8 @@ const SettingsItem = ({ return 'globe'; case 'personalize_notifications': return 'notifications'; + case 'personalize_battery': + return 'battery-half'; case 'personalize_experimental': return 'flask'; case 'personalize_android': @@ -312,6 +315,12 @@ const AppSettingsScreen: React.FC = () => { color: '#FF3B30', onPress: () => navigation.navigate('NotificationSettings' as never), }, + { + id: SETTING_ITEM_ID_MAP.personalize_battery, + title: translate('batterySettings'), + color: '#30B0C7', + onPress: () => navigation.navigate('BatterySettings' as never), + }, // 試験的機能はカナリアリリース(devアプリ)限定で表示する ...(isDevApp ? [ diff --git a/src/screens/BatterySettings.test.tsx b/src/screens/BatterySettings.test.tsx new file mode 100644 index 0000000000..09cb1031ed --- /dev/null +++ b/src/screens/BatterySettings.test.tsx @@ -0,0 +1,94 @@ +import { fireEvent, render } from '@testing-library/react-native'; +import { createStore, Provider } from 'jotai'; +import { Alert } from 'react-native'; +import { STORAGE_KEYS } from '~/constants'; +import { storage } from '~/lib/storage'; +import { powerSavingLocationEnabledAtom } from '~/store/atoms/battery'; +import BatterySettingsScreen from './BatterySettings'; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + goBack: jest.fn(), + }), +})); + +jest.mock('~/components/FooterTabBar', () => () => null); +jest.mock('~/components/SettingsHeader', () => ({ + SettingsHeader: () => null, +})); +jest.mock('~/components/Button', () => () => null); +jest.mock('~/translation', () => ({ + translate: (key: string) => key, +})); + +const renderWithStore = (powerSavingLocationEnabled = false) => { + const store = createStore(); + store.set(powerSavingLocationEnabledAtom, powerSavingLocationEnabled); + + const screen = render( + + + + ); + + return { ...screen, store }; +}; + +describe('BatterySettingsScreen', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('省電力測位モードをONにするとatomとストレージへ保存される', () => { + const { getByLabelText, store } = renderWithStore(false); + + fireEvent.press(getByLabelText('powerSavingLocationTitle')); + + expect(store.get(powerSavingLocationEnabledAtom)).toBe(true); + expect(storage.getString(STORAGE_KEYS.POWER_SAVING_LOCATION_ENABLED)).toBe( + 'true' + ); + }); + + it('省電力測位モードをOFFにするとatomとストレージへ保存される', () => { + const { getByLabelText, store } = renderWithStore(true); + + fireEvent.press(getByLabelText('powerSavingLocationTitle')); + + expect(store.get(powerSavingLocationEnabledAtom)).toBe(false); + expect(storage.getString(STORAGE_KEYS.POWER_SAVING_LOCATION_ENABLED)).toBe( + 'false' + ); + }); + + it('ストレージへの保存に失敗した場合はatom状態をロールバックしエラーを通知する', () => { + const setSpy = jest.spyOn(storage, 'set').mockImplementationOnce(() => { + throw new Error('storage failure'); + }); + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + + const { getByLabelText, store } = renderWithStore(false); + + fireEvent.press(getByLabelText('powerSavingLocationTitle')); + + // 保存失敗後にロールバックされる(MMKVは同期APIのため即時) + expect(store.get(powerSavingLocationEnabledAtom)).toBe(false); + + // エラーログとユーザーへのアラート表示を検証 + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to save power saving location setting', + expect.any(Error) + ); + expect(alertSpy).toHaveBeenCalledWith( + 'errorTitle', + 'failedToSavePreference' + ); + + setSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + alertSpy.mockRestore(); + }); +}); diff --git a/src/screens/BatterySettings.tsx b/src/screens/BatterySettings.tsx new file mode 100644 index 0000000000..b02bb8c1a3 --- /dev/null +++ b/src/screens/BatterySettings.tsx @@ -0,0 +1,155 @@ +import { useNavigation } from '@react-navigation/native'; +import { useAtom, useAtomValue } from 'jotai'; +import React, { useCallback, useRef, useState } from 'react'; +import { + Alert, + type NativeScrollEvent, + type NativeSyntheticEvent, + Pressable, + Animated as RNAnimated, + ScrollView, + StyleSheet, + View, +} from 'react-native'; +import Button from '~/components/Button'; +import FooterTabBar from '~/components/FooterTabBar'; +import { SettingsHeader } from '~/components/SettingsHeader'; +import { StatePanel } from '~/components/ToggleButton'; +import Typography from '~/components/Typography'; +import { powerSavingLocationEnabledAtom } from '~/store/atoms/battery'; +import { isLEDThemeAtom } from '~/store/atoms/theme'; +import { translate } from '~/translation'; +import { STORAGE_KEYS } from '../constants'; +import { storage } from '../lib/storage'; + +const styles = StyleSheet.create({ + root: { + paddingHorizontal: 24, + flex: 1, + }, + screenBg: { + backgroundColor: '#FAFAFA', + }, + description: { + marginTop: 16, + color: '#8B8B8B', + lineHeight: 21, + }, + okButton: { + width: 128, + alignSelf: 'center', + marginTop: 32, + }, +}); + +const ToggleItem = ({ + title, + state, + onToggle, +}: { + title: string; + state: boolean; + onToggle: () => void; +}) => { + const isLEDTheme = useAtomValue(isLEDThemeAtom); + + return ( + + + {title} + + + + + ); +}; + +const BatterySettingsScreen: React.FC = () => { + const [headerHeight, setHeaderHeight] = useState(0); + + const scrollY = useRef(new RNAnimated.Value(0)).current; + + const isLEDTheme = useAtomValue(isLEDThemeAtom); + const [powerSavingLocationEnabled, setPowerSavingLocationEnabled] = useAtom( + powerSavingLocationEnabledAtom + ); + + const navigation = useNavigation(); + + const handleTogglePowerSavingLocation = useCallback(() => { + const flag = !powerSavingLocationEnabled; + setPowerSavingLocationEnabled(flag); + try { + storage.set( + STORAGE_KEYS.POWER_SAVING_LOCATION_ENABLED, + flag ? 'true' : 'false' + ); + } catch (error) { + // 保存に失敗したままだと次回起動時に設定が巻き戻るため、 + // UIと永続値の不整合を防ぐべくatom状態をロールバックする + setPowerSavingLocationEnabled(!flag); + console.error('Failed to save power saving location setting', error); + Alert.alert(translate('errorTitle'), translate('failedToSavePreference')); + } + }, [powerSavingLocationEnabled, setPowerSavingLocationEnabled]); + + const handleScroll = useCallback( + (e: NativeSyntheticEvent) => { + scrollY.setValue(e.nativeEvent.contentOffset.y); + }, + [scrollY] + ); + + return ( + <> + + + + + {translate('powerSavingLocationDescription')} + + + + + setHeaderHeight(e.nativeEvent.layout.height + 32)} + scrollY={scrollY} + /> + + + ); +}; + +export default React.memo(BatterySettingsScreen); diff --git a/src/screens/ExperimentalSettings.test.tsx b/src/screens/ExperimentalSettings.test.tsx index 191494038d..a70d00fabf 100644 --- a/src/screens/ExperimentalSettings.test.tsx +++ b/src/screens/ExperimentalSettings.test.tsx @@ -7,7 +7,6 @@ import { storage } from '~/lib/storage'; import { etaAssistManualEnabledAtom, portraitModeEnabledAtom, - powerSavingLocationEnabledAtom, } from '~/store/atoms/experimental'; import tuningState from '~/store/atoms/tuning'; import ExperimentalSettingsScreen from './ExperimentalSettings'; @@ -30,13 +29,11 @@ jest.mock('~/translation', () => ({ const renderWithStore = ( portraitModeEnabled: boolean, telemetryEnabled = false, - etaAssistManualEnabled = false, - powerSavingLocationEnabled = false + etaAssistManualEnabled = false ) => { const store = createStore(); store.set(portraitModeEnabledAtom, portraitModeEnabled); store.set(etaAssistManualEnabledAtom, etaAssistManualEnabled); - store.set(powerSavingLocationEnabledAtom, powerSavingLocationEnabled); store.set(tuningState, (prev) => ({ ...prev, telemetryEnabled })); const screen = render( @@ -145,33 +142,6 @@ describe('ExperimentalSettingsScreen', () => { ); }); - it('省電力測位モードをONにするとatomとストレージへ保存される', () => { - const { getByLabelText, store } = renderWithStore(false); - - fireEvent.press(getByLabelText('powerSavingLocationTitle')); - - expect(store.get(powerSavingLocationEnabledAtom)).toBe(true); - expect(storage.getString(STORAGE_KEYS.POWER_SAVING_LOCATION_ENABLED)).toBe( - 'true' - ); - }); - - it('省電力測位モードをOFFにするとatomとストレージへ保存される', () => { - const { getByLabelText, store } = renderWithStore( - false, - false, - false, - true - ); - - fireEvent.press(getByLabelText('powerSavingLocationTitle')); - - expect(store.get(powerSavingLocationEnabledAtom)).toBe(false); - expect(storage.getString(STORAGE_KEYS.POWER_SAVING_LOCATION_ENABLED)).toBe( - 'false' - ); - }); - it('テレメトリをONにするとatomとストレージへ保存される', () => { const { getByLabelText, store } = renderWithStore(false); diff --git a/src/screens/ExperimentalSettings.tsx b/src/screens/ExperimentalSettings.tsx index e2e9ef48db..692e991cde 100644 --- a/src/screens/ExperimentalSettings.tsx +++ b/src/screens/ExperimentalSettings.tsx @@ -20,7 +20,6 @@ import { isEtaAssistRemoteEnabled } from '~/lib/remoteConfig'; import { etaAssistManualEnabledAtom, portraitModeEnabledAtom, - powerSavingLocationEnabledAtom, } from '~/store/atoms/experimental'; import { isLEDThemeAtom } from '~/store/atoms/theme'; import tuningState from '~/store/atoms/tuning'; @@ -108,9 +107,6 @@ const ExperimentalSettingsScreen: React.FC = () => { const [etaAssistManualEnabled, setEtaAssistManualEnabled] = useAtom( etaAssistManualEnabledAtom ); - const [powerSavingLocationEnabled, setPowerSavingLocationEnabled] = useAtom( - powerSavingLocationEnabledAtom - ); const [tuning, setTuning] = useAtom(tuningState); // Remote Config(マスタースイッチ)が許可していない間は、手動トグルを操作不可にする。 @@ -158,23 +154,6 @@ const ExperimentalSettingsScreen: React.FC = () => { etaAssistRemoteEnabled, ]); - const handleTogglePowerSavingLocation = useCallback(() => { - const flag = !powerSavingLocationEnabled; - setPowerSavingLocationEnabled(flag); - try { - storage.set( - STORAGE_KEYS.POWER_SAVING_LOCATION_ENABLED, - flag ? 'true' : 'false' - ); - } catch (error) { - // 保存に失敗したままだと次回起動時に設定が巻き戻るため、 - // UIと永続値の不整合を防ぐべくatom状態をロールバックする - setPowerSavingLocationEnabled(!flag); - console.error('Failed to save power saving location setting', error); - Alert.alert(translate('errorTitle'), translate('failedToSavePreference')); - } - }, [powerSavingLocationEnabled, setPowerSavingLocationEnabled]); - const handleToggleTelemetry = useCallback(() => { const flag = !tuning.telemetryEnabled; setTuning((prev) => ({ ...prev, telemetryEnabled: flag })); @@ -235,16 +214,6 @@ const ExperimentalSettingsScreen: React.FC = () => { {translate('etaAssistDescription')} - - - - - {translate('powerSavingLocationDescription')} - {translate('experimentalSettingsNotice')} diff --git a/src/stacks/MainStack.tsx b/src/stacks/MainStack.tsx index 01d1974b8f..90ff542228 100644 --- a/src/stacks/MainStack.tsx +++ b/src/stacks/MainStack.tsx @@ -5,6 +5,7 @@ import { import { useAtomValue } from 'jotai'; import React, { useMemo } from 'react'; import AndroidSettings from '~/screens/AndroidSettings'; +import BatterySettings from '~/screens/BatterySettings'; import ExperimentalSettings from '~/screens/ExperimentalSettings'; import Licenses from '~/screens/Licenses'; import NotificationSettings from '~/screens/NotificationSettings'; @@ -113,6 +114,11 @@ const MainStack: React.FC = () => { name="AndroidSettings" component={AndroidSettings} /> + { expect(body.data.ssmlEn).toBe('test'); }); + it('英語 SSML の可視テキストからマクロンを除去して送信する', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + result: { + id: 'tts-127', + jaAudioContent: 'QQ==', + enAudioContent: 'QQ==', + }, + }), + }); + + await fetchSpeechAudio({ + ...defaultOptions, + textEn: 'Kiryū and Ōhirashita', + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.data.ssmlEn).toBe('Kiryu and Ohirashita'); + }); + + it('マクロン除去時に SSML タグと IPA 発音記号 (ph 属性) は保持する', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + result: { + id: 'tts-128', + jaAudioContent: 'QQ==', + enAudioContent: 'QQ==', + }, + }), + }); + + await fetchSpeechAudio({ + ...defaultOptions, + textEn: 'the Tōkyō Line', + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + // ph 属性内の IPA (長音 ː) はそのまま、可視テキストの Tōkyō のみ Tokyo になる + expect(body.data.ssmlEn).toBe( + 'the Tokyo Line' + ); + }); + + it('マクロン有無で同じキャッシュを共有する', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + result: { + id: 'tts-129', + jaAudioContent: 'QQ==', + enAudioContent: 'QQ==', + }, + }), + }); + + const first = await fetchSpeechAudio({ + ...defaultOptions, + textEn: 'Tōkyō', + }); + const second = await fetchSpeechAudio({ + ...defaultOptions, + textEn: 'Tokyo', + }); + + expect(first).toEqual(second); + // 2 回目はキャッシュヒットで fetch は 1 回だけ + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + it('PCM MIME の場合は WAV として保存する', async () => { mockFetch.mockResolvedValue({ ok: true, diff --git a/src/utils/ttsSpeechFetcher.ts b/src/utils/ttsSpeechFetcher.ts index 012ea3e3d4..a80d9ca9fa 100644 --- a/src/utils/ttsSpeechFetcher.ts +++ b/src/utils/ttsSpeechFetcher.ts @@ -106,6 +106,22 @@ const normalizeOptional = (val: string | undefined): string => { return trimmed.length > 0 ? trimmed : ''; }; +// ヘボン式ローマ字の長音符(マクロン: Ā ā Ē ē Ī ī Ō ō Ū ū)を素の母音へ落とす。 +// NFD で母音と結合マクロン(U+0304)へ分解し、マクロンだけ取り除いて NFC に戻す。 +// 既存の useIsDifferentStationName と同じ手法。 +const COMBINING_MACRON = String.fromCharCode(0x0304); +const stripMacrons = (text: string): string => + text.normalize('NFD').replaceAll(COMBINING_MACRON, '').normalize('NFC'); + +// TTS API(Azure Speech)はマクロン付き母音を正しく読めず、英語駅名を誤読・無音化 +// することがあるため、SSML の可視テキストからマクロンを除去する。 +// タグ(<...>)は属性ごと保護し、タグ外のテキストだけを対象にすることで、 +// の IPA 発音記号など読みの正確さに関わる値は温存する。 +const stripMacronsFromSsmlText = (ssml: string): string => + ssml.replace(/<[^>]*>|[^<]+/g, (token) => + token.startsWith('<') ? token : stripMacrons(token) + ); + const buildCacheKey = (opts: FetchSpeechOptions): string => `${opts.textJa}\0${opts.textEn}\0${normalizeOptional(opts.jaVoiceName)}\0${normalizeOptional(opts.enVoiceName)}`; @@ -136,7 +152,11 @@ export const fetchSpeechAudio = async ( return null; } - const cacheKey = buildCacheKey(options); + // TTS API はマクロン付き英語駅名を誤読・無音化することがあるため、送信前に + // 英語 SSML の可視テキストからマクロンを除去する(日本語側は元々マクロンを含まない)。 + const sanitizedTextEn = stripMacronsFromSsmlText(textEn); + + const cacheKey = buildCacheKey({ ...options, textEn: sanitizedTextEn }); const cached = fetchCache.get(cacheKey); if (cached) { return cached; @@ -148,7 +168,7 @@ export const fetchSpeechAudio = async ( const reqBody = { data: { ssmlJa: `${textJa.trim()}`, - ssmlEn: `${textEn.trim()}`, + ssmlEn: `${sanitizedTextEn.trim()}`, ...(normalizedJaVoiceName ? { jaVoiceName: normalizedJaVoiceName } : {}), ...(normalizedEnVoiceName ? { enVoiceName: normalizedEnVoiceName } : {}), },