690617:1443 237 #01.3
This commit is contained in:
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
// File: frontend/components/dashboard/__tests__/pending-tasks.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PendingTasks } from '../pending-tasks';
|
||||
import { PendingTask } from '@/types/dashboard';
|
||||
|
||||
describe('PendingTasks', () => {
|
||||
const mockTasks: PendingTask[] = [
|
||||
{
|
||||
publicId: 'task-1',
|
||||
title: 'Review RFA-001',
|
||||
description: 'Needs structural review',
|
||||
type: 'rfa_review',
|
||||
dueDate: new Date('2026-06-10T00:00:00Z').toISOString(),
|
||||
daysOverdue: 5,
|
||||
url: '/rfas/task-1'
|
||||
},
|
||||
{
|
||||
publicId: 'task-2',
|
||||
title: 'Approve Transmittal',
|
||||
description: 'Monthly submittals',
|
||||
type: 'transmittal_approval',
|
||||
dueDate: new Date('2026-06-20T00:00:00Z').toISOString(),
|
||||
daysOverdue: 0,
|
||||
url: '/transmittals/task-2'
|
||||
}
|
||||
];
|
||||
|
||||
it('renders loading state when isLoading is true', () => {
|
||||
render(<PendingTasks tasks={[]} isLoading={true} />);
|
||||
|
||||
expect(screen.getByText('Pending Tasks')).toBeInTheDocument();
|
||||
// Check for skeletons (pulse animation)
|
||||
const cardContent = screen.getByText('Pending Tasks').closest('.border');
|
||||
expect(cardContent?.querySelectorAll('.animate-pulse').length).toBe(3);
|
||||
});
|
||||
|
||||
it('renders empty state when no tasks are present', () => {
|
||||
render(<PendingTasks tasks={[]} isLoading={false} />);
|
||||
|
||||
expect(screen.getByText('Pending Tasks')).toBeInTheDocument();
|
||||
expect(screen.getByText('No pending tasks. Good job!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles undefined tasks prop gracefully', () => {
|
||||
render(<PendingTasks tasks={undefined} isLoading={false} />);
|
||||
|
||||
expect(screen.getByText('Pending Tasks')).toBeInTheDocument();
|
||||
expect(screen.getByText('No pending tasks. Good job!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders tasks list correctly', () => {
|
||||
render(<PendingTasks tasks={mockTasks} isLoading={false} />);
|
||||
|
||||
expect(screen.getByText('Review RFA-001')).toBeInTheDocument();
|
||||
expect(screen.getByText('Needs structural review')).toBeInTheDocument();
|
||||
expect(screen.getByText('Approve Transmittal')).toBeInTheDocument();
|
||||
expect(screen.getByText('Monthly submittals')).toBeInTheDocument();
|
||||
|
||||
// Check count badge
|
||||
const countBadge = screen.getByText('2');
|
||||
expect(countBadge).toHaveClass('bg-destructive');
|
||||
});
|
||||
|
||||
it('displays correct badges for overdue and due soon tasks', () => {
|
||||
render(<PendingTasks tasks={mockTasks} isLoading={false} />);
|
||||
|
||||
const overdueBadge = screen.getByText('5d overdue');
|
||||
expect(overdueBadge).toHaveClass('bg-destructive');
|
||||
|
||||
const dueSoonBadge = screen.getByText('Due Soon');
|
||||
expect(dueSoonBadge).toHaveClass('border-yellow-200');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
// File: frontend/components/dashboard/__tests__/quick-actions.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { QuickActions } from '../quick-actions';
|
||||
|
||||
// Mock Next.js Link component
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
||||
<a href={href}>{children}</a>
|
||||
)
|
||||
}));
|
||||
|
||||
describe('QuickActions', () => {
|
||||
it('renders all quick action links correctly', () => {
|
||||
render(<QuickActions />);
|
||||
|
||||
const newRfaLink = screen.getByRole('link', { name: /new rfa/i });
|
||||
expect(newRfaLink).toHaveAttribute('href', '/rfas/new');
|
||||
|
||||
const newCorrespondenceLink = screen.getByRole('link', { name: /new correspondence/i });
|
||||
expect(newCorrespondenceLink).toHaveAttribute('href', '/correspondences/new');
|
||||
|
||||
const uploadDrawingLink = screen.getByRole('link', { name: /upload drawing/i });
|
||||
expect(uploadDrawingLink).toHaveAttribute('href', '/drawings/upload');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
// File: frontend/components/dashboard/__tests__/recent-activity.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RecentActivity } from '../recent-activity';
|
||||
import { ActivityLog } from '@/types/dashboard';
|
||||
|
||||
describe('RecentActivity', () => {
|
||||
const mockActivities: ActivityLog[] = [
|
||||
{
|
||||
id: 'act-1',
|
||||
action: 'Created',
|
||||
description: 'Created new RFA-001',
|
||||
targetUrl: '/rfas/1',
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 5).toISOString(), // 5 minutes ago
|
||||
user: {
|
||||
id: 'u1',
|
||||
name: 'John Doe',
|
||||
initials: 'JD'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'act-2',
|
||||
action: 'Approved',
|
||||
description: 'Approved Transmittal TR-005',
|
||||
targetUrl: '/transmittals/5',
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(), // 2 hours ago
|
||||
user: {
|
||||
id: 'u2',
|
||||
name: 'Jane Smith',
|
||||
initials: 'JS'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
it('renders loading state when isLoading is true', () => {
|
||||
render(<RecentActivity activities={[]} isLoading={true} />);
|
||||
|
||||
expect(screen.getByText('Recent Activity')).toBeInTheDocument();
|
||||
// Check for skeletons (pulse animation)
|
||||
const cardContent = screen.getByText('Recent Activity').closest('.border');
|
||||
expect(cardContent?.querySelectorAll('.animate-pulse').length).toBe(3);
|
||||
});
|
||||
|
||||
it('renders empty state when no activities are present', () => {
|
||||
render(<RecentActivity activities={[]} isLoading={false} />);
|
||||
|
||||
expect(screen.getByText('Recent Activity')).toBeInTheDocument();
|
||||
expect(screen.getByText('No recent activity.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles undefined activities prop gracefully', () => {
|
||||
render(<RecentActivity activities={undefined} isLoading={false} />);
|
||||
|
||||
expect(screen.getByText('Recent Activity')).toBeInTheDocument();
|
||||
expect(screen.getByText('No recent activity.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders activities list correctly', () => {
|
||||
render(<RecentActivity activities={mockActivities} isLoading={false} />);
|
||||
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
expect(screen.getByText('JD')).toBeInTheDocument();
|
||||
expect(screen.getByText('Created')).toBeInTheDocument();
|
||||
expect(screen.getByText('Created new RFA-001')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
|
||||
expect(screen.getByText('JS')).toBeInTheDocument();
|
||||
expect(screen.getByText('Approved')).toBeInTheDocument();
|
||||
expect(screen.getByText('Approved Transmittal TR-005')).toBeInTheDocument();
|
||||
|
||||
// date-fns formatDistanceToNow will output text like '5 minutes ago', 'about 2 hours ago'
|
||||
// We can just verify some part of it or that it renders without error.
|
||||
const createdLink = screen.getByText('Created new RFA-001').closest('a');
|
||||
expect(createdLink).toHaveAttribute('href', '/rfas/1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
// File: frontend/components/dashboard/__tests__/stats-cards.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { StatsCards } from '../stats-cards';
|
||||
import { DashboardStats } from '@/types/dashboard';
|
||||
|
||||
describe('StatsCards', () => {
|
||||
const mockStats: DashboardStats = {
|
||||
totalDocuments: 150,
|
||||
totalRfas: 45,
|
||||
approved: 120,
|
||||
pendingApprovals: 15
|
||||
};
|
||||
|
||||
it('renders loading state when isLoading is true', () => {
|
||||
// using container to query raw DOM for animate-pulse since skeletons don't have text
|
||||
const { container } = render(<StatsCards stats={mockStats} isLoading={true} />);
|
||||
const pulses = container.querySelectorAll('.animate-pulse');
|
||||
expect(pulses.length).toBe(4);
|
||||
});
|
||||
|
||||
it('renders loading state when stats is undefined', () => {
|
||||
const { container } = render(<StatsCards stats={undefined} isLoading={false} />);
|
||||
const pulses = container.querySelectorAll('.animate-pulse');
|
||||
expect(pulses.length).toBe(4);
|
||||
});
|
||||
|
||||
it('renders stats cards correctly with values', () => {
|
||||
render(<StatsCards stats={mockStats} isLoading={false} />);
|
||||
|
||||
expect(screen.getByText('Total Correspondences')).toBeInTheDocument();
|
||||
expect(screen.getByText('150')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Active RFAs')).toBeInTheDocument();
|
||||
expect(screen.getByText('45')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Approved Documents')).toBeInTheDocument();
|
||||
expect(screen.getByText('120')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Pending Approvals')).toBeInTheDocument();
|
||||
expect(screen.getByText('15')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
// File: frontend/components/documents/common/__tests__/server-data-table.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 { ServerDataTable } from '../server-data-table';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
type TestData = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const columns: ColumnDef<TestData>[] = [
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: 'ID',
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
},
|
||||
];
|
||||
|
||||
const mockData: TestData[] = [
|
||||
{ id: '1', name: 'Item 1' },
|
||||
{ id: '2', name: 'Item 2' },
|
||||
];
|
||||
|
||||
describe('ServerDataTable', () => {
|
||||
const onPaginationChange = vi.fn();
|
||||
const onSortingChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders loading state', () => {
|
||||
render(
|
||||
<ServerDataTable
|
||||
columns={columns}
|
||||
data={[]}
|
||||
pageCount={1}
|
||||
pagination={{ pageIndex: 0, pageSize: 10 }}
|
||||
onPaginationChange={onPaginationChange}
|
||||
sorting={[]}
|
||||
onSortingChange={onSortingChange}
|
||||
isLoading={true}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state', () => {
|
||||
render(
|
||||
<ServerDataTable
|
||||
columns={columns}
|
||||
data={[]}
|
||||
pageCount={0}
|
||||
pagination={{ pageIndex: 0, pageSize: 10 }}
|
||||
onPaginationChange={onPaginationChange}
|
||||
sorting={[]}
|
||||
onSortingChange={onSortingChange}
|
||||
isLoading={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('No results.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders data rows', () => {
|
||||
render(
|
||||
<ServerDataTable
|
||||
columns={columns}
|
||||
data={mockData}
|
||||
pageCount={1}
|
||||
pagination={{ pageIndex: 0, pageSize: 10 }}
|
||||
onPaginationChange={onPaginationChange}
|
||||
sorting={[]}
|
||||
onSortingChange={onSortingChange}
|
||||
isLoading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Item 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Page 1 of 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles pagination controls', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ServerDataTable
|
||||
columns={columns}
|
||||
data={mockData}
|
||||
pageCount={3} // Multiple pages
|
||||
pagination={{ pageIndex: 1, pageSize: 10 }} // Currently on page 2 (index 1)
|
||||
onPaginationChange={onPaginationChange}
|
||||
sorting={[]}
|
||||
onSortingChange={onSortingChange}
|
||||
isLoading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Page 2 of 3')).toBeInTheDocument();
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: /Go to next page/i });
|
||||
const prevButton = screen.getByRole('button', { name: /Go to previous page/i });
|
||||
const firstButton = screen.getByRole('button', { name: /Go to first page/i });
|
||||
const lastButton = screen.getByRole('button', { name: /Go to last page/i });
|
||||
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
expect(prevButton).not.toBeDisabled();
|
||||
|
||||
await user.click(nextButton);
|
||||
expect(onPaginationChange).toHaveBeenCalled();
|
||||
|
||||
await user.click(prevButton);
|
||||
expect(onPaginationChange).toHaveBeenCalledTimes(2);
|
||||
|
||||
await user.click(firstButton);
|
||||
expect(onPaginationChange).toHaveBeenCalledTimes(3);
|
||||
|
||||
await user.click(lastButton);
|
||||
expect(onPaginationChange).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('handles page size change', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ServerDataTable
|
||||
columns={columns}
|
||||
data={mockData}
|
||||
pageCount={1}
|
||||
pagination={{ pageIndex: 0, pageSize: 10 }}
|
||||
onPaginationChange={onPaginationChange}
|
||||
sorting={[]}
|
||||
onSortingChange={onSortingChange}
|
||||
isLoading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
// The SelectTrigger for page size has placeholder or value. We can find it by role 'combobox'
|
||||
const selectTrigger = screen.getByRole('combobox');
|
||||
await user.click(selectTrigger);
|
||||
|
||||
// Select option 20
|
||||
const option20 = screen.getByRole('option', { name: '20' });
|
||||
await user.click(option20);
|
||||
|
||||
// setPageSize triggers onPaginationChange with the new page size
|
||||
expect(onPaginationChange).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
// File: frontend/components/migration/__tests__/review-queue-table.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ReviewQueueTable } from '../review-queue-table';
|
||||
import { MigrationReviewStatus } from '@/types/migration';
|
||||
|
||||
// Mock hooks
|
||||
const mockMutateAsyncCommit = vi.fn();
|
||||
const mockMutateAsyncReject = vi.fn();
|
||||
|
||||
vi.mock('@/hooks/use-migration-review', () => ({
|
||||
useCommitMigrationReview: () => ({
|
||||
mutateAsync: mockMutateAsyncCommit,
|
||||
isPending: false
|
||||
}),
|
||||
useRejectMigrationReview: () => ({
|
||||
mutateAsync: mockMutateAsyncReject,
|
||||
isPending: false
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-master-data', () => ({
|
||||
useProjects: () => ({
|
||||
data: [
|
||||
{ publicId: 'proj-1', projectName: 'Project A', projectCode: 'PA' }
|
||||
]
|
||||
}),
|
||||
useOrganizations: () => ({
|
||||
data: [
|
||||
{ publicId: 'org-1', organizationName: 'Org A' }
|
||||
]
|
||||
})
|
||||
}));
|
||||
|
||||
// Mock ResizeObserver for Radix UI
|
||||
class ResizeObserverMock {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
global.ResizeObserver = ResizeObserverMock;
|
||||
|
||||
describe('ReviewQueueTable', () => {
|
||||
const mockItems: any[] = [
|
||||
{
|
||||
id: 1,
|
||||
publicId: 'mig-1',
|
||||
documentNumber: 'DOC-001',
|
||||
subject: 'Test Migration Doc',
|
||||
aiSuggestedCategory: 'RFA',
|
||||
aiConfidence: 0.95,
|
||||
status: MigrationReviewStatus.PENDING,
|
||||
projectId: 'proj-1',
|
||||
senderOrganizationId: 'org-1',
|
||||
receiverOrganizationId: 'org-2',
|
||||
issuedDate: '2026-06-01T00:00:00.000Z',
|
||||
receivedDate: '2026-06-02T00:00:00.000Z',
|
||||
body: 'Migration test body',
|
||||
extractedTags: [{ name: 'Urgent', is_new: false }],
|
||||
aiIssues: [{ message: 'Confidence is slightly low on receiver' }]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
publicId: 'mig-2',
|
||||
documentNumber: 'DOC-002',
|
||||
subject: 'Test Migration Doc 2',
|
||||
aiSuggestedCategory: 'Correspondence',
|
||||
aiConfidence: 0.85,
|
||||
status: MigrationReviewStatus.IMPORTED,
|
||||
}
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Mock window.confirm
|
||||
vi.spyOn(window, 'confirm').mockImplementation(() => true);
|
||||
// Mock scrollIntoView for Radix components
|
||||
window.HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
|
||||
it('renders loading state', () => {
|
||||
render(<ReviewQueueTable items={[]} isLoading={true} />);
|
||||
expect(screen.getByText('กำลังโหลดรายการรอรีวิว...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state', () => {
|
||||
render(<ReviewQueueTable items={[]} isLoading={false} />);
|
||||
expect(screen.getByText('ไม่พบรายการที่รอตรวจสอบในคิวขณะนี้')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders queue items', () => {
|
||||
render(<ReviewQueueTable items={mockItems} isLoading={false} />);
|
||||
expect(screen.getByText('DOC-001')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Migration Doc')).toBeInTheDocument();
|
||||
expect(screen.getByText('95.0%')).toBeInTheDocument();
|
||||
expect(screen.getByText('รอตรวจสอบ')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('DOC-002')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Migration Doc 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('85.0%')).toBeInTheDocument();
|
||||
expect(screen.getByText('นำเข้าแล้ว')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens sheet when review button is clicked', async () => {
|
||||
render(<ReviewQueueTable items={mockItems} isLoading={false} />);
|
||||
|
||||
const reviewButtons = screen.getAllByRole('button', { name: /รีวิว|ดูรายละเอียด/i });
|
||||
// First button is for 'รอตรวจสอบ' (PENDING)
|
||||
fireEvent.click(reviewButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('รีวิวการย้ายข้อมูลเอกสาร')).toBeInTheDocument();
|
||||
// Should show the document number in a badge
|
||||
expect(screen.getAllByText('DOC-001').length).toBeGreaterThan(0);
|
||||
// Should show AI issues
|
||||
expect(screen.getByText('Confidence is slightly low on receiver')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('allows editing subject and other fields', async () => {
|
||||
render(<ReviewQueueTable items={mockItems} isLoading={false} />);
|
||||
|
||||
const reviewButtons = screen.getAllByRole('button', { name: /รีวิว|ดูรายละเอียด/i });
|
||||
fireEvent.click(reviewButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name: /หัวข้อเรื่อง/i })).toHaveValue('Test Migration Doc');
|
||||
});
|
||||
|
||||
const subjectInput = screen.getByRole('textbox', { name: /หัวข้อเรื่อง/i });
|
||||
fireEvent.change(subjectInput, { target: { value: 'Updated Subject' } });
|
||||
expect(subjectInput).toHaveValue('Updated Subject');
|
||||
|
||||
const bodyInput = screen.getByRole('textbox', { name: /เนื้อหาสรุปจดหมาย/i });
|
||||
fireEvent.change(bodyInput, { target: { value: 'Updated Body' } });
|
||||
expect(bodyInput).toHaveValue('Updated Body');
|
||||
|
||||
const issuedDateInput = screen.getByLabelText(/วันที่ออกเอกสาร/i);
|
||||
fireEvent.change(issuedDateInput, { target: { value: '2026-06-10' } });
|
||||
expect(issuedDateInput).toHaveValue('2026-06-10');
|
||||
|
||||
const receivedDateInput = screen.getByLabelText(/วันที่ลงรับเอกสาร/i);
|
||||
fireEvent.change(receivedDateInput, { target: { value: '2026-06-11' } });
|
||||
expect(receivedDateInput).toHaveValue('2026-06-11');
|
||||
});
|
||||
|
||||
it('allows adding and removing tags', async () => {
|
||||
render(<ReviewQueueTable items={mockItems} isLoading={false} />);
|
||||
|
||||
const reviewButtons = screen.getAllByRole('button', { name: /รีวิว|ดูรายละเอียด/i });
|
||||
fireEvent.click(reviewButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
// Urgent is already there
|
||||
expect(screen.getByText('Urgent')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Add new tag with Enter key
|
||||
const addTagInput = screen.getByPlaceholderText('เพิ่มแท็กภาษาไทย...');
|
||||
fireEvent.change(addTagInput, { target: { value: 'NewTag' } });
|
||||
fireEvent.keyDown(addTagInput, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('NewTag')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Add another tag with button
|
||||
fireEvent.change(addTagInput, { target: { value: 'AnotherTag' } });
|
||||
const addButton = screen.getByRole('button', { name: /เพิ่ม/i });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('AnotherTag')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Remove Urgent tag
|
||||
// The tag badge contains 'Urgent' and an 'X' button
|
||||
const removeButtons = screen.getAllByRole('button', { name: '' });
|
||||
// The first X button inside a badge should be the one for 'Urgent' (assuming it's the only icon button without a distinct name there)
|
||||
// Actually, Lucide icon doesn't have a label by default, let's find the button by its parent
|
||||
const urgentTag = screen.getByText('Urgent');
|
||||
const removeUrgentButton = urgentTag.nextElementSibling;
|
||||
if (removeUrgentButton) {
|
||||
fireEvent.click(removeUrgentButton);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Urgent')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls commit mutation on commit', async () => {
|
||||
render(<ReviewQueueTable items={mockItems} isLoading={false} />);
|
||||
|
||||
const reviewButtons = screen.getAllByRole('button', { name: /รีวิว|ดูรายละเอียด/i });
|
||||
fireEvent.click(reviewButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /กดยอมรับการนำเข้า/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const commitButton = screen.getByRole('button', { name: /กดยอมรับการนำเข้า/i });
|
||||
fireEvent.click(commitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsyncCommit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
publicId: 'mig-1',
|
||||
subject: 'Test Migration Doc',
|
||||
category: 'RFA',
|
||||
projectId: 'proj-1',
|
||||
senderId: 'org-1',
|
||||
receiverId: 'org-2',
|
||||
issuedDate: '2026-06-01',
|
||||
receivedDate: '2026-06-02',
|
||||
body: 'Migration test body',
|
||||
tags: ['Urgent'],
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it('calls reject mutation on reject', async () => {
|
||||
render(<ReviewQueueTable items={mockItems} isLoading={false} />);
|
||||
|
||||
const reviewButtons = screen.getAllByRole('button', { name: /รีวิว|ดูรายละเอียด/i });
|
||||
fireEvent.click(reviewButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /ปฏิเสธการนำเข้า/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const rejectButton = screen.getByRole('button', { name: /ปฏิเสธการนำเข้า/i });
|
||||
fireEvent.click(rejectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.confirm).toHaveBeenCalled();
|
||||
expect(mockMutateAsyncReject).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('closes sheet when cancel is clicked', async () => {
|
||||
render(<ReviewQueueTable items={mockItems} isLoading={false} />);
|
||||
|
||||
const reviewButtons = screen.getAllByRole('button', { name: /รีวิว|ดูรายละเอียด/i });
|
||||
fireEvent.click(reviewButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /ยกเลิก/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const cancelButton = screen.getByRole('button', { name: /ยกเลิก/i });
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
// Wait for the sheet to be removed or hidden
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('รีวิวการย้ายข้อมูลเอกสาร')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
// File: frontend/components/numbering/__tests__/audit-logs-table.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { AuditLogsTable } from '../audit-logs-table';
|
||||
import { documentNumberingService } from '@/lib/services/document-numbering.service';
|
||||
|
||||
vi.mock('@/lib/services/document-numbering.service', () => ({
|
||||
documentNumberingService: {
|
||||
getMetrics: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('AuditLogsTable', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders loading state initially', () => {
|
||||
vi.mocked(documentNumberingService.getMetrics).mockImplementation(() => new Promise(() => {}));
|
||||
render(<AuditLogsTable />);
|
||||
expect(screen.getByText('Loading logs...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state when no logs returned', async () => {
|
||||
vi.mocked(documentNumberingService.getMetrics).mockResolvedValue({ audit: [] } as any);
|
||||
render(<AuditLogsTable />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No logs found.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders error state silently as empty state when API fails', async () => {
|
||||
vi.mocked(documentNumberingService.getMetrics).mockRejectedValue(new Error('API failed'));
|
||||
render(<AuditLogsTable />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No logs found.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders logs correctly', async () => {
|
||||
const mockLogs = [
|
||||
{
|
||||
id: 1,
|
||||
createdAt: '2023-10-27T10:00:00Z',
|
||||
operation: 'GENERATE',
|
||||
documentNumber: 'DOC-001',
|
||||
createdBy: 'UserA',
|
||||
status: 'SUCCESS',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
createdAt: '2023-10-27T11:00:00Z',
|
||||
operation: 'VOID',
|
||||
documentNumber: 'DOC-002',
|
||||
createdBy: null,
|
||||
status: 'FAILED',
|
||||
},
|
||||
];
|
||||
vi.mocked(documentNumberingService.getMetrics).mockResolvedValue({ audit: mockLogs } as any);
|
||||
|
||||
render(<AuditLogsTable />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('DOC-001')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('GENERATE')).toBeInTheDocument();
|
||||
expect(screen.getByText('UserA')).toBeInTheDocument();
|
||||
expect(screen.getByText('SUCCESS')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('DOC-002')).toBeInTheDocument();
|
||||
expect(screen.getByText('VOID')).toBeInTheDocument();
|
||||
expect(screen.getByText('System')).toBeInTheDocument(); // Falls back to System
|
||||
expect(screen.getByText('FAILED')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
// File: frontend/components/numbering/__tests__/bulk-import-form.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 { BulkImportForm } from '../bulk-import-form';
|
||||
import { documentNumberingService } from '@/lib/services/document-numbering.service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
vi.mock('@/lib/services/document-numbering.service', () => ({
|
||||
documentNumberingService: {
|
||||
bulkImport: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('BulkImportForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders correctly', () => {
|
||||
render(<BulkImportForm projectId={1} />);
|
||||
expect(screen.getByText('Bulk Import Numbers')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('CSV File')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Upload & Import' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables submit button when file is selected and handles successful upload', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BulkImportForm projectId={1} />);
|
||||
|
||||
const file = new File(['test'], 'test.csv', { type: 'text/csv' });
|
||||
const input = screen.getByLabelText('CSV File') as HTMLInputElement;
|
||||
|
||||
await user.upload(input, file);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Upload & Import' });
|
||||
expect(button).not.toBeDisabled();
|
||||
|
||||
vi.mocked(documentNumberingService.bulkImport).mockResolvedValue({} as any);
|
||||
|
||||
await user.click(button);
|
||||
|
||||
expect(documentNumberingService.bulkImport).toHaveBeenCalledWith(expect.any(FormData));
|
||||
const formDataArg = vi.mocked(documentNumberingService.bulkImport).mock.calls[0][0] as FormData;
|
||||
expect(formDataArg.get('file')).toBe(file);
|
||||
expect(formDataArg.get('projectId')).toBe('1');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith('Bulk import initiated. Check audit logs for progress.');
|
||||
});
|
||||
|
||||
// File input reset means button is disabled again
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it('handles upload failure', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BulkImportForm projectId={1} />);
|
||||
|
||||
const file = new File(['test'], 'test.csv', { type: 'text/csv' });
|
||||
const input = screen.getByLabelText('CSV File') as HTMLInputElement;
|
||||
|
||||
await user.upload(input, file);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Upload & Import' });
|
||||
|
||||
vi.mocked(documentNumberingService.bulkImport).mockRejectedValue(new Error('Failed'));
|
||||
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to import numbers.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
// File: frontend/components/numbering/__tests__/cancel-number-form.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 { CancelNumberForm } from '../cancel-number-form';
|
||||
import { documentNumberingService } from '@/lib/services/document-numbering.service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
vi.mock('@/lib/services/document-numbering.service', () => ({
|
||||
documentNumberingService: {
|
||||
cancelNumber: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('CancelNumberForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders correctly', () => {
|
||||
render(<CancelNumberForm />);
|
||||
expect(screen.getByRole('heading', { name: 'Cancel Number' })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Document Number')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Reason')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Cancel Number' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows validation error for empty fields', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CancelNumberForm />);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Cancel Number' });
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Document Number is required')).toBeInTheDocument();
|
||||
expect(screen.getByText('Reason must be at least 5 characters')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(documentNumberingService.cancelNumber).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows validation error for short reason', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CancelNumberForm />);
|
||||
|
||||
await user.type(screen.getByLabelText('Document Number'), 'DOC-001');
|
||||
await user.type(screen.getByLabelText('Reason'), 'abc'); // too short
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Cancel Number' });
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Reason must be at least 5 characters')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(documentNumberingService.cancelNumber).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles successful cancellation', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CancelNumberForm />);
|
||||
|
||||
await user.type(screen.getByLabelText('Document Number'), 'DOC-001');
|
||||
await user.type(screen.getByLabelText('Reason'), 'Generated by mistake');
|
||||
|
||||
vi.mocked(documentNumberingService.cancelNumber).mockResolvedValue({} as any);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Cancel Number' });
|
||||
await user.click(button);
|
||||
|
||||
expect(documentNumberingService.cancelNumber).toHaveBeenCalledWith({
|
||||
documentNumber: 'DOC-001',
|
||||
reason: 'Generated by mistake',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith('Number cancelled successfully.');
|
||||
});
|
||||
|
||||
// Check if form was reset
|
||||
expect(screen.getByLabelText('Document Number')).toHaveValue('');
|
||||
expect(screen.getByLabelText('Reason')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('handles cancellation failure', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CancelNumberForm />);
|
||||
|
||||
await user.type(screen.getByLabelText('Document Number'), 'DOC-001');
|
||||
await user.type(screen.getByLabelText('Reason'), 'Generated by mistake');
|
||||
|
||||
vi.mocked(documentNumberingService.cancelNumber).mockRejectedValue(new Error('Failed'));
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Cancel Number' });
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to cancel number. It may not exist or is already cancelled.');
|
||||
});
|
||||
|
||||
// Form is not reset on error
|
||||
expect(screen.getByLabelText('Document Number')).toHaveValue('DOC-001');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
// File: frontend/components/numbering/__tests__/template-editor.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { TemplateEditor } from '../template-editor';
|
||||
import { CorrespondenceType, Discipline } from '@/types/master-data';
|
||||
|
||||
const mockTypes: CorrespondenceType[] = [
|
||||
{ publicId: 'type1', typeCode: 'RFA', typeName: 'Request for Approval', isActive: true } as any,
|
||||
{ publicId: 'type2', typeCode: 'TRN', typeName: 'Transmittal', isActive: true } as any,
|
||||
];
|
||||
|
||||
const mockDisciplines: Discipline[] = [
|
||||
{ publicId: 'disc1', disciplineCode: 'STR', codeNameEn: 'Structural', isActive: true } as any,
|
||||
];
|
||||
|
||||
describe('TemplateEditor', () => {
|
||||
const onSave = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders correctly for new template', () => {
|
||||
render(
|
||||
<TemplateEditor
|
||||
projectId={1}
|
||||
projectName="Test Project"
|
||||
correspondenceTypes={mockTypes}
|
||||
disciplines={mockDisciplines}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('New Template')).toBeInTheDocument();
|
||||
expect(screen.getByText('Project: Test Project')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Template Format *')).toHaveValue('');
|
||||
expect(screen.getByRole('button', { name: 'Save Template' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders correctly with existing template data', () => {
|
||||
render(
|
||||
<TemplateEditor
|
||||
template={{
|
||||
formatTemplate: '{ORG}-{TYPE}-{SEQ:4}',
|
||||
correspondenceTypeId: 'type1' as any,
|
||||
disciplineId: 'disc1' as any,
|
||||
resetSequenceYearly: false,
|
||||
} as any}
|
||||
projectId={1}
|
||||
projectName="Test Project"
|
||||
correspondenceTypes={mockTypes}
|
||||
disciplines={mockDisciplines}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Edit Template')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Template Format *')).toHaveValue('{ORG}-{TYPE}-{SEQ:4}');
|
||||
expect(screen.getByRole('button', { name: 'Save Template' })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('allows inserting variables into format', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<TemplateEditor
|
||||
projectId={1}
|
||||
projectName="Test Project"
|
||||
correspondenceTypes={mockTypes}
|
||||
disciplines={mockDisciplines}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
const formatInput = screen.getByLabelText('Template Format *');
|
||||
await user.type(formatInput, 'TEST-');
|
||||
|
||||
// Click a variable button
|
||||
const orgButton = screen.getByRole('button', { name: '{ORG}' });
|
||||
await user.click(orgButton);
|
||||
|
||||
expect(formatInput).toHaveValue('TEST-{ORG}');
|
||||
});
|
||||
|
||||
it('updates preview when format changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<TemplateEditor
|
||||
projectId={1}
|
||||
projectName="Test Project"
|
||||
correspondenceTypes={mockTypes}
|
||||
disciplines={mockDisciplines}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
const formatInput = screen.getByLabelText('Template Format *');
|
||||
fireEvent.change(formatInput, { target: { value: '{PROJECT}-{SEQ:4}' } });
|
||||
|
||||
expect(screen.getByText('LCBP3-0001')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onCancel when cancel button clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<TemplateEditor
|
||||
projectId={1}
|
||||
projectName="Test Project"
|
||||
correspondenceTypes={mockTypes}
|
||||
disciplines={mockDisciplines}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onSave with form data', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<TemplateEditor
|
||||
projectId={1}
|
||||
projectName="Test Project"
|
||||
correspondenceTypes={mockTypes}
|
||||
disciplines={mockDisciplines}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
const formatInput = screen.getByLabelText('Template Format *');
|
||||
fireEvent.change(formatInput, { target: { value: '{PROJECT}-{SEQ:4}' } });
|
||||
|
||||
// We cannot easily test Radix Select interactions in jsdom without massive pointer mocking,
|
||||
// so we'll test the default values submission first.
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'Save Template' });
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
projectId: 1,
|
||||
formatTemplate: '{PROJECT}-{SEQ:4}',
|
||||
resetSequenceYearly: true,
|
||||
correspondenceTypeId: null,
|
||||
disciplineId: 0,
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
// File: frontend/components/numbering/__tests__/template-tester.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 { TemplateTester } from '../template-tester';
|
||||
import { numberingApi } from '@/lib/api/numbering';
|
||||
|
||||
vi.mock('@/lib/api/numbering', () => ({
|
||||
numberingApi: {
|
||||
previewNumber: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-master-data', () => ({
|
||||
useOrganizations: vi.fn(() => ({ data: [{ publicId: 'org1', organizationCode: 'ORG', organizationName: 'Org1' }] })),
|
||||
useCorrespondenceTypes: vi.fn(() => ({ data: [{ id: 1, typeCode: 'TYPE', typeName: 'Type1' }] })),
|
||||
useContracts: vi.fn(() => ({ data: [{ id: 1 }] })),
|
||||
useDisciplines: vi.fn(() => ({ data: [{ id: 1, disciplineCode: 'DISC' }] })),
|
||||
}));
|
||||
|
||||
describe('TemplateTester', () => {
|
||||
const onOpenChange = vi.fn();
|
||||
const mockTemplate = {
|
||||
projectId: 1,
|
||||
formatTemplate: '{ORG}-{TYPE}-{SEQ:4}',
|
||||
} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders correctly when open', () => {
|
||||
render(<TemplateTester open={true} onOpenChange={onOpenChange} template={mockTemplate} />);
|
||||
|
||||
expect(screen.getByText('Test Number Generation')).toBeInTheDocument();
|
||||
expect(screen.getByText('{ORG}-{TYPE}-{SEQ:4}')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Generate Test Number' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when closed', () => {
|
||||
render(<TemplateTester open={false} onOpenChange={onOpenChange} template={mockTemplate} />);
|
||||
expect(screen.queryByText('Test Number Generation')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles successful generation', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TemplateTester open={true} onOpenChange={onOpenChange} template={mockTemplate} />);
|
||||
|
||||
vi.mocked(numberingApi.previewNumber).mockResolvedValue({
|
||||
previewNumber: 'ORG-TYPE-0001',
|
||||
isDefault: true,
|
||||
} as any);
|
||||
|
||||
const generateBtn = screen.getByRole('button', { name: 'Generate Test Number' });
|
||||
await user.click(generateBtn);
|
||||
|
||||
expect(numberingApi.previewNumber).toHaveBeenCalledWith({
|
||||
projectId: 1,
|
||||
originatorOrganizationId: '0',
|
||||
recipientOrganizationId: '0',
|
||||
correspondenceTypeId: 0,
|
||||
disciplineId: 0,
|
||||
year: new Date().getFullYear(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('ORG-TYPE-0001')).toBeInTheDocument();
|
||||
expect(screen.getByText('Default Template')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles API error', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TemplateTester open={true} onOpenChange={onOpenChange} template={mockTemplate} />);
|
||||
|
||||
vi.mocked(numberingApi.previewNumber).mockRejectedValue(new Error('Generation failed'));
|
||||
|
||||
const generateBtn = screen.getByRole('button', { name: 'Generate Test Number' });
|
||||
await user.click(generateBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Error: Generation failed')).toBeInTheDocument();
|
||||
expect(screen.getByText('Generation Failed:')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
// File: frontend/components/numbering/__tests__/void-replace-form.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 { VoidReplaceForm } from '../void-replace-form';
|
||||
import { documentNumberingService } from '@/lib/services/document-numbering.service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
vi.mock('@/lib/services/document-numbering.service', () => ({
|
||||
documentNumberingService: {
|
||||
voidAndReplace: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('VoidReplaceForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders correctly', () => {
|
||||
render(<VoidReplaceForm projectId={1} />);
|
||||
expect(screen.getByText('Void & Replace Number')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Document Number')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Reason')).toBeInTheDocument();
|
||||
expect(screen.getByRole('checkbox', { name: 'Generate Replacement?' })).not.toBeChecked();
|
||||
expect(screen.getByRole('button', { name: 'Void Number' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows validation errors for empty fields', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<VoidReplaceForm projectId={1} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Void Number' });
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Document Number is required')).toBeInTheDocument();
|
||||
expect(screen.getByText('Reason must be at least 5 characters')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(documentNumberingService.voidAndReplace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows validation error for short reason', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<VoidReplaceForm projectId={1} />);
|
||||
|
||||
await user.type(screen.getByLabelText('Document Number'), 'DOC-001');
|
||||
await user.type(screen.getByLabelText('Reason'), '123'); // Too short
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Void Number' });
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Reason must be at least 5 characters')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(documentNumberingService.voidAndReplace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles successful voiding without replacement', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<VoidReplaceForm projectId={1} />);
|
||||
|
||||
await user.type(screen.getByLabelText('Document Number'), 'DOC-001');
|
||||
await user.type(screen.getByLabelText('Reason'), 'Voided because of typo');
|
||||
|
||||
vi.mocked(documentNumberingService.voidAndReplace).mockResolvedValue({} as any);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Void Number' });
|
||||
await user.click(button);
|
||||
|
||||
expect(documentNumberingService.voidAndReplace).toHaveBeenCalledWith({
|
||||
documentNumber: 'DOC-001',
|
||||
reason: 'Voided because of typo',
|
||||
replace: false,
|
||||
projectId: 1,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith('Number voided successfully. ');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles successful voiding with replacement', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<VoidReplaceForm projectId={1} />);
|
||||
|
||||
await user.type(screen.getByLabelText('Document Number'), 'DOC-002');
|
||||
await user.type(screen.getByLabelText('Reason'), 'Voided because of typo');
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: 'Generate Replacement?' });
|
||||
await user.click(checkbox);
|
||||
|
||||
vi.mocked(documentNumberingService.voidAndReplace).mockResolvedValue({} as any);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Void Number' });
|
||||
await user.click(button);
|
||||
|
||||
expect(documentNumberingService.voidAndReplace).toHaveBeenCalledWith({
|
||||
documentNumber: 'DOC-002',
|
||||
reason: 'Voided because of typo',
|
||||
replace: true,
|
||||
projectId: 1,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith('Number voided successfully. Replacement generated.');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles API error', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<VoidReplaceForm projectId={1} />);
|
||||
|
||||
await user.type(screen.getByLabelText('Document Number'), 'DOC-001');
|
||||
await user.type(screen.getByLabelText('Reason'), 'Voided because of typo');
|
||||
|
||||
vi.mocked(documentNumberingService.voidAndReplace).mockRejectedValue(new Error('Failed'));
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Void Number' });
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to void number. Check if it exists.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -164,8 +164,9 @@ export function TemplateEditor({
|
||||
{/* Format Column */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Template Format *</Label>
|
||||
<Label htmlFor="template-format">Template Format *</Label>
|
||||
<Input
|
||||
id="template-format"
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value)}
|
||||
placeholder="{ORG}-{TYPE}-{SEQ:4}"
|
||||
|
||||
Reference in New Issue
Block a user