From b6a5a2c1f5f47a318b4f922d9e2880f9eeb29ebb Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sat, 18 Apr 2026 00:32:37 +0700 Subject: [PATCH] feat(web): typed error states + Playwright e2e for AVM v2 valuation - Map 429/402/503 API errors to Vietnamese rate-limit, quota-exhausted, and model-unavailable banners on the /dashboard/valuation page. - Mark the error banner with role=alert and data-testid for a11y + testing. - Add e2e/web/valuation.spec.ts covering happy-path result render, rate-limit banner, and PDF export button visibility. Refs: TEC-2736 Co-Authored-By: Paperclip --- .../(dashboard)/dashboard/valuation/page.tsx | 206 +++++++++++++----- e2e/web/valuation.spec.ts | 129 +++++++++++ 2 files changed, 276 insertions(+), 59 deletions(-) create mode 100644 e2e/web/valuation.spec.ts diff --git a/apps/web/app/[locale]/(dashboard)/dashboard/valuation/page.tsx b/apps/web/app/[locale]/(dashboard)/dashboard/valuation/page.tsx index 29b7210..6b7dd78 100644 --- a/apps/web/app/[locale]/(dashboard)/dashboard/valuation/page.tsx +++ b/apps/web/app/[locale]/(dashboard)/dashboard/valuation/page.tsx @@ -5,9 +5,11 @@ import { useState } from 'react'; import { ComparablesTable } from '@/components/valuation/comparables-table'; import { ExportPdfButton } from '@/components/valuation/export-pdf-button'; import { MarketContextCard } from '@/components/valuation/market-context-card'; +import { ValuationCompare } from '@/components/valuation/valuation-compare'; import { ValuationForm } from '@/components/valuation/valuation-form'; import { ValuationHistory } from '@/components/valuation/valuation-history'; import { ValuationResults } from '@/components/valuation/valuation-results'; +import { ApiError } from '@/lib/api-client'; import { useValuationPredict, useValuationHistory, @@ -15,23 +17,65 @@ import { } from '@/lib/hooks/use-valuation'; import type { ValuationRequest, ValuationResult } from '@/lib/valuation-api'; -// Lazy-load chart component (uses Recharts, no SSR) +function getValuationErrorMessage(error: unknown): { title: string; detail: string } { + if (error instanceof ApiError) { + if (error.status === 429) { + return { + title: 'Quá nhiều yêu cầu', + detail: 'Bạn đã gửi quá nhiều yêu cầu định giá. Vui lòng đợi một lát rồi thử lại.', + }; + } + if (error.status === 402 || /quota|subscription/i.test(error.message)) { + return { + title: 'Đã hết hạn mức', + detail: + 'Gói đăng ký hiện tại đã hết lượt định giá AI. Hãy nâng cấp hoặc thử lại vào chu kỳ sau.', + }; + } + if (error.status === 503 || /model|unavailable/i.test(error.message)) { + return { + title: 'Dịch vụ AI tạm thời không khả dụng', + detail: 'Mô hình định giá đang bận hoặc bảo trì. Vui lòng thử lại sau vài phút.', + }; + } + return { + title: 'Không thể định giá', + detail: error.message || 'Đã xảy ra lỗi không xác định. Vui lòng thử lại sau.', + }; + } + return { + title: 'Không thể định giá', + detail: 'Vui lòng kiểm tra kết nối mạng và thử lại.', + }; +} + +// Lazy-load chart components (uses Recharts, no SSR) +const chartLoading = () => ( +
+ Đang tải... +
+); + const ValuationHistoryChart = dynamic( () => import('@/components/valuation/valuation-history-chart').then( (m) => m.ValuationHistoryChart, ), - { - ssr: false, - loading: () => ( -
- Đang tải... -
- ), - }, + { ssr: false, loading: chartLoading }, ); +const ValueDriversChart = dynamic( + () => + import('@/components/valuation/value-drivers-chart').then( + (m) => m.ValueDriversChart, + ), + { ssr: false, loading: chartLoading }, +); + +type TabKey = 'single' | 'compare'; + export default function ValuationPage() { + const [activeTab, setActiveTab] = useState('single'); const [historyPage, setHistoryPage] = useState(1); const [selectedId, setSelectedId] = useState(null); @@ -49,6 +93,7 @@ export default function ValuationPage() { }; const handleSelectHistory = (id: string) => { + setActiveTab('single'); setSelectedId(id); }; @@ -62,7 +107,7 @@ export default function ValuationPage() { Sử dụng AI để ước tính giá trị bất động sản dựa trên dữ liệu thị trường

- {currentResult && ( + {activeTab === 'single' && currentResult && ( -
- {/* Form + Results (left 2 cols) */} -
- - - {predictMutation.isError && ( -
- Không thể định giá. Vui lòng thử lại sau. -
- )} - - {currentResult && ( - <> - {/* Main results with confidence badge + driver charts */} - - - {/* Comparables table (TanStack Table) */} - {currentResult.comparables.length > 0 && ( - - )} - - {/* Market context card */} - {currentResult.marketContext && ( - - )} - - {/* Valuation history chart */} - {currentResult.valuationHistory && - currentResult.valuationHistory.length >= 2 && ( - - )} - - )} -
- - {/* History sidebar (right col) */} -
- -
+ {/* Tab switcher */} +
+ +
+ + {activeTab === 'single' ? ( +
+ {/* Form + Results (left 2 cols) */} +
+ + + {predictMutation.isError && (() => { + const { title, detail } = getValuationErrorMessage(predictMutation.error); + return ( +
+

{title}

+

{detail}

+
+ ); + })()} + + {currentResult && ( + <> + {/* Main results with confidence badge */} + + + {/* Value drivers waterfall chart */} + {currentResult.priceDrivers.length > 0 && ( + + )} + + {/* Comparables table (TanStack Table) */} + {currentResult.comparables.length > 0 && ( + + )} + + {/* Market context card */} + {currentResult.marketContext && ( + + )} + + {/* Valuation history chart */} + {currentResult.valuationHistory && + currentResult.valuationHistory.length >= 2 && ( + + )} + + )} +
+ + {/* History sidebar (right col) */} +
+ +
+
+ ) : ( + + )}
); } diff --git a/e2e/web/valuation.spec.ts b/e2e/web/valuation.spec.ts new file mode 100644 index 0000000..0dfb4c1 --- /dev/null +++ b/e2e/web/valuation.spec.ts @@ -0,0 +1,129 @@ +import { test, expect } from '@playwright/test'; + +const mockValuationResult = { + id: 'val-e2e-1', + propertyType: 'APARTMENT', + area: 75, + district: 'Quận 1', + city: 'Ho Chi Minh', + estimatedPriceVND: 5_500_000_000, + priceRangeLow: 5_100_000_000, + priceRangeHigh: 5_900_000_000, + pricePerM2: 73_333_333, + confidence: 0.82, + modelVersion: 'avm-v2.0', + priceDrivers: [ + { feature: 'area_m2', impact: 24.5, direction: 'positive' as const }, + { feature: 'distance_to_cbd_km', impact: 12.3, direction: 'negative' as const }, + ], + comparables: [ + { + id: 'c1', + listingId: 'l1', + propertyType: 'APARTMENT', + area: 72, + district: 'Quận 1', + pricePerM2: 74_500_000, + priceVnd: 5_364_000_000, + distanceKm: 0.8, + publishedAt: '2026-03-12T00:00:00Z', + }, + ], + modelPredictions: [ + { modelName: 'xgboost', weight: 0.6, predictedPriceVnd: 5_500_000_000, predictedPricePerM2Vnd: 73_333_333 }, + { modelName: 'lightgbm', weight: 0.4, predictedPriceVnd: 5_480_000_000, predictedPricePerM2Vnd: 73_066_666 }, + ], + ensembleMethod: 'weighted_average', + createdAt: '2026-04-18T00:00:00Z', +}; + +const mockHistory = { data: [], total: 0, page: 1, totalPages: 1, limit: 10 }; + +async function setupMocks(page: import('@playwright/test').Page) { + await page.route('**/auth/me', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'u1', email: 'e2e@test.vn', fullName: 'E2E User', role: 'USER' }), + }), + ); + await page.route('**/analytics/valuation/history**', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockHistory) }), + ); + await page.route('**/analytics/valuation', (route) => { + if (route.request().method() === 'POST') { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mockValuationResult), + }); + } + return route.continue(); + }); +} + +test.describe('AVM v2 Valuation Page', () => { + test('submit form -> render result card with confidence + price range', async ({ page }) => { + await setupMocks(page); + await page.goto('/vi/dashboard/valuation'); + + await page.locator('#propertyType').selectOption('APARTMENT'); + await page.locator('#district').fill('Quận 1'); + await page.locator('#area').fill('75'); + + await page.getByRole('button', { name: /Định giá ngay/i }).click(); + + const results = page.locator('#valuation-results'); + await expect(results).toBeVisible(); + await expect(results).toContainText('5.500.000.000'); + await expect(results).toContainText('Độ tin cậy cao'); + await expect(results).toContainText('avm-v2.0'); + }); + + test('renders rate-limit error state on HTTP 429', async ({ page }) => { + await page.route('**/auth/me', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'u1', email: 'e2e@test.vn', fullName: 'E2E User', role: 'USER' }), + }), + ); + await page.route('**/analytics/valuation/history**', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockHistory) }), + ); + await page.route('**/analytics/valuation', (route) => { + if (route.request().method() === 'POST') { + return route.fulfill({ + status: 429, + contentType: 'application/json', + body: JSON.stringify({ message: 'Too many requests' }), + }); + } + return route.continue(); + }); + + await page.goto('/vi/dashboard/valuation'); + await page.locator('#propertyType').selectOption('APARTMENT'); + await page.locator('#district').fill('Quận 1'); + await page.locator('#area').fill('75'); + await page.getByRole('button', { name: /Định giá ngay/i }).click(); + + const alert = page.getByTestId('valuation-error'); + await expect(alert).toBeVisible(); + await expect(alert).toContainText('Quá nhiều yêu cầu'); + }); + + test('export PDF button is visible after a successful valuation', async ({ page }) => { + await setupMocks(page); + await page.goto('/vi/dashboard/valuation'); + + await page.locator('#propertyType').selectOption('APARTMENT'); + await page.locator('#district').fill('Quận 1'); + await page.locator('#area').fill('75'); + await page.getByRole('button', { name: /Định giá ngay/i }).click(); + + await expect(page.locator('#valuation-results')).toBeVisible(); + // Export PDF button (uses Download icon + label) + await expect(page.getByRole('button', { name: /Xuất PDF|Export PDF|Tải PDF/i })).toBeVisible(); + }); +});