Files
clinicpro/assets/admin/components/dashboard/TauriCharts.test.tsx
T
hamed e7de7aa88b feat: enhance Tauri charts with smooth curves and forecasting
- Implemented a smooth curve rendering for the TauriLineChart using a cubic Bezier path.
- Added a forecasting feature to project trends based on recent data points.
- Updated TauriDashboardView to pass the current month for accurate forecasting.
- Refactored TauriLineChart to handle actual and forecasted data points distinctly.
- Introduced gradient strokes and glow effects to align with ApexCharts styling.
- Enhanced user interaction with hover markers and tooltips for forecasted data.
- Added a new test suite for ClinicDetailPage to ensure proper rendering and functionality.
2026-07-19 11:25:45 +03:30

168 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { TauriBarChart, TauriLineChart, type ChartPoint } from './TauriCharts';
const zero: ChartPoint[] = Array.from({ length: 7 }, (_, i) => ({ label: `روز ${i}`, value: 0 }));
const real: ChartPoint[] = [
{ label: '۷ خرداد', value: 15 },
{ label: '۸ خرداد', value: 40 },
{ label: '۹ خرداد', value: 48 },
];
const EMPTY = 'داده‌ای برای نمایش نیست';
describe('TauriBarChart', () => {
it('shows the empty state when every value is zero', () => {
render(<TauriBarChart data={zero} />);
expect(screen.getByText(EMPTY)).toBeInTheDocument();
});
it('shows the empty state when there is no data', () => {
render(<TauriBarChart data={[]} />);
expect(screen.getByText(EMPTY)).toBeInTheDocument();
});
it('renders the chart (x labels, no empty state) with real data', () => {
render(<TauriBarChart data={real} />);
expect(screen.queryByText(EMPTY)).not.toBeInTheDocument();
expect(screen.getByText('۸ خرداد')).toBeInTheDocument();
});
it('renders one bar per day of a full Jalali month', () => {
const month: ChartPoint[] = Array.from({ length: 31 }, (_, i) => ({ label: String(i + 1), value: i % 5 }));
const { container } = render(<TauriBarChart data={month} />);
expect(container.querySelectorAll('.bg-\\[\\#5559CE\\]')).toHaveLength(31);
});
/**
* A fixed-width bar makes its column's intrinsic min-width 24px, so a 31-day
* month sums past the plot width and the row overflows out of the card
* (bars land on the neighbouring chart). Columns must be shrinkable and the
* bar fluid-but-capped.
*/
it('keeps a dense month inside the card: shrinkable columns, fluid bars', () => {
const month: ChartPoint[] = Array.from({ length: 31 }, (_, i) => ({ label: String(i + 1), value: i % 5 }));
const { container } = render(<TauriBarChart data={month} />);
const bar = container.querySelector('.bg-\\[\\#5559CE\\]') as HTMLElement;
// کسری از ستون، نه تمام آن — وگرنه میله‌ها بدون فاصله به هم می‌چسبند
expect(bar.className).toMatch(/\bw-\[\d+%\]/);
expect(bar.className.split(/\s+/)).not.toContain('w-full');
expect(bar.className).toContain('max-w-[24px]');
// فقط سقف عرض، نه عرض ثابت (max-w-[24px] خودش شامل رشته‌ی w-[24px] است)
expect(bar.className.split(/\s+/)).not.toContain('w-[24px]');
const column = bar.parentElement as HTMLElement;
expect(column.className).toContain('min-w-0');
const plot = container.querySelector('.relative.flex-1') as HTMLElement;
expect(plot.className).toContain('overflow-hidden');
});
/**
* styles.css ships an unlayered legacy `.flex { align-items: center }` that
* beats Tailwind's `items-*` utilities and collapses the plot column to zero
* height. The frame must pin alignment inline, or the chart renders blank.
*/
it('pins flex alignment inline so the legacy global .flex cannot collapse it', () => {
const { container } = render(<TauriBarChart data={real} />);
const frame = container.querySelector('.h-\\[300px\\]') as HTMLElement;
expect(frame.style.alignItems).toBe('stretch');
expect((frame.firstElementChild as HTMLElement).style.alignItems).toBe('stretch');
});
});
describe('TauriLineChart', () => {
it('shows the empty state when every value is zero', () => {
render(<TauriLineChart data={zero} />);
expect(screen.getByText(EMPTY)).toBeInTheDocument();
});
it('shows the empty state with fewer than two points', () => {
render(<TauriLineChart data={[{ label: 'x', value: 5 }]} />);
expect(screen.getByText(EMPTY)).toBeInTheDocument();
});
it('renders the chart with real data', () => {
const { container } = render(<TauriLineChart data={real} />);
expect(screen.queryByText(EMPTY)).not.toBeInTheDocument();
expect(container.querySelector('svg path')).toBeTruthy();
});
/** سبک «gradient line»: خط از گرادیان افقی رنگ می‌گیرد، نه یک رنگ ثابت. */
it('paints the line with the horizontal gradient and a glow', () => {
const { container } = render(<TauriLineChart data={real} />);
const line = container.querySelectorAll('svg path')[1] as SVGPathElement;
expect(line.getAttribute('stroke')).toBe('url(#tdIncomeStroke)');
expect(line.getAttribute('filter')).toBe('url(#tdIncomeGlow)');
expect(container.querySelector('#tdIncomeStroke')).toBeTruthy();
expect(container.querySelector('#tdIncomeGlow feDropShadow')).toBeTruthy();
});
/** نقطه و تولتیپ برای هر داده ساخته می‌شود و با هاور نمایان می‌شود. */
it('renders a hover marker and value tooltip per point', () => {
const { container } = render(<TauriLineChart data={real} />);
expect(container.querySelectorAll('.td-hit')).toHaveLength(real.length);
expect(container.querySelectorAll('.td-dot')).toHaveLength(real.length);
// مقدارها با ارقام فارسی در تولتیپ
expect(screen.getByText('۴۸')).toBeInTheDocument();
});
});
/**
* ماه‌های نیامده صفر برمی‌گردند؛ رسم‌کردنشان یک خط صاف تا انتهای سال می‌ساخت.
* از آخرین دادهٔ واقعی به بعد باید روند نقطه‌چین ادامه پیدا کند.
*/
describe('TauriLineChart — ادامهٔ پیش‌بینی', () => {
/** ۴ ماه واقعیِ صعودی + ۸ ماه نیامده (صفر) */
const year: ChartPoint[] = Array.from({ length: 12 }, (_, i) => ({
label: `ماه ${i + 1}`,
value: i < 4 ? (i + 1) * 10 : 0,
}));
it('بدون actualCount خط پیش‌بینی ندارد (رفتار قبلی حفظ می‌شود)', () => {
const { container } = render(<TauriLineChart data={year} />);
expect(container.querySelector('.td-forecast')).toBeNull();
expect(screen.queryByText('نقطه‌چین: پیش‌بینی')).not.toBeInTheDocument();
});
it('با actualCount ماه‌های باقی‌مانده را نقطه‌چین ادامه می‌دهد', () => {
const { container } = render(<TauriLineChart data={year} actualCount={4} />);
const forecast = container.querySelector('.td-forecast');
expect(forecast).toBeTruthy();
expect(forecast!.getAttribute('stroke')).toBe('var(--accent)');
expect(screen.getByText('نقطه‌چین: پیش‌بینی')).toBeInTheDocument();
});
it('روند صعودی را ادامه می‌دهد و صفرها را رسم نمی‌کند', () => {
render(<TauriLineChart data={year} actualCount={4} />);
// شیب ۱۰ در ماه، آخرین واقعی ۴۰ ⇒ ماه پنجم ۵۰
expect(screen.getByText('پیش‌بینی: ۵۰')).toBeInTheDocument();
// آخرین ماه: ۴۰ + ۸×۱۰ = ۱۲۰
expect(screen.getByText('پیش‌بینی: ۱۲۰')).toBeInTheDocument();
});
it('پیش‌بینی هرگز منفی نمی‌شود', () => {
const falling: ChartPoint[] = Array.from({ length: 12 }, (_, i) => ({
label: `ماه ${i + 1}`,
value: i < 3 ? 100 - i * 45 : 0,
}));
const { container } = render(<TauriLineChart data={falling} actualCount={3} />);
const tips = Array.from(container.querySelectorAll('.td-tip'))
.map((el) => el.textContent ?? '')
.filter((t) => t.startsWith('پیش‌بینی'));
expect(tips.length).toBeGreaterThan(0);
expect(tips.some((t) => t.includes('-') || t.includes(''))).toBe(false);
});
it('سال کامل (actualCount برابر طول داده) پیش‌بینی ندارد', () => {
const full: ChartPoint[] = Array.from({ length: 12 }, (_, i) => ({ label: `ماه ${i + 1}`, value: 10 + i }));
const { container } = render(<TauriLineChart data={full} actualCount={12} />);
expect(container.querySelector('.td-forecast')).toBeNull();
});
});