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,122 @@
|
||||
// File: frontend/components/admin/__tests__/organization-dialog.test.tsx
|
||||
// Change Log:
|
||||
// - 2026-06-13: Initial creation - test coverage for OrganizationDialog component
|
||||
// - 2026-06-13: Fix createTestQueryClient — ใช้ wrapper pattern ถูกต้อง
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { OrganizationDialog } from '../organization-dialog';
|
||||
import { createTestQueryClient } from '@/lib/test-utils';
|
||||
|
||||
// Mock hooks
|
||||
vi.mock('@/hooks/use-master-data', () => ({
|
||||
useCreateOrganization: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useUpdateOrganization: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock Dialog component เพื่อให้ทดสอบง่ายขึ้น (Radix UI ใน jsdom)
|
||||
vi.mock('@/components/ui/dialog', () => ({
|
||||
Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
|
||||
open ? <div data-testid="dialog">{children}</div> : null,
|
||||
DialogContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="dialog-content">{children}</div>
|
||||
),
|
||||
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
|
||||
DialogFooter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
function renderWithProvider(ui: React.ReactElement) {
|
||||
const { wrapper: Wrapper } = createTestQueryClient();
|
||||
return render(<Wrapper>{ui}</Wrapper>);
|
||||
}
|
||||
|
||||
const mockOnOpenChange = vi.fn();
|
||||
|
||||
describe('OrganizationDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('ควรไม่เรนเดอร์ Dialog เมื่อ open เป็น false', () => {
|
||||
renderWithProvider(
|
||||
<OrganizationDialog open={false} onOpenChange={mockOnOpenChange} />,
|
||||
);
|
||||
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ควรเรนเดอร์ Dialog เมื่อ open เป็น true', () => {
|
||||
renderWithProvider(
|
||||
<OrganizationDialog open={true} onOpenChange={mockOnOpenChange} />,
|
||||
);
|
||||
expect(screen.getByTestId('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ควรแสดง title "New Organization" เมื่อไม่มี organization prop', () => {
|
||||
renderWithProvider(
|
||||
<OrganizationDialog open={true} onOpenChange={mockOnOpenChange} />,
|
||||
);
|
||||
expect(screen.getByText('New Organization')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ควรแสดง title "Edit Organization" เมื่อมี organization prop', () => {
|
||||
const mockOrg = {
|
||||
publicId: '019505a1-7c3e-7000-8000-abc123def001',
|
||||
organizationCode: 'OWNER',
|
||||
organizationName: 'Test Owner Co., Ltd.',
|
||||
isActive: true,
|
||||
} as any;
|
||||
renderWithProvider(
|
||||
<OrganizationDialog open={true} onOpenChange={mockOnOpenChange} organization={mockOrg} />,
|
||||
);
|
||||
expect(screen.getByText('Edit Organization')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ควรแสดงปุ่ม Cancel และ Create Organization สำหรับ New', () => {
|
||||
renderWithProvider(
|
||||
<OrganizationDialog open={true} onOpenChange={mockOnOpenChange} />,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Create Organization' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ควรแสดงปุ่ม Save Changes สำหรับ Edit', () => {
|
||||
const mockOrg = {
|
||||
publicId: '019505a1-7c3e-7000-8000-abc123def001',
|
||||
organizationCode: 'OWNER',
|
||||
organizationName: 'Test Owner Co., Ltd.',
|
||||
isActive: true,
|
||||
} as any;
|
||||
renderWithProvider(
|
||||
<OrganizationDialog open={true} onOpenChange={mockOnOpenChange} organization={mockOrg} />,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Save Changes' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ควรเรียก onOpenChange(false) เมื่อคลิก Cancel', async () => {
|
||||
renderWithProvider(
|
||||
<OrganizationDialog open={true} onOpenChange={mockOnOpenChange} />,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
await waitFor(() => {
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('ควรแสดง validation error เมื่อ submit form ว่างเปล่า', async () => {
|
||||
renderWithProvider(
|
||||
<OrganizationDialog open={true} onOpenChange={mockOnOpenChange} />,
|
||||
);
|
||||
const form = screen.getByRole('button', { name: 'Create Organization' }).closest('form');
|
||||
if (form) fireEvent.submit(form);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Organization Code is required')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// File: frontend/components/admin/__tests__/sidebar.test.tsx
|
||||
// Change Log
|
||||
// - 2026-06-13: Add coverage for admin sidebar navigation and expansion behavior.
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AdminMobileSidebar, AdminSidebar } from '../sidebar';
|
||||
|
||||
const pathnameMock = vi.fn();
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => pathnameMock(),
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ href, children, onClick, className }: { href: string; children: React.ReactNode; onClick?: () => void; className?: string }) => (
|
||||
<a href={href} onClick={onClick} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('AdminSidebar', () => {
|
||||
beforeEach(() => {
|
||||
pathnameMock.mockReturnValue('/admin/access-control/users');
|
||||
});
|
||||
|
||||
it('auto-expands the active menu and renders child links', () => {
|
||||
render(<AdminSidebar />);
|
||||
expect(screen.getByText('Admin Console')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'ผู้ใช้งาน' })).toHaveAttribute('href', '/admin/access-control/users');
|
||||
expect(screen.getByRole('link', { name: /back to dashboard/i })).toHaveAttribute('href', '/dashboard');
|
||||
});
|
||||
|
||||
it('toggles a collapsed menu on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
pathnameMock.mockReturnValue('/admin/settings');
|
||||
render(<AdminSidebar />);
|
||||
expect(screen.queryByRole('link', { name: 'โครงการ' })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /ตั้งค่าโครงการ/i }));
|
||||
expect(screen.getByRole('link', { name: 'โครงการ' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminMobileSidebar', () => {
|
||||
beforeEach(() => {
|
||||
pathnameMock.mockReturnValue('/admin/settings');
|
||||
});
|
||||
|
||||
it('opens mobile navigation from trigger button', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AdminMobileSidebar />);
|
||||
await user.click(screen.getByRole('button', { name: 'Toggle admin menu' }));
|
||||
expect(screen.getByText('Admin Navigation')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /AI Console/i })).toHaveAttribute('href', '/admin/ai');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
// File: frontend/components/admin/__tests__/user-dialog.test.tsx
|
||||
// Change Log
|
||||
// - 2026-06-13: Add coverage for admin user dialog create and edit 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 { UserDialog } from '../user-dialog';
|
||||
import { useCreateUser, useRoles, useUpdateUser } from '@/hooks/use-users';
|
||||
import { useOrganizations } from '@/hooks/use-master-data';
|
||||
import type { User } from '@/types/user';
|
||||
|
||||
const createMutate = vi.fn();
|
||||
const updateMutate = vi.fn();
|
||||
|
||||
vi.mock('@/hooks/use-users', () => ({
|
||||
useCreateUser: vi.fn(),
|
||||
useUpdateUser: vi.fn(),
|
||||
useRoles: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-master-data', () => ({
|
||||
useOrganizations: vi.fn(),
|
||||
}));
|
||||
|
||||
const existingUser: User = {
|
||||
publicId: '019505a1-7c3e-7000-8000-abc123defb01',
|
||||
username: 'existing',
|
||||
email: 'existing@example.com',
|
||||
firstName: 'Existing',
|
||||
lastName: 'User',
|
||||
isActive: true,
|
||||
lineId: 'line-existing',
|
||||
primaryOrganizationId: '019505a1-7c3e-7000-8000-abc123defb02',
|
||||
roles: [
|
||||
{
|
||||
publicId: '019505a1-7c3e-7000-8000-abc123defb03',
|
||||
roleId: 2,
|
||||
roleName: 'Reviewer',
|
||||
description: 'Reviews documents',
|
||||
},
|
||||
],
|
||||
failedAttempts: 0,
|
||||
};
|
||||
|
||||
function input(name: string): HTMLInputElement {
|
||||
const found = document.body.querySelector(`input[name="${name}"]`);
|
||||
if (!(found instanceof HTMLInputElement)) {
|
||||
throw new Error(`Input not found: ${name}`);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
describe('UserDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useCreateUser).mockReturnValue({
|
||||
mutate: createMutate,
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useCreateUser>);
|
||||
vi.mocked(useUpdateUser).mockReturnValue({
|
||||
mutate: updateMutate,
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useUpdateUser>);
|
||||
vi.mocked(useRoles).mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
publicId: '019505a1-7c3e-7000-8000-abc123defb03',
|
||||
roleId: 2,
|
||||
roleName: 'Reviewer',
|
||||
description: 'Reviews documents',
|
||||
},
|
||||
],
|
||||
} as unknown as ReturnType<typeof useRoles>);
|
||||
vi.mocked(useOrganizations).mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
publicId: '019505a1-7c3e-7000-8000-abc123defb02',
|
||||
organizationCode: 'TEAM',
|
||||
organizationName: 'TEAM Consulting',
|
||||
},
|
||||
],
|
||||
} as unknown as ReturnType<typeof useOrganizations>);
|
||||
});
|
||||
|
||||
it('creates a user with required fields and selected role', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
render(<UserDialog open onOpenChange={onOpenChange} />);
|
||||
await user.type(input('username'), 'newuser');
|
||||
await user.type(input('email'), 'new@example.com');
|
||||
await user.type(input('firstName'), 'New');
|
||||
await user.type(input('lastName'), 'User');
|
||||
await user.type(input('password'), 'secret1');
|
||||
await user.type(input('confirmPassword'), 'secret1');
|
||||
await user.click(screen.getByRole('checkbox', { name: /Reviewer/i }));
|
||||
await user.click(screen.getByRole('button', { name: 'Create User' }));
|
||||
await waitFor(() => {
|
||||
expect(createMutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
username: 'newuser',
|
||||
email: 'new@example.com',
|
||||
firstName: 'New',
|
||||
lastName: 'User',
|
||||
password: 'secret1',
|
||||
roleIds: [2],
|
||||
}),
|
||||
expect.objectContaining({ onSuccess: expect.any(Function) })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('pre-fills existing user and submits update without empty password', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
render(<UserDialog open onOpenChange={onOpenChange} user={existingUser} />);
|
||||
expect(input('username')).toHaveValue('existing');
|
||||
await user.clear(input('firstName'));
|
||||
await user.type(input('firstName'), 'Edited');
|
||||
await user.click(screen.getByRole('checkbox', { name: 'Active User' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Update User' }));
|
||||
await waitFor(() => {
|
||||
expect(updateMutate).toHaveBeenCalledWith(
|
||||
{
|
||||
uuid: existingUser.publicId,
|
||||
data: expect.objectContaining({
|
||||
firstName: 'Edited',
|
||||
isActive: false,
|
||||
}),
|
||||
},
|
||||
expect.objectContaining({ onSuccess: expect.any(Function) })
|
||||
);
|
||||
});
|
||||
expect(updateMutate.mock.calls[0][0].data).not.toHaveProperty('password');
|
||||
});
|
||||
|
||||
it('closes when cancel is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
render(<UserDialog open onOpenChange={onOpenChange} />);
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
// File: frontend/components/admin/reference/__tests__/generic-crud-table.test.tsx
|
||||
// Change Log
|
||||
// - 2026-06-13: Add coverage for generic reference CRUD table states and create mutation.
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { type ColumnDef } from '@tanstack/react-table';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { toast } from 'sonner';
|
||||
import { createTestQueryClient } from '@/lib/test-utils';
|
||||
import { GenericCrudTable } from '../generic-crud-table';
|
||||
|
||||
type ReferenceRow = {
|
||||
id: number;
|
||||
publicId: string;
|
||||
code: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
const rows: ReferenceRow[] = [
|
||||
{
|
||||
id: 1,
|
||||
publicId: '019505a1-7c3e-7000-8000-abc123def701',
|
||||
code: 'DISC',
|
||||
name: 'Discipline',
|
||||
active: true,
|
||||
},
|
||||
];
|
||||
|
||||
const columns: ColumnDef<ReferenceRow>[] = [
|
||||
{ accessorKey: 'code', header: 'Code' },
|
||||
{ accessorKey: 'name', header: 'Name' },
|
||||
];
|
||||
|
||||
function renderTable(overrides?: Partial<React.ComponentProps<typeof GenericCrudTable<ReferenceRow>>>) {
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const props: React.ComponentProps<typeof GenericCrudTable<ReferenceRow>> = {
|
||||
title: 'Reference Data',
|
||||
description: 'Manage reference data',
|
||||
entityName: 'Reference',
|
||||
queryKey: ['reference-test'],
|
||||
fetchFn: vi.fn().mockResolvedValue(rows),
|
||||
createFn: vi.fn().mockResolvedValue({ success: true }),
|
||||
updateFn: vi.fn().mockResolvedValue({ success: true }),
|
||||
deleteFn: vi.fn().mockResolvedValue({ success: true }),
|
||||
columns,
|
||||
fields: [
|
||||
{ name: 'code', label: 'Code', type: 'text', required: true },
|
||||
{ name: 'name', label: 'Name', type: 'textarea' },
|
||||
{ name: 'active', label: 'Active', type: 'checkbox' },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
...render(<GenericCrudTable<ReferenceRow> {...props} />, { wrapper }),
|
||||
props,
|
||||
};
|
||||
}
|
||||
|
||||
describe('GenericCrudTable', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders data rows returned by fetchFn', async () => {
|
||||
renderTable();
|
||||
expect(await screen.findByText('DISC')).toBeInTheDocument();
|
||||
expect(screen.getByText('Reference Data')).toBeInTheDocument();
|
||||
expect(screen.getByText('Discipline')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state for wrapped empty data', async () => {
|
||||
renderTable({ fetchFn: vi.fn().mockResolvedValue({ data: [] }) });
|
||||
expect(await screen.findByText('No data found.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('creates a new item from dialog form', async () => {
|
||||
const user = userEvent.setup();
|
||||
const createFn = vi.fn().mockResolvedValue({ success: true });
|
||||
renderTable({ createFn });
|
||||
await user.click(await screen.findByRole('button', { name: /add reference/i }));
|
||||
await user.type(screen.getByLabelText(/code/i), 'AREA');
|
||||
await user.type(screen.getByLabelText(/name/i), 'Area');
|
||||
await user.click(screen.getByRole('button', { name: /^add reference$/i }));
|
||||
await waitFor(() => {
|
||||
expect(createFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ code: 'AREA', name: 'Area', active: true }),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('Reference created successfully');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
// File: frontend/components/admin/security/__tests__/rbac-matrix.test.tsx
|
||||
// Change Log
|
||||
// - 2026-06-13: Add coverage for RBAC matrix load, toggle, and save behavior.
|
||||
|
||||
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 apiClient from '@/lib/api/client';
|
||||
import { createTestQueryClient } from '@/lib/test-utils';
|
||||
import { RbacMatrix } from '../rbac-matrix';
|
||||
|
||||
const roles = [
|
||||
{
|
||||
publicId: '019505a1-7c3e-7000-8000-abc123def601',
|
||||
roleId: 10,
|
||||
roleName: 'Admin',
|
||||
permissions: [{ permissionId: 1, permissionName: 'system.view', description: 'View system' }],
|
||||
},
|
||||
{
|
||||
publicId: '019505a1-7c3e-7000-8000-abc123def602',
|
||||
roleId: 20,
|
||||
roleName: 'Viewer',
|
||||
permissions: [],
|
||||
},
|
||||
];
|
||||
|
||||
const permissions = [
|
||||
{ permissionId: 1, permissionName: 'system.view', description: 'View system' },
|
||||
{ permissionId: 2, permissionName: 'system.manage', description: 'Manage system' },
|
||||
];
|
||||
|
||||
function renderWithQueryClient() {
|
||||
const { wrapper } = createTestQueryClient();
|
||||
return render(<RbacMatrix />, { wrapper });
|
||||
}
|
||||
|
||||
describe('RbacMatrix', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(apiClient.get).mockImplementation((url: string) => {
|
||||
if (url === '/users/roles') return Promise.resolve({ data: { data: roles } });
|
||||
if (url === '/users/permissions') return Promise.resolve({ data: { data: permissions } });
|
||||
return Promise.resolve({ data: [] });
|
||||
});
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: { success: true } });
|
||||
});
|
||||
|
||||
it('renders roles and permissions from API data', async () => {
|
||||
renderWithQueryClient();
|
||||
expect(await screen.findByText('Admin')).toBeInTheDocument();
|
||||
expect(screen.getByText('Viewer')).toBeInTheDocument();
|
||||
expect(screen.getByText('system.manage')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves pending permission changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithQueryClient();
|
||||
await screen.findByText('system.manage');
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await user.click(checkboxes[3]);
|
||||
await user.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
await waitFor(() => {
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/users/roles/20/permissions', { permissionIds: [2] });
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('Permissions updated successfully');
|
||||
});
|
||||
|
||||
it('renders empty matrix safely when API response is malformed', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: { data: { data: null } } });
|
||||
renderWithQueryClient();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /save changes/i })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user