diff --git a/src/pages/listings/components/ListingsTab.jsx b/src/pages/listings/components/ListingsTab.jsx
index 79cef86..2fe81f4 100644
--- a/src/pages/listings/components/ListingsTab.jsx
+++ b/src/pages/listings/components/ListingsTab.jsx
@@ -68,13 +68,30 @@ const ListingsTab = () => {
const handleCollect = async () => {
setCollecting(true);
+ setError('');
+ const prevAt = collectStatus?.collected_at ?? null;
try {
await collectListings();
- setTimeout(() => { loadStatus(); load(); setCollecting(false); }, 3000);
} catch (e) {
setError(e?.message ?? String(e));
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 (
diff --git a/src/pages/listings/components/ToolsTab.test.jsx b/src/pages/listings/components/ToolsTab.test.jsx
index 285c7b7..cca7678 100644
--- a/src/pages/listings/components/ToolsTab.test.jsx
+++ b/src/pages/listings/components/ToolsTab.test.jsx
@@ -1,7 +1,8 @@
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() }));
+import { safetyCheck, budgetCalc } from '../../../api';
import ToolsTab from './ToolsTab.jsx';
beforeEach(() => vi.clearAllMocks());
@@ -14,4 +15,30 @@ describe('ToolsTab', () => {
// 제출 버튼 존재
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();
+ 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();
+ 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);
+ });
});