feat(ai): add ADR-036 unified OCR architecture and frontend test coverage
- Add ADR-036 unified OCR architecture (typhoon-ocr via Ollama) - Extend AI execution profiles for OCR sandbox configuration - Add comprehensive frontend test coverage (components, hooks, services) - Add backend test coverage for document-numbering services - Update OCR sidecar with typhoon-ocr integration - Add AI policy service and execution profile management - Update AGENTS.md and architecture documentation
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
// File: frontend/components/admin/ai/__tests__/ocr-engine-selector.test.tsx
|
||||
// Change Log
|
||||
// - 2026-06-13: Add coverage for OCR engine loading, display, and selection flows.
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { toast } from 'sonner';
|
||||
import OcrEngineSelector from '../OcrEngineSelector';
|
||||
import { adminAiService, type OcrEngineResponse } from '@/lib/services/admin-ai.service';
|
||||
|
||||
vi.mock('@/lib/services/admin-ai.service', () => ({
|
||||
adminAiService: {
|
||||
getOcrEngines: vi.fn(),
|
||||
selectOcrEngine: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const engines: OcrEngineResponse[] = [
|
||||
{
|
||||
engineId: 'tesseract',
|
||||
engineName: 'Tesseract OCR',
|
||||
engineType: 'tesseract',
|
||||
isCurrentActive: true,
|
||||
concurrentLimit: 4,
|
||||
vramRequirementMB: 0,
|
||||
},
|
||||
{
|
||||
engineId: 'typhoon',
|
||||
engineName: 'Typhoon OCR',
|
||||
engineType: 'typhoon_ocr',
|
||||
isCurrentActive: false,
|
||||
concurrentLimit: 1,
|
||||
vramRequirementMB: 6144,
|
||||
},
|
||||
];
|
||||
|
||||
describe('OcrEngineSelector', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(adminAiService.getOcrEngines).mockResolvedValue(engines);
|
||||
vi.mocked(adminAiService.selectOcrEngine).mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
it('renders OCR engine data from admin service', async () => {
|
||||
render(<OcrEngineSelector />);
|
||||
expect(await screen.findByText('Tesseract OCR')).toBeInTheDocument();
|
||||
expect(screen.getByText('Typhoon OCR')).toBeInTheDocument();
|
||||
expect(screen.getByText('AI Powered')).toBeInTheDocument();
|
||||
expect(adminAiService.getOcrEngines).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('selects a non-active OCR engine and refreshes list', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<OcrEngineSelector />);
|
||||
await user.click(await screen.findByRole('button', { name: 'สลับใช้งาน' }));
|
||||
await waitFor(() => {
|
||||
expect(adminAiService.selectOcrEngine).toHaveBeenCalledWith('typhoon');
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('เปลี่ยนเอนจิน OCR หลักเป็น Typhoon OCR สำเร็จ');
|
||||
expect(adminAiService.getOcrEngines).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('shows an error toast when loading engines fails', async () => {
|
||||
vi.mocked(adminAiService.getOcrEngines).mockRejectedValue(new Error('API error'));
|
||||
render(<OcrEngineSelector />);
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('ไม่สามารถดึงข้อมูล OCR Engines ได้');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
// File: frontend/components/admin/ai/__tests__/ocr-sandbox-prompt-manager.test.tsx
|
||||
// Change Log:
|
||||
// - 2026-06-14: Add smoke coverage for OcrSandboxPromptManager sandbox/editor paths
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import OcrSandboxPromptManager from '../OcrSandboxPromptManager';
|
||||
import { AiPrompt } from '@/types/ai-prompts';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
toastSuccess: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
refetchVersions: vi.fn(),
|
||||
createVersion: vi.fn(),
|
||||
activateVersion: vi.fn(),
|
||||
deleteVersion: vi.fn(),
|
||||
updateNote: vi.fn(),
|
||||
resetSandbox: vi.fn(),
|
||||
startPolling: vi.fn(),
|
||||
}));
|
||||
|
||||
const prompts: AiPrompt[] = [
|
||||
{
|
||||
promptType: 'ocr_extraction',
|
||||
versionNumber: 2,
|
||||
template: 'Extract {{ocr_text}} with {{master_data_context}}',
|
||||
isActive: true,
|
||||
testResultJson: null,
|
||||
manualNote: null,
|
||||
lastTestedAt: null,
|
||||
activatedAt: '2026-06-01T00:00:00Z',
|
||||
createdAt: '2026-06-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: mocks.toastSuccess,
|
||||
error: mocks.toastError,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-translations', () => ({
|
||||
useTranslations: () => (key: string, params?: Record<string, string>) => {
|
||||
if (key === 'ai.prompt.activeLabel') return `Active: v${params?.version}`;
|
||||
if (key === 'ai.prompt.charCount') return `${params?.count} / 4000 ตัวอักษร`;
|
||||
return key;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-ai-prompts', () => ({
|
||||
useAiPrompts: () => ({
|
||||
versionsQuery: {
|
||||
data: prompts,
|
||||
isSuccess: true,
|
||||
isLoading: false,
|
||||
refetch: mocks.refetchVersions,
|
||||
},
|
||||
createMutation: { mutateAsync: mocks.createVersion, isPending: false },
|
||||
activateMutation: { mutateAsync: mocks.activateVersion, isPending: false },
|
||||
deleteMutation: { mutateAsync: mocks.deleteVersion, isPending: false },
|
||||
updateNoteMutation: { mutateAsync: mocks.updateNote, isPending: false },
|
||||
}),
|
||||
useSandboxRun: () => ({
|
||||
state: {
|
||||
isRunning: false,
|
||||
progress: 0,
|
||||
statusText: '',
|
||||
result: null,
|
||||
},
|
||||
jobId: null,
|
||||
reset: mocks.resetSandbox,
|
||||
startPolling: mocks.startPolling,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-master-data', () => ({
|
||||
useProjects: () => ({
|
||||
data: [
|
||||
{
|
||||
publicId: '019505a1-7c3e-7000-8000-abc123def201',
|
||||
projectCode: 'LCB3',
|
||||
projectName: 'Laem Chabang Phase 3',
|
||||
},
|
||||
],
|
||||
}),
|
||||
useContracts: () => ({
|
||||
data: [
|
||||
{
|
||||
publicId: '019505a1-7c3e-7000-8000-abc123def301',
|
||||
contractCode: 'C01',
|
||||
contractName: 'Marine Works',
|
||||
},
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/admin-ai.service', () => ({
|
||||
adminAiService: {
|
||||
getOcrEngines: vi.fn().mockResolvedValue([
|
||||
{
|
||||
engineType: 'typhoon_ocr',
|
||||
engineName: 'np-dms-ocr',
|
||||
vramRequirementMB: 4096,
|
||||
isCurrentActive: true,
|
||||
},
|
||||
]),
|
||||
getSandboxProfile: vi.fn().mockResolvedValue({
|
||||
temperature: 0.1,
|
||||
topP: 0.2,
|
||||
repeatPenalty: 1.1,
|
||||
maxTokens: 1024,
|
||||
numCtx: 4096,
|
||||
keepAliveSeconds: 0,
|
||||
}),
|
||||
getProductionDefaults: vi.fn().mockResolvedValue({
|
||||
temperature: 0.2,
|
||||
topP: 0.3,
|
||||
repeatPenalty: 1.2,
|
||||
maxTokens: 2048,
|
||||
numCtx: 8192,
|
||||
keepAliveSeconds: 60,
|
||||
}),
|
||||
saveSandboxProfile: vi.fn().mockResolvedValue({
|
||||
temperature: 0.2,
|
||||
topP: 0.3,
|
||||
repeatPenalty: 1.2,
|
||||
maxTokens: 2048,
|
||||
numCtx: 8192,
|
||||
keepAliveSeconds: 60,
|
||||
}),
|
||||
resetSandboxProfile: vi.fn().mockResolvedValue({
|
||||
temperature: 0.1,
|
||||
topP: 0.2,
|
||||
repeatPenalty: 1.1,
|
||||
maxTokens: 1024,
|
||||
numCtx: 4096,
|
||||
keepAliveSeconds: 0,
|
||||
}),
|
||||
applyProfile: vi.fn().mockResolvedValue({ ok: true }),
|
||||
submitSandboxOcr: vi.fn().mockResolvedValue({
|
||||
requestPublicId: '019505a1-7c3e-7000-8000-abc123def401',
|
||||
}),
|
||||
getSandboxJobStatus: vi.fn().mockResolvedValue({
|
||||
status: 'pending',
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../PromptVersionHistory', () => ({
|
||||
default: ({ versions, onLoadTemplate }: { versions: AiPrompt[]; onLoadTemplate: (version: AiPrompt) => void }) => (
|
||||
<div data-testid="prompt-version-history">
|
||||
<span>{versions.length} versions</span>
|
||||
<button type="button" onClick={() => onLoadTemplate(versions[0])}>
|
||||
Load version
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const renderManager = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<OcrSandboxPromptManager />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('OcrSandboxPromptManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal('confirm', vi.fn().mockReturnValue(true));
|
||||
});
|
||||
|
||||
it('ควร render sandbox tab พร้อม project, contract, engine และ history', async () => {
|
||||
renderManager();
|
||||
expect(screen.getByText('ai.prompt.tabSandbox')).toBeInTheDocument();
|
||||
expect(screen.getByText('Step 1: Run OCR Only')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('prompt-version-history')).toHaveTextContent('1 versions');
|
||||
await waitFor(() => expect(screen.getByText(/np-dms-ocr/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('ควรสลับไป editor และบันทึก prompt version ได้', async () => {
|
||||
mocks.createVersion.mockResolvedValueOnce(prompts[0]);
|
||||
renderManager();
|
||||
fireEvent.click(screen.getByText('ai.prompt.tabEditor'));
|
||||
expect(await screen.findByDisplayValue('Extract {{ocr_text}} with {{master_data_context}}')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('ai.prompt.saveVersion'));
|
||||
await waitFor(() => expect(mocks.createVersion).toHaveBeenCalledWith('Extract {{ocr_text}} with {{master_data_context}}'));
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('ai.prompt.saveVersionSuccess');
|
||||
});
|
||||
|
||||
it('ควร load template จาก history เข้า editor', async () => {
|
||||
renderManager();
|
||||
fireEvent.click(screen.getByText('Load version'));
|
||||
expect(await screen.findByDisplayValue('Extract {{ocr_text}} with {{master_data_context}}')).toBeInTheDocument();
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('ai.prompt.loadSuccess');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
// File: frontend/components/admin/ai/__tests__/prompt-version-history.test.tsx
|
||||
// Change Log
|
||||
// - 2026-06-13: Add coverage for prompt version history rendering and actions.
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import PromptVersionHistory from '../PromptVersionHistory';
|
||||
import type { AiPrompt } from '@/types/ai-prompts';
|
||||
|
||||
const versions: AiPrompt[] = [
|
||||
{
|
||||
publicId: '019505a1-7c3e-7000-8000-abc123defa01',
|
||||
promptType: 'metadata_extraction',
|
||||
versionNumber: 3,
|
||||
template: 'active prompt',
|
||||
isActive: true,
|
||||
createdAt: '2026-06-13T08:00:00.000Z',
|
||||
lastTestedAt: '2026-06-13T09:00:00.000Z',
|
||||
manualNote: 'ผ่านการทดสอบกับ RFA แล้ว',
|
||||
},
|
||||
{
|
||||
publicId: '019505a1-7c3e-7000-8000-abc123defa02',
|
||||
promptType: 'metadata_extraction',
|
||||
versionNumber: 2,
|
||||
template: 'draft prompt',
|
||||
isActive: false,
|
||||
createdAt: '2026-06-12T08:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
describe('PromptVersionHistory', () => {
|
||||
it('renders loading and empty states', () => {
|
||||
const callbacks = {
|
||||
onLoadTemplate: vi.fn(),
|
||||
onActivateVersion: vi.fn(),
|
||||
onDeleteVersion: vi.fn(),
|
||||
};
|
||||
const { rerender } = render(
|
||||
<PromptVersionHistory versions={[]} isLoading {...callbacks} isActivating={false} isDeleting={false} />
|
||||
);
|
||||
expect(screen.getByText('กำลังโหลดประวัติเวอร์ชัน...')).toBeInTheDocument();
|
||||
rerender(<PromptVersionHistory versions={[]} isLoading={false} {...callbacks} isActivating={false} isDeleting={false} />);
|
||||
expect(screen.getByText('ไม่พบเวอร์ชันอื่นในระบบ')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders versions and triggers version actions', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onLoadTemplate = vi.fn();
|
||||
const onActivateVersion = vi.fn();
|
||||
const onDeleteVersion = vi.fn();
|
||||
render(
|
||||
<PromptVersionHistory
|
||||
versions={versions}
|
||||
isLoading={false}
|
||||
onLoadTemplate={onLoadTemplate}
|
||||
onActivateVersion={onActivateVersion}
|
||||
onDeleteVersion={onDeleteVersion}
|
||||
isActivating={false}
|
||||
isDeleting={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('v3')).toBeInTheDocument();
|
||||
expect(screen.getByText('ใช้งานจริง (Active)')).toBeInTheDocument();
|
||||
expect(screen.getByText('ผ่านการทดสอบกับ RFA แล้ว')).toBeInTheDocument();
|
||||
await user.click(screen.getAllByRole('button', { name: 'โหลด (Load)' })[1]);
|
||||
await user.click(screen.getByRole('button', { name: 'ใช้งาน (Activate)' }));
|
||||
await user.click(screen.getByRole('button', { name: '' }));
|
||||
expect(onLoadTemplate).toHaveBeenCalledWith(versions[1]);
|
||||
expect(onActivateVersion).toHaveBeenCalledWith(2);
|
||||
expect(onDeleteVersion).toHaveBeenCalledWith(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user