fix(stock): 매매알람 detail 객체 렌더 크래시(React #31) 방지
/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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
This commit is contained in:
@@ -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 (
|
||||
<div className="wl-alert">
|
||||
<div className="wl-alert__head">
|
||||
@@ -23,7 +24,7 @@ const AlertCard = ({ a }) => {
|
||||
<span className="wl-cond">{conditionLabel(a.condition)}</span>
|
||||
{a.price != null && <span className="wl-alert__price">{formatNumber(a.price)}원</span>}
|
||||
</div>
|
||||
{a.detail && <div className="wl-alert__detail">{a.detail}</div>}
|
||||
{detailText && <div className="wl-alert__detail">{detailText}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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(<WatchlistTab wl={wl} />);
|
||||
expect(screen.getByText('매도')).toBeInTheDocument();
|
||||
expect(screen.getByText('이평선 이탈')).toBeInTheDocument();
|
||||
// 객체 detail이 문자열로 안전 렌더됨 (severity 값 노출)
|
||||
expect(screen.getByText(/normal/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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초 미만은 방금', () => {
|
||||
|
||||
Reference in New Issue
Block a user