feat(ai): add ADR-036 unified OCR architecture and frontend test coverage
CI / CD Pipeline / build (push) Failing after 6m24s
CI / CD Pipeline / deploy (push) Has been skipped

- 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:
2026-06-14 06:34:07 +07:00
parent e3503b6a77
commit 7e8f4859cd
108 changed files with 33914 additions and 339 deletions
@@ -3,16 +3,23 @@
// - 2026-05-25: Created OcrSandboxPromptManager component for dynamic prompt editing, version control, and sandbox testing (ADR-029)
// - 2026-05-25: Extracted inline strings to i18n keys via useTranslations() (Obs #1 fix)
// - 2026-05-25: Refactored sandbox polling to useSandboxRun hook (Obs #2 fix)
// - 2026-05-26: เพิ่มการตรวจสอบ versionsQuery.data แบบทนทานเพื่อป้องกัน Error N.find is not a function ในกรณีที่ API ส่งข้อมูลแบบ wrapped object มา
// - 2026-05-26: เพิ่มการตรวจสอบ versionsQuery.data แบบทนทานเพื่อป้องกัน Error N.find is not a function
// - 2026-05-29: เพิ่ม OCR Raw Text section ในผล sandbox
// - 2026-05-29: ปรับปรุงการโหลด Active Prompt ให้ทนทานต่อ race conditions และรูปแบบประเภทข้อมูลที่ส่งมาจาก API (boolean, number, string)
// - 2026-05-29: ปรับปรุงการโหลด Active Prompt ให้ทนทานต่อ race conditions และรูปแบบประเภทข้อมูล
// - 2026-05-30: Refactor เป็น 2-step flow (Step 1: OCR-only → Step 2: AI Extraction) ตาม spec 231
// - 2026-06-02: ปรับปรุงลำดับปุ่มแท็บเริ่มต้นให้เริ่มที่ OCR Sandbox และเปลี่ยน dropdown labels ของตัวเลือกโมเดล Typhoon OCR ให้แสดงหน่วยความจำ VRAM แม่นยำ (T012, T013, ADR-033)
// - 2026-06-04: เปลี่ยน OCR Engine dropdown จาก hardcoded เป็น dynamic โดยดึงจาก getOcrEngines() API และ map engineType → SandboxOcrEngineType
// - 2026-06-04: เพิ่ม UI sliders (temperature/topP/repeatPenalty) สำหรับ typhoon-np-dms-ocr engine; ส่งเป็น optional override ไปยัง sidecar
// - 2026-06-02: ปรับปรุงลำดับปุ่มแท็บเริ่มต้นให้เริ่มที่ OCR Sandbox และเปลี่ยน dropdown labels ของ Typhoon OCR
// - 2026-06-04: เปลี่ยน OCR Engine dropdown จาก hardcoded เป็น dynamic โดยดึงจาก getOcrEngines() API
// - 2026-06-04: เพิ่ม UI sliders (temperature/topP/repeatPenalty) สำหรับ OCR engine
// - 2026-06-13: ADR-036 — เปลี่ยน sandbox OCR engine key เป็น np-dms-ocr
// - 2026-06-13: T030 — เพิ่ม Sandbox Parameter Panel สำหรับ tuning production profile draft
// - 2026-06-13: T044-T045 — เพิ่มปุ่ม Apply to Production และแสดงผลแผงพารามิเตอร์ของระบบ Production แบบอ่านอย่างเดียว
// - 2026-06-13: US4 — เพิ่ม project/contract selectors สำหรับ sandbox context parity
// - 2026-06-13: US5 — เพิ่มลิงก์สลับไปยังหน้าจัดการ Prompt Version (Editor tab) จากส่วนเลือกเวอร์ชันใน Sandbox
// - 2026-06-13: US9 — แก้ไข ESLint errors: ลบ parseInt และแก้ไข unsafe any type casting ของ projects/contracts
'use client';
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -34,10 +41,23 @@ import {
} from 'lucide-react';
import { useAiPrompts, useSandboxRun } from '@/hooks/use-ai-prompts';
import { useTranslations } from '@/hooks/use-translations';
import { useProjects, useContracts } from '@/hooks/use-master-data';
import PromptVersionHistory from './PromptVersionHistory';
import { cn } from '@/lib/utils';
import { AiPrompt } from '@/types/ai-prompts';
import { adminAiService, OcrEngineResponse } from '@/lib/services/admin-ai.service';
import { adminAiService, OcrEngineResponse, SandboxProfileParams } from '@/lib/services/admin-ai.service';
interface SandboxProjectOption {
publicId: string;
projectCode: string;
projectName: string;
}
interface SandboxContractOption {
publicId: string;
contractCode: string;
contractName: string;
}
const DEFAULT_OCR_TEMPLATE = `คุณคือเอนจิ้นสกัดข้อมูลอัจฉริยะ (Document Intelligence Engine)
วิเคราะห์ข้อความ OCR ที่ได้รับจากเอกสารของโครงการ Laem Chabang Port Phase 3 และสกัดข้อมูลเมตาดาต้าให้ออกมาเป็น JSON object ที่ถูกต้องตามโครงสร้างที่กำหนด
@@ -120,6 +140,110 @@ export default function OcrSandboxPromptManager() {
queryFn: () => adminAiService.getOcrEngines(),
staleTime: 60_000,
});
// --- Sandbox Parameter Panel state (T030, ADR-036) ---
const [selectedModel, setSelectedModel] = useState<'np-dms-ai' | 'np-dms-ocr'>('np-dms-ai');
const profileName = selectedModel === 'np-dms-ai' ? 'standard' : 'ocr-extract';
const [sandboxParams, setSandboxParams] = useState<SandboxProfileParams | null>(null);
const [sandboxParamsDraft, setSandboxParamsDraft] = useState<Partial<SandboxProfileParams>>({});
const [isSavingParams, setIsSavingParams] = useState(false);
const [isResettingParams, setIsResettingParams] = useState(false);
const [showParamPanel, setShowParamPanel] = useState(false);
// --- US4 states ---
const [selectedProjectPublicId, setSelectedProjectPublicId] = useState<string>('');
const [selectedContractPublicId, setSelectedContractPublicId] = useState<string>('');
const { data: projectsData } = useProjects();
const projects = Array.isArray(projectsData) ? (projectsData as SandboxProjectOption[]) : [];
const { data: contractsData } = useContracts(selectedProjectPublicId);
const contracts = Array.isArray(contractsData) ? (contractsData as SandboxContractOption[]) : [];
const handleProjectChange = (projectId: string) => {
setSelectedProjectPublicId(projectId);
setSelectedContractPublicId('');
};
// --- Phase 4 apply and production defaults states (T044, T045) ---
const [prodParams, setProdParams] = useState<SandboxProfileParams | null>(null);
const [isApplyingParams, setIsApplyingParams] = useState(false);
const fetchProdParams = useCallback(async () => {
try {
const params = await adminAiService.getProductionDefaults(profileName);
setProdParams(params);
} catch {
// Ignored
}
}, [profileName]);
useEffect(() => {
adminAiService.getSandboxProfile(profileName)
.then((params) => {
setSandboxParams(params);
setSandboxParamsDraft({
temperature: params.temperature,
topP: params.topP,
repeatPenalty: params.repeatPenalty,
maxTokens: params.maxTokens,
numCtx: params.numCtx,
keepAliveSeconds: params.keepAliveSeconds,
});
})
.catch(() => { /* ไม่ต้องแสดง error — อาจเป็น 403 หาก feature ยังไม่เปิด */ });
fetchProdParams();
}, [profileName, fetchProdParams]);
const handleSaveParams = useCallback(async () => {
setIsSavingParams(true);
try {
const key = `sandbox-params-${profileName}-${Date.now()}`;
const updated = await adminAiService.saveSandboxProfile(profileName, sandboxParamsDraft, key);
setSandboxParams(updated);
toast.success('Sandbox parameters saved');
} catch {
toast.error('Failed to save sandbox parameters');
} finally {
setIsSavingParams(false);
}
}, [profileName, sandboxParamsDraft]);
const handleApplyParams = useCallback(async () => {
if (!confirm(`Are you sure you want to apply sandbox draft parameters for ${profileName} to production? This will immediately affect live production jobs.`)) {
return;
}
setIsApplyingParams(true);
try {
const idempotencyKey = `apply-params-${profileName}-${Date.now()}`;
await adminAiService.applyProfile(profileName, idempotencyKey);
toast.success('Parameters successfully applied to production!');
await fetchProdParams();
} catch {
toast.error('Failed to apply parameters to production');
} finally {
setIsApplyingParams(false);
}
}, [profileName, fetchProdParams]);
const handleResetParams = useCallback(async () => {
setIsResettingParams(true);
try {
const reset = await adminAiService.resetSandboxProfile(profileName);
setSandboxParams(reset);
setSandboxParamsDraft({
temperature: reset.temperature,
topP: reset.topP,
repeatPenalty: reset.repeatPenalty,
maxTokens: reset.maxTokens,
numCtx: reset.numCtx,
keepAliveSeconds: reset.keepAliveSeconds,
});
toast.success('Sandbox parameters reset to production values');
} catch {
toast.error('Failed to reset sandbox parameters');
} finally {
setIsResettingParams(false);
}
}, [profileName]);
const ocrEngineOptions = useMemo(() => {
const base = [{ value: 'auto', label: 'Auto (Current Baseline)' }];
if (!ocrEnginesData) return base;
@@ -128,7 +252,7 @@ export default function OcrSandboxPromptManager() {
e.engineType === 'tesseract'
? 'tesseract'
: e.engineType === 'typhoon_ocr'
? 'typhoon-np-dms-ocr'
? 'np-dms-ocr'
: e.engineType;
const vramLabel =
e.vramRequirementMB > 0
@@ -222,6 +346,10 @@ export default function OcrSandboxPromptManager() {
// Step 1: OCR-only handler
const handleStep1Ocr = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedProjectPublicId) {
toast.error('Please select a project first');
return;
}
if (!ocrFile) {
toast.error(t('ai.prompt.noFile'));
return;
@@ -229,7 +357,7 @@ export default function OcrSandboxPromptManager() {
try {
resetSandbox();
setSandboxStep('ocr');
const typhoonOptions = selectedOcrEngine === 'typhoon-np-dms-ocr'
const typhoonOptions = selectedOcrEngine === 'np-dms-ocr'
? { temperature: typhoonTemperature, topP: typhoonTopP, repeatPenalty: typhoonRepeatPenalty }
: undefined;
const { requestPublicId } = await adminAiService.submitSandboxOcr(
@@ -270,6 +398,10 @@ export default function OcrSandboxPromptManager() {
// Step 2: AI Extraction handler
const handleStep2AiExtract = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedProjectPublicId) {
toast.error('Please select a project first');
return;
}
if (!ocrResult) {
toast.error('Please run Step 1 (OCR) first');
return;
@@ -282,7 +414,9 @@ export default function OcrSandboxPromptManager() {
resetSandbox();
const { requestPublicId } = await adminAiService.submitSandboxAiExtract(
ocrResult.requestPublicId,
selectedPromptVersion
selectedPromptVersion,
selectedProjectPublicId,
selectedContractPublicId || undefined
);
toast.success('AI Extraction started');
// เริ่ม polling ผ่าน useSandboxRun hook
@@ -302,6 +436,8 @@ export default function OcrSandboxPromptManager() {
setTyphoonTopP(0.1);
setTyphoonRepeatPenalty(1.1);
setOcrFile(null);
setSelectedProjectPublicId('');
setSelectedContractPublicId('');
resetSandbox();
};
// แปล status key เป็นข้อความตาม locale ปัจจุบัน
@@ -396,10 +532,140 @@ export default function OcrSandboxPromptManager() {
: 'Step 2: Test AI prompt with OCR text'}
</p>
</CardHeader>
<CardContent>
<CardContent className="space-y-4">
{/* Project and Contract Selectors (US4) */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-xs font-semibold text-foreground flex items-center gap-1">
Project <span className="text-destructive">*</span>
</label>
<select
value={selectedProjectPublicId}
onChange={(e) => handleProjectChange(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-xs"
disabled={sandboxState.isRunning}
>
<option value="">-- Select Project --</option>
{projects.map((proj) => (
<option key={proj.publicId} value={proj.publicId}>
{proj.projectCode} - {proj.projectName}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-xs font-semibold text-foreground">
Contract
</label>
<select
value={selectedContractPublicId}
onChange={(e) => setSelectedContractPublicId(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-xs"
disabled={sandboxState.isRunning || !selectedProjectPublicId}
>
<option value="">-- Select Contract (Optional) --</option>
{contracts.map((ctr) => (
<option key={ctr.publicId} value={ctr.publicId}>
{ctr.contractCode} - {ctr.contractName}
</option>
))}
</select>
</div>
</div>
<div className="border-t border-border/10 my-4" />
{sandboxStep === 'ocr' ? (
<form onSubmit={handleStep1Ocr} className="space-y-4">
<div className="space-y-2">
<div className="space-y-4">
{/* --- Sandbox Parameter Panel (T030) --- */}
{sandboxParams && (
<div className="rounded-md border border-border/30 bg-muted/10">
<button
type="button"
onClick={() => setShowParamPanel((v) => !v)}
className="flex w-full items-center justify-between px-3 py-2 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
<span>LLM Sandbox Parameters (production profile draft)</span>
<span className="text-[10px]">{showParamPanel ? '\u25b2' : '\u25bc'}</span>
</button>
{showParamPanel && (
<div className="px-3 pb-3 space-y-3 border-t border-border/20 pt-3">
<div className="space-y-1">
<label className="text-[10px] font-medium text-muted-foreground">Model Profile (T050)</label>
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value as 'np-dms-ai' | 'np-dms-ocr')}
className="w-full rounded border border-input bg-background px-2.5 py-1 text-xs"
>
<option value="np-dms-ai">LLM Engine (np-dms-ai / standard)</option>
<option value="np-dms-ocr">OCR Engine (np-dms-ocr / ocr-extract)</option>
</select>
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div className="space-y-1">
<div className="flex justify-between"><label>Temperature</label><span className="font-mono text-muted-foreground">{((sandboxParamsDraft.temperature ?? sandboxParams?.temperature) ?? 0).toFixed(2)}</span></div>
<input type="range" min={0} max={1} step={0.01} value={(sandboxParamsDraft.temperature ?? sandboxParams?.temperature) ?? 0} onChange={(e) => setSandboxParamsDraft((p) => ({ ...p, temperature: parseFloat(e.target.value) }))} className="w-full h-1.5 accent-primary" />
</div>
<div className="space-y-1">
<div className="flex justify-between"><label>Top-P</label><span className="font-mono text-muted-foreground">{((sandboxParamsDraft.topP ?? sandboxParams?.topP) ?? 0).toFixed(2)}</span></div>
<input type="range" min={0} max={1} step={0.01} value={(sandboxParamsDraft.topP ?? sandboxParams?.topP) ?? 0} onChange={(e) => setSandboxParamsDraft((p) => ({ ...p, topP: parseFloat(e.target.value) }))} className="w-full h-1.5 accent-primary" />
</div>
<div className="space-y-1">
<div className="flex justify-between"><label>Repeat Penalty</label><span className="font-mono text-muted-foreground">{((sandboxParamsDraft.repeatPenalty ?? sandboxParams?.repeatPenalty) ?? 1).toFixed(2)}</span></div>
<input type="range" min={1} max={2} step={0.01} value={(sandboxParamsDraft.repeatPenalty ?? sandboxParams?.repeatPenalty) ?? 1} onChange={(e) => setSandboxParamsDraft((p) => ({ ...p, repeatPenalty: parseFloat(e.target.value) }))} className="w-full h-1.5 accent-primary" />
</div>
<div className="space-y-1">
<div className="flex justify-between"><label>Keep-Alive (s)</label><span className="font-mono text-muted-foreground">{(sandboxParamsDraft.keepAliveSeconds ?? sandboxParams?.keepAliveSeconds) ?? 0}</span></div>
<input type="range" min={0} max={3600} step={60} value={(sandboxParamsDraft.keepAliveSeconds ?? sandboxParams?.keepAliveSeconds) ?? 0} onChange={(e) => setSandboxParamsDraft((p) => ({ ...p, keepAliveSeconds: Number(e.target.value) }))} className="w-full h-1.5 accent-primary" />
</div>
{selectedModel === 'np-dms-ai' && (
<>
<div className="space-y-1">
<div className="flex justify-between"><label>Max Tokens</label><span className="font-mono text-muted-foreground">{(sandboxParamsDraft.maxTokens ?? sandboxParams?.maxTokens) ?? 4096}</span></div>
<input type="range" min={256} max={16384} step={256} value={(sandboxParamsDraft.maxTokens ?? sandboxParams?.maxTokens) ?? 4096} onChange={(e) => setSandboxParamsDraft((p) => ({ ...p, maxTokens: Number(e.target.value) }))} className="w-full h-1.5 accent-primary" />
</div>
<div className="space-y-1">
<div className="flex justify-between"><label>Ctx Size</label><span className="font-mono text-muted-foreground">{(sandboxParamsDraft.numCtx ?? sandboxParams?.numCtx) ?? 8192}</span></div>
<input type="range" min={1024} max={32768} step={1024} value={(sandboxParamsDraft.numCtx ?? sandboxParams?.numCtx) ?? 8192} onChange={(e) => setSandboxParamsDraft((p) => ({ ...p, numCtx: Number(e.target.value) }))} className="w-full h-1.5 accent-primary" />
</div>
</>
)}
</div>
{/* Production Defaults Read-Only Panel (T045) */}
{prodParams && (
<div className="rounded border border-emerald-500/20 bg-emerald-500/5 p-2.5 text-xs space-y-1">
<p className="font-semibold text-emerald-600 dark:text-emerald-400">Current Production Parameters (Read-only)</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-[11px] font-mono text-muted-foreground font-semibold">
<div>Model: {prodParams.canonicalModel}</div>
<div>Temperature: {prodParams.temperature.toFixed(2)}</div>
<div>Top-P: {prodParams.topP.toFixed(2)}</div>
<div>Repeat Penalty: {prodParams.repeatPenalty.toFixed(2)}</div>
<div>Keep-Alive: {prodParams.keepAliveSeconds}s</div>
{prodParams.maxTokens !== null && <div>Max Tokens: {prodParams.maxTokens}</div>}
{prodParams.numCtx !== null && <div>Ctx Size: {prodParams.numCtx}</div>}
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-1 flex-wrap">
<Button type="button" variant="outline" size="sm" disabled={isResettingParams} onClick={handleResetParams} className="text-xs h-7 px-3">
{isResettingParams ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Reset to Production'}
</Button>
<Button type="button" variant="secondary" size="sm" disabled={isSavingParams} onClick={handleSaveParams} className="text-xs h-7 px-3">
{isSavingParams ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Save Draft'}
</Button>
<Button type="button" variant="destructive" size="sm" disabled={isApplyingParams} onClick={handleApplyParams} className="text-xs h-7 px-3 flex items-center gap-1">
{isApplyingParams ? <Loader2 className="h-3 w-3 animate-spin" /> : <Save className="h-3 w-3" />}
Apply to Production
</Button>
</div>
</div>
)}
</div>
)}
<div className="space-y-2">
<label className="text-xs font-medium">OCR Engine</label>
<select
@@ -407,14 +673,12 @@ export default function OcrSandboxPromptManager() {
onChange={(e) => setSelectedOcrEngine(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-xs"
>
{ocrEngineOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
{ocrEngineOptions.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{selectedOcrEngine === 'typhoon-np-dms-ocr' && (
{selectedOcrEngine === 'np-dms-ocr' && (
<div className="space-y-3 rounded-md border border-dashed border-amber-500/30 bg-amber-500/5 p-3">
<p className="text-xs font-medium text-amber-600 dark:text-amber-400">Typhoon OCR Options <span className="font-normal text-muted-foreground">(override Modelfile defaults)</span></p>
<div className="space-y-1">
@@ -516,7 +780,7 @@ export default function OcrSandboxPromptManager() {
<div className="flex justify-end gap-3 pt-2">
<Button
type="submit"
disabled={sandboxState.isRunning || !ocrFile}
disabled={sandboxState.isRunning || !ocrFile || !selectedProjectPublicId}
className="flex items-center gap-2"
>
{sandboxState.isRunning ? (
@@ -537,7 +801,16 @@ export default function OcrSandboxPromptManager() {
<form onSubmit={handleStep2AiExtract} className="space-y-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium">Prompt Version:</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium">Prompt Version:</span>
<button
type="button"
onClick={() => setActiveTab('editor')}
className="text-[10px] text-primary hover:underline font-semibold"
>
(Manage/Edit Prompts)
</button>
</div>
<select
value={selectedPromptVersion ?? (activePrompt?.versionNumber ?? '')}
onChange={(e) => setSelectedPromptVersion(e.target.value ? Number(e.target.value) : undefined)}
@@ -562,7 +835,7 @@ export default function OcrSandboxPromptManager() {
</Button>
<Button
type="submit"
disabled={sandboxState.isRunning || !activePrompt}
disabled={sandboxState.isRunning || !activePrompt || !selectedProjectPublicId}
className="flex items-center gap-2"
>
{sandboxState.isRunning ? (
@@ -591,7 +864,7 @@ export default function OcrSandboxPromptManager() {
OCR Raw Text (Step 1 Result)
</CardTitle>
<Badge variant="outline" className="text-xs">
{ocrResult.engineUsed === 'typhoon-np-dms-ocr'
{ocrResult.engineUsed === 'np-dms-ocr'
? 'np-dms-ocr'
: ocrResult.ocrUsed
? 'Tesseract'
@@ -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);
});
});