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 <noreply@paperclip.ing>
This commit is contained in:
@@ -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(<DistrictHeatmap data={sampleData} city="Ho Chi Minh" />);
|
||||||
|
// 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<string, string | undefined>)['NEXT_PUBLIC_MAPBOX_TOKEN'];
|
||||||
|
render(<DistrictHeatmap data={[]} city="Ho Chi Minh" />);
|
||||||
|
expect(screen.getByText(/NEXT_PUBLIC_MAPBOX_TOKEN/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders legend labels', () => {
|
||||||
|
render(<DistrictHeatmap data={sampleData} city="Ho Chi Minh" />);
|
||||||
|
expect(screen.getByText('Thấp')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Cao')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts optional className', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<DistrictHeatmap data={sampleData} city="Ho Chi Minh" className="h-[600px]" />,
|
||||||
|
);
|
||||||
|
expect(container.firstChild).toHaveClass('h-[600px]');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: () => <span data-testid="icon-furniture" />,
|
||||||
|
APPLIANCE: () => <span data-testid="icon-appliance" />,
|
||||||
|
OFFICE_EQUIPMENT: () => <span data-testid="icon-office" />,
|
||||||
|
KITCHEN: () => <span data-testid="icon-kitchen" />,
|
||||||
|
PREMISES: () => <span data-testid="icon-premises" />,
|
||||||
|
FULL_UNIT: () => <span data-testid="icon-full" />,
|
||||||
|
},
|
||||||
|
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(<ChuyenNhuongDetailClient listing={listing} />);
|
||||||
|
expect(screen.getByText('Bộ nội thất văn phòng')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders status badge', () => {
|
||||||
|
render(<ChuyenNhuongDetailClient listing={listing} />);
|
||||||
|
expect(screen.getByText('Đang đăng')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders negotiable badge', () => {
|
||||||
|
render(<ChuyenNhuongDetailClient listing={listing} />);
|
||||||
|
expect(screen.getByText('Thương lượng')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders asking price', () => {
|
||||||
|
render(<ChuyenNhuongDetailClient listing={listing} />);
|
||||||
|
// formatVND uses Intl.NumberFormat('vi-VN')
|
||||||
|
expect(screen.getAllByText(/15\.000\.000/).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders AI confidence', () => {
|
||||||
|
render(<ChuyenNhuongDetailClient listing={listing} />);
|
||||||
|
expect(screen.getByText('85%')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders contact info', () => {
|
||||||
|
render(<ChuyenNhuongDetailClient listing={listing} />);
|
||||||
|
expect(screen.getByText('Nguyễn Văn A')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('0912345678')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders business info section', () => {
|
||||||
|
render(<ChuyenNhuongDetailClient listing={listing} />);
|
||||||
|
expect(screen.getByText('Thông tin kinh doanh')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Quán cà phê')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders items table heading', () => {
|
||||||
|
render(<ChuyenNhuongDetailClient listing={listing} />);
|
||||||
|
expect(screen.getByText(/Danh sách vật phẩm/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: () => <span data-testid="icon-furniture" />,
|
||||||
|
APPLIANCE: () => <span data-testid="icon-appliance" />,
|
||||||
|
OFFICE_EQUIPMENT: () => <span data-testid="icon-office" />,
|
||||||
|
KITCHEN: () => <span data-testid="icon-kitchen" />,
|
||||||
|
PREMISES: () => <span data-testid="icon-premises" />,
|
||||||
|
FULL_UNIT: () => <span data-testid="icon-full" />,
|
||||||
|
},
|
||||||
|
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(<TransferWizardClient />);
|
||||||
|
expect(screen.getByText('Đăng tin chuyển nhượng')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders step indicators (4 steps)', () => {
|
||||||
|
render(<TransferWizardClient />);
|
||||||
|
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(<TransferWizardClient />);
|
||||||
|
expect(screen.getByText('Danh mục')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders category selection buttons', () => {
|
||||||
|
render(<TransferWizardClient />);
|
||||||
|
expect(screen.getByText('Nội thất')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Thiết bị')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders navigation buttons', () => {
|
||||||
|
render(<TransferWizardClient />);
|
||||||
|
expect(screen.getByText(/Quay lại/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Tiếp theo/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables back button on first step', () => {
|
||||||
|
render(<TransferWizardClient />);
|
||||||
|
const backBtn = screen.getByText(/Quay lại/).closest('button');
|
||||||
|
expect(backBtn).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
131
apps/web/components/du-an/__tests__/du-an-detail-client.spec.tsx
Normal file
131
apps/web/components/du-an/__tests__/du-an-detail-client.spec.tsx
Normal file
@@ -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 = () => <div data-testid="dynamic-stub" />;
|
||||||
|
Stub.displayName = 'DynamicStub';
|
||||||
|
return Stub;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/image', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: (props: Record<string, unknown>) => <img {...props} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
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: () => <div data-testid="ai-advice-stub" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/listings/image-gallery', () => ({
|
||||||
|
ImageGallery: () => <div data-testid="gallery-stub" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
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(<DuAnDetailClient project={project} />);
|
||||||
|
expect(screen.getByText('Vinhomes Grand Park')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders status badge', () => {
|
||||||
|
render(<DuAnDetailClient project={project} />);
|
||||||
|
expect(screen.getByText('Đang bán')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders property type badge', () => {
|
||||||
|
render(<DuAnDetailClient project={project} />);
|
||||||
|
expect(screen.getByText('Căn hộ')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders developer name', () => {
|
||||||
|
render(<DuAnDetailClient project={project} />);
|
||||||
|
expect(screen.getAllByText('Vingroup').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders quick stats', () => {
|
||||||
|
render(<DuAnDetailClient project={project} />);
|
||||||
|
expect(screen.getByText(/271\.000/)).toBeInTheDocument(); // total area
|
||||||
|
expect(screen.getByText('43000')).toBeInTheDocument(); // total units
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders blocks section', () => {
|
||||||
|
render(<DuAnDetailClient project={project} />);
|
||||||
|
expect(screen.getByText('Phân khu / Block')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('S1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders inquiry form', () => {
|
||||||
|
render(<DuAnDetailClient project={project} />);
|
||||||
|
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(<DuAnDetailClient project={project} />);
|
||||||
|
expect(screen.getByRole('tab', { name: 'Tiện ích' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('tab', { name: 'Vị trí' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('tab', { name: 'Giá' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ProjectAiAdviceCard', () => {
|
||||||
|
it('renders trigger button initially', () => {
|
||||||
|
render(<ProjectAiAdviceCard projectId="p1" />, { 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(<ProjectAiAdviceCard projectId="p1" />, { 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
73
apps/web/components/du-an/__tests__/project-map.spec.tsx
Normal file
73
apps/web/components/du-an/__tests__/project-map.spec.tsx
Normal file
@@ -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<string, string | undefined>)['NEXT_PUBLIC_MAPBOX_TOKEN'];
|
||||||
|
render(<ProjectMap projects={[]} />);
|
||||||
|
expect(screen.getByText(/NEXT_PUBLIC_MAPBOX_TOKEN/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows project count overlay', () => {
|
||||||
|
render(<ProjectMap projects={projects} />);
|
||||||
|
expect(screen.getByText(/1 dự án trên bản đồ/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows 0 projects when empty', () => {
|
||||||
|
render(<ProjectMap projects={[]} />);
|
||||||
|
expect(screen.getByText(/0 dự án trên bản đồ/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(<KhuCongNghiepDetailClient park={park} />);
|
||||||
|
expect(screen.getByText('KCN Tân Bình')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders English name', () => {
|
||||||
|
render(<KhuCongNghiepDetailClient park={park} />);
|
||||||
|
expect(screen.getByText('Tan Binh IP')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders status badge', () => {
|
||||||
|
render(<KhuCongNghiepDetailClient park={park} />);
|
||||||
|
expect(screen.getByText('Hoạt động')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders verified badge', () => {
|
||||||
|
render(<KhuCongNghiepDetailClient park={park} />);
|
||||||
|
expect(screen.getByText('Đã xác minh')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders quick stats', () => {
|
||||||
|
render(<KhuCongNghiepDetailClient park={park} />);
|
||||||
|
expect(screen.getByText('500 ha')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('85%')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('120')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders target industries', () => {
|
||||||
|
render(<KhuCongNghiepDetailClient park={park} />);
|
||||||
|
expect(screen.getByText('Điện tử')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Cơ khí')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders rent info', () => {
|
||||||
|
render(<KhuCongNghiepDetailClient park={park} />);
|
||||||
|
expect(screen.getByText('$55.0000/m²/năm')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders tabs', () => {
|
||||||
|
render(<KhuCongNghiepDetailClient park={park} />);
|
||||||
|
expect(screen.getByRole('tab', { name: 'Hạ tầng' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('tab', { name: 'Doanh nghiệp' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Record<string, unknown>>) => <a {...props}>{children}</a>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/khu-cong-nghiep/listing-card', () => ({
|
||||||
|
IndustrialListingCard: ({ listing }: { listing: { id: string; title?: string } }) => (
|
||||||
|
<div data-testid={`listing-${listing.id}`}>listing</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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(<ListingSearchClient />);
|
||||||
|
expect(screen.getByText('Cho Thuê Bất Động Sản Công Nghiệp')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders search input', () => {
|
||||||
|
render(<ListingSearchClient />);
|
||||||
|
expect(screen.getByPlaceholderText(/Tìm kiếm/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders filter dropdowns', () => {
|
||||||
|
render(<ListingSearchClient />);
|
||||||
|
expect(screen.getByLabelText('Loại BĐS')).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText('Hình thức')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when no results', () => {
|
||||||
|
render(<ListingSearchClient />);
|
||||||
|
expect(screen.getByText('Không tìm thấy tin cho thuê')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 }) => <div data-testid="radar-chart">{children}</div>,
|
||||||
|
PolarGrid: () => null,
|
||||||
|
PolarAngleAxis: () => null,
|
||||||
|
PolarRadiusAxis: () => null,
|
||||||
|
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
Legend: () => null,
|
||||||
|
Tooltip: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/i18n/navigation', () => ({
|
||||||
|
Link: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => <a {...props}>{children}</a>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
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(<ParkCompareClient />);
|
||||||
|
expect(screen.getByText('So Sánh Khu Công Nghiệp')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when fewer than 2 parks selected', () => {
|
||||||
|
render(<ParkCompareClient />);
|
||||||
|
expect(screen.getByText('Chọn ít nhất 2 KCN để so sánh')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders add park button', () => {
|
||||||
|
render(<ParkCompareClient />);
|
||||||
|
expect(screen.getByText('Thêm KCN')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string | undefined>)['NEXT_PUBLIC_MAPBOX_TOKEN'];
|
||||||
|
render(<ParkMap parks={[]} />);
|
||||||
|
expect(screen.getByText(/NEXT_PUBLIC_MAPBOX_TOKEN/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows park count overlay', () => {
|
||||||
|
render(<ParkMap parks={parks} />);
|
||||||
|
expect(screen.getByText(/1 KCN trên bản đồ/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows 0 parks when empty', () => {
|
||||||
|
render(<ParkMap parks={[]} />);
|
||||||
|
expect(screen.getByText(/0 KCN trên bản đồ/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user