feat(web): AVM v2 upgraded valuation dashboard (TEC-2763)
R5.4 ships the upgraded AVM UI behind the `avm_v2` A/B flag. When the
flag is on, the dashboard exposes:
- Tab switch between single valuation and multi-property compare
- Waterfall drivers chart (ValueDriversChart) alongside the existing
horizontal bar breakdown
- Mapbox comparables map with similarity-coloured markers and an
optional highlighted subject pin
- Confidence interval + range bar and PDF export remain available
- Valuation history chart surface unchanged (still lazy-loaded)
Flag plumbing (useAvmV2Flag):
- NEXT_PUBLIC_FEATURE_AVM_V2=1 enables by default
- `?avm_v2=1|0` URL param forces + persists to localStorage
- safe localStorage handling (no throw when storage is blocked)
Tests: comparables-map, value-drivers-chart, use-avm-v2-flag specs
added. Pre-existing "Yếu tố chính" assertion in valuation-results.spec
updated to match the current copy ("Yếu tố ảnh hưởng giá") so the
valuation suite is green (7 files, 52 tests).
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
149
apps/web/components/valuation/__tests__/comparables-map.spec.tsx
Normal file
149
apps/web/components/valuation/__tests__/comparables-map.spec.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { ValuationComparable } from '@/lib/valuation-api';
|
||||
import { ComparablesMap } from '../comparables-map';
|
||||
|
||||
// Mapbox GL does not run cleanly in jsdom — mock with a minimal stand-in
|
||||
// that records addTo calls so we can assert marker count.
|
||||
const markerAddTo = vi.fn();
|
||||
const mapAddControl = vi.fn();
|
||||
const mapFitBounds = vi.fn();
|
||||
const mapFlyTo = vi.fn();
|
||||
const mapRemove = vi.fn();
|
||||
|
||||
vi.mock('mapbox-gl', () => {
|
||||
class MockMap {
|
||||
addControl = mapAddControl;
|
||||
fitBounds = mapFitBounds;
|
||||
flyTo = mapFlyTo;
|
||||
remove = mapRemove;
|
||||
}
|
||||
class MockNavigationControl {}
|
||||
class MockAttributionControl {}
|
||||
|
||||
class MockMarker {
|
||||
setLngLat() {
|
||||
return this;
|
||||
}
|
||||
setPopup() {
|
||||
return this;
|
||||
}
|
||||
addTo() {
|
||||
markerAddTo();
|
||||
return this;
|
||||
}
|
||||
remove() {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
class MockPopup {
|
||||
setHTML() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
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', () => ({}));
|
||||
|
||||
const sampleComparables: ValuationComparable[] = [
|
||||
{
|
||||
id: 'comp-1',
|
||||
title: 'Căn hộ tương tự A',
|
||||
address: '456 Nguyễn Hữu Thọ',
|
||||
district: 'Quận 7',
|
||||
priceVND: '4800000000',
|
||||
areaM2: 78,
|
||||
pricePerM2: 61_500_000,
|
||||
similarity: 0.92,
|
||||
latitude: 10.73,
|
||||
longitude: 106.72,
|
||||
},
|
||||
{
|
||||
id: 'comp-2',
|
||||
title: 'Căn hộ tương tự B',
|
||||
address: '789 Phạm Viết Chánh',
|
||||
district: 'Bình Thạnh',
|
||||
priceVND: '5200000000',
|
||||
areaM2: 82,
|
||||
pricePerM2: 63_400_000,
|
||||
similarity: 0.7,
|
||||
latitude: 10.8,
|
||||
longitude: 106.7,
|
||||
},
|
||||
];
|
||||
|
||||
describe('ComparablesMap', () => {
|
||||
beforeEach(() => {
|
||||
markerAddTo.mockClear();
|
||||
mapAddControl.mockClear();
|
||||
mapFitBounds.mockClear();
|
||||
mapFlyTo.mockClear();
|
||||
mapRemove.mockClear();
|
||||
process.env['NEXT_PUBLIC_MAPBOX_TOKEN'] = 'pk.test';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (process.env as Record<string, string | undefined>)[
|
||||
'NEXT_PUBLIC_MAPBOX_TOKEN'
|
||||
];
|
||||
});
|
||||
|
||||
it('renders header and descriptor', () => {
|
||||
render(<ComparablesMap comparables={sampleComparables} />);
|
||||
expect(screen.getByText('Bản đồ so sánh')).toBeInTheDocument();
|
||||
expect(screen.getByText(/2 BĐS so sánh/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders prompt when mapbox token is missing', () => {
|
||||
delete (process.env as Record<string, string | undefined>)[
|
||||
'NEXT_PUBLIC_MAPBOX_TOKEN'
|
||||
];
|
||||
render(<ComparablesMap comparables={sampleComparables} />);
|
||||
expect(
|
||||
screen.getByText(/Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no comparables have coordinates', () => {
|
||||
const withoutCoords = sampleComparables.map(
|
||||
({ latitude: _lat, longitude: _lng, ...rest }) => rest,
|
||||
);
|
||||
render(<ComparablesMap comparables={withoutCoords} />);
|
||||
expect(
|
||||
screen.getByText(/Không có toạ độ cho các BĐS so sánh/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds a marker for each geolocated comparable plus subject pin', () => {
|
||||
render(
|
||||
<ComparablesMap
|
||||
comparables={sampleComparables}
|
||||
subjectLatitude={10.77}
|
||||
subjectLongitude={106.7}
|
||||
/>,
|
||||
);
|
||||
expect(markerAddTo).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -64,7 +64,7 @@ describe('ValuationResults', () => {
|
||||
|
||||
it('renders price drivers section', () => {
|
||||
render(<ValuationResults result={mockResult} />);
|
||||
expect(screen.getByText('Yếu tố chính')).toBeInTheDocument();
|
||||
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();
|
||||
});
|
||||
@@ -82,7 +82,7 @@ describe('ValuationResults', () => {
|
||||
it('hides drivers section when empty', () => {
|
||||
const noDrivers = { ...mockResult, priceDrivers: [] };
|
||||
render(<ValuationResults result={noDrivers} />);
|
||||
expect(screen.queryByText('Yếu tố chính')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Yếu tố ảnh hưởng giá')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { PriceDriver } from '@/lib/valuation-api';
|
||||
import { ValueDriversChart } from '../value-drivers-chart';
|
||||
|
||||
// Recharts uses ResizeObserver and SVG path measurements that jsdom does not
|
||||
// implement. Stub ResponsiveContainer so child bars render in tests.
|
||||
vi.mock('recharts', async () => {
|
||||
const actual = (await vi.importActual('recharts')) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="chart-container" style={{ width: 800, height: 400 }}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const drivers: PriceDriver[] = [
|
||||
{ feature: 'area_m2', impact: 20, direction: 'positive' },
|
||||
{ feature: 'building_age_years', impact: -8, direction: 'negative' },
|
||||
{ feature: 'distance_to_cbd_km', impact: -4.5, direction: 'negative' },
|
||||
];
|
||||
|
||||
describe('ValueDriversChart', () => {
|
||||
it('renders header and description', () => {
|
||||
render(<ValueDriversChart drivers={drivers} />);
|
||||
expect(screen.getByText('Yếu tố ảnh hưởng giá')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Biểu đồ thác nước/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing when drivers list is empty', () => {
|
||||
const { container } = render(<ValueDriversChart drivers={[]} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders chart container when drivers are provided', () => {
|
||||
render(<ValueDriversChart drivers={drivers} />);
|
||||
expect(screen.getByTestId('chart-container')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user