diff --git a/src/app.ts b/src/app.ts
index 352f552..ad57827 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -1,4 +1,4 @@
-import { applyI18n, getLocale, setLocale, t } from './i18n';
+import { applyI18n, getLocale, setLocale, t, isLocale, LOCALES, localeNames } from './i18n';
import type { Capabilities } from './support';
import { Session, buildExport } from './core/session';
import { mountTrail } from './panels/trail';
@@ -14,7 +14,9 @@ export function renderApp(root: HTMLElement, session: Session, caps: Capabilitie
${t('pageTitle')}
${t('pageSubtitle')}
-
+
${caps.touchDevice ? `${t('touchBanner')}
` : ''}
@@ -35,9 +37,13 @@ export function renderApp(root: HTMLElement, session: Session, caps: Capabilitie
mountScroll(root.querySelector('#panel-scroll') as HTMLElement, session);
mountPinch(root.querySelector('#panel-pinch') as HTMLElement, session, caps);
- (root.querySelector('#lang-toggle') as HTMLButtonElement).addEventListener('click', () => {
- setLocale(getLocale() === 'zh-TW' ? 'en' : 'zh-TW');
+ const langSelect = root.querySelector('#lang-select') as HTMLSelectElement;
+ langSelect.value = getLocale();
+ langSelect.addEventListener('change', () => {
+ if (!isLocale(langSelect.value)) return; // options 由 LOCALES 生成,此守衛防未來 DOM 漂移
+ setLocale(langSelect.value);
applyI18n(document);
+ langSelect.setAttribute('aria-label', t('langSelectLabel'));
document.title = t('pageTitle');
});
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
index 0c4c39b..4eb31a6 100644
--- a/src/i18n/index.ts
+++ b/src/i18n/index.ts
@@ -1,11 +1,18 @@
-import { strings, type Locale, type Strings } from './strings';
+import { strings, LOCALES, type Locale, type Strings } from './strings';
const STORAGE_KEY = 'mac-trackpad-test-lang';
let current: Locale = 'zh-TW';
+export function isLocale(value: string): value is Locale {
+ return (LOCALES as readonly string[]).includes(value);
+}
+
export function detectLocale(navLang: string, stored: string | null): Locale {
- if (stored === 'zh-TW' || stored === 'en') return stored;
- return navLang.toLowerCase().startsWith('zh') ? 'zh-TW' : 'en';
+ if (stored && isLocale(stored)) return stored;
+ const lang = navLang.toLowerCase();
+ if (lang.startsWith('zh')) return 'zh-TW';
+ if (lang.startsWith('ja')) return 'ja';
+ return 'en';
}
// localStorage 在無痕模式(舊 Safari)可能拋錯、在部分測試環境不存在——一律防禦性存取。
@@ -37,4 +44,4 @@ export function applyI18n(root: ParentNode): void {
});
}
-export { strings, type Locale, type Strings };
+export { strings, LOCALES, localeNames, type Locale, type Strings } from './strings';
diff --git a/src/i18n/strings.ts b/src/i18n/strings.ts
index d4a91a8..3402b42 100644
--- a/src/i18n/strings.ts
+++ b/src/i18n/strings.ts
@@ -1,7 +1,17 @@
-export type Locale = 'zh-TW' | 'en';
+// 單一事實來源:新增語言只在此檔動三處(LOCALES、localeNames、strings 字典),
+// Locale type、偵測白名單、選單 options 全部由此派生(verify #2 R1 收斂 finding)。
+export const LOCALES = ['zh-TW', 'en', 'ja'] as const;
+export type Locale = (typeof LOCALES)[number];
+
+// 選單顯示用的語言原生名(以各語言自身書寫,i18n 慣例,不隨介面語言翻譯)
+export const localeNames: Record = {
+ 'zh-TW': '繁體中文',
+ en: 'English',
+ ja: '日本語'
+};
export interface Strings {
- pageTitle: string; pageSubtitle: string; langToggle: string; touchBanner: string;
+ pageTitle: string; pageSubtitle: string; langSelectLabel: string; touchBanner: string;
trailTitle: string; trailHint: string;
hzLabel: string; maxJumpLabel: string; jumpCountLabel: string; clearBtn: string;
gridTitle: string; gridHint: string;
@@ -20,7 +30,7 @@ export const strings: Record = {
'zh-TW': {
pageTitle: 'Mac 觸控板健康檢查',
pageSubtitle: '依序做完五關。每一關都在測觸控板的一種故障模式(死區、微動開關、壓力感應、慣性捲動、多指手勢)。',
- langToggle: 'EN',
+ langSelectLabel: '介面語言',
touchBanner: '本工具供 Mac 觸控板檢測,請在 Mac 上用 Safari 開啟。',
trailTitle: '1. 軌跡測試 — 找死區與跳動',
trailHint: '單指掃滿整個觸控板表面,來回蓋滿。畫出的線應該連續平滑:斷線 = 死區。紅色線段 = 速度突變異常(這一步超過 50px 且是前一步的 3 倍以上)——快滑不會誤標,出現紅色代表游標從平順移動中突然瞬移。',
@@ -49,7 +59,7 @@ export const strings: Record = {
en: {
pageTitle: 'Mac Trackpad Test',
pageSubtitle: 'Run all five checks. Each targets one trackpad failure mode (dead zones, click switches, pressure sensing, inertial scrolling, multi-finger gestures).',
- langToggle: '中文',
+ langSelectLabel: 'Language',
touchBanner: 'This tool checks Mac trackpads — please open it in Safari on a Mac.',
trailTitle: '1. Trail — dead zones & cursor jumps',
trailHint: 'Sweep one finger across the whole trackpad surface until covered. The line should be continuous and smooth: a gap = dead zone. Red segments = velocity anomalies (a step over 50px and 3× the previous step) — fast swipes are not flagged; red means the cursor teleported mid-motion.',
@@ -74,5 +84,34 @@ export const strings: Record = {
needSafariTitle: 'Safari required',
needSafariForce: 'Pressure sensing uses Safari-only webkitForce events; Chrome/Firefox cannot run this check. Other checks still work.',
needSafariPinch: 'Pinch gestures use Safari-only GestureEvent; Chrome/Firefox cannot run this check. Other checks still work.'
+ },
+ ja: {
+ pageTitle: 'Mac トラックパッド健康チェック',
+ pageSubtitle: '5つのチェックを順番に行ってください。それぞれがトラックパッドの故障モード(デッドゾーン、クリックスイッチ、感圧センサー、慣性スクロール、マルチタッチジェスチャー)を検査します。',
+ langSelectLabel: '言語',
+ touchBanner: 'このツールは Mac のトラックパッド検査用です。Mac の Safari で開いてください。',
+ trailTitle: '1. 軌跡テスト — デッドゾーンとカーソル飛び',
+ trailHint: '指1本でトラックパッド全面をまんべんなくなぞってください。線は連続して滑らかなはずです:途切れ=デッドゾーン。赤い線分=速度異常(1回の移動量が50pxを超え、直前の3倍以上)——素早いスワイプは誤検出されません。赤が出たら、カーソルが突然飛んだ証拠です。',
+ hzLabel: 'イベントレート', maxJumpLabel: '最大移動量', jumpCountLabel: '異常数', clearBtn: 'クリア',
+ gridTitle: '2. 3×3 クリックグリッド — 四隅までクリック確認',
+ gridHint: '9マスすべてを左クリック(緑に変化)、次に右クリック/2本指クリック(青に変化)してください。反応しないマス=そのゾーンのスイッチまたはセンサー異常。',
+ leftLabel: '左', rightLabel: '右', dblLabel: 'ダブルクリック',
+ dblPass: '合格', dblPending: '未実施(任意のマスをダブルクリック)',
+ forceTitle: '3. Force Touch 感圧',
+ forceHint: '下のバーを押したまま、徐々に力を加えてください:バーは滑らかに上昇し、白線を超えると強めのクリック(触覚フィードバック)が発動します。数値が飛ぶ、または白線に届かない場合=感圧レイヤーの異常。',
+ forceNowLabel: '現在', forceMaxLabel: '最大', forceClickLabel: '強めのクリック',
+ forceClickDone: '作動 ✓', forceClickPending: '未検出',
+ scrollTitle: '4. 2本指スクロール — 滑らかさと慣性',
+ scrollHint: 'ボックス内を2本指で上下にスクロールし、指を離して慣性で流れるのを確認してください。指に追従し、滑らかに減速するはずです。',
+ scrollLine: '— 滑らかにスクロール', dyLabel: '現在 ΔY', wheelCountLabel: 'イベント数',
+ pinchTitle: '5. ピンチズームと回転',
+ pinchHint: '青い正方形の上で2本指のピンチイン/ピンチアウトや回転を行ってください。リアルタイムに追従すれば、各指が独立してトラッキングされている証拠です。',
+ pinchTarget: '2本指', scaleLabel: '拡大率', rotationLabel: '回転',
+ exportTitle: '6. 結果のエクスポート',
+ exportHint: '5つのチェックを終えたら下のボタンを押してください:結果がクリップボードにコピーされ(JSON もダウンロード)、AI や技術者に共有して判定してもらえます。',
+ exportBtn: '結果をコピー', exportCopied: 'コピーしました ✓', exportCopyFailed: 'クリップボードへのコピーに失敗 — ダウンロードしました',
+ needSafariTitle: 'Safari が必要です',
+ needSafariForce: '感圧センサーは Safari 専用の webkitForce イベントを使用します。Chrome/Firefox ではこのチェックは実行できません。他のチェックは利用できます。',
+ needSafariPinch: 'ピンチジェスチャーは Safari 専用の GestureEvent を使用します。Chrome/Firefox ではこのチェックは実行できません。他のチェックは利用できます。'
}
};
diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts
index 0e44710..fa8ca30 100644
--- a/tests/i18n.test.ts
+++ b/tests/i18n.test.ts
@@ -1,33 +1,52 @@
-import { strings } from '../src/i18n/strings';
-import { detectLocale, setLocale, getLocale, t, applyI18n } from '../src/i18n';
+import { strings, LOCALES, localeNames } from '../src/i18n/strings';
+import { detectLocale, setLocale, getLocale, t, applyI18n, isLocale } from '../src/i18n';
describe('i18n', () => {
- it('兩份字典 key 完全一致', () => {
- expect(Object.keys(strings['zh-TW']).sort()).toEqual(Object.keys(strings.en).sort());
+ it('三份字典 key 完全一致', () => {
+ const zhKeys = Object.keys(strings['zh-TW']).sort();
+ expect(Object.keys(strings.en).sort()).toEqual(zhKeys);
+ expect(Object.keys(strings.ja).sort()).toEqual(zhKeys);
});
- it('detectLocale:stored 優先、zh 前綴 → zh-TW、其他 → en', () => {
+ it('LOCALES 為單一事實來源:strings 與 localeNames 完全對齊', () => {
+ expect(Object.keys(strings).sort()).toEqual([...LOCALES].sort());
+ expect(Object.keys(localeNames).sort()).toEqual([...LOCALES].sort());
+ });
+
+ it('isLocale:LOCALES 全 true,其他 false', () => {
+ for (const l of LOCALES) expect(isLocale(l)).toBe(true);
+ expect(isLocale('garbage')).toBe(false);
+ expect(isLocale('ZH-TW')).toBe(false);
+ expect(isLocale('')).toBe(false);
+ });
+
+ it('detectLocale:stored 優先、zh 前綴 → zh-TW、ja 前綴 → ja、其他 → en', () => {
expect(detectLocale('zh-TW', null)).toBe('zh-TW');
expect(detectLocale('zh-CN', null)).toBe('zh-TW');
- expect(detectLocale('ja-JP', null)).toBe('en');
+ expect(detectLocale('ja-JP', null)).toBe('ja');
+ expect(detectLocale('ja', null)).toBe('ja');
+ expect(detectLocale('ko-KR', null)).toBe('en');
expect(detectLocale('zh-TW', 'en')).toBe('en');
expect(detectLocale('en-US', 'zh-TW')).toBe('zh-TW');
+ expect(detectLocale('en-US', 'ja')).toBe('ja');
expect(detectLocale('en-US', 'garbage')).toBe('en');
});
- it('setLocale + t 切換字典', () => {
+ it('setLocale + t 切換三份字典', () => {
setLocale('zh-TW');
expect(t('exportBtn')).toBe(strings['zh-TW'].exportBtn);
+ setLocale('ja');
+ expect(getLocale()).toBe('ja');
+ expect(t('exportBtn')).toBe(strings.ja.exportBtn);
setLocale('en');
- expect(getLocale()).toBe('en');
expect(t('exportBtn')).toBe(strings.en.exportBtn);
});
it('applyI18n 原地換掉 [data-i18n] 的文字', () => {
- setLocale('en');
+ setLocale('ja');
const div = document.createElement('div');
div.innerHTML = '舊字';
applyI18n(div);
- expect(div.querySelector('span')!.textContent).toBe(strings.en.trailTitle);
+ expect(div.querySelector('span')!.textContent).toBe(strings.ja.trailTitle);
});
});
diff --git a/tests/main-layout.test.ts b/tests/main-layout.test.ts
index 0416ad1..6a065bb 100644
--- a/tests/main-layout.test.ts
+++ b/tests/main-layout.test.ts
@@ -1,16 +1,20 @@
import { renderApp } from '../src/app';
import { Session } from '../src/core/session';
+import { setLocale } from '../src/i18n';
afterEach(() => { document.body.innerHTML = ''; });
describe('renderApp', () => {
- it('掛載六個 section 與語言切換鈕;不支援的面板顯示降級卡', () => {
+ it('掛載六個 section 與語言選單;不支援的面板顯示降級卡', () => {
const root = document.createElement('div');
document.body.appendChild(root);
const session = new Session();
renderApp(root, session, { forceTouch: false, gesture: false, touchDevice: false });
expect(root.querySelectorAll('section')).toHaveLength(6);
- expect(root.querySelector('#lang-toggle')).not.toBeNull();
+ const select = root.querySelector('#lang-select') as HTMLSelectElement;
+ expect(select).not.toBeNull();
+ expect([...select.options].map((o) => o.value)).toEqual(['zh-TW', 'en', 'ja']);
+ expect([...select.options].map((o) => o.textContent)).toEqual(['繁體中文', 'English', '日本語']);
expect(root.querySelectorAll('.notice-card')).toHaveLength(2);
expect(session.skipped.sort()).toEqual(['force', 'pinch']);
});
@@ -22,12 +26,23 @@ describe('renderApp', () => {
expect(root.querySelector('.banner')).not.toBeNull();
});
- it('語言切換鈕換字', () => {
+ it('語言選單切換換字(en 與 ja)', () => {
+ setLocale('zh-TW'); // 固定起點,消除測試順序依賴(verify R1 加固)
const root = document.createElement('div');
document.body.appendChild(root);
renderApp(root, new Session(), { forceTouch: true, gesture: true, touchDevice: false });
+ const select = root.querySelector('#lang-select') as HTMLSelectElement;
const before = root.querySelector('h1')!.textContent;
- (root.querySelector('#lang-toggle') as HTMLButtonElement).click();
- expect(root.querySelector('h1')!.textContent).not.toBe(before);
+
+ select.value = 'en';
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ const enTitle = root.querySelector('h1')!.textContent;
+ expect(enTitle).not.toBe(before);
+
+ select.value = 'ja';
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ const jaTitle = root.querySelector('h1')!.textContent;
+ expect(jaTitle).not.toBe(enTitle);
+ expect(jaTitle).not.toBe(before);
});
});
diff --git a/tests/session.test.ts b/tests/session.test.ts
index 4aea138..4b7f348 100644
--- a/tests/session.test.ts
+++ b/tests/session.test.ts
@@ -42,6 +42,11 @@ describe('Session / buildExport', () => {
expect(out['手勢']['最後縮放']).toBe(2.28);
});
+ it('language 欄位跟隨 meta(ja 案例,verify R1 覆蓋缺口)', () => {
+ const out = buildExport(new Session(), { ...META, language: 'ja' }) as Record;
+ expect(out['language']).toBe('ja');
+ });
+
it('skipped 清單:重複標記去重', () => {
const s = new Session();
s.markSkipped('force'); s.markSkipped('pinch'); s.markSkipped('force');