From d57f9b9b6508e7aea03d1697a8a9dec668c473a4 Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 3 Jul 2026 22:06:11 +0900 Subject: [PATCH] =?UTF-8?q?fix(stock):=20=EB=A7=A4=EB=A7=A4=EC=95=8C?= =?UTF-8?q?=EB=9E=8C=20detail=20=EA=B0=9D=EC=B2=B4=20=EB=A0=8C=EB=8D=94=20?= =?UTF-8?q?=ED=81=AC=EB=9E=98=EC=8B=9C(React=20#31)=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/stock/trade-alerts의 alerts[].detail은 condition별 가변 객체 ({ma50,ma200,severity} 등)인데 JSX 자식으로 직접 렌더 → React #31로 페이지 크래시(워커가 실제 알람 발화 시작하며 발현, BE msg #17). formatDetail 헬퍼 추가: 객체를 안전 문자열로 변환(알려진 키 한글 라벨, *_pct 퍼센트, null 값 스킵, 미정의 키도 원문 폴백 — 스키마 가정 없음). AlertCard가 detail 대신 formatDetail(detail) 렌더. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS --- src/pages/stock/components/WatchlistTab.jsx | 5 +-- .../stock/components/WatchlistTab.test.jsx | 16 +++++++++ src/pages/stock/watchlistUtils.js | 33 +++++++++++++++++++ src/pages/stock/watchlistUtils.test.js | 30 ++++++++++++++++- 4 files changed, 81 insertions(+), 3 deletions(-) diff --git a/src/pages/stock/components/WatchlistTab.jsx b/src/pages/stock/components/WatchlistTab.jsx index 08038f5..e762597 100644 --- a/src/pages/stock/components/WatchlistTab.jsx +++ b/src/pages/stock/components/WatchlistTab.jsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import Loading from '../../../components/Loading'; -import { kindMeta, conditionLabel, relativeTime } from '../watchlistUtils'; +import { kindMeta, conditionLabel, relativeTime, formatDetail } from '../watchlistUtils'; import { formatNumber } from '../stockUtils'; const DAYS_OPTIONS = [ @@ -11,6 +11,7 @@ const DAYS_OPTIONS = [ const AlertCard = ({ a }) => { const meta = kindMeta(a.kind); + const detailText = formatDetail(a.detail); return (
@@ -23,7 +24,7 @@ const AlertCard = ({ a }) => { {conditionLabel(a.condition)} {a.price != null && {formatNumber(a.price)}원}
- {a.detail &&
{a.detail}
} + {detailText &&
{detailText}
}
); }; diff --git a/src/pages/stock/components/WatchlistTab.test.jsx b/src/pages/stock/components/WatchlistTab.test.jsx index ecc4574..a99ddf3 100644 --- a/src/pages/stock/components/WatchlistTab.test.jsx +++ b/src/pages/stock/components/WatchlistTab.test.jsx @@ -28,4 +28,20 @@ describe('WatchlistTab', () => { expect(screen.getByText('매수')).toBeInTheDocument(); expect(screen.getByText('박스 상단 돌파')).toBeInTheDocument(); }); + + it('detail이 객체인 알림도 크래시 없이 렌더 (React #31 회귀 방지)', () => { + const wl = { + ...baseWl, + alerts: [{ + id: 20, ticker: '381170', name: null, kind: 'sell', condition: 'sell_ma_break', + price: 32715, detail: { ma50: 33309.8, ma200: null, severity: 'normal' }, + fired_at: '2026-07-03T07:02:59Z', + }], + }; + render(); + expect(screen.getByText('매도')).toBeInTheDocument(); + expect(screen.getByText('이평선 이탈')).toBeInTheDocument(); + // 객체 detail이 문자열로 안전 렌더됨 (severity 값 노출) + expect(screen.getByText(/normal/)).toBeInTheDocument(); + }); }); diff --git a/src/pages/stock/watchlistUtils.js b/src/pages/stock/watchlistUtils.js index 25dce70..9841430 100644 --- a/src/pages/stock/watchlistUtils.js +++ b/src/pages/stock/watchlistUtils.js @@ -28,6 +28,39 @@ export const conditionLabel = (cond) => export const normalizeTicker = (str) => String(str ?? '').trim(); +/* 알림 detail 은 condition 마다 스키마가 다른 객체(또는 문자열). + 객체를 JSX 자식으로 직접 렌더하면 React #31 크래시 → 항상 문자열로 변환한다. + 특정 필드 존재를 가정하지 않고(스키마 가변) 알려진 키만 한글 라벨, 나머지는 원문 키. */ +const DETAIL_KEY_LABEL = Object.freeze({ + avg_price: '평단', pnl_pct: '손익', stop_pct: '손절', take_pct: '목표', + holding_high: '보유고점', trailing_pct: '트레일링', drawdown_pct: '낙폭', + ma50: 'MA50', ma200: 'MA200', severity: '강도', vol: '거래량', rsi: 'RSI', + breakout_high: '돌파고점', pullback_pct: '눌림', +}); + +const formatDetailValue = (key, v) => { + if (v == null) return null; + if (typeof v === 'number') { + if (key.endsWith('_pct')) return `${(v * 100).toFixed(2)}%`; + return Number.isInteger(v) ? v.toLocaleString('ko-KR') : String(v); + } + if (typeof v === 'object') return JSON.stringify(v); + return String(v); +}; + +export const formatDetail = (detail) => { + if (detail == null) return ''; + if (typeof detail === 'string') return detail; + if (typeof detail !== 'object') return String(detail); + return Object.entries(detail) + .map(([k, v]) => { + const fv = formatDetailValue(k, v); + return fv == null ? null : `${DETAIL_KEY_LABEL[k] ?? k} ${fv}`; + }) + .filter(Boolean) + .join(' · '); +}; + export const relativeTime = (iso, now = Date.now()) => { if (!iso) return ''; const then = new Date(iso).getTime(); diff --git a/src/pages/stock/watchlistUtils.test.js b/src/pages/stock/watchlistUtils.test.js index edbccc6..e3b61aa 100644 --- a/src/pages/stock/watchlistUtils.test.js +++ b/src/pages/stock/watchlistUtils.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { kindMeta, conditionLabel, normalizeTicker, relativeTime } from './watchlistUtils.js'; +import { kindMeta, conditionLabel, normalizeTicker, relativeTime, formatDetail } from './watchlistUtils.js'; describe('kindMeta', () => { it('buy/sell 라벨과 색을 반환', () => { @@ -53,6 +53,34 @@ describe('normalizeTicker', () => { }); }); +describe('formatDetail (알림 detail 객체 → 안전 문자열, React #31 방지)', () => { + it('객체 detail을 읽기 좋은 문자열로 변환하고 절대 객체를 반환하지 않는다', () => { + const s = formatDetail({ avg_price: 75325, pnl_pct: -0.1012, stop_pct: 0.08 }); + expect(typeof s).toBe('string'); + expect(s).toContain('75,325'); // avg_price 천단위 포맷 + expect(s).toContain('-10.12%'); // *_pct 는 퍼센트로 + expect(s).toContain('8.00%'); + }); + it('값이 null인 필드는 건너뛴다', () => { + const s = formatDetail({ ma50: 33309.8, ma200: null, severity: 'normal' }); + expect(typeof s).toBe('string'); + expect(s).toContain('normal'); + expect(s).not.toContain('null'); + }); + it('문자열 detail은 그대로 반환', () => { + expect(formatDetail('박스권 돌파')).toBe('박스권 돌파'); + }); + it('null/undefined 는 빈 문자열', () => { + expect(formatDetail(null)).toBe(''); + expect(formatDetail(undefined)).toBe(''); + }); + it('미정의 키(스키마 가정 없음)도 크래시 없이 문자열로', () => { + const s = formatDetail({ some_new_field: 42 }); + expect(typeof s).toBe('string'); + expect(s).toContain('42'); + }); +}); + describe('relativeTime', () => { const now = new Date('2026-07-03T12:00:00Z').getTime(); it('60초 미만은 방금', () => {