From e6689eeb5b016064f39b3c134b2b98a591a7a81f Mon Sep 17 00:00:00 2001 From: 0xYYBB | ZYY | Bobo <128128010+the-dev-z@users.noreply.github.com> Date: Tue, 11 Nov 2025 09:30:03 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20display=20'=E2=80=94'=20for=20missi?= =?UTF-8?q?ng=20data=20instead=20of=20NaN%=20or=200%=20(#678)=20*=20fix(we?= =?UTF-8?q?b):=20display=20'=E2=80=94'=20for=20missing=20data=20instead=20?= =?UTF-8?q?of=20NaN%=20or=200%=20(#633)=20-=20Add=20hasValidData=20validat?= =?UTF-8?q?ion=20for=20null/undefined/NaN=20-=20Display=20'=E2=80=94'=20fo?= =?UTF-8?q?r=20invalid=20trader.total=5Fpnl=5Fpct=20-=20Only=20show=20gap?= =?UTF-8?q?=20calculations=20when=20both=20values=20are=20valid=20-=20Prev?= =?UTF-8?q?ents=20misleading=20users=20with=200%=20when=20data=20is=20miss?= =?UTF-8?q?ing=20Fixes=20#633=20*=20test(web):=20add=20comprehensive=20uni?= =?UTF-8?q?t=20tests=20for=20CompetitionPage=20NaN=20handling=20-=20Test?= =?UTF-8?q?=20data=20validation=20logic=20(null/undefined/NaN=20detection)?= =?UTF-8?q?=20-=20Test=20gap=20calculation=20with=20valid=20and=20invalid?= =?UTF-8?q?=20data=20-=20Test=20display=20formatting=20(shows=20'=E2=80=94?= =?UTF-8?q?'=20instead=20of=20'NaN%')=20-=20Test=20leading/trailing=20mess?= =?UTF-8?q?age=20display=20conditions=20-=20Test=20edge=20cases=20(Infinit?= =?UTF-8?q?y,=20very=20small/large=20numbers)=20All=2025=20test=20cases=20?= =?UTF-8?q?passed,=20covering:=201.=20hasValidData=20check=20(7=20cases):?= =?UTF-8?q?=20valid/null/undefined/NaN/zero/negative=202.=20gap=20calculat?= =?UTF-8?q?ion=20(3=20cases):=20valid=20data,=20invalid=20data,=20negative?= =?UTF-8?q?=20gap=203.=20display=20formatting=20(6=20cases):=20positive/ne?= =?UTF-8?q?gative/null/undefined/NaN/zero=204.=20leading/trailing=20messag?= =?UTF-8?q?es=20(5=20cases):=20conditional=20display=20logic=205.=20edge?= =?UTF-8?q?=20cases=20(4=20cases):=20Infinity,=20-Infinity,=20very=20small?= =?UTF-8?q?/large=20numbers=20Related=20to=20PR=20#678=20-=20ensures=20mis?= =?UTF-8?q?sing=20data=20displays=20as=20'=E2=80=94'=20instead=20of=20'NaN?= =?UTF-8?q?%'.=20Co-Authored-By:=20tinkle-community=20=20---------=20Co-authored-by:=20ZhouYongyou=20<128128010+zho?= =?UTF-8?q?uyongyou@users.noreply.github.com>=20Co-authored-by:=20tinkle-c?= =?UTF-8?q?ommunity=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/CompetitionPage.test.tsx | 329 ++++++++++++++++++++ web/src/components/CompetitionPage.tsx | 30 +- 2 files changed, 354 insertions(+), 5 deletions(-) create mode 100644 web/src/components/CompetitionPage.test.tsx diff --git a/web/src/components/CompetitionPage.test.tsx b/web/src/components/CompetitionPage.test.tsx new file mode 100644 index 00000000..6b06139d --- /dev/null +++ b/web/src/components/CompetitionPage.test.tsx @@ -0,0 +1,329 @@ +import { describe, it, expect } from 'vitest' + +/** + * PR #678 測試: 修復 CompetitionPage 中 NaN 和缺失數據的顯示問題 + * + * 問題:當 total_pnl_pct 為 null/undefined/NaN 時,會顯示 "NaN%" 或 "0.00%" + * 修復:檢查數據有效性,顯示 "—" 表示缺失數據 + */ + +describe('CompetitionPage - Data Validation Logic (PR #678)', () => { + /** + * 測試數據有效性檢查邏輯 + * 這是 PR #678 引入的核心邏輯 + */ + describe('hasValidData check', () => { + it('should return true for valid numbers', () => { + const trader1 = { total_pnl_pct: 10.5 } + const trader2 = { total_pnl_pct: -5.2 } + + const hasValidData = + trader1.total_pnl_pct != null && + trader2.total_pnl_pct != null && + !isNaN(trader1.total_pnl_pct) && + !isNaN(trader2.total_pnl_pct) + + expect(hasValidData).toBe(true) + }) + + it('should return false when trader1 has null value', () => { + const trader1 = { total_pnl_pct: null } + const trader2 = { total_pnl_pct: 10.5 } + + const hasValidData = + trader1.total_pnl_pct != null && + trader2.total_pnl_pct != null && + !isNaN(trader1.total_pnl_pct!) && + !isNaN(trader2.total_pnl_pct) + + expect(hasValidData).toBe(false) + }) + + it('should return false when trader2 has undefined value', () => { + const trader1 = { total_pnl_pct: 10.5 } + const trader2 = { total_pnl_pct: undefined } + + const hasValidData = + trader1.total_pnl_pct != null && + trader2.total_pnl_pct != null && + !isNaN(trader1.total_pnl_pct) && + !isNaN(trader2.total_pnl_pct!) + + expect(hasValidData).toBe(false) + }) + + it('should return false when trader1 has NaN value', () => { + const trader1 = { total_pnl_pct: NaN } + const trader2 = { total_pnl_pct: 10.5 } + + const hasValidData = + trader1.total_pnl_pct != null && + trader2.total_pnl_pct != null && + !isNaN(trader1.total_pnl_pct) && + !isNaN(trader2.total_pnl_pct) + + expect(hasValidData).toBe(false) + }) + + it('should return false when both traders have invalid data', () => { + const trader1 = { total_pnl_pct: null } + const trader2 = { total_pnl_pct: NaN } + + const hasValidData = + trader1.total_pnl_pct != null && + trader2.total_pnl_pct != null && + !isNaN(trader1.total_pnl_pct!) && + !isNaN(trader2.total_pnl_pct) + + expect(hasValidData).toBe(false) + }) + + it('should handle zero as valid data', () => { + const trader1 = { total_pnl_pct: 0 } + const trader2 = { total_pnl_pct: 10.5 } + + const hasValidData = + trader1.total_pnl_pct != null && + trader2.total_pnl_pct != null && + !isNaN(trader1.total_pnl_pct) && + !isNaN(trader2.total_pnl_pct) + + expect(hasValidData).toBe(true) + }) + + it('should handle negative numbers as valid data', () => { + const trader1 = { total_pnl_pct: -15.5 } + const trader2 = { total_pnl_pct: -8.2 } + + const hasValidData = + trader1.total_pnl_pct != null && + trader2.total_pnl_pct != null && + !isNaN(trader1.total_pnl_pct) && + !isNaN(trader2.total_pnl_pct) + + expect(hasValidData).toBe(true) + }) + }) + + /** + * 測試 gap 計算邏輯 + * gap 應該只在數據有效時計算 + */ + describe('gap calculation', () => { + it('should calculate gap correctly for valid data', () => { + const trader1 = { total_pnl_pct: 15.5 } + const trader2 = { total_pnl_pct: 10.2 } + + const hasValidData = + trader1.total_pnl_pct != null && + trader2.total_pnl_pct != null && + !isNaN(trader1.total_pnl_pct) && + !isNaN(trader2.total_pnl_pct) + + const gap = hasValidData + ? trader1.total_pnl_pct - trader2.total_pnl_pct + : NaN + + expect(gap).toBeCloseTo(5.3, 1) // Allow floating point precision + expect(isNaN(gap)).toBe(false) + }) + + it('should return NaN for invalid data', () => { + const trader1 = { total_pnl_pct: null } + const trader2 = { total_pnl_pct: 10.2 } + + const hasValidData = + trader1.total_pnl_pct != null && + trader2.total_pnl_pct != null && + !isNaN(trader1.total_pnl_pct!) && + !isNaN(trader2.total_pnl_pct) + + const gap = hasValidData + ? trader1.total_pnl_pct! - trader2.total_pnl_pct + : NaN + + expect(isNaN(gap)).toBe(true) + }) + + it('should handle negative gap correctly', () => { + const trader1 = { total_pnl_pct: 5.0 } + const trader2 = { total_pnl_pct: 12.0 } + + const hasValidData = + trader1.total_pnl_pct != null && + trader2.total_pnl_pct != null && + !isNaN(trader1.total_pnl_pct) && + !isNaN(trader2.total_pnl_pct) + + const gap = hasValidData + ? trader1.total_pnl_pct - trader2.total_pnl_pct + : NaN + + expect(gap).toBe(-7.0) + }) + }) + + /** + * 測試顯示邏輯 + * 修復後應顯示「—」而非「NaN%」或「0.00%」 + */ + describe('display formatting', () => { + it('should format valid positive percentage correctly', () => { + const total_pnl_pct = 15.567 + + const display = + total_pnl_pct != null && !isNaN(total_pnl_pct) + ? `${total_pnl_pct >= 0 ? '+' : ''}${total_pnl_pct.toFixed(2)}%` + : '—' + + expect(display).toBe('+15.57%') + }) + + it('should format valid negative percentage correctly', () => { + const total_pnl_pct = -8.234 + + const display = + total_pnl_pct != null && !isNaN(total_pnl_pct) + ? `${total_pnl_pct >= 0 ? '+' : ''}${total_pnl_pct.toFixed(2)}%` + : '—' + + expect(display).toBe('-8.23%') + }) + + it('should display "—" for null value', () => { + const total_pnl_pct = null + + const display = + total_pnl_pct != null && !isNaN(total_pnl_pct) + ? `${total_pnl_pct >= 0 ? '+' : ''}${total_pnl_pct.toFixed(2)}%` + : '—' + + expect(display).toBe('—') + }) + + it('should display "—" for undefined value', () => { + const total_pnl_pct = undefined + + const display = + total_pnl_pct != null && !isNaN(total_pnl_pct) + ? `${total_pnl_pct >= 0 ? '+' : ''}${total_pnl_pct.toFixed(2)}%` + : '—' + + expect(display).toBe('—') + }) + + it('should display "—" for NaN value', () => { + const total_pnl_pct = NaN + + const display = + total_pnl_pct != null && !isNaN(total_pnl_pct) + ? `${total_pnl_pct >= 0 ? '+' : ''}${total_pnl_pct.toFixed(2)}%` + : '—' + + expect(display).toBe('—') + }) + + it('should format zero correctly', () => { + const total_pnl_pct = 0 + + const display = + total_pnl_pct != null && !isNaN(total_pnl_pct) + ? `${total_pnl_pct >= 0 ? '+' : ''}${total_pnl_pct.toFixed(2)}%` + : '—' + + expect(display).toBe('+0.00%') + }) + }) + + /** + * 測試領先/落後訊息顯示邏輯 + * 只有在數據有效時才顯示 "領先" 或 "落後" 訊息 + */ + describe('leading/trailing message display', () => { + it('should show leading message when winning with positive gap', () => { + const isWinning = true + const gap = 5.2 + const hasValidData = true + + const shouldShowLeading = hasValidData && isWinning && gap > 0 + + expect(shouldShowLeading).toBe(true) + }) + + it('should not show leading message when data is invalid', () => { + const isWinning = true + const gap = NaN + const hasValidData = false + + const shouldShowLeading = hasValidData && isWinning && gap > 0 + + expect(shouldShowLeading).toBe(false) + }) + + it('should show trailing message when losing with negative gap', () => { + const isWinning = false + const gap = -3.5 + const hasValidData = true + + const shouldShowTrailing = hasValidData && !isWinning && gap < 0 + + expect(shouldShowTrailing).toBe(true) + }) + + it('should not show trailing message when data is invalid', () => { + const isWinning = false + const gap = NaN + const hasValidData = false + + const shouldShowTrailing = hasValidData && !isWinning && gap < 0 + + expect(shouldShowTrailing).toBe(false) + }) + + it('should show fallback "—" when data is invalid', () => { + const hasValidData = false + + const shouldShowFallback = !hasValidData + + expect(shouldShowFallback).toBe(true) + }) + }) + + /** + * 測試邊界情況 + */ + describe('edge cases', () => { + it('should handle very small positive numbers', () => { + const total_pnl_pct = 0.001 + + const hasValidData = total_pnl_pct != null && !isNaN(total_pnl_pct) + + expect(hasValidData).toBe(true) + }) + + it('should handle very large numbers', () => { + const total_pnl_pct = 9999.99 + + const hasValidData = total_pnl_pct != null && !isNaN(total_pnl_pct) + + expect(hasValidData).toBe(true) + }) + + it('should handle Infinity as invalid (produces NaN in calculations)', () => { + const total_pnl_pct = Infinity + + // Infinity 本身不是 NaN,但在減法運算中可能導致問題 + const hasValidData = total_pnl_pct != null && isFinite(total_pnl_pct) + + expect(hasValidData).toBe(false) + }) + + it('should handle -Infinity as invalid', () => { + const total_pnl_pct = -Infinity + + const hasValidData = total_pnl_pct != null && isFinite(total_pnl_pct) + + expect(hasValidData).toBe(false) + }) + }) +}) diff --git a/web/src/components/CompetitionPage.tsx b/web/src/components/CompetitionPage.tsx index 2c1effd2..e74d119b 100644 --- a/web/src/components/CompetitionPage.tsx +++ b/web/src/components/CompetitionPage.tsx @@ -392,7 +392,17 @@ export function CompetitionPage() { {sortedTraders.map((trader, index) => { const isWinning = index === 0 const opponent = sortedTraders[1 - index] - const gap = trader.total_pnl_pct - opponent.total_pnl_pct + + // Check if both values are valid numbers + const hasValidData = + trader.total_pnl_pct != null && + opponent.total_pnl_pct != null && + !isNaN(trader.total_pnl_pct) && + !isNaN(opponent.total_pnl_pct) + + const gap = hasValidData + ? trader.total_pnl_pct - opponent.total_pnl_pct + : NaN return (
= 0 ? '#0ECB81' : '#F6465D', }} > - {(trader.total_pnl ?? 0) >= 0 ? '+' : ''} - {trader.total_pnl_pct?.toFixed(2) || '0.00'}% + {trader.total_pnl_pct != null && + !isNaN(trader.total_pnl_pct) + ? `${trader.total_pnl_pct >= 0 ? '+' : ''}${trader.total_pnl_pct.toFixed(2)}%` + : '—'}
- {isWinning && gap > 0 && ( + {hasValidData && isWinning && gap > 0 && (
)} - {!isWinning && gap < 0 && ( + {hasValidData && !isWinning && gap < 0 && (
)} + {!hasValidData && ( +
+ — +
+ )}
)