690617:1443 237 #01.3
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
// File: frontend/components/admin/ai/__tests__/ContextConfigEditor.test.tsx
|
||||
// Change Log:
|
||||
// - 2026-06-15: Created test file for ContextConfigEditor
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import ContextConfigEditor from '../ContextConfigEditor';
|
||||
import { projectService } from '@/lib/services/project.service';
|
||||
import { contractService } from '@/lib/services/contract.service';
|
||||
|
||||
// Mock the external services
|
||||
vi.mock('@/lib/services/project.service', () => ({
|
||||
projectService: {
|
||||
getAll: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/contract.service', () => ({
|
||||
contractService: {
|
||||
getAll: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock i18n
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockProjects = [
|
||||
{ publicId: 'proj-1', projectName: 'Project 1' },
|
||||
{ publicId: 'proj-2', projectName: 'Project 2' },
|
||||
];
|
||||
|
||||
const mockContracts = [
|
||||
{ publicId: 'contract-1', contractName: 'Contract 1', project: { publicId: 'proj-1' } },
|
||||
{ publicId: 'contract-2', contractName: 'Contract 2', project: { publicId: 'proj-1' } },
|
||||
{ publicId: 'contract-3', contractName: 'Contract 3', project: { publicId: 'proj-2' } },
|
||||
];
|
||||
|
||||
describe('ContextConfigEditor', () => {
|
||||
const mockOnSave = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(projectService.getAll as any).mockResolvedValue(mockProjects);
|
||||
(contractService.getAll as any).mockResolvedValue(mockContracts);
|
||||
});
|
||||
|
||||
it('renders correctly with default values when initialConfig is null', async () => {
|
||||
render(<ContextConfigEditor initialConfig={null} onSave={mockOnSave} isSaving={false} />);
|
||||
|
||||
// Wait for services to load
|
||||
await waitFor(() => {
|
||||
expect(projectService.getAll).toHaveBeenCalled();
|
||||
expect(contractService.getAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(screen.getByText('การตั้งค่าบริบทข้อมูล (Context Configuration)')).toBeInTheDocument();
|
||||
|
||||
// Check default input value
|
||||
const pageSizeInput = screen.getByRole('spinbutton');
|
||||
expect(pageSizeInput).toHaveValue(3);
|
||||
});
|
||||
|
||||
it('calls onSave with valid data', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ContextConfigEditor initialConfig={null} onSave={mockOnSave} isSaving={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(projectService.getAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /บันทึกบริบท/i });
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(mockOnSave).toHaveBeenCalledWith({
|
||||
filter: {
|
||||
projectId: null,
|
||||
contractId: null,
|
||||
},
|
||||
pageSize: 3,
|
||||
language: 'th',
|
||||
outputLanguage: 'th',
|
||||
});
|
||||
});
|
||||
|
||||
it('validates pageSize input', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ContextConfigEditor initialConfig={null} onSave={mockOnSave} isSaving={false} />);
|
||||
|
||||
const pageSizeInput = screen.getByRole('spinbutton');
|
||||
|
||||
// Set invalid value
|
||||
await user.clear(pageSizeInput);
|
||||
await user.type(pageSizeInput, '2000');
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /บันทึกบริบท/i });
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(mockOnSave).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('prompt_management.pageSize_invalid')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays saving state', () => {
|
||||
render(<ContextConfigEditor initialConfig={null} onSave={mockOnSave} isSaving={true} />);
|
||||
expect(screen.getByRole('button', { name: /กำลังบันทึก/i })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
// File: frontend/components/admin/ai/__tests__/OcrEngineSelector.test.tsx
|
||||
// Change Log:
|
||||
// - 2026-06-15: Created test file for OcrEngineSelector
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor, act } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import OcrEngineSelector from '../OcrEngineSelector';
|
||||
import { adminAiService } from '@/lib/services/admin-ai.service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// Mock the services
|
||||
vi.mock('@/lib/services/admin-ai.service', () => ({
|
||||
adminAiService: {
|
||||
getOcrEngines: vi.fn(),
|
||||
selectOcrEngine: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockEngines = [
|
||||
{
|
||||
engineId: 'engine-1',
|
||||
engineName: 'Tesseract OCR',
|
||||
engineType: 'tesseract',
|
||||
isCurrentActive: true,
|
||||
concurrentLimit: 4,
|
||||
vramRequirementMB: 0,
|
||||
},
|
||||
{
|
||||
engineId: 'engine-2',
|
||||
engineName: 'Typhoon OCR',
|
||||
engineType: 'typhoon_ocr',
|
||||
isCurrentActive: false,
|
||||
concurrentLimit: 1,
|
||||
vramRequirementMB: 4096,
|
||||
},
|
||||
];
|
||||
|
||||
describe('OcrEngineSelector', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders loading state initially', () => {
|
||||
// Return a promise that doesn't resolve immediately to keep it in loading state
|
||||
(adminAiService.getOcrEngines as any).mockReturnValue(new Promise(() => {}));
|
||||
|
||||
const { container } = render(<OcrEngineSelector />);
|
||||
// Card with animate-pulse
|
||||
expect(container.querySelector('.animate-pulse')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders engines list successfully after loading', async () => {
|
||||
(adminAiService.getOcrEngines as any).mockResolvedValue(mockEngines);
|
||||
|
||||
render(<OcrEngineSelector />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('ระบบจัดการ OCR Engine')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Tesseract OCR')).toBeInTheDocument();
|
||||
expect(screen.getByText('Typhoon OCR')).toBeInTheDocument();
|
||||
expect(screen.getByText('กำลังใช้งาน')).toBeInTheDocument(); // Badge for active engine
|
||||
expect(screen.getByText('AI Powered')).toBeInTheDocument(); // Badge for typhoon
|
||||
});
|
||||
|
||||
it('calls selectOcrEngine and shows success toast when changing engine', async () => {
|
||||
const user = userEvent.setup();
|
||||
(adminAiService.getOcrEngines as any).mockResolvedValue(mockEngines);
|
||||
(adminAiService.selectOcrEngine as any).mockResolvedValue({});
|
||||
|
||||
render(<OcrEngineSelector />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('ระบบจัดการ OCR Engine')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The active engine will have "เลือกอยู่แล้ว", the inactive will have "สลับใช้งาน"
|
||||
const switchButton = screen.getByRole('button', { name: /สลับใช้งาน/i });
|
||||
|
||||
await act(async () => {
|
||||
await user.click(switchButton);
|
||||
});
|
||||
|
||||
expect(adminAiService.selectOcrEngine).toHaveBeenCalledWith('engine-2');
|
||||
expect(toast.success).toHaveBeenCalledWith('เปลี่ยนเอนจิน OCR หลักเป็น Typhoon OCR สำเร็จ');
|
||||
|
||||
// It should fetch engines again
|
||||
expect(adminAiService.getOcrEngines).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('shows error toast if fetching fails', async () => {
|
||||
(adminAiService.getOcrEngines as any).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(<OcrEngineSelector />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('ไม่สามารถดึงข้อมูล OCR Engines ได้');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error toast if selecting engine fails', async () => {
|
||||
const user = userEvent.setup();
|
||||
(adminAiService.getOcrEngines as any).mockResolvedValue(mockEngines);
|
||||
(adminAiService.selectOcrEngine as any).mockRejectedValue(new Error('Select error'));
|
||||
|
||||
render(<OcrEngineSelector />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('ระบบจัดการ OCR Engine')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const switchButton = screen.getByRole('button', { name: /สลับใช้งาน/i });
|
||||
|
||||
await act(async () => {
|
||||
await user.click(switchButton);
|
||||
});
|
||||
|
||||
expect(adminAiService.selectOcrEngine).toHaveBeenCalledWith('engine-2');
|
||||
expect(toast.error).toHaveBeenCalledWith('ไม่สามารถเปลี่ยนเอนจิน OCR ได้');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
// File: frontend/components/admin/ai/__tests__/OcrSandboxPromptManager.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import OcrSandboxPromptManager from '../OcrSandboxPromptManager';
|
||||
|
||||
// Mock Hooks
|
||||
vi.mock('@/hooks/use-ai-prompts', () => ({
|
||||
useAiPrompts: vi.fn(() => ({
|
||||
versionsQuery: { data: [], isSuccess: true, refetch: vi.fn() },
|
||||
createMutation: { mutateAsync: vi.fn(), isPending: false },
|
||||
activateMutation: { mutateAsync: vi.fn() },
|
||||
deleteMutation: { mutateAsync: vi.fn() },
|
||||
updateNoteMutation: { mutateAsync: vi.fn() },
|
||||
})),
|
||||
useSandboxRun: vi.fn(() => ({
|
||||
state: { isRunning: false },
|
||||
jobId: null,
|
||||
reset: vi.fn(),
|
||||
startPolling: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-translations', () => ({
|
||||
useTranslations: vi.fn(() => (key: string) => key),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-master-data', () => ({
|
||||
useProjects: vi.fn(() => ({ data: [{ publicId: 'proj-1', projectCode: 'P1', projectName: 'Project 1' }] })),
|
||||
useContracts: vi.fn(() => ({ data: [] })),
|
||||
}));
|
||||
|
||||
// Mock React Query
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: vi.fn(() => ({ data: [] })),
|
||||
}));
|
||||
|
||||
// Mock Service
|
||||
vi.mock('@/lib/services/admin-ai.service', () => ({
|
||||
adminAiService: {
|
||||
getOcrEngines: vi.fn().mockResolvedValue([]),
|
||||
getSandboxProfile: vi.fn().mockResolvedValue({}),
|
||||
getProductionDefaults: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// We must mock PromptVersionHistory since it might rely on other complex contexts
|
||||
vi.mock('../PromptVersionHistory', () => ({
|
||||
default: () => <div data-testid="mock-prompt-version-history" />,
|
||||
}));
|
||||
|
||||
describe('OcrSandboxPromptManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders correctly and defaults to sandbox tab', async () => {
|
||||
const { container } = render(<OcrSandboxPromptManager />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('ai.prompt.sandboxCardTitle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('ai.prompt.tabSandbox')).toBeInTheDocument();
|
||||
expect(screen.getByText('ai.prompt.tabEditor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches to editor tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<OcrSandboxPromptManager />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('ai.prompt.tabEditor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editorTab = screen.getByText('ai.prompt.tabEditor');
|
||||
await user.click(editorTab);
|
||||
|
||||
expect(screen.getByText('ai.prompt.cardTitle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles rate limiting (429/503) errors gracefully on OCR submission', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockSubmitOcr = vi.fn().mockRejectedValue({
|
||||
response: { data: { message: 'Rate limit exceeded. Try again later.' } }
|
||||
});
|
||||
|
||||
// Override the mock implementation for this test
|
||||
const { adminAiService } = await import('@/lib/services/admin-ai.service');
|
||||
(adminAiService.submitSandboxOcr as any) = mockSubmitOcr;
|
||||
|
||||
render(<OcrSandboxPromptManager />);
|
||||
|
||||
// Select project
|
||||
const selects = document.querySelectorAll('select');
|
||||
const projectSelect = selects[0]; // First select is Project
|
||||
await userEvent.selectOptions(projectSelect, 'proj-1');
|
||||
|
||||
// Simulate file drop/upload
|
||||
const file = new File(['dummy content'], 'test.pdf', { type: 'application/pdf' });
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
await userEvent.upload(fileInput, file);
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /Step 1: Run OCR Only/i });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSubmitOcr).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check if error toast was called
|
||||
const { toast } = await import('sonner');
|
||||
expect(toast.error).toHaveBeenCalledWith('Rate limit exceeded. Try again later.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// File: frontend/components/admin/ai/__tests__/PromptEditor.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import PromptEditor from '../PromptEditor';
|
||||
|
||||
// Mock PLACEHOLDER_REQUIREMENTS
|
||||
vi.mock('@/contracts/frontend-types', () => ({
|
||||
PLACEHOLDER_REQUIREMENTS: {
|
||||
ocr_extraction: ['{{ocr_text}}'],
|
||||
rag_query_prompt: ['{{query}}'],
|
||||
},
|
||||
}));
|
||||
|
||||
describe('PromptEditor', () => {
|
||||
const defaultProps = {
|
||||
promptType: 'ocr_extraction' as const,
|
||||
initialTemplate: 'Hello {{ocr_text}}',
|
||||
onSave: vi.fn(),
|
||||
isSaving: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders correctly with initial template', () => {
|
||||
render(<PromptEditor {...defaultProps} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText('เขียนพรอมต์ของคุณที่นี่...') as HTMLTextAreaElement;
|
||||
expect(textarea.value).toBe('Hello {{ocr_text}}');
|
||||
});
|
||||
|
||||
it('disables save button if required placeholders are missing', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PromptEditor {...defaultProps} initialTemplate="Hello world" />);
|
||||
|
||||
// Check missing validation message
|
||||
expect(screen.getByText(/ต้องมีตัวแปร/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('{{ocr_text}}')).toBeInTheDocument();
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /บันทึกเวอร์ชันใหม่/i });
|
||||
expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables save button when placeholders are present and calls onSave', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PromptEditor {...defaultProps} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText('เขียนพรอมต์ของคุณที่นี่...') as HTMLTextAreaElement;
|
||||
await user.type(textarea, ' is awesome');
|
||||
|
||||
const noteInput = screen.getByPlaceholderText(/เช่น ปรับปรุงสัดส่วนความเที่ยงตรง/i);
|
||||
await user.type(noteInput, 'Test Note');
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /บันทึกเวอร์ชันใหม่/i });
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(defaultProps.onSave).toHaveBeenCalledWith('Hello {{ocr_text}} is awesome', 'Test Note');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
// File: frontend/components/admin/ai/__tests__/PromptTypeDropdown.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import PromptTypeDropdown from '../PromptTypeDropdown';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// ResizeObserver mock is needed for Radix UI select
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
window.ResizeObserver = ResizeObserver;
|
||||
|
||||
describe('PromptTypeDropdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// mock pointer event for Radix UI
|
||||
window.PointerEvent = MouseEvent as any;
|
||||
});
|
||||
|
||||
it('renders correctly with default options', async () => {
|
||||
render(<PromptTypeDropdown value="ocr_extraction" onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText('prompt_management.prompt_type')).toBeInTheDocument();
|
||||
|
||||
const trigger = screen.getByRole('combobox');
|
||||
expect(trigger).toHaveTextContent('สกัดข้อความ OCR (OCR Extraction)');
|
||||
});
|
||||
|
||||
it('renders all options when showAllOption is true', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PromptTypeDropdown value="all" onChange={vi.fn()} showAllOption />);
|
||||
|
||||
const trigger = screen.getByRole('combobox');
|
||||
expect(trigger).toHaveTextContent('prompt_management.all_types');
|
||||
|
||||
await user.click(trigger);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('option', { name: 'prompt_management.all_types' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'สกัดข้อความ OCR (OCR Extraction)' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'ค้นหาข้อมูล RAG (RAG Query)' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'เตรียมข้อมูล RAG (RAG Prep)' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'จำแนกประเภทเอกสาร (Classification)' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onChange when an option is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChangeMock = vi.fn();
|
||||
render(<PromptTypeDropdown value="ocr_extraction" onChange={onChangeMock} />);
|
||||
|
||||
const trigger = screen.getByRole('combobox');
|
||||
await user.click(trigger);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('option', { name: 'ค้นหาข้อมูล RAG (RAG Query)' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const option = screen.getByRole('option', { name: 'ค้นหาข้อมูล RAG (RAG Query)' });
|
||||
await user.click(option);
|
||||
|
||||
expect(onChangeMock).toHaveBeenCalledWith('rag_query_prompt');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
// File: frontend/components/admin/ai/__tests__/PromptVersionHistory.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import PromptVersionHistory from '../PromptVersionHistory';
|
||||
import { AiPrompt } from '@/types/ai-prompts';
|
||||
|
||||
describe('PromptVersionHistory', () => {
|
||||
const mockVersions: AiPrompt[] = [
|
||||
{
|
||||
publicId: 'v1-id',
|
||||
versionNumber: 1,
|
||||
promptType: 'ocr_extraction',
|
||||
template: 'Template 1',
|
||||
isActive: false,
|
||||
createdAt: '2026-06-14T10:00:00Z',
|
||||
updatedAt: '2026-06-14T10:00:00Z',
|
||||
manualNote: 'Note 1',
|
||||
},
|
||||
{
|
||||
publicId: 'v2-id',
|
||||
versionNumber: 2,
|
||||
promptType: 'ocr_extraction',
|
||||
template: 'Template 2',
|
||||
isActive: true,
|
||||
createdAt: '2026-06-15T10:00:00Z',
|
||||
updatedAt: '2026-06-15T10:00:00Z',
|
||||
lastTestedAt: '2026-06-15T10:05:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
versions: mockVersions,
|
||||
isLoading: false,
|
||||
onLoadTemplate: vi.fn(),
|
||||
onActivateVersion: vi.fn(),
|
||||
onDeleteVersion: vi.fn(),
|
||||
isActivating: false,
|
||||
isDeleting: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders loading state', () => {
|
||||
render(<PromptVersionHistory {...defaultProps} isLoading={true} />);
|
||||
expect(screen.getByText(/กำลังโหลดประวัติเวอร์ชัน/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state', () => {
|
||||
render(<PromptVersionHistory {...defaultProps} versions={[]} />);
|
||||
expect(screen.getByText(/ไม่พบเวอร์ชันอื่นในระบบ/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders versions correctly', () => {
|
||||
render(<PromptVersionHistory {...defaultProps} />);
|
||||
|
||||
// Check version numbers
|
||||
expect(screen.getByText('v1')).toBeInTheDocument();
|
||||
expect(screen.getByText('v2')).toBeInTheDocument();
|
||||
|
||||
// Check active/inactive badges
|
||||
expect(screen.getByText(/ใช้งานจริง \(Active\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/ร่าง \(Inactive\)/)).toBeInTheDocument();
|
||||
|
||||
// Check manual note
|
||||
expect(screen.getByText('Note 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls action handlers when buttons are clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PromptVersionHistory {...defaultProps} />);
|
||||
|
||||
// There are 2 load buttons
|
||||
const loadButtons = screen.getAllByRole('button', { name: /โหลด \(Load\)/ });
|
||||
expect(loadButtons).toHaveLength(2);
|
||||
|
||||
await user.click(loadButtons[0]);
|
||||
expect(defaultProps.onLoadTemplate).toHaveBeenCalledWith(mockVersions[0]);
|
||||
|
||||
// Active version should not have Activate/Delete buttons
|
||||
const activateButtons = screen.getAllByRole('button', { name: /ใช้งาน \(Activate\)/ });
|
||||
expect(activateButtons).toHaveLength(1); // Only for v1
|
||||
|
||||
await user.click(activateButtons[0]);
|
||||
expect(defaultProps.onActivateVersion).toHaveBeenCalledWith(1);
|
||||
|
||||
// Delete button (it uses an icon, but we can target by role 'button' and filter or use the trash icon if it had aria-label)
|
||||
// Actually, delete button is the 3rd button in the v1 row (Load, Activate, Delete)
|
||||
const deleteButton = screen.getAllByRole('button')[2]; // Load v1, Activate v1, Delete v1
|
||||
await user.click(deleteButton);
|
||||
expect(defaultProps.onDeleteVersion).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
// File: frontend/components/admin/ai/__tests__/RuntimeParametersPanel.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import RuntimeParametersPanel from '../RuntimeParametersPanel';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockGetSandboxProfile = vi.fn();
|
||||
const mockSaveSandboxProfile = vi.fn();
|
||||
const mockResetSandboxProfile = vi.fn();
|
||||
const mockApplyProfile = vi.fn();
|
||||
|
||||
vi.mock('@/lib/services/admin-ai.service', () => ({
|
||||
adminAiService: {
|
||||
getSandboxProfile: (...args: any) => mockGetSandboxProfile(...args),
|
||||
saveSandboxProfile: (...args: any) => mockSaveSandboxProfile(...args),
|
||||
resetSandboxProfile: (...args: any) => mockResetSandboxProfile(...args),
|
||||
applyProfile: (...args: any) => mockApplyProfile(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('uuid', () => ({
|
||||
v7: () => 'mock-uuid-v7',
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// ResizeObserver mock is needed for Radix UI select
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
window.ResizeObserver = ResizeObserver;
|
||||
|
||||
describe('RuntimeParametersPanel', () => {
|
||||
const mockParams = {
|
||||
temperature: 0.7,
|
||||
topP: 0.9,
|
||||
repeatPenalty: 1.1,
|
||||
maxTokens: 2048,
|
||||
numCtx: 4096,
|
||||
keepAliveSeconds: 300,
|
||||
canonicalModel: 'np-dms-ai-test',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetSandboxProfile.mockResolvedValue(mockParams);
|
||||
window.PointerEvent = MouseEvent as any;
|
||||
});
|
||||
|
||||
it('renders loading state initially', () => {
|
||||
// We mock promise without resolving immediately to see loading state
|
||||
let resolvePromise: any;
|
||||
mockGetSandboxProfile.mockReturnValue(new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
}));
|
||||
|
||||
render(<RuntimeParametersPanel />);
|
||||
expect(screen.getByText(/กำลังโหลดพารามิเตอร์/)).toBeInTheDocument();
|
||||
|
||||
// Resolve to avoid act warnings
|
||||
resolvePromise(mockParams);
|
||||
});
|
||||
|
||||
it('renders parameters after loading', async () => {
|
||||
render(<RuntimeParametersPanel />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('sandbox_test.runtime_parameters')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('np-dms-ai-test')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('2048')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('4096')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('300')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls save draft correctly', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockSaveSandboxProfile.mockResolvedValue(mockParams);
|
||||
|
||||
render(<RuntimeParametersPanel />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('sandbox_test.runtime_parameters')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /บันทึกแบบร่าง/i });
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(mockSaveSandboxProfile).toHaveBeenCalledWith('standard', mockParams, 'mock-uuid-v7');
|
||||
|
||||
const { toast } = await import('sonner');
|
||||
expect(toast.success).toHaveBeenCalledWith('บันทึกแบบร่าง Sandbox สำเร็จ');
|
||||
});
|
||||
|
||||
it('calls apply to production correctly', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockApplyProfile.mockResolvedValue(true);
|
||||
|
||||
render(<RuntimeParametersPanel />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('sandbox_test.runtime_parameters')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const applyButton = screen.getByRole('button', { name: /ปรับใช้จริง/i });
|
||||
await user.click(applyButton);
|
||||
|
||||
expect(mockApplyProfile).toHaveBeenCalledWith('standard', 'mock-uuid-v7');
|
||||
|
||||
const { toast } = await import('sonner');
|
||||
expect(toast.success).toHaveBeenCalledWith('ปรับใช้พารามิเตอร์จริงสำเร็จ');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
// File: frontend/components/admin/ai/__tests__/SandboxTabs.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import SandboxTabs from '../SandboxTabs';
|
||||
|
||||
const mockSubmitSandboxOcr = vi.fn();
|
||||
const mockSubmitSandboxAiExtract = vi.fn();
|
||||
const mockSubmitSandboxRagPrep = vi.fn();
|
||||
const mockGetSandboxJobStatus = vi.fn();
|
||||
|
||||
vi.mock('@/lib/services/admin-ai.service', () => ({
|
||||
adminAiService: {
|
||||
submitSandboxOcr: (...args: any) => mockSubmitSandboxOcr(...args),
|
||||
submitSandboxAiExtract: (...args: any) => mockSubmitSandboxAiExtract(...args),
|
||||
submitSandboxRagPrep: (...args: any) => mockSubmitSandboxRagPrep(...args),
|
||||
getSandboxJobStatus: (...args: any) => mockGetSandboxJobStatus(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-master-data', () => ({
|
||||
useProjects: vi.fn(() => ({ data: [{ publicId: 'proj-1', projectCode: 'P1', projectName: 'Project 1' }] })),
|
||||
useContracts: vi.fn(() => ({ data: [] })),
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// ResizeObserver mock is needed for Radix UI select
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
window.ResizeObserver = ResizeObserver;
|
||||
|
||||
describe('SandboxTabs', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.PointerEvent = MouseEvent as any;
|
||||
});
|
||||
|
||||
it('renders correctly', async () => {
|
||||
render(<SandboxTabs promptType="ocr_extraction" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/รันบอร์ดทดลองการทำงาน/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Step 1: Run OCR')).toBeInTheDocument();
|
||||
expect(screen.getByText('Step 2: AI Extract')).toBeInTheDocument();
|
||||
expect(screen.getByText('Step 3: RAG Prep')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('requires project and file for OCR', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SandboxTabs promptType="ocr_extraction" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /เริ่มรัน OCR/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
// Upload file
|
||||
const file = new File(['dummy content'], 'test.pdf', { type: 'application/pdf' });
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
await userEvent.upload(fileInput, file);
|
||||
|
||||
// After uploading file, button is enabled, but submitting will fail without project in AI Extract step.
|
||||
// Wait, handleRunOcr checks if file exists, it doesn't check project!
|
||||
expect(screen.getByRole('button', { name: /เริ่มรัน OCR/i })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('runs OCR and polls status', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
mockSubmitSandboxOcr.mockResolvedValue({ requestPublicId: 'req-1' });
|
||||
mockGetSandboxJobStatus.mockResolvedValue({ status: 'completed', ocrText: 'Extracted text from PDF' });
|
||||
|
||||
render(<SandboxTabs promptType="ocr_extraction" />);
|
||||
|
||||
// Upload file
|
||||
const file = new File(['dummy content'], 'test.pdf', { type: 'application/pdf' });
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
await userEvent.upload(fileInput, file);
|
||||
|
||||
const runBtn = screen.getByRole('button', { name: /เริ่มรัน OCR/i });
|
||||
await user.click(runBtn);
|
||||
|
||||
expect(mockSubmitSandboxOcr).toHaveBeenCalled();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetSandboxJobStatus).toHaveBeenCalledWith('req-1');
|
||||
}, { timeout: 3000 }); // Polling interval is 2s
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
// File: frontend/components/admin/ai/__tests__/SandboxTestArea.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import SandboxTestArea from '../SandboxTestArea';
|
||||
|
||||
const mockSubmitSandboxOcr = vi.fn();
|
||||
const mockSubmitSandboxAiExtract = vi.fn();
|
||||
const mockSubmitSandboxRagPrep = vi.fn();
|
||||
const mockGetSandboxJobStatus = vi.fn();
|
||||
|
||||
vi.mock('@/lib/services/admin-ai.service', () => ({
|
||||
adminAiService: {
|
||||
submitSandboxOcr: (...args: any) => mockSubmitSandboxOcr(...args),
|
||||
submitSandboxAiExtract: (...args: any) => mockSubmitSandboxAiExtract(...args),
|
||||
submitSandboxRagPrep: (...args: any) => mockSubmitSandboxRagPrep(...args),
|
||||
getSandboxJobStatus: (...args: any) => mockGetSandboxJobStatus(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-master-data', () => ({
|
||||
useProjects: vi.fn(() => ({ data: [{ publicId: 'proj-1', projectCode: 'P1', projectName: 'Project 1' }] })),
|
||||
useContracts: vi.fn(() => ({ data: [] })),
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// ResizeObserver mock is needed for Radix UI select
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
window.ResizeObserver = ResizeObserver;
|
||||
|
||||
describe('SandboxTestArea', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.PointerEvent = MouseEvent as any;
|
||||
});
|
||||
|
||||
it('renders correctly', async () => {
|
||||
render(<SandboxTestArea promptType="ocr_extraction" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/รันบอร์ดทดลองการทำงาน/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Step 1: Run OCR')).toBeInTheDocument();
|
||||
expect(screen.getByText('Step 2: AI Extract')).toBeInTheDocument();
|
||||
expect(screen.getByText('Step 3: RAG Prep')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('requires project and file for OCR', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SandboxTestArea promptType="ocr_extraction" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /เริ่มรัน OCR/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
// Upload file
|
||||
const file = new File(['dummy content'], 'test.pdf', { type: 'application/pdf' });
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
await userEvent.upload(fileInput, file);
|
||||
|
||||
// After uploading file, button is enabled, but submitting will fail without project in AI Extract step.
|
||||
expect(screen.getByRole('button', { name: /เริ่มรัน OCR/i })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('runs OCR and polls status', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
mockSubmitSandboxOcr.mockResolvedValue({ requestPublicId: 'req-1' });
|
||||
mockGetSandboxJobStatus.mockResolvedValue({ status: 'completed', ocrText: 'Extracted text from PDF' });
|
||||
|
||||
render(<SandboxTestArea promptType="ocr_extraction" />);
|
||||
|
||||
// Upload file
|
||||
const file = new File(['dummy content'], 'test.pdf', { type: 'application/pdf' });
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
await userEvent.upload(fileInput, file);
|
||||
|
||||
const runBtn = screen.getByRole('button', { name: /เริ่มรัน OCR/i });
|
||||
await user.click(runBtn);
|
||||
|
||||
expect(mockSubmitSandboxOcr).toHaveBeenCalled();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetSandboxJobStatus).toHaveBeenCalledWith('req-1');
|
||||
}, { timeout: 3000 }); // Polling interval is 2s
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
// File: frontend/components/admin/ai/__tests__/VersionHistory.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import VersionHistory from '../VersionHistory';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('VersionHistory', () => {
|
||||
const mockOnLoadTemplate = vi.fn();
|
||||
const mockOnActivateVersion = vi.fn();
|
||||
const mockOnDeleteVersion = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const generateVersions = (count: number) => {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
versionNumber: i + 1,
|
||||
promptType: 'ocr_extraction',
|
||||
promptText: 'prompt text',
|
||||
createdAt: new Date().toISOString(),
|
||||
isActive: i === 0,
|
||||
manualNote: `Note ${i + 1}`,
|
||||
authorName: 'Admin',
|
||||
}));
|
||||
};
|
||||
|
||||
it('renders loading state', () => {
|
||||
render(
|
||||
<VersionHistory
|
||||
versions={[]}
|
||||
isLoading={true}
|
||||
onLoadTemplate={mockOnLoadTemplate}
|
||||
onActivateVersion={mockOnActivateVersion}
|
||||
onDeleteVersion={mockOnDeleteVersion}
|
||||
isActivating={false}
|
||||
isDeleting={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/prompt_management.version_history/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state', () => {
|
||||
render(
|
||||
<VersionHistory
|
||||
versions={[]}
|
||||
isLoading={false}
|
||||
onLoadTemplate={mockOnLoadTemplate}
|
||||
onActivateVersion={mockOnActivateVersion}
|
||||
onDeleteVersion={mockOnDeleteVersion}
|
||||
isActivating={false}
|
||||
isDeleting={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('prompt_management.no_versions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders versions', () => {
|
||||
const versions = generateVersions(2);
|
||||
render(
|
||||
<VersionHistory
|
||||
versions={versions}
|
||||
isLoading={false}
|
||||
onLoadTemplate={mockOnLoadTemplate}
|
||||
onActivateVersion={mockOnActivateVersion}
|
||||
onDeleteVersion={mockOnDeleteVersion}
|
||||
isActivating={false}
|
||||
isDeleting={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('v1')).toBeInTheDocument();
|
||||
expect(screen.getByText('v2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Note 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('prompt_management.is_active')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles pagination', async () => {
|
||||
const user = userEvent.setup();
|
||||
const versions = generateVersions(25); // Page size is 20
|
||||
|
||||
render(
|
||||
<VersionHistory
|
||||
versions={versions}
|
||||
isLoading={false}
|
||||
onLoadTemplate={mockOnLoadTemplate}
|
||||
onActivateVersion={mockOnActivateVersion}
|
||||
onDeleteVersion={mockOnDeleteVersion}
|
||||
isActivating={false}
|
||||
isDeleting={false}
|
||||
/>
|
||||
);
|
||||
|
||||
// Page 1 should have v1 to v20
|
||||
expect(screen.getByText('v1')).toBeInTheDocument();
|
||||
expect(screen.getByText('v20')).toBeInTheDocument();
|
||||
expect(screen.queryByText('v21')).not.toBeInTheDocument();
|
||||
|
||||
// Next page button is the right chevron
|
||||
const nextBtn = document.querySelector('button .lucide-chevron-right')?.closest('button');
|
||||
if (nextBtn) {
|
||||
await user.click(nextBtn);
|
||||
}
|
||||
|
||||
// Page 2 should have v21 to v25
|
||||
expect(screen.queryByText('v1')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('v21')).toBeInTheDocument();
|
||||
expect(screen.getByText('v25')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user