Skip to content
Open
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
14 changes: 10 additions & 4 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -14,7 +14,9 @@ export function renderApp(root: HTMLElement, session: Session, caps: Capabilitie
<h1 data-i18n="pageTitle">${t('pageTitle')}</h1>
<p class="sub" data-i18n="pageSubtitle">${t('pageSubtitle')}</p>
</div>
<button id="lang-toggle" class="lang-btn" data-i18n="langToggle">${t('langToggle')}</button>
<select id="lang-select" class="lang-btn" aria-label="${t('langSelectLabel')}">
${LOCALES.map((l) => `<option value="${l}">${localeNames[l]}</option>`).join('\n ')}
</select>
</header>
${caps.touchDevice ? `<div class="banner" data-i18n="touchBanner">${t('touchBanner')}</div>` : ''}
<section id="panel-trail"></section>
Expand All @@ -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');
});

Expand Down
15 changes: 11 additions & 4 deletions src/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -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)可能拋錯、在部分測試環境不存在——一律防禦性存取。
Expand Down Expand Up @@ -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';
47 changes: 43 additions & 4 deletions src/i18n/strings.ts
Original file line number Diff line number Diff line change
@@ -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<Locale, string> = {
'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;
Expand All @@ -20,7 +30,7 @@ export const strings: Record<Locale, Strings> = {
'zh-TW': {
pageTitle: 'Mac 觸控板健康檢查',
pageSubtitle: '依序做完五關。每一關都在測觸控板的一種故障模式(死區、微動開關、壓力感應、慣性捲動、多指手勢)。',
langToggle: 'EN',
langSelectLabel: '介面語言',
touchBanner: '本工具供 Mac 觸控板檢測,請在 Mac 上用 Safari 開啟。',
trailTitle: '1. 軌跡測試 — 找死區與跳動',
trailHint: '單指掃滿整個觸控板表面,來回蓋滿。畫出的線應該連續平滑:斷線 = 死區。紅色線段 = 速度突變異常(這一步超過 50px 且是前一步的 3 倍以上)——快滑不會誤標,出現紅色代表游標從平順移動中突然瞬移。',
Expand Down Expand Up @@ -49,7 +59,7 @@ export const strings: Record<Locale, Strings> = {
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.',
Expand All @@ -74,5 +84,34 @@ export const strings: Record<Locale, Strings> = {
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 ではこのチェックは実行できません。他のチェックは利用できます。'
}
};
39 changes: 29 additions & 10 deletions tests/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -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 = '<span data-i18n="trailTitle">舊字</span>';
applyI18n(div);
expect(div.querySelector('span')!.textContent).toBe(strings.en.trailTitle);
expect(div.querySelector('span')!.textContent).toBe(strings.ja.trailTitle);
});
});
25 changes: 20 additions & 5 deletions tests/main-layout.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
Expand All @@ -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);
});
});
5 changes: 5 additions & 0 deletions tests/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>;
expect(out['language']).toBe('ja');
});

it('skipped 清單:重複標記去重', () => {
const s = new Session();
s.markSkipped('force'); s.markSkipped('pinch'); s.markSkipped('force');
Expand Down