diff --git a/apps/web/components/design-system/__tests__/ticker-strip.spec.tsx b/apps/web/components/design-system/__tests__/ticker-strip.spec.tsx
new file mode 100644
index 0000000..8522c29
--- /dev/null
+++ b/apps/web/components/design-system/__tests__/ticker-strip.spec.tsx
@@ -0,0 +1,41 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { TickerStrip, type TickerItem } from '../ticker-strip';
+
+const items: TickerItem[] = [
+ { id: 'q1', label: 'Quận 1', changePercent: 2.5 },
+ { id: 'q2', label: 'Quận 7', changePercent: -1.2 },
+ { id: 'q3', label: 'Thủ Đức', changePercent: 0 },
+];
+
+describe('TickerStrip', () => {
+ it('renders each item label twice (duplicated for seamless loop)', () => {
+ render();
+ expect(screen.getAllByText('Quận 1')).toHaveLength(2);
+ expect(screen.getAllByText('Quận 7')).toHaveLength(2);
+ expect(screen.getAllByText('Thủ Đức')).toHaveLength(2);
+ });
+
+ it('applies animate-ticker class when not paused', () => {
+ const { container } = render();
+ const inner = container.querySelector('.animate-ticker');
+ expect(inner).not.toBeNull();
+ });
+
+ it('omits animate-ticker class when paused', () => {
+ const { container } = render();
+ expect(container.querySelector('.animate-ticker')).toBeNull();
+ });
+
+ it('passes through className to root', () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector('.custom-strip')).not.toBeNull();
+ });
+
+ it('renders with empty items without crashing', () => {
+ const { container } = render();
+ expect(container.querySelector('.overflow-hidden')).not.toBeNull();
+ });
+});
diff --git a/apps/web/components/reports/__tests__/report-chart.spec.tsx b/apps/web/components/reports/__tests__/report-chart.spec.tsx
new file mode 100644
index 0000000..1a824d2
--- /dev/null
+++ b/apps/web/components/reports/__tests__/report-chart.spec.tsx
@@ -0,0 +1,98 @@
+import { render, screen } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { ReportChart, ReportChartsGrid } from '../report-chart';
+
+vi.mock('recharts', () => ({
+ ResponsiveContainer: ({ children }: { children: ReactNode }) => (
+
{children}
+ ),
+ AreaChart: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ BarChart: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ Area: ({ stroke }: { stroke: string }) => (
+
+ ),
+ Bar: ({ fill }: { fill: string }) => (
+
+ ),
+ XAxis: () => ,
+ YAxis: () => ,
+ CartesianGrid: () => ,
+ Tooltip: () => ,
+}));
+
+describe('ReportChart', () => {
+ it('renders area chart by default with title', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('GDP')).toBeInTheDocument();
+ expect(screen.getByTestId('area-chart')).toBeInTheDocument();
+ });
+
+ it('renders bar chart when variant=bar', () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
+ });
+
+ it('returns null for empty data', () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('passes color through to area stroke', () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId('area')).toHaveAttribute('data-stroke', '#ff0000');
+ });
+});
+
+describe('ReportChartsGrid', () => {
+ it('returns null when no charts have data', () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders one ReportChart per non-empty key with localized labels', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('GDP')).toBeInTheDocument();
+ expect(screen.getByText('Dân số')).toBeInTheDocument();
+ });
+
+ it('uses custom labels when provided', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('Custom GDP')).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/components/valuation/__tests__/comparables-table.spec.tsx b/apps/web/components/valuation/__tests__/comparables-table.spec.tsx
new file mode 100644
index 0000000..1413933
--- /dev/null
+++ b/apps/web/components/valuation/__tests__/comparables-table.spec.tsx
@@ -0,0 +1,89 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { ComparablesTable } from '../comparables-table';
+import type { ValuationComparable } from '@/lib/valuation-api';
+
+const comparables: ValuationComparable[] = [
+ {
+ id: 'c1',
+ title: 'Căn hộ Vinhomes',
+ address: '123 Nguyễn Huệ',
+ district: 'Quận 1',
+ priceVND: '5000000000',
+ areaM2: 75,
+ pricePerM2: 66_666_666,
+ similarity: 0.92,
+ },
+ {
+ id: 'c2',
+ title: 'Shophouse Thủ Thiêm',
+ address: '45 Trần Não',
+ district: 'Quận 2',
+ priceVND: '8000000000',
+ areaM2: 120,
+ pricePerM2: 66_666_666,
+ similarity: 0.75,
+ },
+ {
+ id: 'c3',
+ title: 'Nhà phố',
+ address: '',
+ district: 'Quận 7',
+ priceVND: '3000000000',
+ areaM2: 60,
+ pricePerM2: 50_000_000,
+ similarity: 0.62,
+ },
+];
+
+describe('ComparablesTable', () => {
+ it('renders header with count and row per comparable', () => {
+ render();
+ expect(screen.getByText('Bất động sản tương tự')).toBeInTheDocument();
+ expect(
+ screen.getByText(/3 bất động sản có đặc điểm tương tự/),
+ ).toBeInTheDocument();
+ expect(screen.getByText('Căn hộ Vinhomes')).toBeInTheDocument();
+ expect(screen.getByText('Shophouse Thủ Thiêm')).toBeInTheDocument();
+ expect(screen.getByText('Nhà phố')).toBeInTheDocument();
+ });
+
+ it('shows similarity badges with correct variants', () => {
+ render();
+ expect(screen.getByText('92% tương tự')).toBeInTheDocument();
+ expect(screen.getByText('75% tương tự')).toBeInTheDocument();
+ expect(screen.getByText('62% tương tự')).toBeInTheDocument();
+ });
+
+ it('renders address with em-dash separator when present', () => {
+ render();
+ expect(
+ screen.getByText(/Quận 1\s*—\s*123 Nguyễn Huệ/),
+ ).toBeInTheDocument();
+ });
+
+ it('omits address dash when address empty', () => {
+ render();
+ const text = screen.getByText(/Quận 7/).textContent ?? '';
+ expect(text).not.toContain('—');
+ });
+
+ it('returns null when comparables list is empty', () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('toggles sort when a column header is clicked', () => {
+ render();
+ const areaBtn = screen.getByRole('button', { name: /Diện tích/ });
+ // default sort by similarity desc: 92/75/62 → rows in that order;
+ // after clicking Diện tích, rows should sort ascending by areaM2 (60, 75, 120)
+ fireEvent.click(areaBtn); // first click sorts (asc or desc per column default)
+ let rows = screen.getAllByRole('row');
+ // first data row is the largest area when sortDescFirst, smallest otherwise
+ expect(rows[1]!.textContent).toMatch(/(60|120) m²/);
+ fireEvent.click(areaBtn); // toggle to opposite direction
+ rows = screen.getAllByRole('row');
+ expect(rows[1]!.textContent).toMatch(/(60|120) m²/);
+ });
+});