ميزة: إضافة مكوني ProcedureSelection و StepProgress لأداة مخططات التدفق بصيغة PDF

- تنفيذ مكون ProcedureSelection لتمكين المستخدمين من اختيار الإجراءات من قائمة، وإدارة الاختيارات، ومعالجة الإجراءات المرفوضة.

- إنشاء مكون StepProgress لعرض تقدم معالج متعدد الخطوات بشكل مرئي.

- تعريف أنواع مشتركة للإجراءات، وخطوات التدفق، ورسائل الدردشة في ملف types.ts.

- إضافة اختبارات وحدة لخطافات useFileUpload و useTaskPolling لضمان الأداء السليم ومعالجة الأخطاء.

- تنفيذ اختبارات واجهة برمجة التطبيقات (API) للتحقق من تنسيقات نقاط النهاية وضمان اتساق ربط الواجهة الأمامية بالخلفية.
This commit is contained in:
Your Name
2026-03-06 17:16:09 +02:00
parent 2e97741d60
commit cfbcc8bd79
62 changed files with 10567 additions and 101 deletions

View File

@@ -0,0 +1,224 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useFileUpload } from './useFileUpload';
// ── mock the api module ────────────────────────────────────────────────────
vi.mock('@/services/api', () => ({
uploadFile: vi.fn(),
}));
import { uploadFile } from '@/services/api';
const mockUpload = vi.mocked(uploadFile);
// ── helpers ────────────────────────────────────────────────────────────────
function makeFile(name: string, sizeBytes: number, type = 'application/pdf'): File {
const buf = new Uint8Array(sizeBytes);
return new File([buf], name, { type });
}
// ── tests ──────────────────────────────────────────────────────────────────
describe('useFileUpload', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ── initial state ──────────────────────────────────────────────────────
it('starts with null file and no error', () => {
const { result } = renderHook(() =>
useFileUpload({ endpoint: '/compress/pdf' })
);
expect(result.current.file).toBeNull();
expect(result.current.error).toBeNull();
expect(result.current.isUploading).toBe(false);
expect(result.current.taskId).toBeNull();
expect(result.current.uploadProgress).toBe(0);
});
// ── selectFile — type validation ───────────────────────────────────────
it('selectFile: accepts a file when no type restriction set', () => {
const { result } = renderHook(() =>
useFileUpload({ endpoint: '/compress/pdf' })
);
const pdf = makeFile('doc.pdf', 100);
act(() => {
result.current.selectFile(pdf);
});
expect(result.current.file).toBe(pdf);
expect(result.current.error).toBeNull();
});
it('selectFile: rejects wrong extension when acceptedTypes given', () => {
const { result } = renderHook(() =>
useFileUpload({ endpoint: '/compress/pdf', acceptedTypes: ['pdf'] })
);
act(() => {
result.current.selectFile(makeFile('photo.jpg', 100));
});
expect(result.current.file).toBeNull();
expect(result.current.error).toMatch(/invalid file type/i);
});
it('selectFile: accepts correct extension', () => {
const { result } = renderHook(() =>
useFileUpload({ endpoint: '/compress/pdf', acceptedTypes: ['pdf', 'docx'] })
);
const docx = makeFile('report.docx', 200);
act(() => {
result.current.selectFile(docx);
});
expect(result.current.file).toBe(docx);
expect(result.current.error).toBeNull();
});
// ── selectFile — size validation ───────────────────────────────────────
it('selectFile: rejects file exceeding maxSizeMB', () => {
const { result } = renderHook(() =>
useFileUpload({ endpoint: '/compress/pdf', maxSizeMB: 1 })
);
// 1.1 MB > 1 MB limit
act(() => {
result.current.selectFile(makeFile('big.pdf', 1.1 * 1024 * 1024));
});
expect(result.current.file).toBeNull();
expect(result.current.error).toMatch(/too large/i);
});
it('selectFile: accepts file exactly at the size limit', () => {
const { result } = renderHook(() =>
useFileUpload({ endpoint: '/compress/pdf', maxSizeMB: 5 })
);
act(() => {
result.current.selectFile(makeFile('ok.pdf', 5 * 1024 * 1024));
});
expect(result.current.file).not.toBeNull();
expect(result.current.error).toBeNull();
});
// ── selectFile: clears previous error / taskId on next pick ───────────
it('selectFile: clears previous error when new valid file selected', () => {
const { result } = renderHook(() =>
useFileUpload({ endpoint: '/compress/pdf', acceptedTypes: ['pdf'] })
);
// First pick — wrong type → sets error
act(() => {
result.current.selectFile(makeFile('bad.exe', 10));
});
expect(result.current.error).not.toBeNull();
// Second pick — valid → error must clear
act(() => {
result.current.selectFile(makeFile('good.pdf', 10));
});
expect(result.current.error).toBeNull();
});
// ── startUpload — no file selected ────────────────────────────────────
it('startUpload: returns null and sets error when no file selected', async () => {
const { result } = renderHook(() =>
useFileUpload({ endpoint: '/compress/pdf' })
);
let returnValue: string | null = 'initial';
await act(async () => {
returnValue = await result.current.startUpload();
});
expect(returnValue).toBeNull();
expect(result.current.error).toMatch(/no file/i);
});
// ── startUpload — success ──────────────────────────────────────────────
it('startUpload: sets taskId on success', async () => {
mockUpload.mockResolvedValueOnce({
task_id: 'abc-123',
message: 'started',
});
const { result } = renderHook(() =>
useFileUpload({ endpoint: '/compress/pdf', extraData: { quality: 'medium' } })
);
act(() => {
result.current.selectFile(makeFile('doc.pdf', 500));
});
let taskId: string | null = null;
await act(async () => {
taskId = await result.current.startUpload();
});
expect(taskId).toBe('abc-123');
expect(result.current.taskId).toBe('abc-123');
expect(result.current.isUploading).toBe(false);
expect(result.current.error).toBeNull();
expect(mockUpload).toHaveBeenCalledWith(
'/compress/pdf',
expect.any(File),
{ quality: 'medium' },
expect.any(Function),
);
});
// ── startUpload — API error ────────────────────────────────────────────
it('startUpload: sets error message when API rejects', async () => {
mockUpload.mockRejectedValueOnce(new Error('File too large.'));
const { result } = renderHook(() =>
useFileUpload({ endpoint: '/compress/pdf' })
);
act(() => {
result.current.selectFile(makeFile('doc.pdf', 500));
});
await act(async () => {
await result.current.startUpload();
});
expect(result.current.error).toBe('File too large.');
expect(result.current.isUploading).toBe(false);
expect(result.current.taskId).toBeNull();
});
// ── reset ──────────────────────────────────────────────────────────────
it('reset: clears all state', async () => {
mockUpload.mockResolvedValueOnce({ task_id: 'xyz', message: 'ok' });
const { result } = renderHook(() =>
useFileUpload({ endpoint: '/compress/pdf' })
);
act(() => {
result.current.selectFile(makeFile('doc.pdf', 500));
});
await act(async () => {
await result.current.startUpload();
});
expect(result.current.taskId).toBe('xyz');
act(() => {
result.current.reset();
});
expect(result.current.file).toBeNull();
expect(result.current.taskId).toBeNull();
expect(result.current.error).toBeNull();
expect(result.current.uploadProgress).toBe(0);
expect(result.current.isUploading).toBe(false);
});
// ── progress callback ──────────────────────────────────────────────────
it('startUpload: progress callback updates uploadProgress', async () => {
mockUpload.mockImplementationOnce(async (_ep, _file, _extra, onProgress) => {
onProgress?.(50);
onProgress?.(100);
return { task_id: 'prog-task', message: 'done' };
});
const { result } = renderHook(() =>
useFileUpload({ endpoint: '/compress/pdf' })
);
act(() => {
result.current.selectFile(makeFile('doc.pdf', 500));
});
await act(async () => {
await result.current.startUpload();
});
// After completion the task id should be set
expect(result.current.taskId).toBe('prog-task');
});
});

View File

@@ -0,0 +1,230 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useTaskPolling } from './useTaskPolling';
// ── mock the api module ────────────────────────────────────────────────────
vi.mock('@/services/api', () => ({
getTaskStatus: vi.fn(),
}));
import { getTaskStatus } from '@/services/api';
const mockGetStatus = vi.mocked(getTaskStatus);
// ── tests ──────────────────────────────────────────────────────────────────
describe('useTaskPolling', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
// ── initial state ──────────────────────────────────────────────────────
it('starts idle when taskId is null', () => {
const { result } = renderHook(() =>
useTaskPolling({ taskId: null })
);
expect(result.current.isPolling).toBe(false);
expect(result.current.status).toBeNull();
expect(result.current.result).toBeNull();
expect(result.current.error).toBeNull();
});
// ── begins polling immediately ─────────────────────────────────────────
it('polls immediately when taskId is provided', async () => {
mockGetStatus.mockResolvedValue({
task_id: 'task-1',
state: 'PENDING',
});
const { result } = renderHook(() =>
useTaskPolling({ taskId: 'task-1', intervalMs: 1000 })
);
// Advance just enough to let the immediate poll() Promise resolve,
// but NOT enough to trigger the setInterval tick (< 1000ms).
await act(async () => {
await vi.advanceTimersByTimeAsync(50);
});
expect(mockGetStatus).toHaveBeenCalledWith('task-1');
expect(result.current.isPolling).toBe(true);
expect(result.current.status?.state).toBe('PENDING');
});
// ── polls at the configured interval ─────────────────────────────────
it('polls again after the interval elapses', async () => {
mockGetStatus.mockResolvedValue({ task_id: 'task-2', state: 'PROCESSING' });
renderHook(() =>
useTaskPolling({ taskId: 'task-2', intervalMs: 1500 })
);
await act(async () => {
await vi.advanceTimersByTimeAsync(1500 * 3 + 100); // 3 intervals
});
// At least 3 calls (initial + 3 interval ticks)
expect(mockGetStatus.mock.calls.length).toBeGreaterThanOrEqual(3);
});
// ── SUCCESS state ──────────────────────────────────────────────────────
it('stops polling and sets result on SUCCESS with completed status', async () => {
const taskResult = {
status: 'completed' as const,
download_url: '/api/download/task-3/output.pdf',
filename: 'output.pdf',
};
mockGetStatus.mockResolvedValueOnce({ task_id: 'task-3', state: 'PENDING' });
mockGetStatus.mockResolvedValueOnce({
task_id: 'task-3',
state: 'SUCCESS',
result: taskResult,
});
const onComplete = vi.fn();
const { result } = renderHook(() =>
useTaskPolling({ taskId: 'task-3', intervalMs: 500, onComplete })
);
await act(async () => {
await vi.advanceTimersByTimeAsync(1200);
});
expect(result.current.isPolling).toBe(false);
expect(result.current.result).toEqual(taskResult);
expect(result.current.error).toBeNull();
expect(onComplete).toHaveBeenCalledWith(taskResult);
});
// ── SUCCESS with error in result ───────────────────────────────────────
it('sets error when SUCCESS result contains status "failed"', async () => {
mockGetStatus.mockResolvedValueOnce({
task_id: 'task-4',
state: 'SUCCESS',
result: { status: 'failed', error: 'Ghostscript not found.' },
});
const onError = vi.fn();
const { result } = renderHook(() =>
useTaskPolling({ taskId: 'task-4', intervalMs: 500, onError })
);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.isPolling).toBe(false);
expect(result.current.result).toBeNull();
expect(result.current.error).toBe('Ghostscript not found.');
expect(onError).toHaveBeenCalledWith('Ghostscript not found.');
});
// ── FAILURE state ──────────────────────────────────────────────────────
it('stops polling and sets error on FAILURE state', async () => {
mockGetStatus.mockResolvedValueOnce({
task_id: 'task-5',
state: 'FAILURE',
error: 'Worker crashed.',
});
const onError = vi.fn();
const { result } = renderHook(() =>
useTaskPolling({ taskId: 'task-5', intervalMs: 500, onError })
);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.isPolling).toBe(false);
expect(result.current.error).toBe('Worker crashed.');
expect(onError).toHaveBeenCalledWith('Worker crashed.');
});
// ── network error ──────────────────────────────────────────────────────
it('stops polling and sets error on network/API exception', async () => {
mockGetStatus.mockRejectedValueOnce(new Error('Network error.'));
const onError = vi.fn();
const { result } = renderHook(() =>
useTaskPolling({ taskId: 'task-6', intervalMs: 500, onError })
);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.isPolling).toBe(false);
expect(result.current.error).toBe('Network error.');
expect(onError).toHaveBeenCalledWith('Network error.');
});
// ── manual stopPolling ─────────────────────────────────────────────────
it('stopPolling immediately halts further requests', async () => {
mockGetStatus.mockResolvedValue({ task_id: 'task-7', state: 'PROCESSING' });
const { result } = renderHook(() =>
useTaskPolling({ taskId: 'task-7', intervalMs: 500 })
);
// Let one poll happen
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
act(() => {
result.current.stopPolling();
});
const callsAfterStop = mockGetStatus.mock.calls.length;
// Advance time — no new calls should happen
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(result.current.isPolling).toBe(false);
expect(mockGetStatus.mock.calls.length).toBe(callsAfterStop);
});
// ── taskId changes ─────────────────────────────────────────────────────
it('resets state and restarts polling when taskId changes', async () => {
mockGetStatus.mockResolvedValue({ task_id: 'task-new', state: 'PENDING' });
const { result, rerender } = renderHook(
({ taskId }: { taskId: string | null }) =>
useTaskPolling({ taskId, intervalMs: 500 }),
{ initialProps: { taskId: null as string | null } }
);
expect(result.current.isPolling).toBe(false);
// Provide a task id
rerender({ taskId: 'task-new' });
await act(async () => {
await vi.advanceTimersByTimeAsync(200);
});
expect(result.current.isPolling).toBe(true);
expect(mockGetStatus).toHaveBeenCalledWith('task-new');
});
// ── FAILURE with missing error message ────────────────────────────────
it('falls back to default error message when FAILURE has no error field', async () => {
mockGetStatus.mockResolvedValueOnce({ task_id: 'task-8', state: 'FAILURE' });
const { result } = renderHook(() =>
useTaskPolling({ taskId: 'task-8', intervalMs: 500 })
);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.error).toBe('Task failed.');
});
});