Tasks 04 and 05 shipped working engines that a clinic could only reach through the API. Both now have the panel that makes them usable. Groups tab - Inline min/max per group, saved on blur, with the meaning of an empty maximum spelled out next to the field rather than left as folklore - Incompatible / prerequisite rows; the prerequisite-cycle 422 surfaces the server's own message, which is more precise than anything generic - A live preview that calls the same service-selection/validate the public site calls, debounced 400ms. Two separate calculations would eventually show the operator and the patient different numbers - The breakdown table shows which item was counted as the anchor and which as additional, so a surprising total explains itself Segments tab - Sequence, duration source, patient-present and mergeable per segment, plus resource requirements with an explanation attached to each occupancy mode - A timeline bar whose widths are proportional to duration, with segments the patient is absent for drawn faded. That contrast is the whole point of task 05: the waiting segment holds the room but frees the operator - "No eligible resource" renders with a link to add one — an error with no route forward is a dead end Task 05's checklist had been left on "not started" this whole time even though its code shipped with the task; it is now filled in against reality. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
92 lines
3.4 KiB
TypeScript
92 lines
3.4 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { screen, waitFor } from '@testing-library/react';
|
|
import { renderWithProviders } from '../test/utils';
|
|
|
|
vi.mock('../lib/api', () => ({
|
|
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
|
ApiError: class extends Error {},
|
|
}));
|
|
|
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
|
|
|
import { api } from '../lib/api';
|
|
import ServiceGroupsTab from './ServiceGroupsTab';
|
|
|
|
const get = api.get as ReturnType<typeof vi.fn>;
|
|
const post = api.post as ReturnType<typeof vi.fn>;
|
|
const patch = api.patch as ReturnType<typeof vi.fn>;
|
|
|
|
const groups = [
|
|
{
|
|
uuid: 'g1',
|
|
name: 'نواحی بدن',
|
|
min_select: 1,
|
|
max_select: null,
|
|
items: [{ uuid: 'i1', name: 'صورت' }],
|
|
},
|
|
];
|
|
|
|
const items = [
|
|
{ uuid: 'i1', name: 'صورت' },
|
|
{ uuid: 'i2', name: 'بیکینی' },
|
|
];
|
|
|
|
function mockApi() {
|
|
get.mockImplementation((path: string) => {
|
|
if (path.endsWith('/groups')) return Promise.resolve({ success: true, data: groups });
|
|
if (path === '/api/v1/service-items') return Promise.resolve({ success: true, data: items });
|
|
return Promise.resolve({ success: true, data: null });
|
|
});
|
|
post.mockResolvedValue({
|
|
success: true,
|
|
data: {
|
|
valid: false,
|
|
errors: [{ code: 'min_select', message: 'انتخاب حداقل ۱ مورد از «نواحی بدن» الزامی است' }],
|
|
total_duration_minutes: 23,
|
|
total_price_rials: 800000,
|
|
breakdown: [
|
|
{ item_uuid: 'i1', item_name: 'صورت', counted_as: 'solo', minutes: 15, price_rials: 500000 },
|
|
{ item_uuid: 'i2', item_name: 'بیکینی', counted_as: 'additional', minutes: 8, price_rials: 300000 },
|
|
],
|
|
},
|
|
});
|
|
patch.mockResolvedValue({ success: true, data: groups[0] });
|
|
}
|
|
|
|
describe('ServiceGroupsTab', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockApi();
|
|
});
|
|
|
|
it('shows each group with its min and max', async () => {
|
|
renderWithProviders(<ServiceGroupsTab serviceUuid="s1" canEdit />, { route: '/admin/services/s1' });
|
|
|
|
await waitFor(() => expect(screen.getByDisplayValue('نواحی بدن')).toBeInTheDocument());
|
|
expect(screen.getByDisplayValue('1')).toBeInTheDocument();
|
|
// حداکثرِ خالی یعنی نامحدود — نه صفر.
|
|
expect(screen.getByPlaceholderText('نامحدود')).toHaveValue(null);
|
|
});
|
|
|
|
/**
|
|
* ⭐ پیشنمایش نباید بدون انتخاب، درخواستی بزند: فراخوانی با سبد خالی هم نویز شبکه
|
|
* است هم خطای «حداقل انتخاب» را بیجا نشان میدهد.
|
|
*/
|
|
it('does not call validate until something is selected', async () => {
|
|
renderWithProviders(<ServiceGroupsTab serviceUuid="s1" canEdit />, { route: '/admin/services/s1' });
|
|
|
|
await waitFor(() => expect(screen.getByText('پیشنمایش انتخاب')).toBeInTheDocument());
|
|
|
|
expect(post).not.toHaveBeenCalledWith('/api/v1/service-selection/validate', expect.anything());
|
|
expect(screen.getByText(/چند آیتم انتخاب کنید/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('explains the meaning of an empty maximum', async () => {
|
|
renderWithProviders(<ServiceGroupsTab serviceUuid="s1" canEdit />, { route: '/admin/services/s1' });
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByText(/حداکثرِ خالی یعنی نامحدود/)).toBeInTheDocument(),
|
|
);
|
|
});
|
|
});
|