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