From 7d2643646114d0fea83d2acb744842cca05d89d9 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 23 Apr 2026 20:29:19 +0700 Subject: [PATCH] test(web): add component tests for 10 untested frontend components (GOO-54) Cover critical-path and feature components that were missing tests: - charts: district-heatmap - chuyen-nhuong: detail-client, transfer-wizard-client - du-an: detail-client, project-ai-advice-card, project-map - khu-cong-nghiep: detail-client, listing-search-client, park-compare-client, park-map All 49 new tests pass with Vitest + React Testing Library. Co-Authored-By: Paperclip --- .../__tests__/district-heatmap.spec.tsx | 79 +++++++++++ .../chuyen-nhuong-detail-client.spec.tsx | 96 +++++++++++++ .../__tests__/transfer-wizard-client.spec.tsx | 89 ++++++++++++ .../__tests__/du-an-detail-client.spec.tsx | 131 ++++++++++++++++++ .../__tests__/project-ai-advice-card.spec.tsx | 37 +++++ .../du-an/__tests__/project-map.spec.tsx | 73 ++++++++++ .../khu-cong-nghiep-detail-client.spec.tsx | 92 ++++++++++++ .../__tests__/listing-search-client.spec.tsx | 50 +++++++ .../__tests__/park-compare-client.spec.tsx | 47 +++++++ .../__tests__/park-map.spec.tsx | 72 ++++++++++ 10 files changed, 766 insertions(+) create mode 100644 apps/web/components/charts/__tests__/district-heatmap.spec.tsx create mode 100644 apps/web/components/chuyen-nhuong/__tests__/chuyen-nhuong-detail-client.spec.tsx create mode 100644 apps/web/components/chuyen-nhuong/__tests__/transfer-wizard-client.spec.tsx create mode 100644 apps/web/components/du-an/__tests__/du-an-detail-client.spec.tsx create mode 100644 apps/web/components/du-an/__tests__/project-ai-advice-card.spec.tsx create mode 100644 apps/web/components/du-an/__tests__/project-map.spec.tsx create mode 100644 apps/web/components/khu-cong-nghiep/__tests__/khu-cong-nghiep-detail-client.spec.tsx create mode 100644 apps/web/components/khu-cong-nghiep/__tests__/listing-search-client.spec.tsx create mode 100644 apps/web/components/khu-cong-nghiep/__tests__/park-compare-client.spec.tsx create mode 100644 apps/web/components/khu-cong-nghiep/__tests__/park-map.spec.tsx diff --git a/apps/web/components/charts/__tests__/district-heatmap.spec.tsx b/apps/web/components/charts/__tests__/district-heatmap.spec.tsx new file mode 100644 index 0000000..e4a4f61 --- /dev/null +++ b/apps/web/components/charts/__tests__/district-heatmap.spec.tsx @@ -0,0 +1,79 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { HeatmapPoint } from '../district-heatmap'; + +vi.mock('mapbox-gl', () => { + class MockMap { + addControl = vi.fn(); + fitBounds = vi.fn(); + setStyle = vi.fn(); + remove = vi.fn(); + on = vi.fn(); + } + class MockNavigationControl {} + class MockMarker { + setLngLat() { return this; } + setPopup() { return this; } + addTo() { return this; } + remove() {} + } + class MockPopup { + setHTML() { return this; } + setLngLat() { return this; } + addTo() { return this; } + remove() {} + } + class MockLngLatBounds { + extend() { return this; } + isEmpty() { return false; } + } + return { + default: { + accessToken: '', + Map: MockMap, + NavigationControl: MockNavigationControl, + Marker: MockMarker, + Popup: MockPopup, + LngLatBounds: MockLngLatBounds, + }, + }; +}); +vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({})); +vi.mock('@/lib/mapbox-style', () => ({ + useMapboxStyle: () => 'mapbox://styles/mapbox/light-v11', + MAPBOX_STYLE_DARK: 'mapbox://styles/mapbox/dark-v11', +})); + +import { DistrictHeatmap } from '../district-heatmap'; + +const sampleData: HeatmapPoint[] = [ + { district: 'Quan 1', avgPriceM2: 80_000_000, totalListings: 120, medianPrice: '7500000000' }, + { district: 'Quan 7', avgPriceM2: 45_000_000, totalListings: 85, medianPrice: '4500000000' }, +]; + +describe('DistrictHeatmap', () => { + it('renders the map container', () => { + render(); + // Legend is always visible + expect(screen.getByText('Giá trung bình/m²')).toBeInTheDocument(); + }); + + it('shows token missing fallback when NEXT_PUBLIC_MAPBOX_TOKEN is absent', () => { + delete (process.env as Record)['NEXT_PUBLIC_MAPBOX_TOKEN']; + render(); + expect(screen.getByText(/NEXT_PUBLIC_MAPBOX_TOKEN/)).toBeInTheDocument(); + }); + + it('renders legend labels', () => { + render(); + expect(screen.getByText('Thấp')).toBeInTheDocument(); + expect(screen.getByText('Cao')).toBeInTheDocument(); + }); + + it('accepts optional className', () => { + const { container } = render( + , + ); + expect(container.firstChild).toHaveClass('h-[600px]'); + }); +}); diff --git a/apps/web/components/chuyen-nhuong/__tests__/chuyen-nhuong-detail-client.spec.tsx b/apps/web/components/chuyen-nhuong/__tests__/chuyen-nhuong-detail-client.spec.tsx new file mode 100644 index 0000000..79b6af3 --- /dev/null +++ b/apps/web/components/chuyen-nhuong/__tests__/chuyen-nhuong-detail-client.spec.tsx @@ -0,0 +1,96 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@/lib/chuyen-nhuong-api', () => ({ + CATEGORY_LABELS: { FURNITURE: 'Nội thất', APPLIANCE: 'Thiết bị', OFFICE_EQUIPMENT: 'Văn phòng', KITCHEN: 'Bếp', PREMISES: 'Mặt bằng', FULL_UNIT: 'Trọn gói' }, + CATEGORY_ICONS: { + FURNITURE: () => , + APPLIANCE: () => , + OFFICE_EQUIPMENT: () => , + KITCHEN: () => , + PREMISES: () => , + FULL_UNIT: () => , + }, + STATUS_LABELS: { DRAFT: 'Nháp', PENDING_REVIEW: 'Chờ duyệt', ACTIVE: 'Đang đăng', RESERVED: 'Đã đặt cọc', SOLD: 'Đã bán', EXPIRED: 'Hết hạn', REJECTED: 'Từ chối' }, + CONDITION_LABELS: { NEW: 'Mới', LIKE_NEW: 'Như mới', GOOD: 'Tốt', FAIR: 'Trung bình', WORN: 'Cũ' }, + CONDITION_COLORS: { NEW: 'bg-green-100 text-green-800', LIKE_NEW: 'bg-blue-100 text-blue-800', GOOD: 'bg-emerald-100 text-emerald-800', FAIR: 'bg-amber-100 text-amber-800', WORN: 'bg-red-100 text-red-800' }, +})); + +import { ChuyenNhuongDetailClient } from '../chuyen-nhuong-detail-client'; + +const listing = { + id: 't1', + sellerId: 'u1', + category: 'FURNITURE' as const, + status: 'ACTIVE' as const, + title: 'Bộ nội thất văn phòng', + description: 'Mô tả chi tiết', + address: '123 Nguyễn Huệ', + ward: 'Bến Nghé', + district: 'Quận 1', + city: 'Hồ Chí Minh', + askingPriceVND: '15000000', + aiEstimatePriceVND: '14000000', + aiConfidence: 0.85, + isNegotiable: true, + areaM2: null, + viewCount: 42, + saveCount: 10, + inquiryCount: 5, + contactName: 'Nguyễn Văn A', + contactPhone: '0912345678', + items: [ + { id: 'i1', name: 'Bàn', brand: null, modelName: null, category: 'FURNITURE' as const, condition: 'GOOD' as const, purchaseYear: 2022, originalPriceVND: 5000000, askingPriceVND: '3000000', aiEstimatePriceVND: null, quantity: 1, notes: null }, + ], + businessType: 'Quán cà phê', + monthlyRentVND: '20000000', + depositMonths: 3, + remainingLeaseMo: 18, + footTraffic: 'Cao', + pricingSource: 'MANUAL' as const, +} as never; + +describe('ChuyenNhuongDetailClient', () => { + it('renders listing title', () => { + render(); + expect(screen.getByText('Bộ nội thất văn phòng')).toBeInTheDocument(); + }); + + it('renders status badge', () => { + render(); + expect(screen.getByText('Đang đăng')).toBeInTheDocument(); + }); + + it('renders negotiable badge', () => { + render(); + expect(screen.getByText('Thương lượng')).toBeInTheDocument(); + }); + + it('renders asking price', () => { + render(); + // formatVND uses Intl.NumberFormat('vi-VN') + expect(screen.getAllByText(/15\.000\.000/).length).toBeGreaterThan(0); + }); + + it('renders AI confidence', () => { + render(); + expect(screen.getByText('85%')).toBeInTheDocument(); + }); + + it('renders contact info', () => { + render(); + expect(screen.getByText('Nguyễn Văn A')).toBeInTheDocument(); + expect(screen.getByText('0912345678')).toBeInTheDocument(); + }); + + it('renders business info section', () => { + render(); + expect(screen.getByText('Thông tin kinh doanh')).toBeInTheDocument(); + expect(screen.getByText('Quán cà phê')).toBeInTheDocument(); + }); + + it('renders items table heading', () => { + render(); + expect(screen.getByText(/Danh sách vật phẩm/)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/chuyen-nhuong/__tests__/transfer-wizard-client.spec.tsx b/apps/web/components/chuyen-nhuong/__tests__/transfer-wizard-client.spec.tsx new file mode 100644 index 0000000..a2e71af --- /dev/null +++ b/apps/web/components/chuyen-nhuong/__tests__/transfer-wizard-client.spec.tsx @@ -0,0 +1,89 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock('@/lib/chuyen-nhuong-api', () => ({ + CATEGORY_LABELS: { FURNITURE: 'Nội thất', APPLIANCE: 'Thiết bị', OFFICE_EQUIPMENT: 'Văn phòng', KITCHEN: 'Bếp', PREMISES: 'Mặt bằng', FULL_UNIT: 'Trọn gói' }, + CATEGORY_ICONS: { + FURNITURE: () => , + APPLIANCE: () => , + OFFICE_EQUIPMENT: () => , + KITCHEN: () => , + PREMISES: () => , + FULL_UNIT: () => , + }, + CONDITION_LABELS: { NEW: 'Mới', LIKE_NEW: 'Như mới', GOOD: 'Tốt', FAIR: 'Trung bình', WORN: 'Cũ' }, + transferApi: { estimate: vi.fn(), create: vi.fn() }, +})); + +vi.mock('@/lib/transfer-wizard-store', () => { + const state = { + currentStep: 0, + category: null as string | null, + items: [] as unknown[], + title: '', + description: '', + address: '', + district: '', + city: '', + askingPriceVND: 0, + pricingSource: 'MANUAL', + isNegotiable: false, + aiEstimate: null, + isEstimating: false, + setCategory: vi.fn(), + setStep: vi.fn(), + addItem: vi.fn(), + removeItem: vi.fn(), + setAiEstimate: vi.fn(), + setIsEstimating: vi.fn(), + setListingDetails: vi.fn(), + reset: vi.fn(), + }; + return { + useTransferWizardStore: () => state, + }; +}); + +import { TransferWizardClient } from '../transfer-wizard-client'; + +describe('TransferWizardClient', () => { + it('renders wizard title', () => { + render(); + expect(screen.getByText('Đăng tin chuyển nhượng')).toBeInTheDocument(); + }); + + it('renders step indicators (4 steps)', () => { + render(); + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.getByText('4')).toBeInTheDocument(); + }); + + it('renders current step label "Danh mục" for step 0', () => { + render(); + expect(screen.getByText('Danh mục')).toBeInTheDocument(); + }); + + it('renders category selection buttons', () => { + render(); + expect(screen.getByText('Nội thất')).toBeInTheDocument(); + expect(screen.getByText('Thiết bị')).toBeInTheDocument(); + }); + + it('renders navigation buttons', () => { + render(); + expect(screen.getByText(/Quay lại/)).toBeInTheDocument(); + expect(screen.getByText(/Tiếp theo/)).toBeInTheDocument(); + }); + + it('disables back button on first step', () => { + render(); + const backBtn = screen.getByText(/Quay lại/).closest('button'); + expect(backBtn).toBeDisabled(); + }); +}); diff --git a/apps/web/components/du-an/__tests__/du-an-detail-client.spec.tsx b/apps/web/components/du-an/__tests__/du-an-detail-client.spec.tsx new file mode 100644 index 0000000..7e0d92e --- /dev/null +++ b/apps/web/components/du-an/__tests__/du-an-detail-client.spec.tsx @@ -0,0 +1,131 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('next/dynamic', () => ({ + __esModule: true, + default: () => { + const Stub = () =>
; + Stub.displayName = 'DynamicStub'; + return Stub; + }, +})); + +vi.mock('next/image', () => ({ + __esModule: true, + default: (props: Record) => , +})); + +vi.mock('@/lib/analytics-api', () => ({ + analyticsApi: { + getNearbyPOIs: vi.fn().mockResolvedValue({ pois: [] }), + getProjectAiAdvice: vi.fn(), + }, +})); + +vi.mock('@/lib/listings-api', () => ({ + listingsApi: { getNeighborhoodScore: vi.fn().mockRejectedValue(new Error('noop')) }, +})); + +vi.mock('@/lib/du-an-api', () => ({ + PROJECT_PROPERTY_TYPE_LABELS: { APARTMENT: 'Căn hộ', VILLA: 'Biệt thự' }, + PROJECT_STATUS_COLORS: { PRE_SALE: 'bg-blue-100 text-blue-800', SELLING: 'bg-green-100 text-green-800' }, + PROJECT_STATUS_LABELS: { PRE_SALE: 'Mở bán sắp tới', SELLING: 'Đang bán' }, + duAnApi: { submitInquiry: vi.fn() }, +})); + +vi.mock('@/lib/currency', () => ({ + formatPrice: (v: string | number) => `${v} VNĐ`, +})); + +vi.mock('@/lib/project-personas', () => ({ + composeWhyThisProject: () => null, + deriveProjectPersonas: () => [], +})); + +vi.mock('@/components/du-an/project-ai-advice-card', () => ({ + ProjectAiAdviceCard: () =>
, +})); + +vi.mock('@/components/listings/image-gallery', () => ({ + ImageGallery: () =>
, +})); + +import { DuAnDetailClient } from '../du-an-detail-client'; + +const project = { + id: 'p1', + name: 'Vinhomes Grand Park', + slug: 'vinhomes-grand-park', + status: 'SELLING' as const, + propertyTypes: ['APARTMENT' as const], + address: '128 Nguyễn Xiển', + district: 'Quận 9', + city: 'Hồ Chí Minh', + latitude: 10.84, + longitude: 106.83, + description: 'Dự án lớn nhất TP.HCM', + totalArea: 271000, + totalUnits: 43000, + minPrice: '2000000000', + completionDate: '2025-12-01', + developer: { name: 'Vingroup', logoUrl: null, totalProjects: 12 }, + blocks: [{ id: 'b1', name: 'S1', totalUnits: 500, availableUnits: 20, floors: 35 }], + amenities: [{ id: 'a1', name: 'Hồ bơi', category: 'Thể thao' }], + priceRanges: [], + priceHistory: [], + media: [], + linkedListingCount: 5, + pois: [], + neighborhoodScores: [], + documents: [], + suitableFor: [], + whyThisLocation: null, +} as never; + +describe('DuAnDetailClient', () => { + it('renders project name', () => { + render(); + expect(screen.getByText('Vinhomes Grand Park')).toBeInTheDocument(); + }); + + it('renders status badge', () => { + render(); + expect(screen.getByText('Đang bán')).toBeInTheDocument(); + }); + + it('renders property type badge', () => { + render(); + expect(screen.getByText('Căn hộ')).toBeInTheDocument(); + }); + + it('renders developer name', () => { + render(); + expect(screen.getAllByText('Vingroup').length).toBeGreaterThan(0); + }); + + it('renders quick stats', () => { + render(); + expect(screen.getByText(/271\.000/)).toBeInTheDocument(); // total area + expect(screen.getByText('43000')).toBeInTheDocument(); // total units + }); + + it('renders blocks section', () => { + render(); + expect(screen.getByText('Phân khu / Block')).toBeInTheDocument(); + expect(screen.getByText('S1')).toBeInTheDocument(); + }); + + it('renders inquiry form', () => { + render(); + expect(screen.getAllByText('Nhận tư vấn').length).toBeGreaterThan(0); + expect(screen.getByLabelText('Họ tên')).toBeInTheDocument(); + expect(screen.getByLabelText('Số điện thoại')).toBeInTheDocument(); + }); + + it('renders tabs', () => { + render(); + expect(screen.getByRole('tab', { name: 'Tiện ích' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Vị trí' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Giá' })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/du-an/__tests__/project-ai-advice-card.spec.tsx b/apps/web/components/du-an/__tests__/project-ai-advice-card.spec.tsx new file mode 100644 index 0000000..d4e5cde --- /dev/null +++ b/apps/web/components/du-an/__tests__/project-ai-advice-card.spec.tsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +vi.mock('@/lib/analytics-api', () => ({ + analyticsApi: { getProjectAiAdvice: vi.fn().mockReturnValue(new Promise(() => {})) }, +})); + +vi.mock('@/lib/api-client', () => ({ + ApiError: class extends Error { status = 500; }, +})); + +vi.mock('@/lib/auth-store', () => ({ + useAuthStore: (selector: (s: { user: null }) => unknown) => selector({ user: null }), +})); + +import { ProjectAiAdviceCard } from '../project-ai-advice-card'; + +function wrapper({ children }: { children: React.ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {children}; +} + +describe('ProjectAiAdviceCard', () => { + it('renders trigger button initially', () => { + render(, { wrapper }); + expect(screen.getByText('Xem phân tích AI về dự án')).toBeInTheDocument(); + }); + + it('shows loading state when clicked', async () => { + const user = userEvent.setup(); + render(, { wrapper }); + await user.click(screen.getByText('Xem phân tích AI về dự án')); + expect(screen.getByText(/AI đang phân tích/)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/du-an/__tests__/project-map.spec.tsx b/apps/web/components/du-an/__tests__/project-map.spec.tsx new file mode 100644 index 0000000..6869f4e --- /dev/null +++ b/apps/web/components/du-an/__tests__/project-map.spec.tsx @@ -0,0 +1,73 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('mapbox-gl', () => { + class MockMap { + addControl = vi.fn(); + fitBounds = vi.fn(); + flyTo = vi.fn(); + setStyle = vi.fn(); + remove = vi.fn(); + on = vi.fn(); + } + class MockNavigationControl {} + class MockAttributionControl {} + class MockMarker { + setLngLat() { return this; } + setPopup() { return this; } + addTo() { return this; } + remove() {} + } + class MockPopup { + setHTML() { return this; } + setLngLat() { return this; } + addTo() { return this; } + remove() {} + } + class MockLngLatBounds { + extend() { return this; } + isEmpty() { return false; } + } + return { + default: { + accessToken: '', + Map: MockMap, + NavigationControl: MockNavigationControl, + AttributionControl: MockAttributionControl, + Marker: MockMarker, + Popup: MockPopup, + LngLatBounds: MockLngLatBounds, + }, + }; +}); +vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({})); +vi.mock('@/lib/mapbox-style', () => ({ useMapboxStyle: () => 'mapbox://styles/mapbox/light-v11' })); +vi.mock('@/lib/currency', () => ({ formatPrice: (v: string | number) => `${v} VNĐ` })); +vi.mock('@/lib/du-an-api', () => ({ + PROJECT_STATUS_LABELS: { SELLING: 'Đang bán' }, + PROJECT_STATUS_COLORS: { SELLING: 'bg-green-100 text-green-800' }, +})); + +import { ProjectMap } from '../project-map'; + +const projects = [ + { id: 'p1', name: 'Vinhomes', slug: 'vinhomes', status: 'SELLING' as const, district: 'Q9', city: 'HCMC', minPrice: '2000000000', latitude: 10.84, longitude: 106.83 }, +] as never[]; + +describe('ProjectMap', () => { + it('renders fallback when no token', () => { + delete (process.env as Record)['NEXT_PUBLIC_MAPBOX_TOKEN']; + render(); + expect(screen.getByText(/NEXT_PUBLIC_MAPBOX_TOKEN/)).toBeInTheDocument(); + }); + + it('shows project count overlay', () => { + render(); + expect(screen.getByText(/1 dự án trên bản đồ/)).toBeInTheDocument(); + }); + + it('shows 0 projects when empty', () => { + render(); + expect(screen.getByText(/0 dự án trên bản đồ/)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/khu-cong-nghiep/__tests__/khu-cong-nghiep-detail-client.spec.tsx b/apps/web/components/khu-cong-nghiep/__tests__/khu-cong-nghiep-detail-client.spec.tsx new file mode 100644 index 0000000..e7906fa --- /dev/null +++ b/apps/web/components/khu-cong-nghiep/__tests__/khu-cong-nghiep-detail-client.spec.tsx @@ -0,0 +1,92 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@/lib/khu-cong-nghiep-api', () => ({ + PARK_STATUS_LABELS: { OPERATIONAL: 'Hoạt động', PLANNING: 'Quy hoạch', UNDER_CONSTRUCTION: 'Đang xây dựng', FULL: 'Đã lấp đầy' }, + PARK_STATUS_COLORS: { OPERATIONAL: 'bg-green-100 text-green-800', PLANNING: 'bg-blue-100 text-blue-800', UNDER_CONSTRUCTION: 'bg-amber-100 text-amber-800', FULL: 'bg-red-100 text-red-800' }, + REGION_LABELS: { NORTH: 'Miền Bắc', CENTRAL: 'Miền Trung', SOUTH: 'Miền Nam' }, +})); + +import { KhuCongNghiepDetailClient } from '../khu-cong-nghiep-detail-client'; + +const park = { + id: 'ip1', + name: 'KCN Tân Bình', + nameEn: 'Tan Binh IP', + slug: 'kcn-tan-binh', + developer: 'Becamex', + operator: 'Becamex IDC', + status: 'OPERATIONAL' as const, + latitude: 10.8, + longitude: 106.6, + address: '123 Đại lộ', + district: 'Tân Bình', + province: 'Bình Dương', + region: 'SOUTH' as const, + totalAreaHa: 500, + leasableAreaHa: 400, + occupancyRate: 85, + remainingAreaHa: 60, + tenantCount: 120, + listingCount: 15, + establishedYear: 2005, + isVerified: true, + landRentUsdM2Year: '55.0000', + rbfRentUsdM2Month: '4.5000', + rbwRentUsdM2Month: '3.2000', + managementFeeUsd: '0.8000', + targetIndustries: ['Điện tử', 'Cơ khí'], + certifications: ['ISO 14001'], + description: 'KCN hàng đầu', + infrastructure: { power: '110kV', water: '50,000 m³/ngày' }, + connectivity: { airport: { name: 'Tân Sơn Nhất', distanceKm: 25 } }, + incentives: { cit: '2 năm miễn thuế' }, + existingTenants: [{ name: 'Samsung', country: 'Hàn Quốc', industry: 'Điện tử' }], + documents: [{ name: 'Brochure.pdf', url: '/docs/b.pdf' }], +} as never; + +describe('KhuCongNghiepDetailClient', () => { + it('renders park name', () => { + render(); + expect(screen.getByText('KCN Tân Bình')).toBeInTheDocument(); + }); + + it('renders English name', () => { + render(); + expect(screen.getByText('Tan Binh IP')).toBeInTheDocument(); + }); + + it('renders status badge', () => { + render(); + expect(screen.getByText('Hoạt động')).toBeInTheDocument(); + }); + + it('renders verified badge', () => { + render(); + expect(screen.getByText('Đã xác minh')).toBeInTheDocument(); + }); + + it('renders quick stats', () => { + render(); + expect(screen.getByText('500 ha')).toBeInTheDocument(); + expect(screen.getByText('85%')).toBeInTheDocument(); + expect(screen.getByText('120')).toBeInTheDocument(); + }); + + it('renders target industries', () => { + render(); + expect(screen.getByText('Điện tử')).toBeInTheDocument(); + expect(screen.getByText('Cơ khí')).toBeInTheDocument(); + }); + + it('renders rent info', () => { + render(); + expect(screen.getByText('$55.0000/m²/năm')).toBeInTheDocument(); + }); + + it('renders tabs', () => { + render(); + expect(screen.getByRole('tab', { name: 'Hạ tầng' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Doanh nghiệp' })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/khu-cong-nghiep/__tests__/listing-search-client.spec.tsx b/apps/web/components/khu-cong-nghiep/__tests__/listing-search-client.spec.tsx new file mode 100644 index 0000000..57e258e --- /dev/null +++ b/apps/web/components/khu-cong-nghiep/__tests__/listing-search-client.spec.tsx @@ -0,0 +1,50 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@/i18n/navigation', () => ({ + Link: ({ children, ...props }: React.PropsWithChildren>) => {children}, +})); + +vi.mock('@/components/khu-cong-nghiep/listing-card', () => ({ + IndustrialListingCard: ({ listing }: { listing: { id: string; title?: string } }) => ( +
listing
+ ), +})); + +vi.mock('@/lib/hooks/use-khu-cong-nghiep', () => ({ + useIndustrialListingsSearch: () => ({ + data: { data: [], total: 0, page: 1, totalPages: 1 }, + isLoading: false, + isError: false, + }), +})); + +vi.mock('@/lib/khu-cong-nghiep-api', () => ({ + PROPERTY_TYPE_LABELS: { FACTORY: 'Nhà xưởng', WAREHOUSE: 'Kho bãi', LAND: 'Đất CN' }, + LEASE_TYPE_LABELS: { LONG_TERM: 'Dài hạn', SHORT_TERM: 'Ngắn hạn' }, +})); + +import { ListingSearchClient } from '../listing-search-client'; + +describe('ListingSearchClient', () => { + it('renders page heading', () => { + render(); + expect(screen.getByText('Cho Thuê Bất Động Sản Công Nghiệp')).toBeInTheDocument(); + }); + + it('renders search input', () => { + render(); + expect(screen.getByPlaceholderText(/Tìm kiếm/)).toBeInTheDocument(); + }); + + it('renders filter dropdowns', () => { + render(); + expect(screen.getByLabelText('Loại BĐS')).toBeInTheDocument(); + expect(screen.getByLabelText('Hình thức')).toBeInTheDocument(); + }); + + it('shows empty state when no results', () => { + render(); + expect(screen.getByText('Không tìm thấy tin cho thuê')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/khu-cong-nghiep/__tests__/park-compare-client.spec.tsx b/apps/web/components/khu-cong-nghiep/__tests__/park-compare-client.spec.tsx new file mode 100644 index 0000000..fff93a9 --- /dev/null +++ b/apps/web/components/khu-cong-nghiep/__tests__/park-compare-client.spec.tsx @@ -0,0 +1,47 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('recharts', () => ({ + Radar: () => null, + RadarChart: ({ children }: { children: React.ReactNode }) =>
{children}
, + PolarGrid: () => null, + PolarAngleAxis: () => null, + PolarRadiusAxis: () => null, + ResponsiveContainer: ({ children }: { children: React.ReactNode }) =>
{children}
, + Legend: () => null, + Tooltip: () => null, +})); + +vi.mock('@/i18n/navigation', () => ({ + Link: ({ children, ...props }: React.PropsWithChildren>) => {children}, +})); + +vi.mock('@/lib/hooks/use-khu-cong-nghiep', () => ({ + useIndustrialCompare: () => ({ data: undefined, isLoading: false }), + useIndustrialParksSearch: () => ({ data: { data: [] } }), +})); + +vi.mock('@/lib/khu-cong-nghiep-api', () => ({ + PARK_STATUS_COLORS: { OPERATIONAL: 'bg-green-100 text-green-800' }, + PARK_STATUS_LABELS: { OPERATIONAL: 'Hoạt động' }, + REGION_LABELS: { SOUTH: 'Miền Nam' }, +})); + +import { ParkCompareClient } from '../park-compare-client'; + +describe('ParkCompareClient', () => { + it('renders heading', () => { + render(); + expect(screen.getByText('So Sánh Khu Công Nghiệp')).toBeInTheDocument(); + }); + + it('shows empty state when fewer than 2 parks selected', () => { + render(); + expect(screen.getByText('Chọn ít nhất 2 KCN để so sánh')).toBeInTheDocument(); + }); + + it('renders add park button', () => { + render(); + expect(screen.getByText('Thêm KCN')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/khu-cong-nghiep/__tests__/park-map.spec.tsx b/apps/web/components/khu-cong-nghiep/__tests__/park-map.spec.tsx new file mode 100644 index 0000000..50e4b47 --- /dev/null +++ b/apps/web/components/khu-cong-nghiep/__tests__/park-map.spec.tsx @@ -0,0 +1,72 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('mapbox-gl', () => { + class MockMap { + addControl = vi.fn(); + fitBounds = vi.fn(); + flyTo = vi.fn(); + setStyle = vi.fn(); + remove = vi.fn(); + on = vi.fn(); + } + class MockNavigationControl {} + class MockAttributionControl {} + class MockMarker { + setLngLat() { return this; } + setPopup() { return this; } + addTo() { return this; } + remove() {} + } + class MockPopup { + setHTML() { return this; } + setLngLat() { return this; } + addTo() { return this; } + remove() {} + } + class MockLngLatBounds { + extend() { return this; } + isEmpty() { return false; } + } + return { + default: { + accessToken: '', + Map: MockMap, + NavigationControl: MockNavigationControl, + AttributionControl: MockAttributionControl, + Marker: MockMarker, + Popup: MockPopup, + LngLatBounds: MockLngLatBounds, + }, + }; +}); +vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({})); +vi.mock('@/lib/mapbox-style', () => ({ useMapboxStyle: () => 'mapbox://styles/mapbox/light-v11' })); +vi.mock('@/lib/khu-cong-nghiep-api', () => ({ + PARK_STATUS_LABELS: { OPERATIONAL: 'Hoạt động' }, + PARK_STATUS_COLORS: { OPERATIONAL: 'bg-green-100 text-green-800' }, +})); + +import { ParkMap } from '../park-map'; + +const parks = [ + { id: 'ip1', name: 'KCN A', slug: 'kcn-a', status: 'OPERATIONAL' as const, province: 'Bình Dương', totalAreaHa: 500, occupancyRate: 80, tenantCount: 50, landRentUsdM2Year: '55', latitude: 10.8, longitude: 106.6 }, +] as never[]; + +describe('ParkMap', () => { + it('renders fallback when no token', () => { + delete (process.env as Record)['NEXT_PUBLIC_MAPBOX_TOKEN']; + render(); + expect(screen.getByText(/NEXT_PUBLIC_MAPBOX_TOKEN/)).toBeInTheDocument(); + }); + + it('shows park count overlay', () => { + render(); + expect(screen.getByText(/1 KCN trên bản đồ/)).toBeInTheDocument(); + }); + + it('shows 0 parks when empty', () => { + render(); + expect(screen.getByText(/0 KCN trên bản đồ/)).toBeInTheDocument(); + }); +});