diff --git a/.changeset/e8-wave-b-v2-completeness.md b/.changeset/e8-wave-b-v2-completeness.md new file mode 100644 index 000000000..e80ad9ab6 --- /dev/null +++ b/.changeset/e8-wave-b-v2-completeness.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': minor +--- + +等值雙列(v2)功能補全第二波:換錢所來源切換(支援幣別如 KRW 一鍵切換 bank ⇄ 換錢所)、計算進行中的迷你運算式列、趨勢圖 sheet 新增現金/即期基準切換與「查看幣別攻略」連結、螢幕閱讀器換算結果播報(settle 後 aria-live 摘要,4 語系)。 diff --git a/apps/ratewise/src/features/ratewise/components/converter-v2/ConverterKeypad.tsx b/apps/ratewise/src/features/ratewise/components/converter-v2/ConverterKeypad.tsx index cb3d9a5a9..92d91b05d 100644 --- a/apps/ratewise/src/features/ratewise/components/converter-v2/ConverterKeypad.tsx +++ b/apps/ratewise/src/features/ratewise/components/converter-v2/ConverterKeypad.tsx @@ -19,6 +19,8 @@ export interface ConverterKeypadProps { initialValue: number; /** 鍵入後的即時等值(純數字直出、含運算子時取引擎 preview)。 */ onValueChange: (value: number) => void; + /** 表達式變動即回報原始字串(含掛載種子);父層據此顯示運算式列(E8 缺口 5)。 */ + onExpressionChange?: (expression: string) => void; /** 實體鍵盤是否啟用;sheet 開啟時由父層關閉,避免與 sheet 鍵盤語意衝突。預設啟用。 */ keyboardEnabled?: boolean; } @@ -74,6 +76,7 @@ function getKeyClassName(kind: KeypadKey['kind']): string { export function ConverterKeypad({ initialValue, onValueChange, + onExpressionChange, keyboardEnabled = true, }: ConverterKeypadProps) { const { t } = useTranslation(); @@ -88,6 +91,15 @@ export function ConverterKeypad({ const prevExpressionRef = useRef(expression); const longPressTimerRef = useRef(null); const longPressFiredRef = useRef(false); + const onExpressionChangeRef = useRef(onExpressionChange); + useEffect(() => { + onExpressionChangeRef.current = onExpressionChange; + }); + + // 表達式回報與回寫閘門分離:掛載種子與每次變動皆回報,可見性判斷交由父層。 + useEffect(() => { + onExpressionChangeRef.current?.(expression); + }, [expression]); // 鍵入即時回寫活躍列:純數字直接送出,含運算子時等引擎 preview(50ms 防抖)。 useEffect(() => { diff --git a/apps/ratewise/src/features/ratewise/components/converter-v2/SingleConverterV2.tsx b/apps/ratewise/src/features/ratewise/components/converter-v2/SingleConverterV2.tsx index 7fcad68a7..17bc3946e 100644 --- a/apps/ratewise/src/features/ratewise/components/converter-v2/SingleConverterV2.tsx +++ b/apps/ratewise/src/features/ratewise/components/converter-v2/SingleConverterV2.tsx @@ -15,8 +15,10 @@ import { lazy, } from 'react'; import { useTranslation } from 'react-i18next'; +import { Store } from 'lucide-react'; import type { SingleConverterProps } from '../SingleConverter'; import type { CurrencyCode, RateType } from '../../types'; +import { isCurrencyPagePath, isReverseCurrencyPagePath } from '../../../../config/seo-paths'; import { CURRENCY_DEFINITIONS, CURRENCY_QUICK_AMOUNTS, @@ -69,6 +71,8 @@ interface CurrencyRowProps { onActivate: (field: RowField) => void; /** 長按(或桌面右鍵)金額列時複製換算結果(E8 缺口 6)。 */ onCopyResult: () => void; + /** 計算進行中的迷你運算式(E8 缺口 5);僅活躍列顯示,overlay 定位不佔版面高度。 */ + liveExpression?: string | null; } function CurrencyRow({ @@ -79,6 +83,7 @@ function CurrencyRow({ onOpenPicker, onActivate, onCopyResult, + liveExpression = null, }: CurrencyRowProps) { const { t } = useTranslation(); const display = formatAmountDisplay(amount, currency) || '0'; @@ -163,10 +168,21 @@ function CurrencyRow({ return (
+ {/* E8 缺口 5:迷你運算式列——absolute overlay 疊在列頂緣,顯示/隱藏零位移(CLS 0)。 + justify-end+overflow-hidden:超長表達式左緣截尾,最新輸入(右端)必可見。 */} + {isActive && liveExpression && ( + + )} + )}
+ {/* E8 缺口 10(#620):settle 後以 aria-live 播報換算結果(非逐鍵,節流即 settle 機制本身)。 + 常駐 DOM 供 SR 註冊 live region;視覺隱藏(sr-only),畫面零變化。 */} +
+ {settledSnapshot + ? t('converterV2.settleAnnouncement', { + amount: + formatAmountDisplay(settledSnapshot.fromAmount, settledSnapshot.fromCurrency) || + settledSnapshot.fromAmount, + from: settledSnapshot.fromCurrency, + result: + formatAmountDisplay(settledSnapshot.toAmount, settledSnapshot.toCurrency) || + settledSnapshot.toAmount, + to: settledSnapshot.toCurrency, + }) + : ''} +
+ setIsTrendOpen(false)} fromCurrency={fromCurrency} toCurrency={toCurrency} - data={trend.data} - basisLabel={trendBasisLabel} + data={sheetTrend.data} + basisLabel={sheetBasisLabel} + basis={sheetBasis} + onBasisChange={handleSheetBasisChange} + showBasisToggle={!isExchangeShopActive} + guidePath={trendGuidePath} + guideCode={trendGuideCode} /> ); diff --git a/apps/ratewise/src/features/ratewise/components/converter-v2/TrendSheet.tsx b/apps/ratewise/src/features/ratewise/components/converter-v2/TrendSheet.tsx index ea1a0f6d4..b2e29c7b7 100644 --- a/apps/ratewise/src/features/ratewise/components/converter-v2/TrendSheet.tsx +++ b/apps/ratewise/src/features/ratewise/components/converter-v2/TrendSheet.tsx @@ -2,17 +2,20 @@ * E3 v2 趨勢 bottom sheet:65vh、7D/30D/90D 切換、min/max 標記、長按 crosshair(chart 內建)。 * 基準誠實標註:資料不足期間不推估,顯示實際可用天數。 * E1 收斂:殼層改用 BottomSheet primitive、範圍切換改用 SegmentedControl(radiogroup 語意)。 + * E8 缺口 8:基準切換(現金/即期,值-標籤由父層 resolveTrendSeries 同源)+幣別攻略連結。 * @see .claude/prds/ratewise-e1-design-system-design.md + * @see .claude/prds/converter-v2-completeness-design.md */ import { useMemo, useState, Suspense, lazy } from 'react'; import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; import { BottomSheet } from '../../../../components/BottomSheet'; import { SegmentedControl } from '../../../../components/SegmentedControl'; import { formatExchangeRate } from '../../../../utils/currencyFormatter'; import type { MiniTrendDataPoint } from '../MiniTrendChart'; import { TrendChartSkeleton } from '../TrendChartSkeleton'; -import type { CurrencyCode } from '../../types'; +import type { CurrencyCode, RateType } from '../../types'; const MiniTrendChart = lazy(() => import('../MiniTrendChart').then((m) => ({ default: m.MiniTrendChart })), @@ -27,9 +30,17 @@ export interface TrendSheetProps { onClose: () => void; fromCurrency: CurrencyCode; toCurrency: CurrencyCode; - /** 已排序(舊→新)的完整趨勢資料。 */ + /** 已排序(舊→新)的完整趨勢資料(跟隨 sheet 選擇的基準)。 */ data: MiniTrendDataPoint[]; basisLabel: string; + /** sheet 內選擇的趨勢基準(E8 缺口 8);實際採用序列可能誠實回落,標註以 basisLabel 為準。 */ + basis: RateType; + onBasisChange: (basis: RateType) => void; + /** 換錢所來源無現金/即期語意,隱藏基準切換。 */ + showBasisToggle: boolean; + /** 幣別攻略落地頁路徑(seo-paths SSOT 判斷存在才提供);null 不渲染連結。 */ + guidePath: string | null; + guideCode: CurrencyCode; } export function TrendSheet({ @@ -39,6 +50,11 @@ export function TrendSheet({ toCurrency, data, basisLabel, + basis, + onBasisChange, + showBasisToggle, + guidePath, + guideCode, }: TrendSheetProps) { const { t } = useTranslation(); const [range, setRange] = useState('30d'); @@ -89,43 +105,79 @@ export function TrendSheet({ }))} /> - {/* min/max 標記 */} -
- {stats ? ( - <> - - {t('converterV2.trendMin')}{' '} - - {formatExchangeRate(stats.min.rate)} - {' '} - ({stats.min.date.slice(5)}) - - - {t('converterV2.trendMax')}{' '} - - {formatExchangeRate(stats.max.rate)} - {' '} - ({stats.max.date.slice(5)}) - - - ) : ( - {t('converterV2.trendEmpty')} + {/* min/max 標記+基準切換(E8 缺口 8):窄視口避免同列擠壓,min/max 收縮截尾、切換鈕不縮。 */} +
+
+ {stats ? ( + <> + + {t('converterV2.trendMin')}{' '} + + {formatExchangeRate(stats.min.rate)} + {' '} + ({stats.min.date.slice(5, 10)}) + + + {t('converterV2.trendMax')}{' '} + + {formatExchangeRate(stats.max.rate)} + {' '} + ({stats.max.date.slice(5, 10)}) + + + ) : ( + {t('converterV2.trendEmpty')} + )} +
+ {/* 基準切換:值-標籤同步由父層 resolveTrendSeries 保證(即期缺失誠實回落現金)。 */} + {showBasisToggle && ( +
+ +
)}
- {/* 主圖表:長按 crosshair 由 lightweight-charts trackingMode 內建 */} + {/* 主圖表:長按 crosshair 由 lightweight-charts trackingMode 內建; + basisLabel 傳入後圖角常駐標註+hover/touch tooltip 顯示基準與數據點值(E8 缺口 8)。 */}
{displayed.length >= 2 ? ( }> - + ) : ( )}
- {/* 基準誠實標註 */} + {/* 基準誠實標註+幣別攻略連結(E8 缺口 8:pair 落地頁存在才顯示) */}
+ {guidePath && ( + + {t('converterV2.trendGuideLink', { code: guideCode })} + + )}
{t('converterV2.trendBasisNote', { basis: basisLabel })}
{isDataShort && (
diff --git a/apps/ratewise/src/features/ratewise/components/converter-v2/__tests__/SingleConverterV2.test.tsx b/apps/ratewise/src/features/ratewise/components/converter-v2/__tests__/SingleConverterV2.test.tsx index ce9907b41..fb736df91 100644 --- a/apps/ratewise/src/features/ratewise/components/converter-v2/__tests__/SingleConverterV2.test.tsx +++ b/apps/ratewise/src/features/ratewise/components/converter-v2/__tests__/SingleConverterV2.test.tsx @@ -5,8 +5,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, act } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; import '@testing-library/jest-dom/vitest'; import { SingleConverterV2 } from '../SingleConverterV2'; +import { useConverterTrend } from '../useConverterTrend'; import type { SingleConverterProps } from '../../SingleConverter'; import type { CurrencyCode } from '../../../types'; import type { RateDetails } from '../../../../../utils/offlineStorage'; @@ -1244,3 +1246,400 @@ describe('SingleConverterV2 - 大金額自適應縮放(#590)', () => { ); }); }); + +describe('SingleConverterV2 - E8 wave-B:運算式列(缺口 5)', () => { + beforeEach(() => { + vi.useFakeTimers(); + Object.defineProperty(navigator, 'vibrate', { value: vi.fn(), writable: true }); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('預設與純數字輸入不顯示運算式列(僅計算進行中顯示)', () => { + render(); + + expect(screen.queryByTestId('converter-v2-expression')).not.toBeInTheDocument(); + + // 純數字(#633 首鍵取代種子):無運算子,不視為計算進行中。 + fireEvent.click(screen.getByTestId('converter-v2-key-7')); + act(() => { + vi.advanceTimersByTime(100); + }); + expect(screen.queryByTestId('converter-v2-expression')).not.toBeInTheDocument(); + }); + + it('含運算子表達式顯示於活躍列(如「1000 + 5」),settle 後隱藏', () => { + render(); + + fireEvent.click(screen.getByTestId('converter-v2-key-+')); + fireEvent.click(screen.getByTestId('converter-v2-key-5')); + act(() => { + vi.advanceTimersByTime(100); + }); + + expect(screen.getByTestId('converter-v2-expression')).toHaveTextContent('1000 + 5'); + + // settle(停頓 2s)=計算結束,運算式列隱藏。 + act(() => { + vi.advanceTimersByTime(2100); + }); + expect(screen.queryByTestId('converter-v2-expression')).not.toBeInTheDocument(); + }); + + it('CLS 零位移:運算式列為 absolute overlay,不佔版面高度', () => { + render(); + + fireEvent.click(screen.getByTestId('converter-v2-key-+')); + fireEvent.click(screen.getByTestId('converter-v2-key-3')); + act(() => { + vi.advanceTimersByTime(100); + }); + + const overlay = screen.getByTestId('converter-v2-expression'); + expect(overlay.className).toContain('absolute'); + expect(overlay.className).toContain('pointer-events-none'); + }); + + it('切列(settle 邊界)後運算式列隱藏', () => { + render(); + + fireEvent.click(screen.getByTestId('converter-v2-key-+')); + fireEvent.click(screen.getByTestId('converter-v2-key-5')); + act(() => { + vi.advanceTimersByTime(100); + }); + expect(screen.getByTestId('converter-v2-expression')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('converter-v2-amount-to')); + expect(screen.queryByTestId('converter-v2-expression')).not.toBeInTheDocument(); + }); + + it('B2 語意變更(swap)取消 settle 同時隱藏運算式列', () => { + render(); + + fireEvent.click(screen.getByTestId('converter-v2-key-+')); + fireEvent.click(screen.getByTestId('converter-v2-key-5')); + act(() => { + vi.advanceTimersByTime(100); + }); + + fireEvent.click(screen.getByTestId('converter-v2-swap')); + expect(screen.queryByTestId('converter-v2-expression')).not.toBeInTheDocument(); + }); +}); + +describe('SingleConverterV2 - E8 wave-B:換錢所來源切換(缺口 3)', () => { + beforeEach(() => { + vi.useFakeTimers(); + Object.defineProperty(navigator, 'vibrate', { value: vi.fn(), writable: true }); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('不支援幣別零暴露:無 exchangeShopCurrency 時不渲染切換鈕', () => { + render(); + + expect(screen.queryByTestId('converter-v2-rate-source')).not.toBeInTheDocument(); + }); + + it('支援幣別(KRW)顯示切換鈕,點擊切至換錢所', () => { + const onRateSourceChange = vi.fn(); + render( + , + ); + + const toggle = screen.getByTestId('converter-v2-rate-source'); + expect(toggle).toHaveAttribute('aria-pressed', 'false'); + + fireEvent.click(toggle); + expect(onRateSourceChange).toHaveBeenCalledWith('exchange-shop'); + }); + + it('換錢所啟用中:chip 標註換錢所(#659 分支)、切換鈕 aria-pressed、再點回銀行', () => { + const onRateSourceChange = vi.fn(); + render( + , + ); + + expect(screen.getByTestId('converter-v2-rate-chip')).toHaveTextContent('換錢所'); + const toggle = screen.getByTestId('converter-v2-rate-source'); + expect(toggle).toHaveAttribute('aria-pressed', 'true'); + + fireEvent.click(toggle); + expect(onRateSourceChange).toHaveBeenCalledWith('bank'); + }); + + it('換錢所啟用中 chip tap=回銀行基準,不切現金/即期', () => { + const onRateSourceChange = vi.fn(); + const props = buildProps({ + fromCurrency: 'TWD', + toCurrency: 'KRW', + rateSource: 'exchange-shop', + exchangeShopCurrency: 'KRW', + onRateSourceChange, + }); + render(); + + fireEvent.click(screen.getByTestId('converter-v2-rate-chip')); + + expect(onRateSourceChange).toHaveBeenCalledWith('bank'); + expect(props.onRateTypeChange).not.toHaveBeenCalled(); + }); + + it('B2:pending settle 中切換來源取消寫入(語意變更不寫失真值)', () => { + const props = buildProps({ + fromCurrency: 'TWD', + toCurrency: 'KRW', + exchangeShopCurrency: 'KRW', + onRateSourceChange: vi.fn(), + }); + render(); + + fireEvent.click(screen.getByTestId('converter-v2-key-5')); + fireEvent.click(screen.getByTestId('converter-v2-rate-source')); + + act(() => { + vi.advanceTimersByTime(3000); + }); + expect(props.onAddToHistory).not.toHaveBeenCalled(); + }); +}); + +describe('SingleConverterV2 - E8 wave-B:SR settle 播報(缺口 10,#620)', () => { + beforeEach(() => { + vi.useFakeTimers(); + Object.defineProperty(navigator, 'vibrate', { value: vi.fn(), writable: true }); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('aria-live=polite 摘要區常駐 DOM 且初始為空(SR 註冊 live region 前置條件)', () => { + render(); + + const region = screen.getByTestId('converter-v2-sr-summary'); + expect(region).toHaveAttribute('aria-live', 'polite'); + expect(region).toHaveAttribute('role', 'status'); + expect(region).toHaveTextContent(''); + }); + + it('settle 後播報「{amount} {from} 等於 {result} {to}」,逐鍵期間不播報', () => { + render(); + + fireEvent.click(screen.getByTestId('converter-v2-key-5')); + act(() => { + vi.advanceTimersByTime(500); + }); + // 非逐鍵:settle 前不得有播報內容。 + expect(screen.getByTestId('converter-v2-sr-summary')).toHaveTextContent(''); + + act(() => { + vi.advanceTimersByTime(1700); + }); + expect(screen.getByTestId('converter-v2-sr-summary')).toHaveTextContent( + '1,000.00 TWD 等於 31.58 USD', + ); + }); + + it('切列 settle 邊界同樣觸發播報(共用同一 settle 事件,無平行計時器)', () => { + render(); + + fireEvent.click(screen.getByTestId('converter-v2-key-5')); + fireEvent.click(screen.getByTestId('converter-v2-amount-to')); + + expect(screen.getByTestId('converter-v2-sr-summary')).toHaveTextContent( + '1,000.00 TWD 等於 31.58 USD', + ); + }); + + it('B1 守門共用:金額 0 settle 不播報', () => { + const props = buildProps({ fromAmount: '5', toAmount: '0.16' }); + const { rerender } = render(); + + fireEvent.click(screen.getByTestId('converter-v2-key-backspace')); + act(() => { + vi.advanceTimersByTime(100); + }); + rerender(); + + act(() => { + vi.advanceTimersByTime(2100); + }); + expect(screen.getByTestId('converter-v2-sr-summary')).toHaveTextContent(''); + }); + + it('視覺零變化:摘要區為 sr-only', () => { + render(); + + expect(screen.getByTestId('converter-v2-sr-summary').className).toContain('sr-only'); + }); +}); + +describe('SingleConverterV2 - E8 wave-B:趨勢 sheet 強化(缺口 8)', () => { + const trendPoints = [ + { date: '2026-07-01', rate: 31.2 }, + { date: '2026-07-02', rate: 31.4 }, + { date: '2026-07-03', rate: 31.6 }, + ]; + + beforeEach(() => { + vi.useFakeTimers(); + Object.defineProperty(navigator, 'vibrate', { value: vi.fn(), writable: true }); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + vi.clearAllMocks(); + // 還原檔頭預設 mock,避免污染其他 describe。 + vi.mocked(useConverterTrend).mockImplementation(() => ({ + data: [], + isLoading: false, + trendRateType: 'spot', + })); + }); + + function renderWithRouter(props = buildProps()) { + return render( + + + , + ); + } + + function openTrendSheet() { + fireEvent.click(screen.getByTestId('converter-v2-sparkline')); + } + + it('sheet 內顯示基準切換(現金/即期),點擊切換後標註跟隨', () => { + vi.mocked(useConverterTrend).mockImplementation(({ rateType }) => ({ + data: trendPoints, + isLoading: false, + trendRateType: rateType, + })); + renderWithRouter(buildProps({ rateType: 'cash' })); + openTrendSheet(); + + const cashOption = screen.getByTestId('converter-v2-trend-basis-cash'); + expect(cashOption).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByText(/基準:現金賣出/)).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('converter-v2-trend-basis-spot')); + expect(screen.getByTestId('converter-v2-trend-basis-spot')).toHaveAttribute( + 'aria-checked', + 'true', + ); + expect(screen.getByText(/基準:即期賣出/)).toBeInTheDocument(); + }); + + it('誠實回落(#564):KRW 即期序列缺失時切即期,序列與標註同步回落現金賣出', () => { + // 模擬 resolveTrendSeries 行為:要求 spot 但可繪點不足 → 回傳 cash 序列+實際基準 cash。 + vi.mocked(useConverterTrend).mockImplementation(({ rateType }) => + rateType === 'spot' + ? { data: trendPoints, isLoading: false, trendRateType: 'cash' } + : { data: trendPoints, isLoading: false, trendRateType: rateType }, + ); + renderWithRouter( + buildProps({ + fromCurrency: 'KRW', + toCurrency: 'TWD', + rateType: 'cash', + rateTypeAvailability: { spot: false, cash: true }, + }), + ); + openTrendSheet(); + + fireEvent.click(screen.getByTestId('converter-v2-trend-basis-spot')); + + // 值-標籤同步:實際採用序列為現金 → 標註必須顯示現金賣出,不得宣稱即期。 + expect(screen.getByText(/基準:現金賣出/)).toBeInTheDocument(); + expect(screen.queryByText(/基準:即期賣出/)).not.toBeInTheDocument(); + }); + + it('攻略連結:pair 落地頁存在(TWD→USD=/twd-usd/)時顯示並指向該頁', () => { + renderWithRouter(); + openTrendSheet(); + + const link = screen.getByTestId('converter-v2-trend-guide-link'); + expect(link).toHaveAttribute('href', '/twd-usd/'); + expect(link).toHaveTextContent('查看 USD 攻略'); + }); + + it('攻略連結:pair 落地頁不存在(USD→JPY)時零暴露', () => { + renderWithRouter(buildProps({ fromCurrency: 'USD', toCurrency: 'JPY' })); + openTrendSheet(); + + expect(screen.queryByTestId('converter-v2-trend-guide-link')).not.toBeInTheDocument(); + }); + + it('換錢所來源時 sheet 隱藏基準切換(無現金/即期語意)', () => { + renderWithRouter( + buildProps({ + fromCurrency: 'TWD', + toCurrency: 'KRW', + rateSource: 'exchange-shop', + exchangeShopCurrency: 'KRW', + onRateSourceChange: vi.fn(), + }), + ); + openTrendSheet(); + + expect(screen.queryByTestId('converter-v2-trend-basis-cash')).not.toBeInTheDocument(); + expect(screen.queryByTestId('converter-v2-trend-basis-spot')).not.toBeInTheDocument(); + }); +}); + +describe('SingleConverterV2 - E8 wave-B i18n keys(四語系守門)', () => { + const locales = [ + ['zh-TW', zhTW], + ['en', en], + ['ja', ja], + ['ko', ko], + ] as const; + + it.each(locales)('%s 應提供 converterV2.settleAnnouncement(含四個插值槽)', (_name, locale) => { + const template = locale.converterV2.settleAnnouncement; + expect(template).toEqual(expect.any(String)); + for (const slot of ['{{amount}}', '{{from}}', '{{result}}', '{{to}}']) { + expect(template).toContain(slot); + } + }); + + it.each(locales)( + '%s 應提供 converterV2.trendGuideLink 與 trendBasisSwitchLabel', + (_name, locale) => { + expect(locale.converterV2.trendGuideLink).toContain('{{code}}'); + expect(locale.converterV2.trendBasisSwitchLabel.length).toBeGreaterThan(0); + }, + ); + + it('zh-TW settle 播報格式為「{amount} {from} 等於 {result} {to}」', () => { + expect(zhTW.converterV2.settleAnnouncement).toBe('{{amount}} {{from}} 等於 {{result}} {{to}}'); + }); +}); diff --git a/apps/ratewise/src/i18n/locales/en.ts b/apps/ratewise/src/i18n/locales/en.ts index 25fd9d7ab..4c5164bd9 100644 --- a/apps/ratewise/src/i18n/locales/en.ts +++ b/apps/ratewise/src/i18n/locales/en.ts @@ -121,8 +121,11 @@ const en = { trendMin: 'Low', trendMax: 'High', trendBasisNote: 'Basis: {{basis}} · Bank of Taiwan board rate', + trendBasisSwitchLabel: 'Trend basis', + trendGuideLink: 'View {{code}} guide →', trendHonestRange: 'Only {{days}} days of history available; missing range is not estimated', trendEmpty: 'No historical data yet', + settleAnnouncement: '{{amount}} {{from}} equals {{result}} {{to}}', close: 'Close', skeletonLoading: 'Loading dual-row converter...', }, diff --git a/apps/ratewise/src/i18n/locales/ja.ts b/apps/ratewise/src/i18n/locales/ja.ts index 677a71ff3..20525d948 100644 --- a/apps/ratewise/src/i18n/locales/ja.ts +++ b/apps/ratewise/src/i18n/locales/ja.ts @@ -121,8 +121,11 @@ const ja = { trendMin: '最安', trendMax: '最高', trendBasisNote: '基準:{{basis}}・台湾銀行公示レート', + trendBasisSwitchLabel: '推移基準', + trendGuideLink: '{{code}} ガイドを見る →', trendHonestRange: '履歴データは現在 {{days}} 日分のみで、不足期間は推定しません', trendEmpty: '履歴データがありません', + settleAnnouncement: '{{amount}} {{from}} は {{result}} {{to}} です', close: '閉じる', skeletonLoading: '等価2行換算を読み込み中...', }, diff --git a/apps/ratewise/src/i18n/locales/ko.ts b/apps/ratewise/src/i18n/locales/ko.ts index bebc69e3a..2168d3f19 100644 --- a/apps/ratewise/src/i18n/locales/ko.ts +++ b/apps/ratewise/src/i18n/locales/ko.ts @@ -121,8 +121,11 @@ const ko = { trendMin: '최저', trendMax: '최고', trendBasisNote: '기준: {{basis}}・대만은행 고시 환율', + trendBasisSwitchLabel: '추이 기준', + trendGuideLink: '{{code}} 가이드 보기 →', trendHonestRange: '현재 {{days}}일치 데이터만 제공되며 부족 구간은 추정하지 않습니다', trendEmpty: '과거 데이터가 없습니다', + settleAnnouncement: '{{amount}} {{from}}은(는) {{result}} {{to}}입니다', close: '닫기', skeletonLoading: '등가 2행 환산 불러오는 중...', }, diff --git a/apps/ratewise/src/i18n/locales/zh-TW.ts b/apps/ratewise/src/i18n/locales/zh-TW.ts index 039d8da88..55ea21273 100644 --- a/apps/ratewise/src/i18n/locales/zh-TW.ts +++ b/apps/ratewise/src/i18n/locales/zh-TW.ts @@ -121,8 +121,11 @@ const zhTW = { trendMin: '最低', trendMax: '最高', trendBasisNote: '基準:{{basis}}・臺灣銀行牌告', + trendBasisSwitchLabel: '走勢基準', + trendGuideLink: '查看 {{code}} 攻略 →', trendHonestRange: '目前歷史資料僅 {{days}} 天,不足期間不做推估', trendEmpty: '暫無歷史資料', + settleAnnouncement: '{{amount}} {{from}} 等於 {{result}} {{to}}', close: '關閉', skeletonLoading: '載入等值雙列換算中...', }, diff --git a/apps/ratewise/tests/e2e/converter-v2.spec.ts b/apps/ratewise/tests/e2e/converter-v2.spec.ts index 07f200a20..2df197d5b 100644 --- a/apps/ratewise/tests/e2e/converter-v2.spec.ts +++ b/apps/ratewise/tests/e2e/converter-v2.spec.ts @@ -497,6 +497,148 @@ test.describe('Converter v2 等值雙列(flag on)', () => { expect(consoleErrors).toEqual([]); }); + test('E8 wave-B:運算式列進行中顯示(overlay 零位移)、settle 後隱藏+SR 播報', async ({ + rateWisePage: page, + }, testInfo) => { + const consoleErrors: string[] = []; + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()); + }); + + await gotoConverterV2(page); + + const scrollHeightBefore = await page.evaluate(() => document.documentElement.scrollHeight); + expect(await page.getByTestId('converter-v2-expression').count()).toBe(0); + + // 鍵入含運算子表達式:迷你運算式列顯示於活躍列。 + await page.keyboard.press('Delete'); + await page.keyboard.type('5+3'); + await expect(page.getByTestId('converter-v2-expression')).toHaveText('5 + 3'); + + // CLS 零位移:overlay 不得改變文件高度(三視口零捲動硬約束不回歸)。 + const scrollHeightDuring = await page.evaluate(() => document.documentElement.scrollHeight); + expect(scrollHeightDuring).toBe(scrollHeightBefore); + + // e2e 阻擋 SW 產生的更新失敗 toast 與待測 UI 無關,證據截圖前先隱藏。 + await page.addStyleTag({ + content: '[aria-labelledby="update-prompt-title"] { display: none !important; }', + }); + await page.screenshot({ + path: `test-results/v2-e8-expression-row-${testInfo.project.name}.png`, + fullPage: false, + }); + + // settle(停頓 2s)後運算式列隱藏,SR 摘要區完成播報(#620)。 + await expect(page.getByTestId('converter-v2-expression')).toHaveCount(0, { timeout: 5000 }); + await expect(page.getByTestId('converter-v2-sr-summary')).not.toBeEmpty(); + + expect(consoleErrors).toEqual([]); + }); + + test('E8 wave-B:換錢所來源切換——KRW 顯示、切換後 chip 標註、320px 不溢出', async ({ + rateWisePage: page, + }, testInfo) => { + const consoleErrors: string[] = []; + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()); + }); + + // mock 換錢所 provider 端點避免觸外網(latest 與 daily history 均回 200 空物件, + // parse 失敗會走 committed fallback snapshot,UI 行為不受影響)。 + await page.route( + (url) => url.toString().includes('/providers/moneybox/'), + async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }); + }, + ); + await gotoConverterV2(page); + + // 預設 pair(TWD/USD)不支援換錢所:零暴露。 + await expect(page.getByTestId('converter-v2-rate-source')).toHaveCount(0); + + // 換第二列為 KRW:TWD↔KRW 支援換錢所 → 切換鈕出現。 + await page.getByTestId('converter-v2-currency-to').click(); + await page.getByTestId('currency-option-KRW').click(); + const sourceToggle = page.getByTestId('converter-v2-rate-source'); + await expect(sourceToggle).toBeVisible(); + await expect(sourceToggle).toHaveAttribute('aria-pressed', 'false'); + + // 切至換錢所:chip 基準標註「換錢所」(#659 分支接上)。 + await sourceToggle.click(); + await expect(sourceToggle).toHaveAttribute('aria-pressed', 'true'); + await expect(page.getByTestId('converter-v2-rate-chip')).toContainText('換錢所'); + + // e2e 阻擋 SW 產生的更新失敗 toast 與待測 UI 無關,量測與截圖前先隱藏。 + await page.addStyleTag({ + content: '[aria-labelledby="update-prompt-title"] { display: none !important; }', + }); + + // 320px 最小寬度:chip 列(chip+來源切換鈕+⇄ 鈕)不得溢出。 + await page.setViewportSize({ width: 320, height: 844 }); + await expect + .poll(() => page.evaluate(() => document.documentElement.scrollWidth), { + message: '320px 視口不得出現水平捲動', + }) + .toBeLessThanOrEqual(320); + for (const testId of [ + 'converter-v2-rate-chip', + 'converter-v2-rate-source', + 'converter-v2-rate-flip', + ]) { + const box = await page.getByTestId(testId).boundingBox(); + expect(box, `${testId} 於 320px 視口應可見`).not.toBeNull(); + expect(box!.x, `${testId} 左緣不得超出視口`).toBeGreaterThanOrEqual(-0.5); + expect(box!.x + box!.width, `${testId} 右緣不得超出 320px 視口`).toBeLessThanOrEqual(320.5); + } + await page.screenshot({ + path: `test-results/v2-e8-rate-source-320-${testInfo.project.name}.png`, + fullPage: false, + }); + + expect(consoleErrors).toEqual([]); + }); + + test('E8 wave-B:趨勢 sheet 基準切換與攻略連結', async ({ rateWisePage: page }, testInfo) => { + const consoleErrors: string[] = []; + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()); + }); + + await gotoConverterV2(page); + + // 展開趨勢 sheet:基準切換(現金/即期)與攻略連結(TWD→外幣 pair 均有 /twd-xxx/ 落地頁)。 + const fromCode = (await page.getByTestId('converter-v2-currency-from').innerText()) + .replace(/[^A-Z]/g, '') + .toLowerCase(); + const toCode = (await page.getByTestId('converter-v2-currency-to').innerText()) + .replace(/[^A-Z]/g, '') + .toLowerCase(); + await page.getByTestId('converter-v2-sparkline').click(); + await expect(page.getByTestId('converter-v2-trend-sheet')).toBeVisible(); + await expect(page.getByTestId('converter-v2-trend-basis-cash')).toBeVisible(); + const guideLink = page.getByTestId('converter-v2-trend-guide-link'); + await expect(guideLink).toBeVisible(); + await expect(guideLink).toHaveAttribute('href', new RegExp(`/${fromCode}-${toCode}/$`)); + + // 切換基準:radiogroup 選中態跟隨,誠實標註同步(TWD/USD 兩基準俱在,直接切換)。 + await page.getByTestId('converter-v2-trend-basis-cash').click(); + await expect(page.getByTestId('converter-v2-trend-basis-cash')).toHaveAttribute( + 'aria-checked', + 'true', + ); + + // e2e 阻擋 SW 產生的更新失敗 toast 與待測 UI 無關,證據截圖前先隱藏。 + await page.addStyleTag({ + content: '[aria-labelledby="update-prompt-title"] { display: none !important; }', + }); + await page.screenshot({ + path: `test-results/v2-e8-trend-sheet-${testInfo.project.name}.png`, + fullPage: false, + }); + + expect(consoleErrors).toEqual([]); + }); + test('觸控目標:v2 互動元素 ≥44px', async ({ rateWisePage: page }) => { await gotoConverterV2(page); diff --git a/docs/dev/002_development_reward_penalty_log.md b/docs/dev/002_development_reward_penalty_log.md index a01b9d6ec..35f2791c3 100644 --- a/docs/dev/002_development_reward_penalty_log.md +++ b/docs/dev/002_development_reward_penalty_log.md @@ -2,7 +2,7 @@ > 版本:outline-v2-ultra > 原則:每筆只保留日期、ID、原因、解法。 -> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+180 +> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+181 ## 新增模板(4 行) @@ -13,6 +13,11 @@ ## 條目(新→舊) +- 日期:2026-07-07 +- ID:reward-rw-e8-wave-b-v2-completeness +- 原因:v2 等值雙列相對 v1 仍缺換錢所來源切換、運算式可見性、趨勢 sheet 基準切換/攻略導流與鍵盤輸入 SR 播報四項能力(E8 簡報缺口 3/5/8/10,#620),且不得破壞 wave-A 三視口零捲動硬約束與 settle 機制 +- 解法:rate chip 同列新增 icon-only 換錢所切換鈕(exchangeShopCurrency 零暴露守門,接上 #659 既有 basisLabel 分支,換錢所啟用中 chip tap=回銀行對齊 v1 pill 語意)、keypad 表達式經 onExpressionChange 回報並以 absolute overlay 疊於活躍列頂緣(含運算子才顯示、settle/語意變更即隱藏,零位移零捲動)、趨勢 sheet 內嵌現金/即期 SegmentedControl(sheet 專屬 useConverterTrend 序列走 resolveTrendSeries 誠實回落、值-標籤同源)+以 seo-paths SSOT 判斷 pair 落地頁存在才渲染「查看 {幣別} 攻略」連結+MiniTrendChart 傳入 basisLabel 啟用資料點 tooltip 基準標註、settle 事件共用觸發 aria-live=polite sr-only 摘要播報(「{amount} {from} 等於 {result} {to}」4 語系、非逐鍵、B1 零值守門共用) + - 日期:2026-07-07 - ID:reward-rw-e8-wave-a-v2-completeness - 原因:v2 等值雙列相對 v1 缺快速金額、歷史寫入、雙向匯率與複製結果四項能力(E8 簡報缺口 1/2/4/6),且補全不得把 390×844/375×667/430×932 首屏推出視口(一頁完整顯示硬約束)