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:
2026-07-03 22:06:11 +09:00
parent 970c8164e0
commit d57f9b9b65
4 changed files with 81 additions and 3 deletions

View File

@@ -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>
);
};

View File

@@ -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();
});
});

View File

@@ -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();

View File

@@ -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초 미만은 방금', () => {