690617:1443 237 #01.3
CI / CD Pipeline / build (push) Failing after 7m26s
CI / CD Pipeline / deploy (push) Has been skipped

This commit is contained in:
2026-06-17 14:43:30 +07:00
parent 82b41ad5d9
commit db16c95019
42 changed files with 3084 additions and 352 deletions
@@ -249,6 +249,7 @@ export default function ContextConfigEditor({
<SelectContent>
<SelectItem value="th"> (TH)</SelectItem>
<SelectItem value="en">English (EN)</SelectItem>
<SelectItem value="mixed"> + (MIXED)</SelectItem>
</SelectContent>
</Select>
{errors.language && (
@@ -270,6 +271,7 @@ export default function ContextConfigEditor({
<SelectContent>
<SelectItem value="th"> (TH)</SelectItem>
<SelectItem value="en">English (EN)</SelectItem>
<SelectItem value="mixed"> + (MIXED)</SelectItem>
</SelectContent>
</Select>
{errors.outputLanguage && (
+26 -3
View File
@@ -1,6 +1,8 @@
// File: frontend/components/admin/ai/SandboxTabs.tsx
// Change Log:
// - 2026-06-14: Created SandboxTabs component with 3-step testing (OCR -> AI Extract -> RAG Prep) (conforming to task T037)
// - 2026-06-15: ลบ Tesseract ออกจาก OCR Engine dropdown — canonical engines: auto + np-dms-ocr เท่านั้น (ADR-034)
// - 2026-06-15: เพิ่ม read-only prompt info banner แสดง version + template snippet ที่กำลังทดสอบ
import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
@@ -26,6 +28,7 @@ import {
interface SandboxTabsProps {
promptType: string;
selectedVersionNumber?: number;
selectedTemplate?: string;
onActivateVersion?: (versionNumber: number) => void;
}
@@ -53,6 +56,7 @@ interface SandboxJobResult {
export default function SandboxTabs({
promptType: _promptType,
selectedVersionNumber,
selectedTemplate,
onActivateVersion,
}: SandboxTabsProps) {
// Master data state
@@ -215,6 +219,26 @@ export default function SandboxTabs({
</CardDescription>
</CardHeader>
<CardContent className="pt-5 space-y-6">
{/* Prompt info banner — read-only, แสดง version + template snippet ที่กำลังทดสอบ */}
<div className="rounded-lg border border-primary/20 bg-primary/[0.03] px-4 py-3 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[11px] font-semibold text-primary">
(Prompt Under Test)
</span>
{selectedVersionNumber ? (
<span className="font-mono text-[11px] font-bold text-foreground">v{selectedVersionNumber}</span>
) : (
<span className="text-[11px] text-muted-foreground italic"> Active</span>
)}
</div>
{selectedTemplate ? (
<p className="text-[10px] text-muted-foreground font-mono leading-relaxed line-clamp-3 whitespace-pre-wrap select-text">
{selectedTemplate.slice(0, 300)}{selectedTemplate.length > 300 ? '…' : ''}
</p>
) : (
<p className="text-[10px] text-muted-foreground italic"> Version History template</p>
)}
</div>
<div className="flex flex-wrap items-center gap-4 border-b border-border/10 pb-4">
<div className="flex-1 min-w-[200px] space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
@@ -253,9 +277,8 @@ export default function SandboxTabs({
<SelectValue placeholder="เลือกเอนจิน..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto" className="text-xs">Auto (Baseline)</SelectItem>
<SelectItem value="tesseract" className="text-xs">Tesseract (CPU)</SelectItem>
<SelectItem value="np-dms-ocr" className="text-xs">Typhoon OCR (GPU)</SelectItem>
<SelectItem value="auto" className="text-xs">Auto (np-dms-ocr )</SelectItem>
<SelectItem value="np-dms-ocr" className="text-xs">np-dms-ocr (Force GPU)</SelectItem>
</SelectContent>
</Select>
</div>
+49 -56
View File
@@ -3,13 +3,14 @@
// - 2026-06-14: Created VersionHistory component with type filtering and nice badges (conforming to task T017)
// - 2026-06-15: Added All Types view grouped by prompt type (T065)
// - 2026-06-15: Added pagination (20 versions/page) (T075)
// - 2026-06-15: เปลี่ยน button pagination เป็น infinite scroll ตาม spec FR (T075)
import React, { useState } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { CheckCircle2, Trash2, BookOpen, Clock, StickyNote, Folder, ChevronLeft, ChevronRight } from 'lucide-react';
import { CheckCircle2, Trash2, BookOpen, Clock, StickyNote, Folder } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { PromptVersion } from '@/lib/types/ai-prompts';
import { cn } from '@/lib/utils';
@@ -40,17 +41,9 @@ export default function VersionHistory({
showAllTypes = false,
}: VersionHistoryProps) {
const { t } = useTranslation('ai');
const [currentPage, setCurrentPage] = useState(1);
const PAGE_SIZE = 20; // T075: 20 versions per page
if (isLoading) {
return (
<div className="flex h-[300px] items-center justify-center text-sm text-muted-foreground">
<Clock className="mr-2 h-4 w-4 animate-spin text-primary" />
{t('prompt_management.version_history')}...
</div>
);
}
const PAGE_SIZE = 20;
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const sentinelRef = useRef<HTMLDivElement>(null);
// Group versions by prompt type when showing all types
const groupedVersions = showAllTypes
@@ -74,19 +67,40 @@ export default function VersionHistory({
return labels[type] || type;
};
// Pagination logic (T075)
const totalPages = Math.ceil(versions.length / PAGE_SIZE);
const startIndex = (currentPage - 1) * PAGE_SIZE;
const endIndex = startIndex + PAGE_SIZE;
const paginatedVersions = versions.slice(startIndex, endIndex);
// รีเซ็ต visible count เมื่อ versions เปลี่ยน (เช่น เปลี่ยน prompt type)
useEffect(() => {
setVisibleCount(PAGE_SIZE);
}, [versions, PAGE_SIZE]);
const handlePreviousPage = () => {
setCurrentPage((prev) => Math.max(1, prev - 1));
};
// Infinite scroll ด้วย IntersectionObserver
const handleObserver = useCallback(
(entries: IntersectionObserverEntry[]) => {
const target = entries[0];
if (target.isIntersecting && visibleCount < versions.length) {
setVisibleCount((prev) => Math.min(prev + PAGE_SIZE, versions.length));
}
},
[visibleCount, versions.length, PAGE_SIZE]
);
const handleNextPage = () => {
setCurrentPage((prev) => Math.min(totalPages, prev + 1));
};
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(handleObserver, { threshold: 0.1 });
observer.observe(sentinel);
return () => observer.disconnect();
}, [handleObserver]);
const visibleVersions = versions.slice(0, visibleCount);
if (isLoading) {
return (
<div className="flex h-[300px] items-center justify-center text-sm text-muted-foreground">
<Clock className="mr-2 h-4 w-4 animate-spin text-primary" />
{t('prompt_management.version_history')}...
</div>
);
}
return (
<Card className="border border-border/50 bg-background/30 backdrop-blur-md transition-all duration-300 hover:shadow-md">
@@ -102,16 +116,16 @@ export default function VersionHistory({
{t('prompt_management.no_versions')}
</div>
) : showAllTypes && groupedVersions ? (
// Grouped view by prompt type (with pagination applied to each group)
// Grouped view by prompt type — infinite scroll applies across all groups
Object.entries(groupedVersions).map(([promptType, typeVersions]) => {
const paginatedGroupVersions = typeVersions.slice(startIndex, endIndex);
const visibleGroupVersions = typeVersions.slice(0, visibleCount);
return (
<div key={promptType} className="space-y-2">
<div className="flex items-center gap-2 text-xs font-semibold text-foreground/80 bg-muted/30 px-2 py-1.5 rounded">
<Folder className="h-3.5 w-3.5 text-primary" />
{getPromptTypeLabel(promptType)}
</div>
{paginatedGroupVersions.map((version) => {
{visibleGroupVersions.map((version) => {
const isActive = version.isActive === true;
return (
<div
@@ -191,8 +205,8 @@ export default function VersionHistory({
);
})
) : (
// Single type view with pagination (T075)
paginatedVersions.map((version) => {
// Single type view — infinite scroll (T075)
visibleVersions.map((version) => {
const isActive = version.isActive === true;
return (
<div
@@ -269,32 +283,11 @@ export default function VersionHistory({
);
})
)}
{/* Pagination controls (T075) */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-3 border-t border-border/10 mt-3">
<div className="text-[10px] text-muted-foreground">
{currentPage} {totalPages} ({versions.length} )
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={handlePreviousPage}
disabled={currentPage === 1}
className="h-7 px-2 text-[10px] border-border/50 bg-background/50"
>
<ChevronLeft className="h-3 w-3" />
</Button>
<Button
variant="outline"
size="sm"
onClick={handleNextPage}
disabled={currentPage === totalPages}
className="h-7 px-2 text-[10px] border-border/50 bg-background/50"
>
<ChevronRight className="h-3 w-3" />
</Button>
</div>
{/* Sentinel สำหรับ infinite scroll — IntersectionObserver จะโหลดเพิ่มเมื่อ scroll ถึง */}
<div ref={sentinelRef} className="py-1" />
{visibleCount < versions.length && (
<div className="text-center text-[10px] text-muted-foreground py-2">
{visibleCount} {versions.length}
</div>
)}
</CardContent>
@@ -0,0 +1,112 @@
// File: frontend/components/admin/ai/__tests__/ContextConfigEditor.test.tsx
// Change Log:
// - 2026-06-15: Created test file for ContextConfigEditor
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import ContextConfigEditor from '../ContextConfigEditor';
import { projectService } from '@/lib/services/project.service';
import { contractService } from '@/lib/services/contract.service';
// Mock the external services
vi.mock('@/lib/services/project.service', () => ({
projectService: {
getAll: vi.fn(),
},
}));
vi.mock('@/lib/services/contract.service', () => ({
contractService: {
getAll: vi.fn(),
},
}));
// Mock i18n
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
const mockProjects = [
{ publicId: 'proj-1', projectName: 'Project 1' },
{ publicId: 'proj-2', projectName: 'Project 2' },
];
const mockContracts = [
{ publicId: 'contract-1', contractName: 'Contract 1', project: { publicId: 'proj-1' } },
{ publicId: 'contract-2', contractName: 'Contract 2', project: { publicId: 'proj-1' } },
{ publicId: 'contract-3', contractName: 'Contract 3', project: { publicId: 'proj-2' } },
];
describe('ContextConfigEditor', () => {
const mockOnSave = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
(projectService.getAll as any).mockResolvedValue(mockProjects);
(contractService.getAll as any).mockResolvedValue(mockContracts);
});
it('renders correctly with default values when initialConfig is null', async () => {
render(<ContextConfigEditor initialConfig={null} onSave={mockOnSave} isSaving={false} />);
// Wait for services to load
await waitFor(() => {
expect(projectService.getAll).toHaveBeenCalled();
expect(contractService.getAll).toHaveBeenCalled();
});
expect(screen.getByText('การตั้งค่าบริบทข้อมูล (Context Configuration)')).toBeInTheDocument();
// Check default input value
const pageSizeInput = screen.getByRole('spinbutton');
expect(pageSizeInput).toHaveValue(3);
});
it('calls onSave with valid data', async () => {
const user = userEvent.setup();
render(<ContextConfigEditor initialConfig={null} onSave={mockOnSave} isSaving={false} />);
await waitFor(() => {
expect(projectService.getAll).toHaveBeenCalled();
});
const saveButton = screen.getByRole('button', { name: /บันทึกบริบท/i });
await user.click(saveButton);
expect(mockOnSave).toHaveBeenCalledWith({
filter: {
projectId: null,
contractId: null,
},
pageSize: 3,
language: 'th',
outputLanguage: 'th',
});
});
it('validates pageSize input', async () => {
const user = userEvent.setup();
render(<ContextConfigEditor initialConfig={null} onSave={mockOnSave} isSaving={false} />);
const pageSizeInput = screen.getByRole('spinbutton');
// Set invalid value
await user.clear(pageSizeInput);
await user.type(pageSizeInput, '2000');
const saveButton = screen.getByRole('button', { name: /บันทึกบริบท/i });
await user.click(saveButton);
expect(mockOnSave).not.toHaveBeenCalled();
expect(screen.getByText('prompt_management.pageSize_invalid')).toBeInTheDocument();
});
it('displays saving state', () => {
render(<ContextConfigEditor initialConfig={null} onSave={mockOnSave} isSaving={true} />);
expect(screen.getByRole('button', { name: /กำลังบันทึก/i })).toBeDisabled();
});
});
@@ -0,0 +1,131 @@
// File: frontend/components/admin/ai/__tests__/OcrEngineSelector.test.tsx
// Change Log:
// - 2026-06-15: Created test file for OcrEngineSelector
import React from 'react';
import { render, screen, waitFor, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import OcrEngineSelector from '../OcrEngineSelector';
import { adminAiService } from '@/lib/services/admin-ai.service';
import { toast } from 'sonner';
// Mock the services
vi.mock('@/lib/services/admin-ai.service', () => ({
adminAiService: {
getOcrEngines: vi.fn(),
selectOcrEngine: vi.fn(),
},
}));
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
const mockEngines = [
{
engineId: 'engine-1',
engineName: 'Tesseract OCR',
engineType: 'tesseract',
isCurrentActive: true,
concurrentLimit: 4,
vramRequirementMB: 0,
},
{
engineId: 'engine-2',
engineName: 'Typhoon OCR',
engineType: 'typhoon_ocr',
isCurrentActive: false,
concurrentLimit: 1,
vramRequirementMB: 4096,
},
];
describe('OcrEngineSelector', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders loading state initially', () => {
// Return a promise that doesn't resolve immediately to keep it in loading state
(adminAiService.getOcrEngines as any).mockReturnValue(new Promise(() => {}));
const { container } = render(<OcrEngineSelector />);
// Card with animate-pulse
expect(container.querySelector('.animate-pulse')).toBeInTheDocument();
});
it('renders engines list successfully after loading', async () => {
(adminAiService.getOcrEngines as any).mockResolvedValue(mockEngines);
render(<OcrEngineSelector />);
await waitFor(() => {
expect(screen.getByText('ระบบจัดการ OCR Engine')).toBeInTheDocument();
});
expect(screen.getByText('Tesseract OCR')).toBeInTheDocument();
expect(screen.getByText('Typhoon OCR')).toBeInTheDocument();
expect(screen.getByText('กำลังใช้งาน')).toBeInTheDocument(); // Badge for active engine
expect(screen.getByText('AI Powered')).toBeInTheDocument(); // Badge for typhoon
});
it('calls selectOcrEngine and shows success toast when changing engine', async () => {
const user = userEvent.setup();
(adminAiService.getOcrEngines as any).mockResolvedValue(mockEngines);
(adminAiService.selectOcrEngine as any).mockResolvedValue({});
render(<OcrEngineSelector />);
await waitFor(() => {
expect(screen.getByText('ระบบจัดการ OCR Engine')).toBeInTheDocument();
});
// The active engine will have "เลือกอยู่แล้ว", the inactive will have "สลับใช้งาน"
const switchButton = screen.getByRole('button', { name: /สลับใช้งาน/i });
await act(async () => {
await user.click(switchButton);
});
expect(adminAiService.selectOcrEngine).toHaveBeenCalledWith('engine-2');
expect(toast.success).toHaveBeenCalledWith('เปลี่ยนเอนจิน OCR หลักเป็น Typhoon OCR สำเร็จ');
// It should fetch engines again
expect(adminAiService.getOcrEngines).toHaveBeenCalledTimes(2);
});
it('shows error toast if fetching fails', async () => {
(adminAiService.getOcrEngines as any).mockRejectedValue(new Error('Network error'));
render(<OcrEngineSelector />);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('ไม่สามารถดึงข้อมูล OCR Engines ได้');
});
});
it('shows error toast if selecting engine fails', async () => {
const user = userEvent.setup();
(adminAiService.getOcrEngines as any).mockResolvedValue(mockEngines);
(adminAiService.selectOcrEngine as any).mockRejectedValue(new Error('Select error'));
render(<OcrEngineSelector />);
await waitFor(() => {
expect(screen.getByText('ระบบจัดการ OCR Engine')).toBeInTheDocument();
});
const switchButton = screen.getByRole('button', { name: /สลับใช้งาน/i });
await act(async () => {
await user.click(switchButton);
});
expect(adminAiService.selectOcrEngine).toHaveBeenCalledWith('engine-2');
expect(toast.error).toHaveBeenCalledWith('ไม่สามารถเปลี่ยนเอนจิน OCR ได้');
});
});
@@ -0,0 +1,123 @@
// File: frontend/components/admin/ai/__tests__/OcrSandboxPromptManager.test.tsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import OcrSandboxPromptManager from '../OcrSandboxPromptManager';
// Mock Hooks
vi.mock('@/hooks/use-ai-prompts', () => ({
useAiPrompts: vi.fn(() => ({
versionsQuery: { data: [], isSuccess: true, refetch: vi.fn() },
createMutation: { mutateAsync: vi.fn(), isPending: false },
activateMutation: { mutateAsync: vi.fn() },
deleteMutation: { mutateAsync: vi.fn() },
updateNoteMutation: { mutateAsync: vi.fn() },
})),
useSandboxRun: vi.fn(() => ({
state: { isRunning: false },
jobId: null,
reset: vi.fn(),
startPolling: vi.fn(),
})),
}));
vi.mock('@/hooks/use-translations', () => ({
useTranslations: vi.fn(() => (key: string) => key),
}));
vi.mock('@/hooks/use-master-data', () => ({
useProjects: vi.fn(() => ({ data: [{ publicId: 'proj-1', projectCode: 'P1', projectName: 'Project 1' }] })),
useContracts: vi.fn(() => ({ data: [] })),
}));
// Mock React Query
vi.mock('@tanstack/react-query', () => ({
useQuery: vi.fn(() => ({ data: [] })),
}));
// Mock Service
vi.mock('@/lib/services/admin-ai.service', () => ({
adminAiService: {
getOcrEngines: vi.fn().mockResolvedValue([]),
getSandboxProfile: vi.fn().mockResolvedValue({}),
getProductionDefaults: vi.fn().mockResolvedValue({}),
},
}));
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
// We must mock PromptVersionHistory since it might rely on other complex contexts
vi.mock('../PromptVersionHistory', () => ({
default: () => <div data-testid="mock-prompt-version-history" />,
}));
describe('OcrSandboxPromptManager', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders correctly and defaults to sandbox tab', async () => {
const { container } = render(<OcrSandboxPromptManager />);
await waitFor(() => {
expect(screen.getByText('ai.prompt.sandboxCardTitle')).toBeInTheDocument();
});
expect(screen.getByText('ai.prompt.tabSandbox')).toBeInTheDocument();
expect(screen.getByText('ai.prompt.tabEditor')).toBeInTheDocument();
});
it('switches to editor tab', async () => {
const user = userEvent.setup();
render(<OcrSandboxPromptManager />);
await waitFor(() => {
expect(screen.getByText('ai.prompt.tabEditor')).toBeInTheDocument();
});
const editorTab = screen.getByText('ai.prompt.tabEditor');
await user.click(editorTab);
expect(screen.getByText('ai.prompt.cardTitle')).toBeInTheDocument();
});
it('handles rate limiting (429/503) errors gracefully on OCR submission', async () => {
const user = userEvent.setup();
const mockSubmitOcr = vi.fn().mockRejectedValue({
response: { data: { message: 'Rate limit exceeded. Try again later.' } }
});
// Override the mock implementation for this test
const { adminAiService } = await import('@/lib/services/admin-ai.service');
(adminAiService.submitSandboxOcr as any) = mockSubmitOcr;
render(<OcrSandboxPromptManager />);
// Select project
const selects = document.querySelectorAll('select');
const projectSelect = selects[0]; // First select is Project
await userEvent.selectOptions(projectSelect, 'proj-1');
// Simulate file drop/upload
const file = new File(['dummy content'], 'test.pdf', { type: 'application/pdf' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await userEvent.upload(fileInput, file);
const submitButton = screen.getByRole('button', { name: /Step 1: Run OCR Only/i });
await user.click(submitButton);
await waitFor(() => {
expect(mockSubmitOcr).toHaveBeenCalled();
});
// Check if error toast was called
const { toast } = await import('sonner');
expect(toast.error).toHaveBeenCalledWith('Rate limit exceeded. Try again later.');
});
});
@@ -0,0 +1,64 @@
// File: frontend/components/admin/ai/__tests__/PromptEditor.test.tsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import PromptEditor from '../PromptEditor';
// Mock PLACEHOLDER_REQUIREMENTS
vi.mock('@/contracts/frontend-types', () => ({
PLACEHOLDER_REQUIREMENTS: {
ocr_extraction: ['{{ocr_text}}'],
rag_query_prompt: ['{{query}}'],
},
}));
describe('PromptEditor', () => {
const defaultProps = {
promptType: 'ocr_extraction' as const,
initialTemplate: 'Hello {{ocr_text}}',
onSave: vi.fn(),
isSaving: false,
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders correctly with initial template', () => {
render(<PromptEditor {...defaultProps} />);
const textarea = screen.getByPlaceholderText('เขียนพรอมต์ของคุณที่นี่...') as HTMLTextAreaElement;
expect(textarea.value).toBe('Hello {{ocr_text}}');
});
it('disables save button if required placeholders are missing', async () => {
const user = userEvent.setup();
render(<PromptEditor {...defaultProps} initialTemplate="Hello world" />);
// Check missing validation message
expect(screen.getByText(/ต้องมีตัวแปร/i)).toBeInTheDocument();
expect(screen.getByText('{{ocr_text}}')).toBeInTheDocument();
const saveButton = screen.getByRole('button', { name: /บันทึกเวอร์ชันใหม่/i });
expect(saveButton).toBeDisabled();
});
it('enables save button when placeholders are present and calls onSave', async () => {
const user = userEvent.setup();
render(<PromptEditor {...defaultProps} />);
const textarea = screen.getByPlaceholderText('เขียนพรอมต์ของคุณที่นี่...') as HTMLTextAreaElement;
await user.type(textarea, ' is awesome');
const noteInput = screen.getByPlaceholderText(/เช่น ปรับปรุงสัดส่วนความเที่ยงตรง/i);
await user.type(noteInput, 'Test Note');
const saveButton = screen.getByRole('button', { name: /บันทึกเวอร์ชันใหม่/i });
expect(saveButton).not.toBeDisabled();
await user.click(saveButton);
expect(defaultProps.onSave).toHaveBeenCalledWith('Hello {{ocr_text}} is awesome', 'Test Note');
});
});
@@ -0,0 +1,73 @@
// File: frontend/components/admin/ai/__tests__/PromptTypeDropdown.test.tsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import PromptTypeDropdown from '../PromptTypeDropdown';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
// ResizeObserver mock is needed for Radix UI select
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
window.ResizeObserver = ResizeObserver;
describe('PromptTypeDropdown', () => {
beforeEach(() => {
vi.clearAllMocks();
// mock pointer event for Radix UI
window.PointerEvent = MouseEvent as any;
});
it('renders correctly with default options', async () => {
render(<PromptTypeDropdown value="ocr_extraction" onChange={vi.fn()} />);
expect(screen.getByText('prompt_management.prompt_type')).toBeInTheDocument();
const trigger = screen.getByRole('combobox');
expect(trigger).toHaveTextContent('สกัดข้อความ OCR (OCR Extraction)');
});
it('renders all options when showAllOption is true', async () => {
const user = userEvent.setup();
render(<PromptTypeDropdown value="all" onChange={vi.fn()} showAllOption />);
const trigger = screen.getByRole('combobox');
expect(trigger).toHaveTextContent('prompt_management.all_types');
await user.click(trigger);
await waitFor(() => {
expect(screen.getByRole('option', { name: 'prompt_management.all_types' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'สกัดข้อความ OCR (OCR Extraction)' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'ค้นหาข้อมูล RAG (RAG Query)' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'เตรียมข้อมูล RAG (RAG Prep)' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'จำแนกประเภทเอกสาร (Classification)' })).toBeInTheDocument();
});
});
it('calls onChange when an option is selected', async () => {
const user = userEvent.setup();
const onChangeMock = vi.fn();
render(<PromptTypeDropdown value="ocr_extraction" onChange={onChangeMock} />);
const trigger = screen.getByRole('combobox');
await user.click(trigger);
await waitFor(() => {
expect(screen.getByRole('option', { name: 'ค้นหาข้อมูล RAG (RAG Query)' })).toBeInTheDocument();
});
const option = screen.getByRole('option', { name: 'ค้นหาข้อมูล RAG (RAG Query)' });
await user.click(option);
expect(onChangeMock).toHaveBeenCalledWith('rag_query_prompt');
});
});
@@ -0,0 +1,96 @@
// File: frontend/components/admin/ai/__tests__/PromptVersionHistory.test.tsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import PromptVersionHistory from '../PromptVersionHistory';
import { AiPrompt } from '@/types/ai-prompts';
describe('PromptVersionHistory', () => {
const mockVersions: AiPrompt[] = [
{
publicId: 'v1-id',
versionNumber: 1,
promptType: 'ocr_extraction',
template: 'Template 1',
isActive: false,
createdAt: '2026-06-14T10:00:00Z',
updatedAt: '2026-06-14T10:00:00Z',
manualNote: 'Note 1',
},
{
publicId: 'v2-id',
versionNumber: 2,
promptType: 'ocr_extraction',
template: 'Template 2',
isActive: true,
createdAt: '2026-06-15T10:00:00Z',
updatedAt: '2026-06-15T10:00:00Z',
lastTestedAt: '2026-06-15T10:05:00Z',
},
];
const defaultProps = {
versions: mockVersions,
isLoading: false,
onLoadTemplate: vi.fn(),
onActivateVersion: vi.fn(),
onDeleteVersion: vi.fn(),
isActivating: false,
isDeleting: false,
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders loading state', () => {
render(<PromptVersionHistory {...defaultProps} isLoading={true} />);
expect(screen.getByText(/กำลังโหลดประวัติเวอร์ชัน/)).toBeInTheDocument();
});
it('renders empty state', () => {
render(<PromptVersionHistory {...defaultProps} versions={[]} />);
expect(screen.getByText(/ไม่พบเวอร์ชันอื่นในระบบ/)).toBeInTheDocument();
});
it('renders versions correctly', () => {
render(<PromptVersionHistory {...defaultProps} />);
// Check version numbers
expect(screen.getByText('v1')).toBeInTheDocument();
expect(screen.getByText('v2')).toBeInTheDocument();
// Check active/inactive badges
expect(screen.getByText(/ใช้งานจริง \(Active\)/)).toBeInTheDocument();
expect(screen.getByText(/ร่าง \(Inactive\)/)).toBeInTheDocument();
// Check manual note
expect(screen.getByText('Note 1')).toBeInTheDocument();
});
it('calls action handlers when buttons are clicked', async () => {
const user = userEvent.setup();
render(<PromptVersionHistory {...defaultProps} />);
// There are 2 load buttons
const loadButtons = screen.getAllByRole('button', { name: /โหลด \(Load\)/ });
expect(loadButtons).toHaveLength(2);
await user.click(loadButtons[0]);
expect(defaultProps.onLoadTemplate).toHaveBeenCalledWith(mockVersions[0]);
// Active version should not have Activate/Delete buttons
const activateButtons = screen.getAllByRole('button', { name: /ใช้งาน \(Activate\)/ });
expect(activateButtons).toHaveLength(1); // Only for v1
await user.click(activateButtons[0]);
expect(defaultProps.onActivateVersion).toHaveBeenCalledWith(1);
// Delete button (it uses an icon, but we can target by role 'button' and filter or use the trash icon if it had aria-label)
// Actually, delete button is the 3rd button in the v1 row (Load, Activate, Delete)
const deleteButton = screen.getAllByRole('button')[2]; // Load v1, Activate v1, Delete v1
await user.click(deleteButton);
expect(defaultProps.onDeleteVersion).toHaveBeenCalledWith(1);
});
});
@@ -0,0 +1,128 @@
// File: frontend/components/admin/ai/__tests__/RuntimeParametersPanel.test.tsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import RuntimeParametersPanel from '../RuntimeParametersPanel';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
const mockGetSandboxProfile = vi.fn();
const mockSaveSandboxProfile = vi.fn();
const mockResetSandboxProfile = vi.fn();
const mockApplyProfile = vi.fn();
vi.mock('@/lib/services/admin-ai.service', () => ({
adminAiService: {
getSandboxProfile: (...args: any) => mockGetSandboxProfile(...args),
saveSandboxProfile: (...args: any) => mockSaveSandboxProfile(...args),
resetSandboxProfile: (...args: any) => mockResetSandboxProfile(...args),
applyProfile: (...args: any) => mockApplyProfile(...args),
},
}));
vi.mock('uuid', () => ({
v7: () => 'mock-uuid-v7',
}));
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
// ResizeObserver mock is needed for Radix UI select
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
window.ResizeObserver = ResizeObserver;
describe('RuntimeParametersPanel', () => {
const mockParams = {
temperature: 0.7,
topP: 0.9,
repeatPenalty: 1.1,
maxTokens: 2048,
numCtx: 4096,
keepAliveSeconds: 300,
canonicalModel: 'np-dms-ai-test',
};
beforeEach(() => {
vi.clearAllMocks();
mockGetSandboxProfile.mockResolvedValue(mockParams);
window.PointerEvent = MouseEvent as any;
});
it('renders loading state initially', () => {
// We mock promise without resolving immediately to see loading state
let resolvePromise: any;
mockGetSandboxProfile.mockReturnValue(new Promise((resolve) => {
resolvePromise = resolve;
}));
render(<RuntimeParametersPanel />);
expect(screen.getByText(/กำลังโหลดพารามิเตอร์/)).toBeInTheDocument();
// Resolve to avoid act warnings
resolvePromise(mockParams);
});
it('renders parameters after loading', async () => {
render(<RuntimeParametersPanel />);
await waitFor(() => {
expect(screen.getByText('sandbox_test.runtime_parameters')).toBeInTheDocument();
});
expect(screen.getByText('np-dms-ai-test')).toBeInTheDocument();
expect(screen.getByDisplayValue('2048')).toBeInTheDocument();
expect(screen.getByDisplayValue('4096')).toBeInTheDocument();
expect(screen.getByDisplayValue('300')).toBeInTheDocument();
});
it('calls save draft correctly', async () => {
const user = userEvent.setup();
mockSaveSandboxProfile.mockResolvedValue(mockParams);
render(<RuntimeParametersPanel />);
await waitFor(() => {
expect(screen.getByText('sandbox_test.runtime_parameters')).toBeInTheDocument();
});
const saveButton = screen.getByRole('button', { name: /บันทึกแบบร่าง/i });
await user.click(saveButton);
expect(mockSaveSandboxProfile).toHaveBeenCalledWith('standard', mockParams, 'mock-uuid-v7');
const { toast } = await import('sonner');
expect(toast.success).toHaveBeenCalledWith('บันทึกแบบร่าง Sandbox สำเร็จ');
});
it('calls apply to production correctly', async () => {
const user = userEvent.setup();
mockApplyProfile.mockResolvedValue(true);
render(<RuntimeParametersPanel />);
await waitFor(() => {
expect(screen.getByText('sandbox_test.runtime_parameters')).toBeInTheDocument();
});
const applyButton = screen.getByRole('button', { name: /ปรับใช้จริง/i });
await user.click(applyButton);
expect(mockApplyProfile).toHaveBeenCalledWith('standard', 'mock-uuid-v7');
const { toast } = await import('sonner');
expect(toast.success).toHaveBeenCalledWith('ปรับใช้พารามิเตอร์จริงสำเร็จ');
});
});
@@ -0,0 +1,100 @@
// File: frontend/components/admin/ai/__tests__/SandboxTabs.test.tsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import SandboxTabs from '../SandboxTabs';
const mockSubmitSandboxOcr = vi.fn();
const mockSubmitSandboxAiExtract = vi.fn();
const mockSubmitSandboxRagPrep = vi.fn();
const mockGetSandboxJobStatus = vi.fn();
vi.mock('@/lib/services/admin-ai.service', () => ({
adminAiService: {
submitSandboxOcr: (...args: any) => mockSubmitSandboxOcr(...args),
submitSandboxAiExtract: (...args: any) => mockSubmitSandboxAiExtract(...args),
submitSandboxRagPrep: (...args: any) => mockSubmitSandboxRagPrep(...args),
getSandboxJobStatus: (...args: any) => mockGetSandboxJobStatus(...args),
},
}));
vi.mock('@/hooks/use-master-data', () => ({
useProjects: vi.fn(() => ({ data: [{ publicId: 'proj-1', projectCode: 'P1', projectName: 'Project 1' }] })),
useContracts: vi.fn(() => ({ data: [] })),
}));
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
// ResizeObserver mock is needed for Radix UI select
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
window.ResizeObserver = ResizeObserver;
describe('SandboxTabs', () => {
beforeEach(() => {
vi.clearAllMocks();
window.PointerEvent = MouseEvent as any;
});
it('renders correctly', async () => {
render(<SandboxTabs promptType="ocr_extraction" />);
await waitFor(() => {
expect(screen.getByText(/รันบอร์ดทดลองการทำงาน/i)).toBeInTheDocument();
});
expect(screen.getByText('Step 1: Run OCR')).toBeInTheDocument();
expect(screen.getByText('Step 2: AI Extract')).toBeInTheDocument();
expect(screen.getByText('Step 3: RAG Prep')).toBeInTheDocument();
});
it('requires project and file for OCR', async () => {
const user = userEvent.setup();
render(<SandboxTabs promptType="ocr_extraction" />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /เริ่มรัน OCR/i })).toBeDisabled();
});
// Upload file
const file = new File(['dummy content'], 'test.pdf', { type: 'application/pdf' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await userEvent.upload(fileInput, file);
// After uploading file, button is enabled, but submitting will fail without project in AI Extract step.
// Wait, handleRunOcr checks if file exists, it doesn't check project!
expect(screen.getByRole('button', { name: /เริ่มรัน OCR/i })).not.toBeDisabled();
});
it('runs OCR and polls status', async () => {
const user = userEvent.setup();
mockSubmitSandboxOcr.mockResolvedValue({ requestPublicId: 'req-1' });
mockGetSandboxJobStatus.mockResolvedValue({ status: 'completed', ocrText: 'Extracted text from PDF' });
render(<SandboxTabs promptType="ocr_extraction" />);
// Upload file
const file = new File(['dummy content'], 'test.pdf', { type: 'application/pdf' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await userEvent.upload(fileInput, file);
const runBtn = screen.getByRole('button', { name: /เริ่มรัน OCR/i });
await user.click(runBtn);
expect(mockSubmitSandboxOcr).toHaveBeenCalled();
await waitFor(() => {
expect(mockGetSandboxJobStatus).toHaveBeenCalledWith('req-1');
}, { timeout: 3000 }); // Polling interval is 2s
});
});
@@ -0,0 +1,99 @@
// File: frontend/components/admin/ai/__tests__/SandboxTestArea.test.tsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import SandboxTestArea from '../SandboxTestArea';
const mockSubmitSandboxOcr = vi.fn();
const mockSubmitSandboxAiExtract = vi.fn();
const mockSubmitSandboxRagPrep = vi.fn();
const mockGetSandboxJobStatus = vi.fn();
vi.mock('@/lib/services/admin-ai.service', () => ({
adminAiService: {
submitSandboxOcr: (...args: any) => mockSubmitSandboxOcr(...args),
submitSandboxAiExtract: (...args: any) => mockSubmitSandboxAiExtract(...args),
submitSandboxRagPrep: (...args: any) => mockSubmitSandboxRagPrep(...args),
getSandboxJobStatus: (...args: any) => mockGetSandboxJobStatus(...args),
},
}));
vi.mock('@/hooks/use-master-data', () => ({
useProjects: vi.fn(() => ({ data: [{ publicId: 'proj-1', projectCode: 'P1', projectName: 'Project 1' }] })),
useContracts: vi.fn(() => ({ data: [] })),
}));
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
// ResizeObserver mock is needed for Radix UI select
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
window.ResizeObserver = ResizeObserver;
describe('SandboxTestArea', () => {
beforeEach(() => {
vi.clearAllMocks();
window.PointerEvent = MouseEvent as any;
});
it('renders correctly', async () => {
render(<SandboxTestArea promptType="ocr_extraction" />);
await waitFor(() => {
expect(screen.getByText(/รันบอร์ดทดลองการทำงาน/i)).toBeInTheDocument();
});
expect(screen.getByText('Step 1: Run OCR')).toBeInTheDocument();
expect(screen.getByText('Step 2: AI Extract')).toBeInTheDocument();
expect(screen.getByText('Step 3: RAG Prep')).toBeInTheDocument();
});
it('requires project and file for OCR', async () => {
const user = userEvent.setup();
render(<SandboxTestArea promptType="ocr_extraction" />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /เริ่มรัน OCR/i })).toBeDisabled();
});
// Upload file
const file = new File(['dummy content'], 'test.pdf', { type: 'application/pdf' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await userEvent.upload(fileInput, file);
// After uploading file, button is enabled, but submitting will fail without project in AI Extract step.
expect(screen.getByRole('button', { name: /เริ่มรัน OCR/i })).not.toBeDisabled();
});
it('runs OCR and polls status', async () => {
const user = userEvent.setup();
mockSubmitSandboxOcr.mockResolvedValue({ requestPublicId: 'req-1' });
mockGetSandboxJobStatus.mockResolvedValue({ status: 'completed', ocrText: 'Extracted text from PDF' });
render(<SandboxTestArea promptType="ocr_extraction" />);
// Upload file
const file = new File(['dummy content'], 'test.pdf', { type: 'application/pdf' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await userEvent.upload(fileInput, file);
const runBtn = screen.getByRole('button', { name: /เริ่มรัน OCR/i });
await user.click(runBtn);
expect(mockSubmitSandboxOcr).toHaveBeenCalled();
await waitFor(() => {
expect(mockGetSandboxJobStatus).toHaveBeenCalledWith('req-1');
}, { timeout: 3000 }); // Polling interval is 2s
});
});
@@ -0,0 +1,117 @@
// File: frontend/components/admin/ai/__tests__/VersionHistory.test.tsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import VersionHistory from '../VersionHistory';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
describe('VersionHistory', () => {
const mockOnLoadTemplate = vi.fn();
const mockOnActivateVersion = vi.fn();
const mockOnDeleteVersion = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
const generateVersions = (count: number) => {
return Array.from({ length: count }, (_, i) => ({
versionNumber: i + 1,
promptType: 'ocr_extraction',
promptText: 'prompt text',
createdAt: new Date().toISOString(),
isActive: i === 0,
manualNote: `Note ${i + 1}`,
authorName: 'Admin',
}));
};
it('renders loading state', () => {
render(
<VersionHistory
versions={[]}
isLoading={true}
onLoadTemplate={mockOnLoadTemplate}
onActivateVersion={mockOnActivateVersion}
onDeleteVersion={mockOnDeleteVersion}
isActivating={false}
isDeleting={false}
/>
);
expect(screen.getByText(/prompt_management.version_history/i)).toBeInTheDocument();
});
it('renders empty state', () => {
render(
<VersionHistory
versions={[]}
isLoading={false}
onLoadTemplate={mockOnLoadTemplate}
onActivateVersion={mockOnActivateVersion}
onDeleteVersion={mockOnDeleteVersion}
isActivating={false}
isDeleting={false}
/>
);
expect(screen.getByText('prompt_management.no_versions')).toBeInTheDocument();
});
it('renders versions', () => {
const versions = generateVersions(2);
render(
<VersionHistory
versions={versions}
isLoading={false}
onLoadTemplate={mockOnLoadTemplate}
onActivateVersion={mockOnActivateVersion}
onDeleteVersion={mockOnDeleteVersion}
isActivating={false}
isDeleting={false}
/>
);
expect(screen.getByText('v1')).toBeInTheDocument();
expect(screen.getByText('v2')).toBeInTheDocument();
expect(screen.getByText('Note 1')).toBeInTheDocument();
expect(screen.getByText('prompt_management.is_active')).toBeInTheDocument();
});
it('handles pagination', async () => {
const user = userEvent.setup();
const versions = generateVersions(25); // Page size is 20
render(
<VersionHistory
versions={versions}
isLoading={false}
onLoadTemplate={mockOnLoadTemplate}
onActivateVersion={mockOnActivateVersion}
onDeleteVersion={mockOnDeleteVersion}
isActivating={false}
isDeleting={false}
/>
);
// Page 1 should have v1 to v20
expect(screen.getByText('v1')).toBeInTheDocument();
expect(screen.getByText('v20')).toBeInTheDocument();
expect(screen.queryByText('v21')).not.toBeInTheDocument();
// Next page button is the right chevron
const nextBtn = document.querySelector('button .lucide-chevron-right')?.closest('button');
if (nextBtn) {
await user.click(nextBtn);
}
// Page 2 should have v21 to v25
expect(screen.queryByText('v1')).not.toBeInTheDocument();
expect(screen.getByText('v21')).toBeInTheDocument();
expect(screen.getByText('v25')).toBeInTheDocument();
});
});