258 lines
12 KiB
TypeScript
258 lines
12 KiB
TypeScript
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%');
|
||
}
|
||
});
|
||
});
|