fix(stock): watchlist 렌더 크래시 가드·성공 시 폼 리셋·정렬 테스트
- watchlistUtils: Object.hasOwn 가드 + Object.freeze (프로토타입 키 → 함수 반환 방지) - useWatchlist.add: boolean 반환 + 재진입 가드; 성공 시에만 폼 리셋 - byFiredAtDesc 멀티 알림 정렬 테스트 추가 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:
@@ -34,8 +34,8 @@ const WatchlistTab = ({ wl }) => {
|
|||||||
const submit = async (e) => {
|
const submit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!form.ticker.trim()) return;
|
if (!form.ticker.trim()) return;
|
||||||
await wl.add(form);
|
const ok = await wl.add(form);
|
||||||
setForm({ ticker: '', name: '', note: '' });
|
if (ok) setForm({ ticker: '', name: '', note: '' });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -48,11 +48,12 @@ export default function useWatchlist() {
|
|||||||
useEffect(() => { loadAlerts(alertDays); }, [loadAlerts, alertDays]);
|
useEffect(() => { loadAlerts(alertDays); }, [loadAlerts, alertDays]);
|
||||||
|
|
||||||
const add = useCallback(async ({ ticker, name, note }) => {
|
const add = useCallback(async ({ ticker, name, note }) => {
|
||||||
|
if (adding) return false;
|
||||||
const t = normalizeTicker(ticker);
|
const t = normalizeTicker(ticker);
|
||||||
if (!t) return;
|
if (!t) return false;
|
||||||
if (items.some((it) => it.ticker === t)) {
|
if (items.some((it) => it.ticker === t)) {
|
||||||
setError(`이미 관심종목에 있습니다: ${t}`);
|
setError(`이미 관심종목에 있습니다: ${t}`);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
setAdding(true);
|
setAdding(true);
|
||||||
setError('');
|
setError('');
|
||||||
@@ -63,13 +64,15 @@ export default function useWatchlist() {
|
|||||||
try {
|
try {
|
||||||
await addWatchlist({ ticker: t, name: cleanName || undefined, note: cleanNote || undefined });
|
await addWatchlist({ ticker: t, name: cleanName || undefined, note: cleanNote || undefined });
|
||||||
await loadWatchlist();
|
await loadWatchlist();
|
||||||
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setItems((prev) => prev.filter((it) => it.ticker !== t)); // 롤백
|
setItems((prev) => prev.filter((it) => it.ticker !== t)); // 롤백
|
||||||
setError(e?.message ?? String(e));
|
setError(e?.message ?? String(e));
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setAdding(false);
|
setAdding(false);
|
||||||
}
|
}
|
||||||
}, [items, loadWatchlist]);
|
}, [items, loadWatchlist, adding]);
|
||||||
|
|
||||||
const remove = useCallback(async (ticker) => {
|
const remove = useCallback(async (ticker) => {
|
||||||
const prev = items;
|
const prev = items;
|
||||||
|
|||||||
@@ -39,8 +39,10 @@ describe('useWatchlist', () => {
|
|||||||
.mockResolvedValueOnce({ watchlist: [{ ticker: '000660', name: 'SK하이닉스', note: '', added_at: '2026-07-03T00:00:00Z' }] });
|
.mockResolvedValueOnce({ watchlist: [{ ticker: '000660', name: 'SK하이닉스', note: '', added_at: '2026-07-03T00:00:00Z' }] });
|
||||||
const { result } = renderHook(() => useWatchlist());
|
const { result } = renderHook(() => useWatchlist());
|
||||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
await act(async () => { await result.current.add({ ticker: ' 000660 ', name: 'SK하이닉스' }); });
|
let ok;
|
||||||
|
await act(async () => { ok = await result.current.add({ ticker: ' 000660 ', name: 'SK하이닉스' }); });
|
||||||
expect(addWatchlist).toHaveBeenCalledWith({ ticker: '000660', name: 'SK하이닉스', note: undefined });
|
expect(addWatchlist).toHaveBeenCalledWith({ ticker: '000660', name: 'SK하이닉스', note: undefined });
|
||||||
|
expect(ok).toBe(true);
|
||||||
await waitFor(() => expect(result.current.items.some((i) => i.ticker === '000660')).toBe(true));
|
await waitFor(() => expect(result.current.items.some((i) => i.ticker === '000660')).toBe(true));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,7 +51,9 @@ describe('useWatchlist', () => {
|
|||||||
addWatchlist.mockRejectedValue(new Error('HTTP 500 err'));
|
addWatchlist.mockRejectedValue(new Error('HTTP 500 err'));
|
||||||
const { result } = renderHook(() => useWatchlist());
|
const { result } = renderHook(() => useWatchlist());
|
||||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
await act(async () => { await result.current.add({ ticker: '000660' }); });
|
let ok;
|
||||||
|
await act(async () => { ok = await result.current.add({ ticker: '000660' }); });
|
||||||
|
expect(ok).toBe(false);
|
||||||
await waitFor(() => expect(result.current.error).toContain('HTTP 500'));
|
await waitFor(() => expect(result.current.error).toContain('HTTP 500'));
|
||||||
expect(result.current.items.some((i) => i.ticker === '000660')).toBe(false);
|
expect(result.current.items.some((i) => i.ticker === '000660')).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -57,9 +61,11 @@ describe('useWatchlist', () => {
|
|||||||
it('중복 ticker는 add 차단', async () => {
|
it('중복 ticker는 add 차단', async () => {
|
||||||
const { result } = renderHook(() => useWatchlist());
|
const { result } = renderHook(() => useWatchlist());
|
||||||
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||||
await act(async () => { await result.current.add({ ticker: '005930' }); });
|
let ok;
|
||||||
|
await act(async () => { ok = await result.current.add({ ticker: '005930' }); });
|
||||||
expect(addWatchlist).not.toHaveBeenCalled();
|
expect(addWatchlist).not.toHaveBeenCalled();
|
||||||
expect(result.current.error).toContain('이미');
|
expect(result.current.error).toContain('이미');
|
||||||
|
expect(ok).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('remove: 낙관적 제거 + DELETE 호출', async () => {
|
it('remove: 낙관적 제거 + DELETE 호출', async () => {
|
||||||
@@ -77,4 +83,15 @@ describe('useWatchlist', () => {
|
|||||||
await waitFor(() => expect(result.current.alertError).toContain('HTTP 404'));
|
await waitFor(() => expect(result.current.alertError).toContain('HTTP 404'));
|
||||||
expect(result.current.alerts).toHaveLength(0);
|
expect(result.current.alerts).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('alerts를 fired_at 내림차순으로 정렬', async () => {
|
||||||
|
getTradeAlerts.mockResolvedValue({ alerts: [
|
||||||
|
{ id: 1, ticker: 'A', fired_at: '2026-07-01T00:00:00Z' },
|
||||||
|
{ id: 2, ticker: 'B', fired_at: '2026-07-03T00:00:00Z' },
|
||||||
|
{ id: 3, ticker: 'C', fired_at: '2026-07-02T00:00:00Z' },
|
||||||
|
] });
|
||||||
|
const { result } = renderHook(() => useWatchlist());
|
||||||
|
await waitFor(() => expect(result.current.alerts).toHaveLength(3));
|
||||||
|
expect(result.current.alerts.map((a) => a.id)).toEqual([2, 3, 1]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
/* ── 관심종목 탭 순수 헬퍼 (라벨/색/시간) ── */
|
/* ── 관심종목 탭 순수 헬퍼 (라벨/색/시간) ── */
|
||||||
|
|
||||||
export const KIND_META = {
|
export const KIND_META = Object.freeze({
|
||||||
buy: { label: '매수', color: '#22c55e', bg: 'rgba(34,197,94,0.12)' },
|
buy: Object.freeze({ label: '매수', color: '#22c55e', bg: 'rgba(34,197,94,0.12)' }),
|
||||||
sell: { label: '매도', color: '#ef4444', bg: 'rgba(239,68,68,0.12)' },
|
sell: Object.freeze({ label: '매도', color: '#ef4444', bg: 'rgba(239,68,68,0.12)' }),
|
||||||
};
|
});
|
||||||
|
|
||||||
const FALLBACK_KIND = { color: '#94a3b8', bg: 'rgba(148,163,184,0.12)' };
|
const FALLBACK_KIND = { color: '#94a3b8', bg: 'rgba(148,163,184,0.12)' };
|
||||||
|
|
||||||
export const kindMeta = (kind) => {
|
export const kindMeta = (kind) => {
|
||||||
const meta = KIND_META[kind];
|
if (Object.hasOwn(KIND_META, kind)) return KIND_META[kind];
|
||||||
if (meta) return meta;
|
|
||||||
return { ...FALLBACK_KIND, label: kind ?? '' };
|
return { ...FALLBACK_KIND, label: kind ?? '' };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CONDITION_LABEL = {
|
export const CONDITION_LABEL = Object.freeze({
|
||||||
buy_ma20_pullback: 'MA20 눌림 반등',
|
buy_ma20_pullback: 'MA20 눌림 반등',
|
||||||
buy_breakout: '박스 상단 돌파',
|
buy_breakout: '박스 상단 돌파',
|
||||||
buy_rsi_bounce: 'RSI 과매도 반등',
|
buy_rsi_bounce: 'RSI 과매도 반등',
|
||||||
@@ -22,9 +21,10 @@ export const CONDITION_LABEL = {
|
|||||||
sell_take_profit: '목표가 도달',
|
sell_take_profit: '목표가 도달',
|
||||||
sell_climax: '과열 소진',
|
sell_climax: '과열 소진',
|
||||||
sell_trailing_stop: '트레일링 스톱',
|
sell_trailing_stop: '트레일링 스톱',
|
||||||
};
|
});
|
||||||
|
|
||||||
export const conditionLabel = (cond) => CONDITION_LABEL[cond] ?? cond ?? '';
|
export const conditionLabel = (cond) =>
|
||||||
|
Object.hasOwn(CONDITION_LABEL, cond) ? CONDITION_LABEL[cond] : (cond ?? '');
|
||||||
|
|
||||||
export const normalizeTicker = (str) => String(str ?? '').trim();
|
export const normalizeTicker = (str) => String(str ?? '').trim();
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,20 @@ describe('conditionLabel', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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', () => {
|
describe('normalizeTicker', () => {
|
||||||
it('공백 trim', () => {
|
it('공백 trim', () => {
|
||||||
expect(normalizeTicker(' 005930 ')).toBe('005930');
|
expect(normalizeTicker(' 005930 ')).toBe('005930');
|
||||||
|
|||||||
Reference in New Issue
Block a user