diff --git a/apps/web/components/design-system/__tests__/badge.spec.tsx b/apps/web/components/design-system/__tests__/badge.spec.tsx
new file mode 100644
index 0000000..3f05793
--- /dev/null
+++ b/apps/web/components/design-system/__tests__/badge.spec.tsx
@@ -0,0 +1,45 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { Badge } from '../badge';
+
+describe('Badge (design-system)', () => {
+ it('renders children', () => {
+ render(Hoạt động);
+ expect(screen.getByText('Hoạt động')).toBeInTheDocument();
+ });
+
+ it('renders as a span', () => {
+ render(Test);
+ expect(screen.getByTestId('b').tagName).toBe('SPAN');
+ });
+
+ it('applies default variant classes', () => {
+ render(Default);
+ expect(screen.getByTestId('b')).toHaveClass('bg-muted');
+ });
+
+ it('applies primary variant', () => {
+ render(Primary);
+ expect(screen.getByTestId('b')).toHaveClass('bg-primary/10');
+ });
+
+ it('applies warning variant', () => {
+ render(Warning);
+ expect(screen.getByTestId('b')).toHaveClass('bg-warning/10');
+ });
+
+ it('applies destructive variant', () => {
+ render(Error);
+ expect(screen.getByTestId('b')).toHaveClass('bg-destructive/10');
+ });
+
+ it('applies outline variant', () => {
+ render(Outline);
+ expect(screen.getByTestId('b')).toHaveClass('bg-transparent');
+ });
+
+ it('merges custom className', () => {
+ render(Custom);
+ expect(screen.getByTestId('b')).toHaveClass('custom-class');
+ });
+});
diff --git a/apps/web/components/design-system/__tests__/compact-header.spec.tsx b/apps/web/components/design-system/__tests__/compact-header.spec.tsx
new file mode 100644
index 0000000..78b52c0
--- /dev/null
+++ b/apps/web/components/design-system/__tests__/compact-header.spec.tsx
@@ -0,0 +1,48 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { CompactHeader } from '../compact-header';
+
+describe('CompactHeader', () => {
+ it('renders a header element', () => {
+ render();
+ expect(screen.getByRole('banner')).toBeInTheDocument();
+ });
+
+ it('renders logo when provided', () => {
+ render(} />);
+ expect(screen.getByTestId('logo')).toBeInTheDocument();
+ });
+
+ it('renders breadcrumb when provided', () => {
+ render(Trang chủ} />);
+ expect(screen.getByText('Trang chủ')).toBeInTheDocument();
+ });
+
+ it('renders search slot when provided', () => {
+ render(} />);
+ expect(screen.getByTestId('search')).toBeInTheDocument();
+ });
+
+ it('renders actions when provided', () => {
+ render(Đăng nhập} />);
+ expect(screen.getByTestId('act')).toBeInTheDocument();
+ });
+
+ it('does not render logo slot when omitted', () => {
+ const { container } = render();
+ // no extra wrapping div for logo
+ const header = container.querySelector('header') as HTMLElement;
+ // Slot divs: just the ml-auto actions div should be present
+ expect(header.children.length).toBe(1);
+ });
+
+ it('is sticky and has border-b class', () => {
+ render();
+ expect(screen.getByRole('banner')).toHaveClass('sticky', 'border-b');
+ });
+
+ it('merges custom className', () => {
+ render();
+ expect(screen.getByRole('banner')).toHaveClass('custom-header');
+ });
+});
diff --git a/apps/web/components/design-system/__tests__/density-toggle.spec.tsx b/apps/web/components/design-system/__tests__/density-toggle.spec.tsx
new file mode 100644
index 0000000..ee3ed8e
--- /dev/null
+++ b/apps/web/components/design-system/__tests__/density-toggle.spec.tsx
@@ -0,0 +1,60 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import { DensityToggle } from '../density-toggle';
+
+// Mock the zustand store to avoid localStorage/persist issues in jsdom
+const mockToggleDensity = vi.fn();
+let mockDensity = 'regular';
+
+vi.mock('@/lib/preferences-store', () => ({
+ usePreferencesStore: () => ({
+ get density() {
+ return mockDensity;
+ },
+ toggleDensity: mockToggleDensity,
+ }),
+}));
+
+describe('DensityToggle', () => {
+ beforeEach(() => {
+ mockDensity = 'regular';
+ mockToggleDensity.mockClear();
+ });
+
+ it('renders a button with role="switch"', () => {
+ render();
+ expect(screen.getByRole('switch')).toBeInTheDocument();
+ });
+
+ it('has aria-checked="false" when density is regular', () => {
+ render();
+ expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false');
+ });
+
+ it('has aria-checked="true" when density is compact', () => {
+ mockDensity = 'compact';
+ render();
+ expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true');
+ });
+
+ it('calls toggleDensity on click', () => {
+ render();
+ fireEvent.click(screen.getByRole('switch'));
+ expect(mockToggleDensity).toHaveBeenCalledOnce();
+ });
+
+ it('applies custom aria-label via label prop', () => {
+ render();
+ expect(screen.getByRole('switch')).toHaveAttribute('aria-label', 'Toggle view');
+ });
+
+ it('has a default aria-label', () => {
+ render();
+ expect(screen.getByRole('switch')).toHaveAttribute('aria-label');
+ });
+
+ it('merges custom className', () => {
+ render();
+ expect(screen.getByRole('switch')).toHaveClass('extra');
+ });
+});
diff --git a/apps/web/components/design-system/__tests__/divider.spec.tsx b/apps/web/components/design-system/__tests__/divider.spec.tsx
new file mode 100644
index 0000000..77c2bcf
--- /dev/null
+++ b/apps/web/components/design-system/__tests__/divider.spec.tsx
@@ -0,0 +1,41 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { Divider } from '../divider';
+
+describe('Divider', () => {
+ it('renders with role="separator"', () => {
+ render();
+ expect(screen.getByRole('separator')).toBeInTheDocument();
+ });
+
+ it('defaults to horizontal orientation', () => {
+ render();
+ expect(screen.getByTestId('d')).toHaveAttribute('aria-orientation', 'horizontal');
+ });
+
+ it('renders vertical orientation', () => {
+ render();
+ expect(screen.getByTestId('d')).toHaveAttribute('aria-orientation', 'vertical');
+ expect(screen.getByTestId('d')).toHaveClass('h-full', 'w-px');
+ });
+
+ it('applies strong variant class', () => {
+ render();
+ expect(screen.getByTestId('d')).toHaveClass('bg-border-strong');
+ });
+
+ it('applies default border class without strong', () => {
+ render();
+ expect(screen.getByTestId('d')).toHaveClass('bg-border');
+ });
+
+ it('horizontal adds h-px and w-full', () => {
+ render();
+ expect(screen.getByTestId('d')).toHaveClass('h-px', 'w-full');
+ });
+
+ it('merges custom className', () => {
+ render();
+ expect(screen.getByTestId('d')).toHaveClass('my-custom');
+ });
+});
diff --git a/apps/web/components/design-system/__tests__/empty-state.spec.tsx b/apps/web/components/design-system/__tests__/empty-state.spec.tsx
new file mode 100644
index 0000000..c100606
--- /dev/null
+++ b/apps/web/components/design-system/__tests__/empty-state.spec.tsx
@@ -0,0 +1,40 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { EmptyState } from '../empty-state';
+
+describe('EmptyState', () => {
+ it('renders title', () => {
+ render();
+ expect(screen.getByText('Không có dữ liệu')).toBeInTheDocument();
+ });
+
+ it('renders description when provided', () => {
+ render();
+ expect(screen.getByText('Mô tả phụ')).toBeInTheDocument();
+ });
+
+ it('does not render description when omitted', () => {
+ render();
+ expect(screen.queryByText('Mô tả phụ')).not.toBeInTheDocument();
+ });
+
+ it('renders icon node', () => {
+ render(} />);
+ expect(screen.getByTestId('icon')).toBeInTheDocument();
+ });
+
+ it('renders action node', () => {
+ render(
+ Thêm mới}
+ />,
+ );
+ expect(screen.getByTestId('action')).toBeInTheDocument();
+ });
+
+ it('merges custom className', () => {
+ render();
+ expect(screen.getByTestId('es')).toHaveClass('custom');
+ });
+});
diff --git a/apps/web/components/design-system/__tests__/footer.spec.tsx b/apps/web/components/design-system/__tests__/footer.spec.tsx
new file mode 100644
index 0000000..264b3bd
--- /dev/null
+++ b/apps/web/components/design-system/__tests__/footer.spec.tsx
@@ -0,0 +1,87 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { Footer, type FooterProps } from '../footer';
+
+const renderLink: FooterProps['renderLink'] = ({ href, children, className }) => (
+ {children}
+);
+
+const defaultProps: FooterProps = {
+ brand: 'GoodGo',
+ description: 'Nền tảng bất động sản',
+ copyright: '© 2024 GoodGo',
+ linkGroups: [
+ {
+ title: 'Sản phẩm',
+ links: [
+ { label: 'Bán', href: '/ban' },
+ { label: 'Thuê', href: '/thue' },
+ ],
+ },
+ ],
+ renderLink,
+};
+
+describe('Footer', () => {
+ it('renders brand name', () => {
+ render();
+ expect(screen.getByText('GoodGo')).toBeInTheDocument();
+ });
+
+ it('renders description', () => {
+ render();
+ expect(screen.getByText('Nền tảng bất động sản')).toBeInTheDocument();
+ });
+
+ it('renders copyright text', () => {
+ render();
+ expect(screen.getByText('© 2024 GoodGo')).toBeInTheDocument();
+ });
+
+ it('renders link group title', () => {
+ render();
+ expect(screen.getByText('Sản phẩm')).toBeInTheDocument();
+ });
+
+ it('renders link group links', () => {
+ render();
+ expect(screen.getByText('Bán')).toBeInTheDocument();
+ expect(screen.getByText('Thuê')).toBeInTheDocument();
+ });
+
+ it('renders contact address when provided', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('123 Nguyễn Huệ, TP.HCM')).toBeInTheDocument();
+ });
+
+ it('renders contact phone with tel link', () => {
+ render();
+ expect(screen.getByRole('link', { name: '0901234567' })).toHaveAttribute(
+ 'href',
+ 'tel:0901234567',
+ );
+ });
+
+ it('renders contact email with mailto link', () => {
+ render();
+ expect(screen.getByRole('link', { name: 'hello@goodgo.vn' })).toHaveAttribute(
+ 'href',
+ 'mailto:hello@goodgo.vn',
+ );
+ });
+
+ it('renders footer element with role="contentinfo"', () => {
+ render();
+ expect(screen.getByRole('contentinfo')).toBeInTheDocument();
+ });
+
+ it('does not render contact section when omitted', () => {
+ render();
+ expect(screen.queryByText(/Nguyễn Huệ/)).toBeNull();
+ });
+});
diff --git a/apps/web/components/design-system/__tests__/kpi-card.spec.tsx b/apps/web/components/design-system/__tests__/kpi-card.spec.tsx
new file mode 100644
index 0000000..522781d
--- /dev/null
+++ b/apps/web/components/design-system/__tests__/kpi-card.spec.tsx
@@ -0,0 +1,46 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { KpiCard } from '../kpi-card';
+
+describe('KpiCard', () => {
+ it('renders label', () => {
+ render();
+ expect(screen.getByText('Giá TB')).toBeInTheDocument();
+ });
+
+ it('renders value', () => {
+ render();
+ expect(screen.getByText('100')).toBeInTheDocument();
+ });
+
+ it('renders footnote', () => {
+ render();
+ expect(screen.getByText('Hôm nay')).toBeInTheDocument();
+ });
+
+ it('renders PriceDelta when delta provided', () => {
+ render();
+ expect(screen.getByText('+1.50%')).toBeInTheDocument();
+ });
+
+ it('renders icon', () => {
+ render(} />);
+ expect(screen.getByTestId('icon')).toBeInTheDocument();
+ });
+
+ it('renders loading skeleton when loading=true', () => {
+ const { container } = render();
+ expect(container.querySelector('[aria-busy]')).toBeInTheDocument();
+ expect(container.querySelector('.animate-pulse')).toBeInTheDocument();
+ });
+
+ it('does not show value when loading', () => {
+ render();
+ expect(screen.queryByText('100')).toBeNull();
+ });
+
+ it('merges custom className', () => {
+ render();
+ expect(screen.getByTestId('kc')).toHaveClass('extra');
+ });
+});
diff --git a/apps/web/components/design-system/__tests__/market-index.spec.tsx b/apps/web/components/design-system/__tests__/market-index.spec.tsx
new file mode 100644
index 0000000..201c8d0
--- /dev/null
+++ b/apps/web/components/design-system/__tests__/market-index.spec.tsx
@@ -0,0 +1,57 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { MarketIndex } from '../market-index';
+
+describe('MarketIndex', () => {
+ it('renders index name', () => {
+ render();
+ expect(screen.getByText('GGX Market')).toBeInTheDocument();
+ });
+
+ it('renders value', () => {
+ render();
+ expect(screen.getByText('1,234')).toBeInTheDocument();
+ });
+
+ it('renders PriceDelta for positive changePercent', () => {
+ render();
+ expect(screen.getByText('+2.50%')).toBeInTheDocument();
+ });
+
+ it('renders PriceDelta for negative changePercent', () => {
+ render();
+ expect(screen.getByText('-1.20%')).toBeInTheDocument();
+ });
+
+ it('renders default window "24h" when change is provided', () => {
+ render();
+ expect(screen.getByText(/24h/)).toBeInTheDocument();
+ });
+
+ it('renders custom window when specified', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('7 ngày')).toBeInTheDocument();
+ });
+
+ it('renders change value with window in parentheses', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('+50 (24h)')).toBeInTheDocument();
+ });
+
+ it('renders value with data-numeric', () => {
+ render();
+ const elements = document.querySelectorAll('[data-numeric]');
+ expect(elements.length).toBeGreaterThan(0);
+ });
+
+ it('merges custom className', () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId('mi')).toHaveClass('extra');
+ });
+});
diff --git a/apps/web/components/design-system/__tests__/numeric.spec.tsx b/apps/web/components/design-system/__tests__/numeric.spec.tsx
new file mode 100644
index 0000000..0725e2f
--- /dev/null
+++ b/apps/web/components/design-system/__tests__/numeric.spec.tsx
@@ -0,0 +1,45 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { Numeric } from '../numeric';
+
+describe('Numeric', () => {
+ it('formats VND by default', () => {
+ render();
+ // Intl.NumberFormat vi-VN with currency VND — should contain digits
+ expect(screen.getByText(/1\.000\.000/)).toBeInTheDocument();
+ });
+
+ it('formats percent', () => {
+ render();
+ expect(screen.getByText('+5.5%')).toBeInTheDocument();
+ });
+
+ it('formats negative percent without plus sign', () => {
+ render();
+ expect(screen.getByText('-3.2%')).toBeInTheDocument();
+ });
+
+ it('formats decimal', () => {
+ render();
+ // vi-VN decimal uses comma as decimal separator
+ expect(screen.getByText(/1\.234/)).toBeInTheDocument();
+ });
+
+ it('formats compact', () => {
+ render();
+ expect(screen.getByText(/\d/)).toBeInTheDocument();
+ });
+
+ it('renders as span with data-numeric attribute', () => {
+ render();
+ const el = screen.getByTestId('num');
+ expect(el.tagName).toBe('SPAN');
+ expect(el).toHaveAttribute('data-numeric');
+ });
+
+ it('has tabular-nums and font-mono classes', () => {
+ render();
+ const el = screen.getByTestId('num');
+ expect(el).toHaveClass('tabular-nums', 'font-mono');
+ });
+});
diff --git a/apps/web/components/design-system/__tests__/price-delta.spec.tsx b/apps/web/components/design-system/__tests__/price-delta.spec.tsx
new file mode 100644
index 0000000..35388df
--- /dev/null
+++ b/apps/web/components/design-system/__tests__/price-delta.spec.tsx
@@ -0,0 +1,61 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { PriceDelta } from '../price-delta';
+
+describe('PriceDelta', () => {
+ it('renders formatted positive value with default unit %', () => {
+ render();
+ expect(screen.getByText('+3.14%')).toBeInTheDocument();
+ });
+
+ it('renders negative value', () => {
+ render();
+ expect(screen.getByText('-2.50%')).toBeInTheDocument();
+ });
+
+ it('renders zero value as neutral', () => {
+ render();
+ // 0 is not > 0 so no "+" prefix
+ expect(screen.getByText('0.00%')).toBeInTheDocument();
+ });
+
+ it('renders with custom unit', () => {
+ render();
+ expect(screen.getByText('+1.5 tr')).toBeInTheDocument();
+ });
+
+ it('hides icon when hideIcon=true', () => {
+ const { container } = render();
+ // With hideIcon, the ArrowUp/Down/Minus svg is not rendered
+ expect(container.querySelector('svg')).toBeNull();
+ });
+
+ it('renders icon svg by default', () => {
+ const { container } = render();
+ expect(container.querySelector('svg')).toBeInTheDocument();
+ });
+
+ it('has data-numeric attribute on root span', () => {
+ render();
+ expect(screen.getByTestId('pd')).toHaveAttribute('data-numeric');
+ });
+
+ it('has font-mono class', () => {
+ render();
+ expect(screen.getByTestId('pd')).toHaveClass('font-mono');
+ });
+
+ it('merges custom className', () => {
+ render();
+ expect(screen.getByTestId('pd')).toHaveClass('extra');
+ });
+
+ it('renders direction override independently of value sign', () => {
+ // Positive value with forced down direction still shows the down arrow
+ const { container } = render();
+ // Should still render an svg (ArrowDown)
+ expect(container.querySelector('svg')).toBeInTheDocument();
+ // Value text is formatted from the `value` prop
+ expect(screen.getByText('+5.00%')).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/components/design-system/__tests__/signal.spec.tsx b/apps/web/components/design-system/__tests__/signal.spec.tsx
new file mode 100644
index 0000000..ebc54fb
--- /dev/null
+++ b/apps/web/components/design-system/__tests__/signal.spec.tsx
@@ -0,0 +1,45 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { Signal } from '../signal';
+
+describe('Signal', () => {
+ it('renders without a label', () => {
+ const { container } = render();
+ expect(container.querySelector('span')).toBeInTheDocument();
+ });
+
+ it('renders label text when provided', () => {
+ render();
+ expect(screen.getByText('Tăng')).toBeInTheDocument();
+ });
+
+ it('renders icon with aria-hidden for up direction', () => {
+ const { container } = render();
+ expect(container.querySelector('[aria-hidden="true"]')).toBeInTheDocument();
+ });
+
+ it('renders icon with aria-hidden for down direction', () => {
+ const { container } = render();
+ expect(container.querySelector('[aria-hidden="true"]')).toBeInTheDocument();
+ });
+
+ it('renders icon with aria-hidden for neutral direction', () => {
+ const { container } = render();
+ expect(container.querySelector('[aria-hidden="true"]')).toBeInTheDocument();
+ });
+
+ it('renders as an inline span', () => {
+ const { container } = render();
+ expect(container.firstChild?.nodeName).toBe('SPAN');
+ });
+
+ it('merges custom className onto root span', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('extra');
+ });
+
+ it('contains an svg icon', () => {
+ const { container } = render();
+ expect(container.querySelector('svg')).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/components/design-system/__tests__/skeleton.spec.tsx b/apps/web/components/design-system/__tests__/skeleton.spec.tsx
new file mode 100644
index 0000000..a4071b8
--- /dev/null
+++ b/apps/web/components/design-system/__tests__/skeleton.spec.tsx
@@ -0,0 +1,47 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { Skeleton } from '../skeleton';
+
+describe('Skeleton', () => {
+ it('renders base skeleton with animate-pulse', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('animate-pulse');
+ });
+
+ it('base skeleton has aria-hidden', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveAttribute('aria-hidden');
+ });
+
+ it('Skeleton.Row renders row placeholder', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveAttribute('aria-hidden');
+ // Should have multiple skeleton blocks
+ expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0);
+ });
+
+ it('Skeleton.Card renders card placeholder', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveAttribute('aria-hidden');
+ });
+
+ it('Skeleton.Table renders header and default 5 rows', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveAttribute('aria-hidden');
+ // header + 5 rows = 6 row-like blocks; just check children exist
+ expect(container.children.length).toBeGreaterThan(0);
+ });
+
+ it('Skeleton.Table renders custom row count', () => {
+ const { container } = render();
+ // container.firstChild has header div + 3 SkeletonRow children
+ const el = container.firstChild as HTMLElement;
+ // header + 3 rows
+ expect(el.children.length).toBe(4);
+ });
+
+ it('merges custom className on base', () => {
+ render();
+ expect(screen.getByTestId('sk')).toHaveClass('h-10');
+ });
+});
diff --git a/apps/web/components/design-system/__tests__/stat-card.spec.tsx b/apps/web/components/design-system/__tests__/stat-card.spec.tsx
new file mode 100644
index 0000000..124a6ab
--- /dev/null
+++ b/apps/web/components/design-system/__tests__/stat-card.spec.tsx
@@ -0,0 +1,53 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { StatCard } from '../stat-card';
+
+describe('StatCard', () => {
+ it('renders label', () => {
+ render();
+ expect(screen.getByText('Giá TB/m²')).toBeInTheDocument();
+ });
+
+ it('renders string value', () => {
+ render();
+ expect(screen.getByText('25 tr/m²')).toBeInTheDocument();
+ });
+
+ it('renders number value', () => {
+ render();
+ expect(screen.getByText('1234')).toBeInTheDocument();
+ });
+
+ it('renders unit when provided', () => {
+ render();
+ expect(screen.getByText('tr/m²')).toBeInTheDocument();
+ });
+
+ it('renders PriceDelta when delta provided', () => {
+ const { container } = render();
+ expect(container.querySelector('[data-numeric]')).toBeInTheDocument();
+ });
+
+ it('does not render delta section when delta is undefined', () => {
+ render();
+ // no PriceDelta text like +x%
+ expect(screen.queryByText(/\+\d/)).toBeNull();
+ });
+
+ it('renders sublabel', () => {
+ render();
+ expect(screen.getByText('7 ngày')).toBeInTheDocument();
+ });
+
+ it('renders icon node', () => {
+ render(
+ } />,
+ );
+ expect(screen.getByTestId('icon')).toBeInTheDocument();
+ });
+
+ it('merges custom className', () => {
+ render();
+ expect(screen.getByTestId('sc')).toHaveClass('extra');
+ });
+});
diff --git a/apps/web/components/design-system/__tests__/status-chip.spec.tsx b/apps/web/components/design-system/__tests__/status-chip.spec.tsx
new file mode 100644
index 0000000..442267a
--- /dev/null
+++ b/apps/web/components/design-system/__tests__/status-chip.spec.tsx
@@ -0,0 +1,49 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { StatusChip, type PropertyStatus } from '../status-chip';
+
+const statuses: PropertyStatus[] = ['active', 'pending', 'sold', 'rented', 'rejected', 'draft'];
+
+const labels: Record = {
+ active: 'Đang bán',
+ pending: 'Chờ duyệt',
+ sold: 'Đã bán',
+ rented: 'Đã thuê',
+ rejected: 'Từ chối',
+ draft: 'Bản nháp',
+};
+
+describe('StatusChip', () => {
+ statuses.forEach((status) => {
+ it(`renders label for status "${status}"`, () => {
+ render();
+ expect(screen.getByText(labels[status])).toBeInTheDocument();
+ });
+ });
+
+ it('shows dot indicator by default', () => {
+ const { container } = render();
+ // dot is a span with aria-hidden
+ expect(container.querySelector('[aria-hidden]')).toBeInTheDocument();
+ });
+
+ it('hides dot when hideDot=true', () => {
+ const { container } = render();
+ expect(container.querySelector('[aria-hidden]')).toBeNull();
+ });
+
+ it('merges custom className', () => {
+ render();
+ expect(screen.getByTestId('sc')).toHaveClass('extra');
+ });
+
+ it('applies active color classes', () => {
+ render();
+ expect(screen.getByTestId('sc')).toHaveClass('bg-signal-up-bg');
+ });
+
+ it('applies rejected color classes', () => {
+ render();
+ expect(screen.getByTestId('sc')).toHaveClass('bg-destructive/10');
+ });
+});
diff --git a/apps/web/components/design-system/__tests__/surface.spec.tsx b/apps/web/components/design-system/__tests__/surface.spec.tsx
new file mode 100644
index 0000000..0c07a2c
--- /dev/null
+++ b/apps/web/components/design-system/__tests__/surface.spec.tsx
@@ -0,0 +1,42 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { Surface, SurfaceElevated } from '../surface';
+
+describe('Surface', () => {
+ it('renders children', () => {
+ render(Content);
+ expect(screen.getByText('Content')).toBeInTheDocument();
+ });
+
+ it('has bg-background class', () => {
+ render(x);
+ expect(screen.getByTestId('s')).toHaveClass('bg-background');
+ });
+
+ it('has rounded-lg class', () => {
+ render(x);
+ expect(screen.getByTestId('s')).toHaveClass('rounded-lg');
+ });
+
+ it('merges custom className', () => {
+ render(x);
+ expect(screen.getByTestId('s')).toHaveClass('p-4');
+ });
+});
+
+describe('SurfaceElevated', () => {
+ it('renders children', () => {
+ render(Elevated);
+ expect(screen.getByText('Elevated')).toBeInTheDocument();
+ });
+
+ it('has bg-background-elevated class', () => {
+ render(x);
+ expect(screen.getByTestId('se')).toHaveClass('bg-background-elevated');
+ });
+
+ it('has shadow-elevation-1 class', () => {
+ render(x);
+ expect(screen.getByTestId('se')).toHaveClass('shadow-elevation-1');
+ });
+});