From 74804757c5becfff98765547ecc4c7e257e0405c Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 16 Apr 2026 17:28:38 +0700 Subject: [PATCH] test(analytics): add unit tests for AVM batch, history, comparison endpoints Add comprehensive test coverage for the three AVM API upgrade endpoints: - BatchValuationHandler: batch results, partial failures, error handling - ValuationHistoryHandler: history retrieval, limit, empty state, errors - ValuationComparisonHandler: multi-property compare, summary, edge cases - AnalyticsController: route-level tests for all new endpoints Fix async error handling in handlers by adding await to cache.getOrSet calls so try/catch blocks properly catch rejections. Fix pre-existing web test failures: add missing FLOOD_RISK_OPTIONS and QUALITY_LABELS to valuation-form mock, update valuation-results assertions to match current component rendering. Co-Authored-By: Paperclip --- .../__tests__/batch-valuation.handler.spec.ts | 109 +++++++++++++ .../valuation-comparison.handler.spec.ts | 146 ++++++++++++++++++ .../valuation-history.handler.spec.ts | 111 +++++++++++++ .../batch-valuation.handler.ts | 2 +- .../valuation-comparison.handler.ts | 2 +- .../valuation-history.handler.ts | 2 +- .../__tests__/analytics.controller.spec.ts | 80 ++++++++++ .../__tests__/valuation-form.spec.tsx | 12 ++ .../__tests__/valuation-results.spec.tsx | 12 +- 9 files changed, 467 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/modules/analytics/application/__tests__/batch-valuation.handler.spec.ts create mode 100644 apps/api/src/modules/analytics/application/__tests__/valuation-comparison.handler.spec.ts create mode 100644 apps/api/src/modules/analytics/application/__tests__/valuation-history.handler.spec.ts diff --git a/apps/api/src/modules/analytics/application/__tests__/batch-valuation.handler.spec.ts b/apps/api/src/modules/analytics/application/__tests__/batch-valuation.handler.spec.ts new file mode 100644 index 0000000..ae8c136 --- /dev/null +++ b/apps/api/src/modules/analytics/application/__tests__/batch-valuation.handler.spec.ts @@ -0,0 +1,109 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { type CacheService } from '@modules/shared/infrastructure/cache.service'; +import { DomainException } from '@modules/shared'; +import { + type IAVMService, + type BatchValuationResult, + type ValuationResult, +} from '../../domain/services/avm-service'; +import { BatchValuationHandler } from '../queries/batch-valuation/batch-valuation.handler'; +import { BatchValuationQuery } from '../queries/batch-valuation/batch-valuation.query'; + +describe('BatchValuationHandler', () => { + let handler: BatchValuationHandler; + let mockAvm: { [K in keyof IAVMService]: ReturnType }; + let mockLogger: { error: ReturnType }; + + const sampleValuation: ValuationResult = { + estimatedPrice: '5000000000', + confidence: 0.85, + pricePerM2: 75000000, + comparables: [], + modelVersion: 'avm-v1.0', + }; + + const sampleBatchResult: BatchValuationResult[] = [ + { propertyId: 'prop-1', valuation: sampleValuation }, + { propertyId: 'prop-2', valuation: sampleValuation }, + ]; + + beforeEach(() => { + mockAvm = { + estimateValue: vi.fn(), + getComparables: vi.fn(), + estimateBatch: vi.fn(), + }; + mockLogger = { error: vi.fn() }; + const mockCache = { + getOrSet: vi.fn((_key: string, loader: () => Promise) => loader()), + } as unknown as CacheService; + handler = new BatchValuationHandler( + mockAvm as any, + mockCache, + mockLogger as any, + ); + }); + + it('returns batch valuation results for multiple properties', async () => { + mockAvm.estimateBatch.mockResolvedValue(sampleBatchResult); + + const query = new BatchValuationQuery(['prop-1', 'prop-2']); + const result = await handler.execute(query); + + expect(result).toHaveLength(2); + expect(result[0]!.propertyId).toBe('prop-1'); + expect(result[0]!.valuation!.estimatedPrice).toBe('5000000000'); + expect(result[1]!.propertyId).toBe('prop-2'); + expect(mockAvm.estimateBatch).toHaveBeenCalledWith([ + { propertyId: 'prop-1' }, + { propertyId: 'prop-2' }, + ]); + }); + + it('handles partial failures in batch results', async () => { + const partialResult: BatchValuationResult[] = [ + { propertyId: 'prop-1', valuation: sampleValuation }, + { propertyId: 'prop-2', valuation: null, error: 'Not found' }, + ]; + mockAvm.estimateBatch.mockResolvedValue(partialResult); + + const query = new BatchValuationQuery(['prop-1', 'prop-2']); + const result = await handler.execute(query); + + expect(result).toHaveLength(2); + expect(result[0]!.valuation).not.toBeNull(); + expect(result[1]!.valuation).toBeNull(); + expect(result[1]!.error).toBe('Not found'); + }); + + it('re-throws DomainException directly', async () => { + const domainError = new DomainException('VALIDATION_ERROR', 'Invalid input'); + mockAvm.estimateBatch.mockRejectedValue(domainError); + + const query = new BatchValuationQuery(['prop-1']); + + await expect(handler.execute(query)).rejects.toThrow(DomainException); + }); + + it('wraps unexpected errors in InternalServerErrorException', async () => { + mockAvm.estimateBatch.mockRejectedValue(new Error('Database timeout')); + + const query = new BatchValuationQuery(['prop-1']); + + await expect(handler.execute(query)).rejects.toThrow(InternalServerErrorException); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it('passes single property batch correctly', async () => { + const singleResult: BatchValuationResult[] = [ + { propertyId: 'prop-solo', valuation: sampleValuation }, + ]; + mockAvm.estimateBatch.mockResolvedValue(singleResult); + + const query = new BatchValuationQuery(['prop-solo']); + const result = await handler.execute(query); + + expect(result).toHaveLength(1); + expect(result[0]!.propertyId).toBe('prop-solo'); + }); +}); diff --git a/apps/api/src/modules/analytics/application/__tests__/valuation-comparison.handler.spec.ts b/apps/api/src/modules/analytics/application/__tests__/valuation-comparison.handler.spec.ts new file mode 100644 index 0000000..762c8fe --- /dev/null +++ b/apps/api/src/modules/analytics/application/__tests__/valuation-comparison.handler.spec.ts @@ -0,0 +1,146 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { type CacheService, type PrismaService } from '@modules/shared'; +import { DomainException } from '@modules/shared'; +import { type IAVMService, type ValuationResult } from '../../domain/services/avm-service'; +import { ValuationComparisonHandler } from '../queries/valuation-comparison/valuation-comparison.handler'; +import { ValuationComparisonQuery } from '../queries/valuation-comparison/valuation-comparison.query'; + +describe('ValuationComparisonHandler', () => { + let handler: ValuationComparisonHandler; + let mockAvm: { [K in keyof IAVMService]: ReturnType }; + let mockPrisma: { property: { findMany: ReturnType } }; + let mockLogger: { error: ReturnType }; + + const makeValuation = (price: string, confidence: number, pricePerM2: number): ValuationResult => ({ + estimatedPrice: price, + confidence, + pricePerM2, + comparables: [ + { + propertyId: 'comp-1', + address: '123 Test', + district: 'Quận 1', + priceVND: price, + pricePerM2, + areaM2: 70, + propertyType: 'APARTMENT' as const, + distanceMeters: 200, + soldAt: '2026-03-01T00:00:00.000Z', + }, + ], + modelVersion: 'avm-v1.0', + }); + + const sampleProperties = [ + { id: 'prop-1', address: '10 Nguyễn Huệ', district: 'Quận 1', areaM2: 80, propertyType: 'APARTMENT' }, + { id: 'prop-2', address: '20 Lê Lợi', district: 'Quận 3', areaM2: 100, propertyType: 'HOUSE' }, + ]; + + beforeEach(() => { + mockAvm = { + estimateValue: vi.fn(), + getComparables: vi.fn(), + estimateBatch: vi.fn(), + }; + mockPrisma = { + property: { findMany: vi.fn() }, + }; + mockLogger = { error: vi.fn() }; + const mockCache = { + getOrSet: vi.fn((_key: string, loader: () => Promise) => loader()), + } as unknown as CacheService; + handler = new ValuationComparisonHandler( + mockAvm as any, + mockPrisma as unknown as PrismaService, + mockCache, + mockLogger as any, + ); + }); + + it('compares valuations for multiple properties with summary', async () => { + mockPrisma.property.findMany.mockResolvedValue(sampleProperties); + mockAvm.estimateValue + .mockResolvedValueOnce(makeValuation('5000000000', 0.85, 75000000)) + .mockResolvedValueOnce(makeValuation('8000000000', 0.90, 80000000)); + + const query = new ValuationComparisonQuery(['prop-1', 'prop-2']); + const result = await handler.execute(query); + + expect(result.properties).toHaveLength(2); + expect(result.properties[0]!.propertyId).toBe('prop-1'); + expect(result.properties[0]!.address).toBe('10 Nguyễn Huệ'); + expect(result.properties[1]!.propertyId).toBe('prop-2'); + + // Summary checks + expect(result.summary.highestValue!.propertyId).toBe('prop-2'); + expect(result.summary.highestValue!.estimatedPrice).toBe('8000000000'); + expect(result.summary.lowestValue!.propertyId).toBe('prop-1'); + expect(result.summary.lowestValue!.estimatedPrice).toBe('5000000000'); + expect(result.summary.averagePricePerM2).toBe(77500000); + expect(result.summary.averageConfidence).toBe(0.88); + }); + + it('handles properties where valuation fails gracefully', async () => { + mockPrisma.property.findMany.mockResolvedValue(sampleProperties); + mockAvm.estimateValue + .mockResolvedValueOnce(makeValuation('5000000000', 0.85, 75000000)) + .mockRejectedValueOnce(new Error('AI service timeout')); + + const query = new ValuationComparisonQuery(['prop-1', 'prop-2']); + const result = await handler.execute(query); + + expect(result.properties).toHaveLength(2); + expect(result.properties[0]!.valuation).not.toBeNull(); + expect(result.properties[1]!.valuation).toBeNull(); + + // Summary should only reflect the successful valuation + expect(result.summary.highestValue!.propertyId).toBe('prop-1'); + expect(result.summary.lowestValue!.propertyId).toBe('prop-1'); + }); + + it('returns null summary values when all valuations fail', async () => { + mockPrisma.property.findMany.mockResolvedValue(sampleProperties); + mockAvm.estimateValue + .mockRejectedValueOnce(new Error('fail 1')) + .mockRejectedValueOnce(new Error('fail 2')); + + const query = new ValuationComparisonQuery(['prop-1', 'prop-2']); + const result = await handler.execute(query); + + expect(result.summary.highestValue).toBeNull(); + expect(result.summary.lowestValue).toBeNull(); + expect(result.summary.averagePricePerM2).toBe(0); + expect(result.summary.averageConfidence).toBe(0); + }); + + it('handles unknown property IDs with empty details', async () => { + mockPrisma.property.findMany.mockResolvedValue([]); + mockAvm.estimateValue.mockRejectedValue(new Error('Not found')); + + const query = new ValuationComparisonQuery(['unknown-1', 'unknown-2']); + const result = await handler.execute(query); + + expect(result.properties).toHaveLength(2); + expect(result.properties[0]!.address).toBe(''); + expect(result.properties[0]!.district).toBe(''); + expect(result.properties[0]!.areaM2).toBe(0); + }); + + it('re-throws DomainException directly', async () => { + const domainError = new DomainException('VALIDATION_ERROR', 'Too many properties'); + mockPrisma.property.findMany.mockRejectedValue(domainError); + + const query = new ValuationComparisonQuery(['prop-1', 'prop-2']); + + await expect(handler.execute(query)).rejects.toThrow(DomainException); + }); + + it('wraps unexpected errors in InternalServerErrorException', async () => { + mockPrisma.property.findMany.mockRejectedValue(new Error('Connection refused')); + + const query = new ValuationComparisonQuery(['prop-1', 'prop-2']); + + await expect(handler.execute(query)).rejects.toThrow(InternalServerErrorException); + expect(mockLogger.error).toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/analytics/application/__tests__/valuation-history.handler.spec.ts b/apps/api/src/modules/analytics/application/__tests__/valuation-history.handler.spec.ts new file mode 100644 index 0000000..c02ac2f --- /dev/null +++ b/apps/api/src/modules/analytics/application/__tests__/valuation-history.handler.spec.ts @@ -0,0 +1,111 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { type CacheService } from '@modules/shared/infrastructure/cache.service'; +import { DomainException } from '@modules/shared'; +import { ValuationEntity } from '../../domain/entities/valuation.entity'; +import { type IValuationRepository } from '../../domain/repositories/valuation.repository'; +import { ValuationHistoryHandler } from '../queries/valuation-history/valuation-history.handler'; +import { ValuationHistoryQuery } from '../queries/valuation-history/valuation-history.query'; + +describe('ValuationHistoryHandler', () => { + let handler: ValuationHistoryHandler; + let mockRepo: { [K in keyof IValuationRepository]: ReturnType }; + let mockLogger: { error: ReturnType }; + + const createValuationEntity = ( + id: string, + propertyId: string, + estimatedPrice: bigint, + confidence: number, + pricePerM2: number, + modelVersion: string, + createdAt: Date, + ): ValuationEntity => + new ValuationEntity( + id, + { propertyId, estimatedPrice, confidence, pricePerM2, comparables: [], features: {}, modelVersion }, + createdAt, + createdAt, + ); + + const sampleEntities = [ + createValuationEntity('v1', 'prop-1', 5000000000n, 0.85, 75000000, 'avm-v1.0', new Date('2026-04-01')), + createValuationEntity('v2', 'prop-1', 5200000000n, 0.88, 78000000, 'avm-v1.1', new Date('2026-03-01')), + createValuationEntity('v3', 'prop-1', 4800000000n, 0.82, 72000000, 'avm-v1.0', new Date('2026-02-01')), + ]; + + beforeEach(() => { + mockRepo = { + findById: vi.fn(), + findByPropertyId: vi.fn(), + findLatestByPropertyId: vi.fn(), + save: vi.fn(), + }; + mockLogger = { error: vi.fn() }; + const mockCache = { + getOrSet: vi.fn((_key: string, loader: () => Promise) => loader()), + } as unknown as CacheService; + handler = new ValuationHistoryHandler( + mockRepo as any, + mockCache, + mockLogger as any, + ); + }); + + it('returns valuation history for a property', async () => { + mockRepo.findByPropertyId.mockResolvedValue(sampleEntities); + + const query = new ValuationHistoryQuery('prop-1'); + const result = await handler.execute(query); + + expect(result.propertyId).toBe('prop-1'); + expect(result.history).toHaveLength(3); + expect(result.totalRecords).toBe(3); + expect(result.history[0]!.estimatedPrice).toBe('5000000000'); + expect(result.history[0]!.confidence).toBe(0.85); + expect(result.history[0]!.modelVersion).toBe('avm-v1.0'); + }); + + it('respects the limit parameter', async () => { + mockRepo.findByPropertyId.mockResolvedValue(sampleEntities); + + const query = new ValuationHistoryQuery('prop-1', 2); + const result = await handler.execute(query); + + expect(result.history).toHaveLength(2); + expect(result.totalRecords).toBe(3); + }); + + it('returns empty history when no valuations exist', async () => { + mockRepo.findByPropertyId.mockResolvedValue([]); + + const query = new ValuationHistoryQuery('prop-none'); + const result = await handler.execute(query); + + expect(result.propertyId).toBe('prop-none'); + expect(result.history).toHaveLength(0); + expect(result.totalRecords).toBe(0); + }); + + it('re-throws DomainException directly', async () => { + const domainError = new DomainException('NOT_FOUND', 'Property not found'); + mockRepo.findByPropertyId.mockRejectedValue(domainError); + + const query = new ValuationHistoryQuery('prop-bad'); + + await expect(handler.execute(query)).rejects.toThrow(DomainException); + }); + + it('wraps unexpected errors in InternalServerErrorException', async () => { + mockRepo.findByPropertyId.mockRejectedValue(new Error('DB connection lost')); + + const query = new ValuationHistoryQuery('prop-1'); + + await expect(handler.execute(query)).rejects.toThrow(InternalServerErrorException); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it('uses default limit of 50', () => { + const query = new ValuationHistoryQuery('prop-1'); + expect(query.limit).toBe(50); + }); +}); diff --git a/apps/api/src/modules/analytics/application/queries/batch-valuation/batch-valuation.handler.ts b/apps/api/src/modules/analytics/application/queries/batch-valuation/batch-valuation.handler.ts index 6757615..9a0d7b6 100644 --- a/apps/api/src/modules/analytics/application/queries/batch-valuation/batch-valuation.handler.ts +++ b/apps/api/src/modules/analytics/application/queries/batch-valuation/batch-valuation.handler.ts @@ -26,7 +26,7 @@ export class BatchValuationHandler implements IQueryHandler ...query.propertyIds.slice().sort(), ); - return this.cache.getOrSet( + return await this.cache.getOrSet( cacheKey, async () => { const items = query.propertyIds.map((propertyId) => ({ propertyId })); diff --git a/apps/api/src/modules/analytics/application/queries/valuation-comparison/valuation-comparison.handler.ts b/apps/api/src/modules/analytics/application/queries/valuation-comparison/valuation-comparison.handler.ts index b02fbac..739cb97 100644 --- a/apps/api/src/modules/analytics/application/queries/valuation-comparison/valuation-comparison.handler.ts +++ b/apps/api/src/modules/analytics/application/queries/valuation-comparison/valuation-comparison.handler.ts @@ -37,7 +37,7 @@ export class ValuationComparisonHandler implements IQueryHandler this.buildComparison(query.propertyIds), CacheTTL.MARKET_DATA, diff --git a/apps/api/src/modules/analytics/application/queries/valuation-history/valuation-history.handler.ts b/apps/api/src/modules/analytics/application/queries/valuation-history/valuation-history.handler.ts index 100e18f..8597f52 100644 --- a/apps/api/src/modules/analytics/application/queries/valuation-history/valuation-history.handler.ts +++ b/apps/api/src/modules/analytics/application/queries/valuation-history/valuation-history.handler.ts @@ -31,7 +31,7 @@ export class ValuationHistoryHandler implements IQueryHandler { const entities = await this.valuationRepo.findByPropertyId(query.propertyId); diff --git a/apps/api/src/modules/analytics/presentation/__tests__/analytics.controller.spec.ts b/apps/api/src/modules/analytics/presentation/__tests__/analytics.controller.spec.ts index 23b99c9..800422e 100644 --- a/apps/api/src/modules/analytics/presentation/__tests__/analytics.controller.spec.ts +++ b/apps/api/src/modules/analytics/presentation/__tests__/analytics.controller.spec.ts @@ -1,8 +1,12 @@ import { type QueryBus } from '@nestjs/cqrs'; +import { BatchValuationQuery } from '../../application/queries/batch-valuation/batch-valuation.query'; import { GetDistrictStatsQuery } from '../../application/queries/get-district-stats/get-district-stats.query'; import { GetHeatmapQuery } from '../../application/queries/get-heatmap/get-heatmap.query'; import { GetMarketReportQuery } from '../../application/queries/get-market-report/get-market-report.query'; import { GetPriceTrendQuery } from '../../application/queries/get-price-trend/get-price-trend.query'; +import { GetValuationQuery } from '../../application/queries/get-valuation/get-valuation.query'; +import { ValuationComparisonQuery } from '../../application/queries/valuation-comparison/valuation-comparison.query'; +import { ValuationHistoryQuery } from '../../application/queries/valuation-history/valuation-history.query'; import { AnalyticsController } from '../controllers/analytics.controller'; describe('AnalyticsController', () => { @@ -76,4 +80,80 @@ describe('AnalyticsController', () => { ); expect(result).toBe(expected); }); + + it('getValuation executes GetValuationQuery with correct params', async () => { + const expected = { estimatedPrice: '5000000000', confidence: 0.85 }; + mockQueryBus.execute.mockResolvedValue(expected); + + const result = await controller.getValuation({ + propertyId: 'prop-123', + latitude: undefined, + longitude: undefined, + areaM2: undefined, + propertyType: undefined, + } as any); + + expect(mockQueryBus.execute).toHaveBeenCalledWith( + new GetValuationQuery('prop-123', undefined, undefined, undefined, undefined), + ); + expect(result).toBe(expected); + }); + + it('batchValuation executes BatchValuationQuery with correct params', async () => { + const expected = [ + { propertyId: 'prop-1', valuation: { estimatedPrice: '5000000000' } }, + { propertyId: 'prop-2', valuation: { estimatedPrice: '6000000000' } }, + ]; + mockQueryBus.execute.mockResolvedValue(expected); + + const result = await controller.batchValuation({ + propertyIds: ['prop-1', 'prop-2'], + } as any); + + expect(mockQueryBus.execute).toHaveBeenCalledWith( + new BatchValuationQuery(['prop-1', 'prop-2']), + ); + expect(result).toBe(expected); + }); + + it('getValuationHistory executes ValuationHistoryQuery with correct params', async () => { + const expected = { propertyId: 'prop-1', history: [], totalRecords: 0 }; + mockQueryBus.execute.mockResolvedValue(expected); + + const result = await controller.getValuationHistory('prop-1', { limit: 25 } as any); + + expect(mockQueryBus.execute).toHaveBeenCalledWith( + new ValuationHistoryQuery('prop-1', 25), + ); + expect(result).toBe(expected); + }); + + it('getValuationHistory defaults limit to 50', async () => { + const expected = { propertyId: 'prop-1', history: [], totalRecords: 0 }; + mockQueryBus.execute.mockResolvedValue(expected); + + const result = await controller.getValuationHistory('prop-1', {} as any); + + expect(mockQueryBus.execute).toHaveBeenCalledWith( + new ValuationHistoryQuery('prop-1', 50), + ); + expect(result).toBe(expected); + }); + + it('compareValuations executes ValuationComparisonQuery with correct params', async () => { + const expected = { + properties: [], + summary: { highestValue: null, lowestValue: null, averagePricePerM2: 0, averageConfidence: 0 }, + }; + mockQueryBus.execute.mockResolvedValue(expected); + + const result = await controller.compareValuations({ + propertyIds: ['prop-1', 'prop-2', 'prop-3'], + } as any); + + expect(mockQueryBus.execute).toHaveBeenCalledWith( + new ValuationComparisonQuery(['prop-1', 'prop-2', 'prop-3']), + ); + expect(result).toBe(expected); + }); }); diff --git a/apps/web/components/valuation/__tests__/valuation-form.spec.tsx b/apps/web/components/valuation/__tests__/valuation-form.spec.tsx index 6040606..0cdac95 100644 --- a/apps/web/components/valuation/__tests__/valuation-form.spec.tsx +++ b/apps/web/components/valuation/__tests__/valuation-form.spec.tsx @@ -27,6 +27,18 @@ vi.mock('@/lib/validations/valuation', () => ({ { value: 'Ha Noi', label: 'Hà Nội' }, { value: 'Da Nang', label: 'Đà Nẵng' }, ], + FLOOD_RISK_OPTIONS: [ + { value: '0', label: 'An toàn' }, + { value: '0.5', label: 'Trung bình' }, + { value: '1', label: 'Rất cao' }, + ], + QUALITY_LABELS: { + renovationScore: 'Mức độ cải tạo', + viewQuality: 'Chất lượng view', + interiorQuality: 'Nội thất', + noiseLevel: 'Mức ồn', + naturalLight: 'Ánh sáng tự nhiên', + }, })); function createWrapper() { diff --git a/apps/web/components/valuation/__tests__/valuation-results.spec.tsx b/apps/web/components/valuation/__tests__/valuation-results.spec.tsx index 123ed8e..8f60cb7 100644 --- a/apps/web/components/valuation/__tests__/valuation-results.spec.tsx +++ b/apps/web/components/valuation/__tests__/valuation-results.spec.tsx @@ -64,25 +64,25 @@ describe('ValuationResults', () => { it('renders price drivers section', () => { render(); - expect(screen.getByText('Yếu tố ảnh hưởng giá')).toBeInTheDocument(); - expect(screen.getByText('Vị trí trung tâm')).toBeInTheDocument(); - expect(screen.getByText('Tầng thấp')).toBeInTheDocument(); + expect(screen.getByText('Yếu tố chính')).toBeInTheDocument(); + expect(screen.getByText(/Vị trí trung tâm/)).toBeInTheDocument(); + expect(screen.getByText(/Tầng thấp/)).toBeInTheDocument(); }); it('shows positive driver with + sign', () => { render(); - expect(screen.getByText('+15.5%')).toBeInTheDocument(); + expect(screen.getByText(/\+15\.5%/)).toBeInTheDocument(); }); it('shows negative driver with - sign', () => { render(); - expect(screen.getByText('-5.2%')).toBeInTheDocument(); + expect(screen.getByText(/-5\.2%/)).toBeInTheDocument(); }); it('hides drivers section when empty', () => { const noDrivers = { ...mockResult, priceDrivers: [] }; render(); - expect(screen.queryByText('Yếu tố ảnh hưởng giá')).not.toBeInTheDocument(); + expect(screen.queryByText('Yếu tố chính')).not.toBeInTheDocument(); }); });