fix(listings): ToolsTab 제출·disclaimer 테스트 추가 + 수집 완료 폴링

최종 리뷰 반영: (1) ToolsTab 제출→중첩객체·disclaimer 렌더 경로 테스트
2건 추가(최고위험 미검증 경로), (2) handleCollect의 고정 3초 타임아웃을
collected_at 변경 기반 폴링(최대 60s)으로 교체 — 진행 중을 완료로 오표시
하지 않도록.

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-09 16:39:34 +09:00
parent 690964c994
commit f7c7394a94
2 changed files with 46 additions and 2 deletions

View File

@@ -68,13 +68,30 @@ const ListingsTab = () => {
const handleCollect = async () => { const handleCollect = async () => {
setCollecting(true); setCollecting(true);
setError('');
const prevAt = collectStatus?.collected_at ?? null;
try { try {
await collectListings(); await collectListings();
setTimeout(() => { loadStatus(); load(); setCollecting(false); }, 3000);
} catch (e) { } catch (e) {
setError(e?.message ?? String(e)); setError(e?.message ?? String(e));
setCollecting(false); setCollecting(false);
return;
} }
let tries = 0;
const poll = async () => {
tries += 1;
let st = null;
try { st = await getListingsCollectStatus(); } catch { /* noop */ }
if (st) setCollectStatus(st);
const done = st && (st.collected_at !== prevAt || st.error);
if (done || tries >= 12) {
setCollecting(false);
load();
return;
}
window.setTimeout(poll, 5000);
};
window.setTimeout(poll, 5000);
}; };
return ( return (

View File

@@ -1,7 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react';
vi.mock('../../../api', () => ({ safetyCheck: vi.fn(), budgetCalc: vi.fn() })); vi.mock('../../../api', () => ({ safetyCheck: vi.fn(), budgetCalc: vi.fn() }));
import { safetyCheck, budgetCalc } from '../../../api';
import ToolsTab from './ToolsTab.jsx'; import ToolsTab from './ToolsTab.jsx';
beforeEach(() => vi.clearAllMocks()); beforeEach(() => vi.clearAllMocks());
@@ -14,4 +15,30 @@ describe('ToolsTab', () => {
// 제출 버튼 존재 // 제출 버튼 존재
expect(screen.getAllByRole('button', { name: /판정|계산/ }).length).toBeGreaterThan(0); expect(screen.getAllByRole('button', { name: /판정|계산/ }).length).toBeGreaterThan(0);
}); });
it('안전마진 제출 → tier·비율·disclaimer 렌더 (객체 아님)', async () => {
safetyCheck.mockResolvedValue({ median: 45000, ratio: 0.72, tier: '안전', sample: 7, is_toheo: false, disclaimer: '등기부 선순위·근사치 주의' });
render(<ToolsTab />);
fireEvent.change(screen.getByPlaceholderText(/면적/), { target: { value: '84' } });
fireEvent.change(screen.getByPlaceholderText(/금액/), { target: { value: '45000' } });
fireEvent.click(screen.getByRole('button', { name: '판정' }));
await waitFor(() => expect(screen.getByText(/등기부 선순위/)).toBeInTheDocument());
// "안전마진 체커" 헤딩과 구분하기 위해 "마진"이 뒤따르지 않는 "안전"만 매칭
expect(screen.getByText(/안전(?!마진)/)).toBeInTheDocument();
});
it('예산 제출 → 전세/매매 금액·disclaimer 렌더 (중첩 객체 필드별 렌더)', async () => {
budgetCalc.mockResolvedValue({
jeonse: { loan_limit: 30000, max_deposit: 40000, notes: '' },
purchase: { ltv_pct: 70, loan_cap: 50000, max_price: 90000, dsr_note: 'DSR 40%', regulation_flags: ['규제지역'] },
region: { is_toheo: true, is_regulated: true, notes: '토허 구역' },
disclaimer: '정책 수치는 근사치입니다',
});
render(<ToolsTab />);
fireEvent.change(screen.getByPlaceholderText(/자기자본/), { target: { value: '20000' } });
fireEvent.click(screen.getByRole('button', { name: '계산' }));
await waitFor(() => expect(screen.getByText(/정책 수치는 근사치/)).toBeInTheDocument());
// purchase.regulation_flags 와 region.is_regulated 플래그 둘 다 "규제지역" 텍스트를 렌더함
expect(screen.getAllByText('규제지역').length).toBeGreaterThan(0);
});
}); });