From 8a7e3d04707b21ac6dd7ed232c283850c360e195 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 13:37:34 +0000 Subject: [PATCH 1/3] =?UTF-8?q?DevOverlay=E3=81=AB=E8=A8=BA=E6=96=AD?= =?UTF-8?q?=E6=83=85=E5=A0=B1=E3=82=92=E3=82=AF=E3=83=AA=E3=83=83=E3=83=97?= =?UTF-8?q?=E3=83=9C=E3=83=BC=E3=83=89=E3=81=B8=E3=82=B3=E3=83=94=E3=83=BC?= =?UTF-8?q?=E3=81=99=E3=82=8B=E3=83=9C=E3=82=BF=E3=83=B3=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 地下のワープ調査で必要になる値を、画面から読み上げる代わりに丸ごと持ち出せるようにする。 テレメトリは本番で無効、かつ送っているのはlocationAtom(フィルタ後)だけなので、 生の精度・実効設定を取り出す手段が無かった。 ヘッダーのステータスピル横にCOPYボタンを置き、押すと整形済みJSONをクリップボードへ載せる。 含めるのは、フィルタ前後の測位(座標・精度・速度・タイムスタンプ)、精度履歴、ETAの フェーズとアンカー、次駅と距離、ビルド情報、そして実効設定(max_permit_accuracy / eta_assist_enabled / オートモード / テレメトリ / バックグラウンド測位)。設定が分からないと 同じ測位でも挙動を説明できないため、座標と必ずセットで持ち出す。 JSONの組み立てはsrc/utils/devDiagnosticsSnapshot.tsへ純関数として切り出した (DevOverlay.tsxが既に1100行あるため)。タイムスタンプを持たない測位が届いても 例外を出さないよう、ISO文字列化はnullへ倒す。診断情報の持ち出しで落ちては本末転倒なため。 クリップボードはreact-native coreのClipboardを使う。core から切り出され将来削除が 予告されている非推奨APIだが、expo-clipboardの追加はネイティブモジュールの追加になり Devクライアントのリビルドとロックファイルの更新を伴うため、まずは依存を増やさない。 呼び出しをsrc/utils/clipboard.tsの1関数へ閉じてあるので、移行時はそこだけ差し替えればよい。 パネルのPanResponderはcaptureを使っていないため子のPressableが先にタッチを取り、 展開/折りたたみのトグルとは競合しない。折りたたみ中は上に載るcollapsedOverlayが タッチを受けるので押せない。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Sfi8S4Yvob2sEzib4VUUBs --- src/components/DevOverlay.test.tsx | 52 +++++++- src/components/DevOverlay.tsx | 89 +++++++++++++ src/utils/clipboard.ts | 16 +++ src/utils/devDiagnosticsSnapshot.test.ts | 152 +++++++++++++++++++++++ src/utils/devDiagnosticsSnapshot.ts | 129 +++++++++++++++++++ 5 files changed, 437 insertions(+), 1 deletion(-) create mode 100644 src/utils/clipboard.ts create mode 100644 src/utils/devDiagnosticsSnapshot.test.ts create mode 100644 src/utils/devDiagnosticsSnapshot.ts diff --git a/src/components/DevOverlay.test.tsx b/src/components/DevOverlay.test.tsx index e4d2fb9b0..60a0e6524 100644 --- a/src/components/DevOverlay.test.tsx +++ b/src/components/DevOverlay.test.tsx @@ -1,4 +1,4 @@ -import { act, render } from '@testing-library/react-native'; +import { act, fireEvent, render } from '@testing-library/react-native'; import * as Application from 'expo-application'; import { useAtomValue } from 'jotai'; import { Dimensions, StyleSheet } from 'react-native'; @@ -55,8 +55,14 @@ jest.mock('~/utils/etaPhaseNow', () => ({ getEtaPhaseNow: jest.fn(() => null), })); +// クリップボードは react-native core の非推奨 Clipboard を触るため、テストでは差し替える +jest.mock('~/utils/clipboard', () => ({ + copyTextToClipboard: jest.fn(), +})); + // Import mocked hooks for type safety import { useDistanceToNextStation, useNextStation } from '~/hooks'; +import { copyTextToClipboard } from '~/utils/clipboard'; const mockUseAtomValue = useAtomValue as jest.MockedFunction< typeof useAtomValue @@ -72,6 +78,9 @@ const mockUseNextStation = useNextStation as jest.MockedFunction< const mockGetEtaPhaseNow = getEtaPhaseNow as jest.MockedFunction< typeof getEtaPhaseNow >; +const mockCopyTextToClipboard = copyTextToClipboard as jest.MockedFunction< + typeof copyTextToClipboard +>; describe('DevOverlay', () => { const mockDimensionsGet = jest.spyOn(Dimensions, 'get'); @@ -291,6 +300,47 @@ describe('DevOverlay', () => { }); }); + describe('診断情報のコピー', () => { + it('ボタンを押すと診断情報をクリップボードへ載せる', () => { + const { getByTestId } = render(); + + fireEvent.press(getByTestId('dev-overlay-copy-button')); + + expect(mockCopyTextToClipboard).toHaveBeenCalledTimes(1); + const copied = JSON.parse(mockCopyTextToClipboard.mock.calls[0][0]); + // 座標だけでなく実効設定も載っていること。設定が無いと同じ測位でも + // 挙動を説明できないため、これが欠けると持ち出す意味が薄れる + expect(copied.config).toMatchObject({ + maxPermitAccuracy: MAX_PERMIT_ACCURACY, + telemetryEnabled: true, + autoModeEnabled: false, + }); + expect(copied.location.raw).toMatchObject({ accuracy: 15 }); + expect(copied.build.appVersion).toBe( + `${Application.nativeApplicationVersion}(${Application.nativeBuildVersion})` + ); + }); + + it('押した直後はCOPIED表示になり、一定時間で戻る', () => { + jest.useFakeTimers(); + try { + const { getByTestId, getByText, queryByText } = render(); + expect(getByText('COPY')).toBeTruthy(); + + fireEvent.press(getByTestId('dev-overlay-copy-button')); + expect(getByText('COPIED')).toBeTruthy(); + + act(() => { + jest.advanceTimersByTime(1500); + }); + expect(queryByText('COPIED')).toBeNull(); + expect(getByText('COPY')).toBeTruthy(); + } finally { + jest.useRealTimers(); + } + }); + }); + describe('D&D座標変換', () => { it('物理横向きではドラッグ量をright/top基準の移動量に変換する', () => { expect(getDevOverlayDragTranslation(24, 10, false)).toEqual({ diff --git a/src/components/DevOverlay.tsx b/src/components/DevOverlay.tsx index 7a67f909d..6099404d0 100644 --- a/src/components/DevOverlay.tsx +++ b/src/components/DevOverlay.tsx @@ -7,6 +7,8 @@ import { Animated, Easing, PanResponder, + Platform, + Pressable, type StyleProp, StyleSheet, type TextStyle, @@ -29,11 +31,14 @@ import { rawLocationAtom, } from '~/store/atoms/location'; import { autoModeEnabledAtom } from '~/store/atoms/navigation'; +import { copyTextToClipboard } from '~/utils/clipboard'; +import { formatDevDiagnosticsSnapshot } from '~/utils/devDiagnosticsSnapshot'; import { getDisplacementSpeed, hasMeasuredSpeed, } from '~/utils/displacementSpeed'; import { getEtaPhaseNow } from '~/utils/etaPhaseNow'; +import { isDevApp } from '~/utils/isDevApp'; import AccuracyHistoryChart from './AccuracyHistoryChart'; import Typography from './Typography'; @@ -45,6 +50,9 @@ const EXPAND_DURATION = 280; const ACCURACY_CHART_SAMPLE_INTERVAL_MS = 1000; const ACCURACY_CHART_LIMIT = 12; +// 「コピーした」表示を出しておく時間(ms) +const COPIED_FEEDBACK_DURATION_MS = 1500; + const PANEL_BORDER = 'rgba(255,255,255,0.18)'; const PANEL_BG = 'rgba(7, 11, 24, 0.78)'; const LABEL_COLOR = 'rgba(199, 210, 254, 0.72)'; @@ -163,6 +171,19 @@ const styles = StyleSheet.create({ borderWidth: 1, minWidth: 72, }, + copyButton: { + borderRadius: 999, + paddingHorizontal: 10, + paddingVertical: 6, + borderWidth: 1, + minWidth: 72, + borderColor: 'rgba(148, 163, 184, 0.45)', + backgroundColor: 'rgba(30, 41, 59, 0.55)', + }, + copyButtonPressed: { + borderColor: 'rgba(56, 189, 248, 0.6)', + backgroundColor: 'rgba(14, 165, 233, 0.28)', + }, statusLabel: { color: 'rgba(226, 232, 240, 0.78)', fontSize: 8, @@ -502,6 +523,55 @@ const DevOverlay: React.FC = ({ unrotated = false }) => { const versionLabel = `TrainLCD DO ${Application.nativeApplicationVersion}(${Application.nativeBuildVersion})`; const telemetryValue = isTelemetryEnabled ? 'ON' : 'OFF'; const backgroundValue = isBackgroundLocationTracking ? 'ON' : 'OFF'; + + // 診断情報をクリップボードへ載せたことの一時的なフィードバック。 + // タイマーはアンマウントと連打で必ず張り直す(残ると解除済みの状態を書きに行く)。 + const [hasCopied, setHasCopied] = useState(false); + const copiedTimerRef = useRef | null>(null); + useEffect( + () => () => { + if (copiedTimerRef.current !== null) { + clearTimeout(copiedTimerRef.current); + } + }, + [] + ); + + const handleCopyDiagnostics = () => { + copyTextToClipboard( + formatDevDiagnosticsSnapshot({ + // レンダー中ではなくイベントハンドラ内なので Date.now() を直接読んでよい + nowMs: Date.now(), + appVersion: Application.nativeApplicationVersion ?? 'unknown', + buildNumber: Application.nativeBuildVersion ?? 'unknown', + channel: isDevApp ? 'canary' : 'production', + platform: Platform.OS, + osVersion: Platform.Version, + autoModeEnabled, + telemetryEnabled: isTelemetryEnabled, + backgroundLocationTracking: isBackgroundLocationTracking, + rawLocation, + filteredLocation: simulatedLocation, + accuracyHistory: chartHistory, + effectiveSpeedMps: effectiveSpeed, + hasMeasuredSpeed: hasEverMeasuredSpeed, + maxPermitAccuracy, + etaAssistEnabled, + etaPhase, + etaAnchor, + nextStation, + distanceToNextStation, + }) + ); + setHasCopied(true); + if (copiedTimerRef.current !== null) { + clearTimeout(copiedTimerRef.current); + } + copiedTimerRef.current = setTimeout(() => { + copiedTimerRef.current = null; + setHasCopied(false); + }, COPIED_FEEDBACK_DURATION_MS); + }; // ETA推定フェーズ(RUNNING/APPROACHING/DWELLING)を表示。フェーズ未推定時は IDLE。 const etaFallbackValue = etaPhase?.kind ?? 'IDLE'; // 推定対象の駅ID(走行/接近中は目標駅、停車中は当該駅)。 @@ -850,6 +920,25 @@ const DevOverlay: React.FC = ({ unrotated = false }) => { value={backgroundValue} style={statusPillStyle} /> + {/* パネルのPanResponderはcaptureを使っていないため、子のPressableが + 先にタッチを取る。展開/折りたたみのトグルとは競合しない。 + 折りたたみ中は上に載るcollapsedOverlayがタッチを受けるので押せない。 */} + [ + styles.copyButton, + statusPillStyle, + pressed && styles.copyButtonPressed, + ]} + > + DIAGNOSTICS + + {hasCopied ? 'COPIED' : 'COPY'} + + diff --git a/src/utils/clipboard.ts b/src/utils/clipboard.ts new file mode 100644 index 000000000..d34cdd017 --- /dev/null +++ b/src/utils/clipboard.ts @@ -0,0 +1,16 @@ +import { Clipboard } from 'react-native'; + +/** + * 文字列をクリップボードへ載せる。 + * + * react-native の Clipboard は core から切り出され「将来のリリースで削除する」と + * 予告されている(参照するとその旨の警告が出る)。本来は expo-clipboard へ移すべきだが、 + * ネイティブモジュールの追加になり、Devクライアントのリビルドとロックファイルの更新を伴う。 + * まずは依存を増やさずに済むこちらを使う。呼び出し側をこの1か所に閉じてあるので、 + * expo-clipboard を入れるときはこの関数の中だけを差し替えればよい。 + * + * 用途は DevOverlay の診断情報の持ち出しに限る(本番の画面からは呼ばない)。 + */ +export const copyTextToClipboard = (text: string): void => { + Clipboard.setString(text); +}; diff --git a/src/utils/devDiagnosticsSnapshot.test.ts b/src/utils/devDiagnosticsSnapshot.test.ts new file mode 100644 index 000000000..9cd9ca299 --- /dev/null +++ b/src/utils/devDiagnosticsSnapshot.test.ts @@ -0,0 +1,152 @@ +import type * as Location from 'expo-location'; +import type { Station } from '~/@types/graphql'; +import { + buildDevDiagnosticsSnapshot, + type DevDiagnosticsInput, + formatDevDiagnosticsSnapshot, +} from './devDiagnosticsSnapshot'; + +const makeLocation = ( + latitude: number, + longitude: number, + accuracy: number | null, + timestamp: number +): Location.LocationObject => ({ + coords: { + latitude, + longitude, + accuracy, + altitude: null, + altitudeAccuracy: null, + heading: null, + speed: 12.5, + }, + timestamp, +}); + +const baseInput: DevDiagnosticsInput = { + nowMs: Date.UTC(2026, 8, 15, 4, 5, 6), + appVersion: '10.15.1', + buildNumber: '123', + channel: 'canary', + platform: 'ios', + osVersion: '18.2', + autoModeEnabled: false, + telemetryEnabled: true, + backgroundLocationTracking: true, + rawLocation: makeLocation(35.732538, 139.670653, 312, 1_700_000_000_000), + filteredLocation: makeLocation(35.7325, 139.6706, 312, 1_700_000_000_000), + accuracyHistory: [20, 45, 310], + effectiveSpeedMps: 12.5, + hasMeasuredSpeed: true, + maxPermitAccuracy: 1500, + etaAssistEnabled: false, + etaPhase: { kind: 'RUNNING', targetStationId: 9930135 }, + etaAnchor: { + stationId: 9930134, + kind: 'DEPARTED', + observedAtMs: 1_700_000_000_000, + }, + nextStation: { id: 9930135, name: '練馬' } as Station, + distanceToNextStation: '1,234', +}; + +describe('buildDevDiagnosticsSnapshot', () => { + it('実効設定を座標と一緒に持ち出す', () => { + // 設定が分からないと同じ測位でも挙動を説明できないため、 + // 座標だけを持ち出せても診断には足りない + const snapshot = buildDevDiagnosticsSnapshot(baseInput); + + expect(snapshot.config).toEqual({ + maxPermitAccuracy: 1500, + etaAssistEnabled: false, + autoModeEnabled: false, + telemetryEnabled: true, + backgroundLocationTracking: true, + }); + expect(snapshot.build).toEqual({ + appVersion: '10.15.1(123)', + channel: 'canary', + platform: 'ios', + osVersion: '18.2', + }); + }); + + it('フィルタ前の生の測位と、アプリが使っている測位の両方を持つ', () => { + // 片方だけだと「どの測位がどう補正されたか」が追えない + const snapshot = buildDevDiagnosticsSnapshot(baseInput); + + expect(snapshot.location.raw).toMatchObject({ + latitude: 35.732538, + longitude: 139.670653, + accuracy: 312, + timestamp: 1_700_000_000_000, + }); + expect(snapshot.location.filtered).toMatchObject({ + latitude: 35.7325, + longitude: 139.6706, + }); + expect(snapshot.location.accuracyHistory).toEqual([20, 45, 310]); + }); + + it('測位が無い場合もnullで表現して壊れない', () => { + const snapshot = buildDevDiagnosticsSnapshot({ + ...baseInput, + rawLocation: null, + filteredLocation: null, + }); + + expect(snapshot.location.raw).toBeNull(); + expect(snapshot.location.filtered).toBeNull(); + }); + + it('タイムスタンプをISO文字列でも併記する', () => { + // 生のミリ秒だけだと目視で時系列を追えない + const snapshot = buildDevDiagnosticsSnapshot(baseInput); + + expect(snapshot.capturedAt).toBe('2026-09-15T04:05:06.000Z'); + expect(snapshot.location.raw?.timestampISO).toBe( + new Date(1_700_000_000_000).toISOString() + ); + }); + + it('タイムスタンプを持たない測位でも例外を出さない', () => { + // 診断情報の持ち出しで落ちては本末転倒。Date は不正な値へ toISOString すると送出する + const withoutTimestamp = { + coords: { speed: 10, accuracy: 15 }, + } as unknown as Location.LocationObject; + + const snapshot = buildDevDiagnosticsSnapshot({ + ...baseInput, + rawLocation: withoutTimestamp, + }); + + expect(snapshot.location.raw).toMatchObject({ + accuracy: 15, + timestamp: null, + timestampISO: null, + latitude: null, + }); + }); + + it('ETAのフェーズとアンカーをそのまま持つ', () => { + const snapshot = buildDevDiagnosticsSnapshot(baseInput); + + expect(snapshot.eta.phase).toEqual({ + kind: 'RUNNING', + targetStationId: 9930135, + }); + expect(snapshot.eta.anchor?.stationId).toBe(9930134); + }); +}); + +describe('formatDevDiagnosticsSnapshot', () => { + it('貼り付けられる整形済みJSONを返す', () => { + const text = formatDevDiagnosticsSnapshot(baseInput); + + expect(() => JSON.parse(text)).not.toThrow(); + expect(JSON.parse(text)).toEqual(buildDevDiagnosticsSnapshot(baseInput)); + // 読みながら貼れるようインデントする + expect(text).toContain('\n "config": {'); + }); +}); diff --git a/src/utils/devDiagnosticsSnapshot.ts b/src/utils/devDiagnosticsSnapshot.ts new file mode 100644 index 000000000..b1e3f64f5 --- /dev/null +++ b/src/utils/devDiagnosticsSnapshot.ts @@ -0,0 +1,129 @@ +import type * as Location from 'expo-location'; +import type { Station } from '~/@types/graphql'; +import type { EtaAnchor, EtaPhase } from './etaFallback'; + +/** + * DevOverlay が表示している診断値を、そのまま貼り付けられる JSON へ組み立てる。 + * + * 画面の数値を読み上げてもらう代わりに、判断に要る値を丸ごと持ち出せるようにするのが目的。 + * 画面には出していない実効設定(リモート設定の値・プラットフォーム)も含める。設定が + * 分からないと同じ測位でも挙動を説明できないため、座標だけ持ち出しても再現できない。 + * + * ここで持ち出せるのは「その瞬間のスナップショット」であって、棄却された測位の履歴や + * 測位の出所(継続測位/補完測位)ではない。それらは setLocation / handleTrackingLocation の + * 判定箇所に記録を足さないと取れないので、本関数の対象外。 + */ + +export type DevDiagnosticsInput = { + /** 生成時刻(ms)。呼び出し側から渡してレンダーの純粋性を保つ */ + nowMs: number; + appVersion: string; + buildNumber: string; + /** canary(開発ビルド)か production か */ + channel: 'canary' | 'production'; + platform: string; + osVersion: string | number; + autoModeEnabled: boolean; + telemetryEnabled: boolean; + backgroundLocationTracking: boolean; + /** 継続測位の生の値(フィルタ前) */ + rawLocation: Location.LocationObject | null; + /** フィルタ・スムージングを通した、アプリが現在地として使っている値 */ + filteredLocation: Location.LocationObject | null; + /** DevOverlay のチャートが持つ精度履歴(古い順) */ + accuracyHistory: number[]; + /** 表示に使っている速度(m/s)と、それが実測かどうか */ + effectiveSpeedMps: number; + hasMeasuredSpeed: boolean; + maxPermitAccuracy: number; + etaAssistEnabled: boolean; + etaPhase: EtaPhase | null; + etaAnchor: EtaAnchor | null; + nextStation: Station | null | undefined; + /** + * 次駅までの距離。useDistanceToNextStation は表示用に桁区切りした文字列 + * (測位が無いときは 0)を返すので、型もそれに合わせる。ここで数値へ直すと + * フックの算出と二重に持つことになるため、表示値をそのまま持ち出す。 + */ + distanceToNextStation: string | number | null | undefined; +}; + +type CoordsSnapshot = { + latitude: number | null; + longitude: number | null; + accuracy: number | null; + speed: number | null; + timestamp: number | null; + timestampISO: string | null; +} | null; + +/** + * ミリ秒をISO文字列にする。値が無い・不正なら null を返す。 + * + * 診断情報の持ち出しで例外を出すわけにはいかない。Date は不正な値へ toISOString すると + * 送出するので、ここで必ず止める。OSやシミュレーションの経路によっては timestamp を + * 持たない測位が届きうる。 + */ +const toISOStringOrNull = (ms: number | null | undefined): string | null => { + if (ms == null || !Number.isFinite(ms)) { + return null; + } + const date = new Date(ms); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +}; + +const toCoordsSnapshot = ( + location: Location.LocationObject | null +): CoordsSnapshot => { + if (!location) { + return null; + } + return { + latitude: location.coords?.latitude ?? null, + longitude: location.coords?.longitude ?? null, + accuracy: location.coords?.accuracy ?? null, + speed: location.coords?.speed ?? null, + timestamp: location.timestamp ?? null, + timestampISO: toISOStringOrNull(location.timestamp), + }; +}; + +export const buildDevDiagnosticsSnapshot = (input: DevDiagnosticsInput) => ({ + capturedAt: toISOStringOrNull(input.nowMs), + build: { + appVersion: `${input.appVersion}(${input.buildNumber})`, + channel: input.channel, + platform: input.platform, + osVersion: String(input.osVersion), + }, + // 実効設定。同じ測位でもこれが違えば挙動が変わるので、座標と必ずセットで持ち出す + config: { + maxPermitAccuracy: input.maxPermitAccuracy, + etaAssistEnabled: input.etaAssistEnabled, + autoModeEnabled: input.autoModeEnabled, + telemetryEnabled: input.telemetryEnabled, + backgroundLocationTracking: input.backgroundLocationTracking, + }, + location: { + raw: toCoordsSnapshot(input.rawLocation), + filtered: toCoordsSnapshot(input.filteredLocation), + accuracyHistory: input.accuracyHistory, + effectiveSpeedMps: input.effectiveSpeedMps, + // 変位から算出した値か、測位が運んできた実測かを区別する + speedIsMeasured: input.hasMeasuredSpeed, + }, + eta: { + phase: input.etaPhase, + anchor: input.etaAnchor, + }, + derived: { + nextStationId: input.nextStation?.id ?? null, + nextStationName: input.nextStation?.name ?? null, + distanceToNextStation: input.distanceToNextStation ?? null, + }, +}); + +/** クリップボードへ載せる文字列。読みながら貼れるよう整形する */ +export const formatDevDiagnosticsSnapshot = ( + input: DevDiagnosticsInput +): string => JSON.stringify(buildDevDiagnosticsSnapshot(input), null, 2); From 011f2a0bc1b03418c15e0673973a9d305c14ac73 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 14:02:19 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=E3=82=AF=E3=83=AA=E3=83=83=E3=83=97?= =?UTF-8?q?=E3=83=9C=E3=83=BC=E3=83=89=E3=82=92expo-clipboard=E3=81=B8?= =?UTF-8?q?=E7=A7=BB=E3=81=97=E9=9D=9E=E6=8E=A8=E5=A5=A8=E3=81=AEreact-nat?= =?UTF-8?q?ive=20Clipboard=E3=82=92=E3=82=84=E3=82=81=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit react-nativeのClipboardはcoreから切り出され「将来のリリースで削除する」と予告されている 非推奨API。現時点のRN 0.86.2では動くが、新規に使い始める先としては選ばない。 expo-clipboard@~57.0.2(expoのbundledNativeModulesがSDK 57に対して示すバージョン)を追加し、 src/utils/clipboard.tsの中だけを差し替える。setStringAsyncはPromiseを返すため、 copyTextToClipboardも非同期にして載せられたかどうかを返す。DevOverlay側は結果を待ち、 実際に載ったときだけCOPIEDを出す。失敗しているのに成功表示を出すと、貼り付けてみるまで 気付けないため。失敗時はCOPYのまま据え置き、例外はwarnで記録する。 ネイティブモジュールの追加なので、Devクライアントの再ビルドが要る。 ロックファイルはnpm 11で生成し、npm ci --dry-runが通ることを確認した。差分は expo-clipboardの12行のみで他パッケージへの波及は無い。 CodeRabbitの指摘(#6983)への対応。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Sfi8S4Yvob2sEzib4VUUBs --- package-lock.json | 12 ++++++++++++ package.json | 1 + src/components/DevOverlay.test.tsx | 29 ++++++++++++++++++++++++++--- src/components/DevOverlay.tsx | 13 ++++++++++--- src/utils/clipboard.ts | 19 +++++++++++-------- 5 files changed, 60 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index db9656854..069393955 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "expo-battery": "~57.0.2", "expo-blur": "~57.0.2", "expo-build-properties": "~57.0.14", + "expo-clipboard": "~57.0.2", "expo-constants": "~57.0.10", "expo-crypto": "~57.0.2", "expo-device": "~57.0.1", @@ -8965,6 +8966,17 @@ "node": ">=10" } }, + "node_modules/expo-clipboard": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-57.0.2.tgz", + "integrity": "sha512-VB4Au8X/RbvJKUtJk+87vdtveWSNEa5RzY3ooeO6VNF7Rd49RbQXZOu3/TfEW9p7mU8TCfyTvjyCiAUwSoSJ4w==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-constants": { "version": "57.0.14", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.14.tgz", diff --git a/package.json b/package.json index 0652df1f0..5b4af88ab 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "expo-battery": "~57.0.2", "expo-blur": "~57.0.2", "expo-build-properties": "~57.0.14", + "expo-clipboard": "~57.0.2", "expo-constants": "~57.0.10", "expo-crypto": "~57.0.2", "expo-device": "~57.0.1", diff --git a/src/components/DevOverlay.test.tsx b/src/components/DevOverlay.test.tsx index 60a0e6524..64d7c60ed 100644 --- a/src/components/DevOverlay.test.tsx +++ b/src/components/DevOverlay.test.tsx @@ -301,10 +301,19 @@ describe('DevOverlay', () => { }); describe('診断情報のコピー', () => { - it('ボタンを押すと診断情報をクリップボードへ載せる', () => { + // DevOverlay の COPIED_FEEDBACK_DURATION_MS と同値。exportしていないのでここで持つ + const COPIED_FEEDBACK_DURATION_MS = 1500; + + beforeEach(() => { + mockCopyTextToClipboard.mockResolvedValue(true); + }); + + it('ボタンを押すと診断情報をクリップボードへ載せる', async () => { const { getByTestId } = render(); fireEvent.press(getByTestId('dev-overlay-copy-button')); + // コピーはPromiseを返すので、解決後の状態更新までactの中で流す + await act(async () => {}); expect(mockCopyTextToClipboard).toHaveBeenCalledTimes(1); const copied = JSON.parse(mockCopyTextToClipboard.mock.calls[0][0]); @@ -321,17 +330,18 @@ describe('DevOverlay', () => { ); }); - it('押した直後はCOPIED表示になり、一定時間で戻る', () => { + it('押した直後はCOPIED表示になり、一定時間で戻る', async () => { jest.useFakeTimers(); try { const { getByTestId, getByText, queryByText } = render(); expect(getByText('COPY')).toBeTruthy(); fireEvent.press(getByTestId('dev-overlay-copy-button')); + await act(async () => {}); expect(getByText('COPIED')).toBeTruthy(); act(() => { - jest.advanceTimersByTime(1500); + jest.advanceTimersByTime(COPIED_FEEDBACK_DURATION_MS); }); expect(queryByText('COPIED')).toBeNull(); expect(getByText('COPY')).toBeTruthy(); @@ -339,6 +349,19 @@ describe('DevOverlay', () => { jest.useRealTimers(); } }); + + it('クリップボードへ載せられなかった場合はCOPIEDを出さない', async () => { + // 失敗しているのに成功表示を出すと、貼り付けてみるまで気付けない + mockCopyTextToClipboard.mockResolvedValue(false); + const { getByTestId, getByText, queryByText } = render(); + + fireEvent.press(getByTestId('dev-overlay-copy-button')); + await act(async () => {}); + + expect(mockCopyTextToClipboard).toHaveBeenCalledTimes(1); + expect(queryByText('COPIED')).toBeNull(); + expect(getByText('COPY')).toBeTruthy(); + }); }); describe('D&D座標変換', () => { diff --git a/src/components/DevOverlay.tsx b/src/components/DevOverlay.tsx index 6099404d0..4bc08bf11 100644 --- a/src/components/DevOverlay.tsx +++ b/src/components/DevOverlay.tsx @@ -537,8 +537,10 @@ const DevOverlay: React.FC = ({ unrotated = false }) => { [] ); - const handleCopyDiagnostics = () => { - copyTextToClipboard( + const handleCopyDiagnostics = async () => { + // 実際に載ったときだけ COPIED を出す。失敗しているのに成功表示を出すと、 + // 貼り付けてみるまで気付けない。 + const copied = await copyTextToClipboard( formatDevDiagnosticsSnapshot({ // レンダー中ではなくイベントハンドラ内なので Date.now() を直接読んでよい nowMs: Date.now(), @@ -563,6 +565,9 @@ const DevOverlay: React.FC = ({ unrotated = false }) => { distanceToNextStation, }) ); + if (!copied) { + return; + } setHasCopied(true); if (copiedTimerRef.current !== null) { clearTimeout(copiedTimerRef.current); @@ -927,7 +932,9 @@ const DevOverlay: React.FC = ({ unrotated = false }) => { accessibilityRole="button" accessibilityLabel="診断情報をコピー" testID="dev-overlay-copy-button" - onPress={handleCopyDiagnostics} + onPress={() => { + void handleCopyDiagnostics(); + }} style={({ pressed }) => [ styles.copyButton, statusPillStyle, diff --git a/src/utils/clipboard.ts b/src/utils/clipboard.ts index d34cdd017..01eae4220 100644 --- a/src/utils/clipboard.ts +++ b/src/utils/clipboard.ts @@ -1,16 +1,19 @@ -import { Clipboard } from 'react-native'; +import * as Clipboard from 'expo-clipboard'; /** - * 文字列をクリップボードへ載せる。 + * 文字列をクリップボードへ載せる。載せられたかどうかを返す。 * * react-native の Clipboard は core から切り出され「将来のリリースで削除する」と - * 予告されている(参照するとその旨の警告が出る)。本来は expo-clipboard へ移すべきだが、 - * ネイティブモジュールの追加になり、Devクライアントのリビルドとロックファイルの更新を伴う。 - * まずは依存を増やさずに済むこちらを使う。呼び出し側をこの1か所に閉じてあるので、 - * expo-clipboard を入れるときはこの関数の中だけを差し替えればよい。 + * 予告されている(参照するとその旨の警告が出る)。新しく使い始める先としては選ばない。 * + * 呼び出し側をこの1か所に閉じてあるので、載せ方を変えるときはこの関数の中だけで済む。 * 用途は DevOverlay の診断情報の持ち出しに限る(本番の画面からは呼ばない)。 */ -export const copyTextToClipboard = (text: string): void => { - Clipboard.setString(text); +export const copyTextToClipboard = async (text: string): Promise => { + try { + return await Clipboard.setStringAsync(text); + } catch (error) { + console.warn('クリップボードへのコピーに失敗しました:', error); + return false; + } }; From ee01a960b65f92761b69cb4daec0bda3a69121a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 14:07:49 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=E3=82=B3=E3=83=94=E3=83=BC=E5=A4=B1?= =?UTF-8?q?=E6=95=97=E6=99=82=E3=81=ABCOPIED=E8=A1=A8=E7=A4=BA=E3=81=A8?= =?UTF-8?q?=E8=A7=A3=E9=99=A4=E3=82=BF=E3=82=A4=E3=83=9E=E3=83=BC=E3=82=92?= =?UTF-8?q?=E8=90=BD=E3=81=A8=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 成功で立てた解除タイマーが生きているあいだに次のコピーが失敗すると、早期returnが 表示とタイマーを残したままにしていた。結果として、最後のコピーが失敗しているのに 前回のタイマーが切れるまでボタンがCOPIEDのままになる。「実際に載ったときだけCOPIEDを 出す」という契約と食い違う。 タイマーの解除を成否の判定より前へ出し、失敗時はsetHasCopied(false)して戻る。 成功直後に失敗させる回帰テストを追加した(修正前は失敗する)。 CodeRabbitの指摘(#6983)への対応。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Sfi8S4Yvob2sEzib4VUUBs --- src/components/DevOverlay.test.tsx | 25 +++++++++++++++++++++++++ src/components/DevOverlay.tsx | 10 +++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/components/DevOverlay.test.tsx b/src/components/DevOverlay.test.tsx index 64d7c60ed..79cc0bb34 100644 --- a/src/components/DevOverlay.test.tsx +++ b/src/components/DevOverlay.test.tsx @@ -350,6 +350,31 @@ describe('DevOverlay', () => { } }); + it('成功直後にコピーが失敗したらCOPIED表示とタイマーを解除する', async () => { + // 成功のタイマーが生きている間に失敗すると、古い表示が残って + // 「最後のコピーは失敗しているのにCOPIEDに見える」状態になる + jest.useFakeTimers(); + try { + const { getByTestId, getByText, queryByText } = render(); + + fireEvent.press(getByTestId('dev-overlay-copy-button')); + await act(async () => {}); + expect(getByText('COPIED')).toBeTruthy(); + + mockCopyTextToClipboard.mockResolvedValue(false); + act(() => { + jest.advanceTimersByTime(COPIED_FEEDBACK_DURATION_MS / 2); + }); + fireEvent.press(getByTestId('dev-overlay-copy-button')); + await act(async () => {}); + + expect(queryByText('COPIED')).toBeNull(); + expect(getByText('COPY')).toBeTruthy(); + } finally { + jest.useRealTimers(); + } + }); + it('クリップボードへ載せられなかった場合はCOPIEDを出さない', async () => { // 失敗しているのに成功表示を出すと、貼り付けてみるまで気付けない mockCopyTextToClipboard.mockResolvedValue(false); diff --git a/src/components/DevOverlay.tsx b/src/components/DevOverlay.tsx index 4bc08bf11..83072c378 100644 --- a/src/components/DevOverlay.tsx +++ b/src/components/DevOverlay.tsx @@ -565,13 +565,17 @@ const DevOverlay: React.FC = ({ unrotated = false }) => { distanceToNextStation, }) ); + if (copiedTimerRef.current !== null) { + clearTimeout(copiedTimerRef.current); + copiedTimerRef.current = null; + } if (!copied) { + // 直前の成功で立てた表示とタイマーをここで落とす。残すと、最後のコピーが + // 失敗しているのに前回のタイマーが切れるまでCOPIEDのままになる。 + setHasCopied(false); return; } setHasCopied(true); - if (copiedTimerRef.current !== null) { - clearTimeout(copiedTimerRef.current); - } copiedTimerRef.current = setTimeout(() => { copiedTimerRef.current = null; setHasCopied(false);