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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
100 changes: 99 additions & 1 deletion src/components/DevOverlay.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -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');
Expand Down Expand Up @@ -291,6 +300,95 @@ describe('DevOverlay', () => {
});
});

describe('診断情報のコピー', () => {
// DevOverlay の COPIED_FEEDBACK_DURATION_MS と同値。exportしていないのでここで持つ
const COPIED_FEEDBACK_DURATION_MS = 1500;

beforeEach(() => {
mockCopyTextToClipboard.mockResolvedValue(true);
});

it('ボタンを押すと診断情報をクリップボードへ載せる', async () => {
const { getByTestId } = render(<DevOverlay />);

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]);
// 座標だけでなく実効設定も載っていること。設定が無いと同じ測位でも
// 挙動を説明できないため、これが欠けると持ち出す意味が薄れる
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表示になり、一定時間で戻る', async () => {
jest.useFakeTimers();
try {
const { getByTestId, getByText, queryByText } = render(<DevOverlay />);
expect(getByText('COPY')).toBeTruthy();

fireEvent.press(getByTestId('dev-overlay-copy-button'));
await act(async () => {});
expect(getByText('COPIED')).toBeTruthy();

act(() => {
jest.advanceTimersByTime(COPIED_FEEDBACK_DURATION_MS);
});
expect(queryByText('COPIED')).toBeNull();
expect(getByText('COPY')).toBeTruthy();
} finally {
jest.useRealTimers();
}
});

it('成功直後にコピーが失敗したらCOPIED表示とタイマーを解除する', async () => {
// 成功のタイマーが生きている間に失敗すると、古い表示が残って
// 「最後のコピーは失敗しているのにCOPIEDに見える」状態になる
jest.useFakeTimers();
try {
const { getByTestId, getByText, queryByText } = render(<DevOverlay />);

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);
const { getByTestId, getByText, queryByText } = render(<DevOverlay />);

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座標変換', () => {
it('物理横向きではドラッグ量をright/top基準の移動量に変換する', () => {
expect(getDevOverlayDragTranslation(24, 10, false)).toEqual({
Expand Down
100 changes: 100 additions & 0 deletions src/components/DevOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
Animated,
Easing,
PanResponder,
Platform,
Pressable,
type StyleProp,
StyleSheet,
type TextStyle,
Expand All @@ -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';

Expand All @@ -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)';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -502,6 +523,64 @@ const DevOverlay: React.FC<Props> = ({ 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<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (copiedTimerRef.current !== null) {
clearTimeout(copiedTimerRef.current);
}
},
[]
);

const handleCopyDiagnostics = async () => {
// 実際に載ったときだけ COPIED を出す。失敗しているのに成功表示を出すと、
// 貼り付けてみるまで気付けない。
const copied = await 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,
})
);
if (copiedTimerRef.current !== null) {
clearTimeout(copiedTimerRef.current);
copiedTimerRef.current = null;
}
if (!copied) {
// 直前の成功で立てた表示とタイマーをここで落とす。残すと、最後のコピーが
// 失敗しているのに前回のタイマーが切れるまでCOPIEDのままになる。
setHasCopied(false);
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
setHasCopied(true);
copiedTimerRef.current = setTimeout(() => {
copiedTimerRef.current = null;
setHasCopied(false);
}, COPIED_FEEDBACK_DURATION_MS);
};
// ETA推定フェーズ(RUNNING/APPROACHING/DWELLING)を表示。フェーズ未推定時は IDLE。
const etaFallbackValue = etaPhase?.kind ?? 'IDLE';
// 推定対象の駅ID(走行/接近中は目標駅、停車中は当該駅)。
Expand Down Expand Up @@ -850,6 +929,27 @@ const DevOverlay: React.FC<Props> = ({ unrotated = false }) => {
value={backgroundValue}
style={statusPillStyle}
/>
{/* パネルのPanResponderはcaptureを使っていないため、子のPressableが
先にタッチを取る。展開/折りたたみのトグルとは競合しない。
折りたたみ中は上に載るcollapsedOverlayがタッチを受けるので押せない。 */}
<Pressable
accessibilityRole="button"
accessibilityLabel="診断情報をコピー"
testID="dev-overlay-copy-button"
onPress={() => {
void handleCopyDiagnostics();
}}
style={({ pressed }) => [
styles.copyButton,
statusPillStyle,
pressed && styles.copyButtonPressed,
]}
>
<Typography style={styles.statusLabel}>DIAGNOSTICS</Typography>
<Typography style={styles.statusValue}>
{hasCopied ? 'COPIED' : 'COPY'}
</Typography>
</Pressable>
</View>
</View>

Expand Down
19 changes: 19 additions & 0 deletions src/utils/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as Clipboard from 'expo-clipboard';

/**
* 文字列をクリップボードへ載せる。載せられたかどうかを返す。
*
* react-native の Clipboard は core から切り出され「将来のリリースで削除する」と
* 予告されている(参照するとその旨の警告が出る)。新しく使い始める先としては選ばない。
*
* 呼び出し側をこの1か所に閉じてあるので、載せ方を変えるときはこの関数の中だけで済む。
* 用途は DevOverlay の診断情報の持ち出しに限る(本番の画面からは呼ばない)。
*/
export const copyTextToClipboard = async (text: string): Promise<boolean> => {
try {
return await Clipboard.setStringAsync(text);
} catch (error) {
console.warn('クリップボードへのコピーに失敗しました:', error);
return false;
}
};
Loading
Loading