Add tests and implementation for ServiceDetailPage and PriceInput components
- Implement PriceInput component tests to validate Persian and Arabic numeral handling, input formatting, and controlled behavior. - Create ServiceDetailPage component with detailed service information, including pricing, insurance coverage, and editing capabilities. - Add API tests for service item detail retrieval and coverage synchronization with insurance contracts. - Ensure proper error handling and user feedback for service item retrieval and coverage management.
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { useState } from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import PriceInput from './PriceInput';
|
||||
|
||||
/** پوشش حالت واقعی: مقدار توسط والد نگه داشته میشود (controlled). */
|
||||
function Controlled({ initial = 0, ...rest }: { initial?: number } & Record<string, unknown>) {
|
||||
const [value, setValue] = useState(initial);
|
||||
return (
|
||||
<>
|
||||
<PriceInput value={value} onChange={setValue} {...rest} />
|
||||
<span data-testid="value">{value}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const input = () => screen.getByRole('textbox') as HTMLInputElement;
|
||||
const emitted = () => screen.getByTestId('value').textContent;
|
||||
|
||||
describe('PriceInput', () => {
|
||||
it('رقم فارسی را میپذیرد و عدد لاتین میدهد', () => {
|
||||
render(<Controlled />);
|
||||
fireEvent.change(input(), { target: { value: '۸۵۰۰۰' } });
|
||||
|
||||
expect(emitted()).toBe('85000');
|
||||
expect(input().value).toBe('۸۵٬۰۰۰');
|
||||
});
|
||||
|
||||
it('رقم عربی را میپذیرد', () => {
|
||||
render(<Controlled />);
|
||||
fireEvent.change(input(), { target: { value: '٢٥٠' } });
|
||||
|
||||
expect(emitted()).toBe('250');
|
||||
});
|
||||
|
||||
it('پیست با واحد و جداکننده پاکسازی میشود', () => {
|
||||
render(<Controlled />);
|
||||
fireEvent.change(input(), { target: { value: '۸۵,۰۰۰ تومان' } });
|
||||
|
||||
expect(emitted()).toBe('85000');
|
||||
});
|
||||
|
||||
it('صفرِ تایپشده حفظ میشود و به خالی تبدیل نمیشود', () => {
|
||||
render(<Controlled />);
|
||||
fireEvent.change(input(), { target: { value: '۰' } });
|
||||
|
||||
expect(input().value).toBe('۰');
|
||||
expect(emitted()).toBe('0');
|
||||
});
|
||||
|
||||
it('پاککردن فیلد ⇒ خالی و مقدار صفر', () => {
|
||||
render(<Controlled initial={5000} />);
|
||||
fireEvent.change(input(), { target: { value: '' } });
|
||||
|
||||
expect(input().value).toBe('');
|
||||
expect(emitted()).toBe('0');
|
||||
});
|
||||
|
||||
it('مقدار اولیه صفر ⇒ فیلد خالی (نه ۰)', () => {
|
||||
render(<Controlled initial={0} />);
|
||||
expect(input().value).toBe('');
|
||||
});
|
||||
|
||||
it('min حین تایپ اعمال نمیشود ولی روی blur اعمال میشود', () => {
|
||||
render(<Controlled min={100} />);
|
||||
fireEvent.change(input(), { target: { value: '۵' } });
|
||||
expect(emitted()).toBe('5');
|
||||
|
||||
fireEvent.blur(input());
|
||||
expect(emitted()).toBe('100');
|
||||
expect(input().value).toBe('۱۰۰');
|
||||
});
|
||||
|
||||
it('max روی blur اعمال میشود', () => {
|
||||
render(<Controlled max={1000} />);
|
||||
fireEvent.change(input(), { target: { value: '۵۰۰۰' } });
|
||||
fireEvent.blur(input());
|
||||
|
||||
expect(emitted()).toBe('1000');
|
||||
});
|
||||
|
||||
it('فیلد خالی روی blur به min پرت نمیشود', () => {
|
||||
render(<Controlled min={100} />);
|
||||
fireEvent.blur(input());
|
||||
|
||||
expect(input().value).toBe('');
|
||||
expect(emitted()).toBe('0');
|
||||
});
|
||||
|
||||
it('حروف نامعتبر حذف میشوند و هرگز NaN نمیدهد', () => {
|
||||
render(<Controlled />);
|
||||
fireEvent.change(input(), { target: { value: 'ابج' } });
|
||||
|
||||
expect(input().value).toBe('');
|
||||
expect(emitted()).toBe('0');
|
||||
expect(emitted()).not.toContain('NaN');
|
||||
});
|
||||
|
||||
it('latin ارقام لاتین با کاما نمایش میدهد', () => {
|
||||
render(<Controlled latin />);
|
||||
fireEvent.change(input(), { target: { value: '۱۲۰۰' } });
|
||||
|
||||
expect(input().value).toBe('1,200');
|
||||
});
|
||||
|
||||
it('suffix واحد را کنار فیلد نشان میدهد', () => {
|
||||
render(<Controlled suffix="تومان" />);
|
||||
expect(screen.getByText('تومان')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('تغییر مقدار از بیرون متن فیلد را همگام میکند', () => {
|
||||
const { rerender } = render(<PriceInput value={1000} onChange={vi.fn()} />);
|
||||
expect(input().value).toBe('۱٬۰۰۰');
|
||||
|
||||
rerender(<PriceInput value={0} onChange={vi.fn()} />);
|
||||
expect(input().value).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { toEnglishDigits } from '../../lib/utils';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { digitsOnly, parseUserNumber } from '../../lib/utils';
|
||||
|
||||
interface PriceInputProps {
|
||||
value: number | '';
|
||||
@@ -9,15 +9,30 @@ interface PriceInputProps {
|
||||
style?: React.CSSProperties;
|
||||
disabled?: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
/** نمایش ارقام لاتین با جداکنندهٔ کاما (پیشفرض: فارسی). */
|
||||
latin?: boolean;
|
||||
/** واحد نمایشی داخل فیلد، مثلاً «تومان». */
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
function formatDisplay(num: number, latin: boolean): string {
|
||||
if (num === 0) return '';
|
||||
return new Intl.NumberFormat(latin ? 'en-US' : 'fa-IR').format(num);
|
||||
}
|
||||
|
||||
/** مقدار prop را به متن نمایشی تبدیل میکند؛ صفر و خالی هر دو فیلد خالی هستند. */
|
||||
function textFromValue(value: number | '', latin: boolean): string {
|
||||
const num = value === '' ? 0 : Number(value);
|
||||
return Number.isFinite(num) && num !== 0 ? formatDisplay(num, latin) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* ورودی مبلغ: ارقام فارسی/عربی، جداکننده و واحد چسبیده به عدد را میپذیرد (پیست
|
||||
* «۸۵,۰۰۰ تومان» ⇒ 85000) و همیشه یک عدد معتبر — هرگز NaN — به بالا میدهد.
|
||||
*
|
||||
* متن فیلد state مستقل است تا «۰ تایپشده» از «خالی» تفکیک شود؛ محدودکردن به بازهٔ
|
||||
* min/max فقط روی blur اعمال میشود تا رقم اول تایپ به مرز نپرد.
|
||||
*/
|
||||
export default function PriceInput({
|
||||
value,
|
||||
onChange,
|
||||
@@ -26,31 +41,58 @@ export default function PriceInput({
|
||||
style,
|
||||
disabled,
|
||||
min = 0,
|
||||
max,
|
||||
latin = false,
|
||||
suffix,
|
||||
}: PriceInputProps) {
|
||||
const [display, setDisplay] = useState(() => (value !== '' && value > 0 ? formatDisplay(value, latin) : ''));
|
||||
const [text, setText] = useState(() => textFromValue(value, latin));
|
||||
const textRef = useRef(text);
|
||||
textRef.current = text;
|
||||
|
||||
// فقط وقتی مقدار از بیرون عوض شده باشد (reset فرم، بارگذاری داده) متن را بازنویسی کن.
|
||||
useEffect(() => {
|
||||
setDisplay(value !== '' && Number(value) > 0 ? formatDisplay(Number(value), latin) : '');
|
||||
const shown = parseUserNumber(textRef.current) ?? 0;
|
||||
const incoming = value === '' ? 0 : Number(value);
|
||||
if (shown !== incoming) setText(textFromValue(value, latin));
|
||||
}, [value, latin]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const raw = toEnglishDigits(e.target.value).replace(/[^0-9]/g, '');
|
||||
const num = raw === '' ? 0 : Math.max(min, parseInt(raw, 10));
|
||||
const raw = digitsOnly(e.target.value);
|
||||
const num = parseUserNumber(raw) ?? 0;
|
||||
setText(raw === '' ? '' : formatDisplay(num, latin));
|
||||
onChange(num);
|
||||
setDisplay(num > 0 ? formatDisplay(num, latin) : '');
|
||||
};
|
||||
|
||||
return (
|
||||
const handleBlur = () => {
|
||||
if (text === '') return;
|
||||
const num = parseUserNumber(text) ?? 0;
|
||||
const clamped = Math.min(max ?? Infinity, Math.max(min, num));
|
||||
if (clamped !== num) {
|
||||
setText(formatDisplay(clamped, latin));
|
||||
onChange(clamped);
|
||||
}
|
||||
};
|
||||
|
||||
const input = (
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={display}
|
||||
value={text}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
style={{ textAlign: 'left', direction: 'ltr', ...style }}
|
||||
style={suffix ? { textAlign: 'left', direction: 'ltr', border: 'none', background: 'transparent', outline: 'none', flex: 1, minWidth: 0, padding: 0, ...style } : { textAlign: 'left', direction: 'ltr', ...style }}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!suffix) return input;
|
||||
|
||||
return (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%' }}>
|
||||
{input}
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)', flexShrink: 0 }}>{suffix}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user