Files

258 lines
12 KiB
TypeScript
Raw Permalink 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 = 'داده‌ای برای نمایش نیست';
/**
* صفحه RTL است، پس در یک ردیفِ flex «اولین فرزند» سمت راست رندر می‌شود. ستون مقادیر
* باید سمت چپ بنشیند، یعنی باید فرزند **آخر** باشد و فاصلهٔ ردیف برچسب‌ها هم از چپ
* گرفته شود. جابه‌جا شدن این ترتیب، اعداد را بی‌صدا به سمت راست برمی‌گرداند.
*/
describe('ChartFrame — جای محور مقادیر', () => {
const frameOf = (container: HTMLElement) => {
const root = container.firstElementChild as HTMLElement;
return {
plotRow: root.firstElementChild as HTMLElement,
labelRow: root.lastElementChild as HTMLElement,
};
};
it('ستون مقادیر آخرین فرزند ردیف است تا در RTL سمت چپ بیفتد', () => {
const { container } = render(<TauriLineChart data={real} />);
const { plotRow } = frameOf(container);
const ticks = plotRow.lastElementChild as HTMLElement;
expect(ticks.textContent).toContain('۰');
expect(ticks.className).toContain('flex-col');
// نمودار خودش فرزند اول است، پس سمت راست می‌ماند.
expect((plotRow.firstElementChild as HTMLElement).className).toContain('overflow-hidden');
});
it('ردیف برچسب‌ها به اندازهٔ ستون مقادیر از چپ فاصله می‌گیرد', () => {
const { container } = render(<TauriLineChart data={real} />);
const { labelRow } = frameOf(container);
expect(labelRow.style.paddingLeft).not.toBe('');
expect(labelRow.style.paddingRight).toBe('');
});
});
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('[class*="bg-[var(--primary)]"]')).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('[class*="bg-[var(--primary)]"]') 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();
});
});
/**
* محور زمان راست‌به‌چپ است: برچسب‌ها در جریان RTL چیده می‌شوند، پس فروردین سمت راست
* می‌نشیند. SVG جریان RTL ندارد و اگر اندیس ۰ به `x=0` برود، خط نسبت به برچسب‌هایش
* آینه می‌شود — دقیقاً باگی که دادهٔ واقعیِ ابتدای سال را روی ماه‌های پایانی می‌انداخت.
*/
describe('TauriLineChart — جهت محور زمان', () => {
const year: ChartPoint[] = Array.from({ length: 12 }, (_, i) => ({
label: `ماه ${i + 1}`,
value: i < 4 ? (i + 1) * 10 : 0,
}));
/** ستونِ هاورِ هر نقطه دقیقاً حول همان نقطه می‌نشیند، پس `left`ش جای نقطه را می‌گوید. */
const hitCenters = (container: HTMLElement): number[] =>
Array.from(container.querySelectorAll<HTMLElement>('.td-hit')).map((el) => {
const left = parseFloat(el.style.left);
const width = parseFloat(el.style.width);
return left + width / 2;
});
it('اولین نقطه سمت راست و آخرین نقطه سمت چپ می‌نشیند', () => {
const { container } = render(<TauriLineChart data={year} actualCount={4} />);
const centers = hitCenters(container);
expect(centers[0]).toBeCloseTo(100, 5);
expect(centers[centers.length - 1]).toBeCloseTo(0, 5);
});
it('نقطه‌ها به ترتیب زمان از راست به چپ پیش می‌روند', () => {
const { container } = render(<TauriLineChart data={year} actualCount={4} />);
const centers = hitCenters(container);
for (let i = 1; i < centers.length; i++) {
expect(centers[i]).toBeLessThan(centers[i - 1]);
}
});
/** بخش واقعی سمت راست است و پیش‌بینی سمت چپ، نه برعکس. */
it('بخش واقعی راست‌تر از بخش پیش‌بینی است', () => {
const { container } = render(<TauriLineChart data={year} actualCount={4} />);
const centers = hitCenters(container);
const lastActual = centers[3];
const firstForecast = centers[4];
expect(lastActual).toBeGreaterThan(firstForecast);
});
/** نقطه باید روی خط بنشیند، نه داخل ستونِ خودش سُر بخورد. */
it('نقطه در مرکز ستون هاورِ خودش است', () => {
const { container } = render(<TauriLineChart data={year} actualCount={4} />);
for (const dot of Array.from(container.querySelectorAll<HTMLElement>('.td-dot'))) {
expect(dot.style.left).toBe('50%');
}
});
});