/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
100 lines
4.1 KiB
JavaScript
100 lines
4.1 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
|
import { kindMeta, conditionLabel, normalizeTicker, relativeTime, formatDetail } from './watchlistUtils.js';
|
|
|
|
describe('kindMeta', () => {
|
|
it('buy/sell 라벨과 색을 반환', () => {
|
|
expect(kindMeta('buy').label).toBe('매수');
|
|
expect(kindMeta('buy').color).toBe('#22c55e');
|
|
expect(kindMeta('sell').label).toBe('매도');
|
|
expect(kindMeta('sell').color).toBe('#ef4444');
|
|
});
|
|
it('미정의 kind는 회색 폴백 + 원문 label', () => {
|
|
const m = kindMeta('weird');
|
|
expect(m.label).toBe('weird');
|
|
expect(m.color).toBe('#94a3b8');
|
|
});
|
|
});
|
|
|
|
describe('conditionLabel', () => {
|
|
it('정의된 8종을 한글로 매핑', () => {
|
|
expect(conditionLabel('buy_ma20_pullback')).toBe('MA20 눌림 반등');
|
|
expect(conditionLabel('buy_breakout')).toBe('박스 상단 돌파');
|
|
expect(conditionLabel('buy_rsi_bounce')).toBe('RSI 과매도 반등');
|
|
expect(conditionLabel('sell_stop_loss')).toBe('손절 라인');
|
|
expect(conditionLabel('sell_ma_break')).toBe('이평선 이탈');
|
|
expect(conditionLabel('sell_take_profit')).toBe('목표가 도달');
|
|
expect(conditionLabel('sell_climax')).toBe('과열 소진');
|
|
expect(conditionLabel('sell_trailing_stop')).toBe('트레일링 스톱');
|
|
});
|
|
it('미정의 condition은 원문 폴백', () => {
|
|
expect(conditionLabel('buy_unknown')).toBe('buy_unknown');
|
|
expect(conditionLabel(undefined)).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('프로토타입 키 방어 (render-safe)', () => {
|
|
it('conditionLabel은 상속 키에도 문자열을 반환', () => {
|
|
expect(conditionLabel('toString')).toBe('toString');
|
|
expect(typeof conditionLabel('toString')).toBe('string');
|
|
expect(typeof conditionLabel('constructor')).toBe('string');
|
|
});
|
|
it('kindMeta는 상속 키에도 문자열 label + 회색 폴백', () => {
|
|
const m = kindMeta('constructor');
|
|
expect(typeof m.label).toBe('string');
|
|
expect(m.label).toBe('constructor');
|
|
expect(m.color).toBe('#94a3b8');
|
|
});
|
|
});
|
|
|
|
describe('normalizeTicker', () => {
|
|
it('공백 trim', () => {
|
|
expect(normalizeTicker(' 005930 ')).toBe('005930');
|
|
expect(normalizeTicker(undefined)).toBe('');
|
|
});
|
|
});
|
|
|
|
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초 미만은 방금', () => {
|
|
expect(relativeTime('2026-07-03T11:59:30Z', now)).toBe('방금');
|
|
});
|
|
it('분/시간/어제/일 경계', () => {
|
|
expect(relativeTime('2026-07-03T11:55:00Z', now)).toBe('5분 전');
|
|
expect(relativeTime('2026-07-03T09:00:00Z', now)).toBe('3시간 전');
|
|
expect(relativeTime('2026-07-02T10:00:00Z', now)).toBe('어제');
|
|
expect(relativeTime('2026-06-30T12:00:00Z', now)).toBe('3일 전');
|
|
});
|
|
it('잘못된/빈 값은 빈 문자열', () => {
|
|
expect(relativeTime('', now)).toBe('');
|
|
expect(relativeTime('not-a-date', now)).toBe('');
|
|
});
|
|
});
|