mirror of
https://github.com/coleam00/Archon.git
synced 2025-12-24 02:39:17 -05:00
test: reorganize test infrastructure with colocated tests in subdirectories
- Move tests into dedicated tests/ subdirectories within each feature - Create centralized test utilities in src/features/testing/ - Update all import paths to match new structure - Configure tsconfig.prod.json to exclude test files - Remove legacy test files from old test/ directory - All 32 tests passing with proper provider wrapping
This commit is contained in:
@@ -43,6 +43,16 @@ docker-compose.yml
|
||||
# Tests
|
||||
coverage
|
||||
test-results
|
||||
tests/
|
||||
**/*.test.ts
|
||||
**/*.test.tsx
|
||||
**/*.spec.ts
|
||||
**/*.spec.tsx
|
||||
**/__tests__
|
||||
**/*.e2e.test.ts
|
||||
**/*.integration.test.ts
|
||||
vitest.config.ts
|
||||
tsconfig.prod.json
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
import React from 'react'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
const createMockIcon = (name: string) => {
|
||||
const MockIcon = React.forwardRef(({ className, ...props }: any, ref: any) => (
|
||||
<span
|
||||
ref={ref}
|
||||
className={className}
|
||||
data-testid={`${name.toLowerCase()}-icon`}
|
||||
data-lucide={name}
|
||||
{...props}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))
|
||||
MockIcon.displayName = name
|
||||
return MockIcon
|
||||
}
|
||||
|
||||
// Export all icons used in the app
|
||||
export const Settings = createMockIcon('Settings')
|
||||
export const Check = createMockIcon('Check')
|
||||
export const CheckCircle = createMockIcon('CheckCircle')
|
||||
export const X = createMockIcon('X')
|
||||
export const XCircle = createMockIcon('XCircle')
|
||||
export const Eye = createMockIcon('Eye')
|
||||
export const EyeOff = createMockIcon('EyeOff')
|
||||
export const Save = createMockIcon('Save')
|
||||
export const Loader = createMockIcon('Loader')
|
||||
export const Loader2 = createMockIcon('Loader2')
|
||||
export const RefreshCw = createMockIcon('RefreshCw')
|
||||
export const Play = createMockIcon('Play')
|
||||
export const Pause = createMockIcon('Pause')
|
||||
export const Square = createMockIcon('Square')
|
||||
export const FileText = createMockIcon('FileText')
|
||||
export const Download = createMockIcon('Download')
|
||||
export const Upload = createMockIcon('Upload')
|
||||
export const ChevronDown = createMockIcon('ChevronDown')
|
||||
export const ChevronUp = createMockIcon('ChevronUp')
|
||||
export const ChevronLeft = createMockIcon('ChevronLeft')
|
||||
export const ChevronRight = createMockIcon('ChevronRight')
|
||||
export const Plus = createMockIcon('Plus')
|
||||
export const Minus = createMockIcon('Minus')
|
||||
export const Edit = createMockIcon('Edit')
|
||||
export const Edit2 = createMockIcon('Edit2')
|
||||
export const Edit3 = createMockIcon('Edit3')
|
||||
export const Trash = createMockIcon('Trash')
|
||||
export const Trash2 = createMockIcon('Trash2')
|
||||
export const User = createMockIcon('User')
|
||||
export const Users = createMockIcon('Users')
|
||||
export const Bot = createMockIcon('Bot')
|
||||
export const Database = createMockIcon('Database')
|
||||
export const Server = createMockIcon('Server')
|
||||
export const Globe = createMockIcon('Globe')
|
||||
export const Search = createMockIcon('Search')
|
||||
export const Filter = createMockIcon('Filter')
|
||||
export const Copy = createMockIcon('Copy')
|
||||
export const ExternalLink = createMockIcon('ExternalLink')
|
||||
export const Info = createMockIcon('Info')
|
||||
export const AlertCircle = createMockIcon('AlertCircle')
|
||||
export const AlertTriangle = createMockIcon('AlertTriangle')
|
||||
export const Zap = createMockIcon('Zap')
|
||||
export const Code = createMockIcon('Code')
|
||||
export const Terminal = createMockIcon('Terminal')
|
||||
export const Book = createMockIcon('Book')
|
||||
export const BookOpen = createMockIcon('BookOpen')
|
||||
export const Folder = createMockIcon('Folder')
|
||||
export const FolderOpen = createMockIcon('FolderOpen')
|
||||
export const File = createMockIcon('File')
|
||||
export const Hash = createMockIcon('Hash')
|
||||
export const Tag = createMockIcon('Tag')
|
||||
export const Clock = createMockIcon('Clock')
|
||||
export const Calendar = createMockIcon('Calendar')
|
||||
export const MapPin = createMockIcon('MapPin')
|
||||
export const Link = createMockIcon('Link')
|
||||
export const Mail = createMockIcon('Mail')
|
||||
export const Phone = createMockIcon('Phone')
|
||||
export const Home = createMockIcon('Home')
|
||||
export const Menu = createMockIcon('Menu')
|
||||
export const MoreHorizontal = createMockIcon('MoreHorizontal')
|
||||
export const MoreVertical = createMockIcon('MoreVertical')
|
||||
export const Refresh = createMockIcon('Refresh')
|
||||
export const RotateCcw = createMockIcon('RotateCcw')
|
||||
export const RotateCw = createMockIcon('RotateCw')
|
||||
export const Sun = createMockIcon('Sun')
|
||||
export const Moon = createMockIcon('Moon')
|
||||
export const Monitor = createMockIcon('Monitor')
|
||||
export const Wifi = createMockIcon('Wifi')
|
||||
export const WifiOff = createMockIcon('WifiOff')
|
||||
export const Volume2 = createMockIcon('Volume2')
|
||||
export const VolumeX = createMockIcon('VolumeX')
|
||||
export const BarChart = createMockIcon('BarChart')
|
||||
export const PieChart = createMockIcon('PieChart')
|
||||
export const TrendingUp = createMockIcon('TrendingUp')
|
||||
export const TrendingDown = createMockIcon('TrendingDown')
|
||||
export const ArrowUp = createMockIcon('ArrowUp')
|
||||
export const ArrowDown = createMockIcon('ArrowDown')
|
||||
export const ArrowLeft = createMockIcon('ArrowLeft')
|
||||
export const ArrowRight = createMockIcon('ArrowRight')
|
||||
export const Send = createMockIcon('Send')
|
||||
export const MessageSquare = createMockIcon('MessageSquare')
|
||||
export const MessageCircle = createMockIcon('MessageCircle')
|
||||
export const Heart = createMockIcon('Heart')
|
||||
export const Star = createMockIcon('Star')
|
||||
export const Bookmark = createMockIcon('Bookmark')
|
||||
export const Share = createMockIcon('Share')
|
||||
export const Share2 = createMockIcon('Share2')
|
||||
export const Maximize = createMockIcon('Maximize')
|
||||
export const Minimize = createMockIcon('Minimize')
|
||||
export const Expand = createMockIcon('Expand')
|
||||
export const Shrink = createMockIcon('Shrink')
|
||||
export const Move = createMockIcon('Move')
|
||||
export const Shuffle = createMockIcon('Shuffle')
|
||||
export const Repeat = createMockIcon('Repeat')
|
||||
export const StopCircle = createMockIcon('StopCircle')
|
||||
export const SkipBack = createMockIcon('SkipBack')
|
||||
export const SkipForward = createMockIcon('SkipForward')
|
||||
export const FastForward = createMockIcon('FastForward')
|
||||
export const Rewind = createMockIcon('Rewind')
|
||||
export const Camera = createMockIcon('Camera')
|
||||
export const Image = createMockIcon('Image')
|
||||
export const Video = createMockIcon('Video')
|
||||
export const Mic = createMockIcon('Mic')
|
||||
export const MicOff = createMockIcon('MicOff')
|
||||
export const Headphones = createMockIcon('Headphones')
|
||||
export const Speaker = createMockIcon('Speaker')
|
||||
export const Bell = createMockIcon('Bell')
|
||||
export const BellOff = createMockIcon('BellOff')
|
||||
export const Shield = createMockIcon('Shield')
|
||||
export const ShieldCheck = createMockIcon('ShieldCheck')
|
||||
export const ShieldAlert = createMockIcon('ShieldAlert')
|
||||
export const Key = createMockIcon('Key')
|
||||
export const Lock = createMockIcon('Lock')
|
||||
export const Unlock = createMockIcon('Unlock')
|
||||
export const LogIn = createMockIcon('LogIn')
|
||||
export const LogOut = createMockIcon('LogOut')
|
||||
export const UserPlus = createMockIcon('UserPlus')
|
||||
export const UserMinus = createMockIcon('UserMinus')
|
||||
export const UserCheck = createMockIcon('UserCheck')
|
||||
export const UserX = createMockIcon('UserX')
|
||||
export const Package = createMockIcon('Package')
|
||||
export const Package2 = createMockIcon('Package2')
|
||||
export const ShoppingCart = createMockIcon('ShoppingCart')
|
||||
export const ShoppingBag = createMockIcon('ShoppingBag')
|
||||
export const CreditCard = createMockIcon('CreditCard')
|
||||
export const DollarSign = createMockIcon('DollarSign')
|
||||
export const Percent = createMockIcon('Percent')
|
||||
export const Activity = createMockIcon('Activity')
|
||||
export const Cpu = createMockIcon('Cpu')
|
||||
export const HardDrive = createMockIcon('HardDrive')
|
||||
export const MemoryStick = createMockIcon('MemoryStick')
|
||||
export const Smartphone = createMockIcon('Smartphone')
|
||||
export const Tablet = createMockIcon('Tablet')
|
||||
export const Laptop = createMockIcon('Laptop')
|
||||
export const Monitor2 = createMockIcon('Monitor2')
|
||||
export const Tv = createMockIcon('Tv')
|
||||
export const Watch = createMockIcon('Watch')
|
||||
export const Gamepad2 = createMockIcon('Gamepad2')
|
||||
export const Mouse = createMockIcon('Mouse')
|
||||
export const Keyboard = createMockIcon('Keyboard')
|
||||
export const Printer = createMockIcon('Printer')
|
||||
export const Scanner = createMockIcon('Scanner')
|
||||
export const Webcam = createMockIcon('Webcam')
|
||||
export const Bluetooth = createMockIcon('Bluetooth')
|
||||
export const Usb = createMockIcon('Usb')
|
||||
export const Zap2 = createMockIcon('Zap2')
|
||||
export const Battery = createMockIcon('Battery')
|
||||
export const BatteryCharging = createMockIcon('BatteryCharging')
|
||||
export const Plug = createMockIcon('Plug')
|
||||
export const Power = createMockIcon('Power')
|
||||
export const PowerOff = createMockIcon('PowerOff')
|
||||
export const BarChart2 = createMockIcon('BarChart2')
|
||||
export const BarChart3 = createMockIcon('BarChart3')
|
||||
export const BarChart4 = createMockIcon('BarChart4')
|
||||
export const LineChart = createMockIcon('LineChart')
|
||||
export const PieChart2 = createMockIcon('PieChart2')
|
||||
export const Layers = createMockIcon('Layers')
|
||||
export const Layers2 = createMockIcon('Layers2')
|
||||
export const Layers3 = createMockIcon('Layers3')
|
||||
export const Grid = createMockIcon('Grid')
|
||||
export const Grid2x2 = createMockIcon('Grid2x2')
|
||||
export const Grid3x3 = createMockIcon('Grid3x3')
|
||||
export const List = createMockIcon('List')
|
||||
export const ListChecks = createMockIcon('ListChecks')
|
||||
export const ListTodo = createMockIcon('ListTodo')
|
||||
export const CheckSquare = createMockIcon('CheckSquare')
|
||||
export const Square2 = createMockIcon('Square2')
|
||||
export const Circle = createMockIcon('Circle')
|
||||
export const CircleCheck = createMockIcon('CircleCheck')
|
||||
export const CircleX = createMockIcon('CircleX')
|
||||
export const CircleDot = createMockIcon('CircleDot')
|
||||
export const Target = createMockIcon('Target')
|
||||
export const Focus = createMockIcon('Focus')
|
||||
export const Crosshair = createMockIcon('Crosshair')
|
||||
export const Locate = createMockIcon('Locate')
|
||||
export const LocateFixed = createMockIcon('LocateFixed')
|
||||
export const Navigation = createMockIcon('Navigation')
|
||||
export const Navigation2 = createMockIcon('Navigation2')
|
||||
export const Compass = createMockIcon('Compass')
|
||||
export const Map = createMockIcon('Map')
|
||||
export const TestTube = createMockIcon('TestTube')
|
||||
export const FlaskConical = createMockIcon('FlaskConical')
|
||||
export const Bug = createMockIcon('Bug')
|
||||
export const GitBranch = createMockIcon('GitBranch')
|
||||
export const GitCommit = createMockIcon('GitCommit')
|
||||
export const GitMerge = createMockIcon('GitMerge')
|
||||
export const GitPullRequest = createMockIcon('GitPullRequest')
|
||||
export const Github = createMockIcon('Github')
|
||||
export const Gitlab = createMockIcon('Gitlab')
|
||||
export const Bitbucket = createMockIcon('Bitbucket')
|
||||
export const Network = createMockIcon('Network')
|
||||
export const GitGraph = createMockIcon('GitGraph')
|
||||
export const ListFilter = createMockIcon('ListFilter')
|
||||
export const CheckSquare2 = createMockIcon('CheckSquare2')
|
||||
export const CircleSlash2 = createMockIcon('CircleSlash2')
|
||||
export const Clock3 = createMockIcon('Clock3')
|
||||
export const GitCommitHorizontal = createMockIcon('GitCommitHorizontal')
|
||||
export const CalendarDays = createMockIcon('CalendarDays')
|
||||
export const Sparkles = createMockIcon('Sparkles')
|
||||
export const Layout = createMockIcon('Layout')
|
||||
export const Table = createMockIcon('Table')
|
||||
export const Columns = createMockIcon('Columns')
|
||||
export const GitPullRequestDraft = createMockIcon('GitPullRequestDraft')
|
||||
export const BrainCircuit = createMockIcon('BrainCircuit')
|
||||
export const Wrench = createMockIcon('Wrench')
|
||||
export const PlugZap = createMockIcon('PlugZap')
|
||||
export const BoxIcon = createMockIcon('BoxIcon')
|
||||
export const Box = createMockIcon('Box')
|
||||
export const Brain = createMockIcon('Brain')
|
||||
export const LinkIcon = createMockIcon('LinkIcon')
|
||||
export const Sparkle = createMockIcon('Sparkle')
|
||||
export const FolderTree = createMockIcon('FolderTree')
|
||||
export const Lightbulb = createMockIcon('Lightbulb')
|
||||
export const Rocket = createMockIcon('Rocket')
|
||||
export const Building = createMockIcon('Building')
|
||||
export const FileCode = createMockIcon('FileCode')
|
||||
export const FileJson = createMockIcon('FileJson')
|
||||
export const Braces = createMockIcon('Braces')
|
||||
export const Binary = createMockIcon('Binary')
|
||||
export const Palette = createMockIcon('Palette')
|
||||
export const Paintbrush = createMockIcon('Paintbrush')
|
||||
export const Type = createMockIcon('Type')
|
||||
export const Heading = createMockIcon('Heading')
|
||||
export const AlignLeft = createMockIcon('AlignLeft')
|
||||
export const AlignCenter = createMockIcon('AlignCenter')
|
||||
export const AlignRight = createMockIcon('AlignRight')
|
||||
export const Bold = createMockIcon('Bold')
|
||||
export const Italic = createMockIcon('Italic')
|
||||
export const Underline = createMockIcon('Underline')
|
||||
export const Strikethrough = createMockIcon('Strikethrough')
|
||||
export const FileCheck = createMockIcon('FileCheck')
|
||||
export const FileX = createMockIcon('FileX')
|
||||
export const FilePlus = createMockIcon('FilePlus')
|
||||
export const FileMinus = createMockIcon('FileMinus')
|
||||
export const FolderPlus = createMockIcon('FolderPlus')
|
||||
export const FolderMinus = createMockIcon('FolderMinus')
|
||||
export const FolderCheck = createMockIcon('FolderCheck')
|
||||
export const FolderX = createMockIcon('FolderX')
|
||||
export const startMCPServer = createMockIcon('startMCPServer')
|
||||
export const Pin = createMockIcon('Pin')
|
||||
export const CheckCircle2 = createMockIcon('CheckCircle2')
|
||||
export const Clipboard = createMockIcon('Clipboard')
|
||||
export const LayoutGrid = createMockIcon('LayoutGrid')
|
||||
export const Pencil = createMockIcon('Pencil')
|
||||
export const MousePointer = createMockIcon('MousePointer')
|
||||
export const GripVertical = createMockIcon('GripVertical')
|
||||
export const History = createMockIcon('History')
|
||||
export const PlusCircle = createMockIcon('PlusCircle')
|
||||
export const MinusCircle = createMockIcon('MinusCircle')
|
||||
export const ChevronDownIcon = createMockIcon('ChevronDownIcon')
|
||||
export const FileIcon = createMockIcon('FileIcon')
|
||||
export const AlertCircleIcon = createMockIcon('AlertCircleIcon')
|
||||
export const Clock4 = createMockIcon('Clock4')
|
||||
export const XIcon = createMockIcon('XIcon')
|
||||
export const CheckIcon = createMockIcon('CheckIcon')
|
||||
export const TrashIcon = createMockIcon('TrashIcon')
|
||||
export const EyeIcon = createMockIcon('EyeIcon')
|
||||
export const EditIcon = createMockIcon('EditIcon')
|
||||
export const DownloadIcon = createMockIcon('DownloadIcon')
|
||||
export const RefreshIcon = createMockIcon('RefreshIcon')
|
||||
export const SearchIcon = createMockIcon('SearchIcon')
|
||||
export const FilterIcon = createMockIcon('FilterIcon')
|
||||
export const PlusIcon = createMockIcon('PlusIcon')
|
||||
export const FolderIcon = createMockIcon('FolderIcon')
|
||||
export const FileTextIcon = createMockIcon('FileTextIcon')
|
||||
export const BookOpenIcon = createMockIcon('BookOpenIcon')
|
||||
export const DatabaseIcon = createMockIcon('DatabaseIcon')
|
||||
export const GlobeIcon = createMockIcon('GlobeIcon')
|
||||
export const TagIcon = createMockIcon('TagIcon')
|
||||
export const CalendarIcon = createMockIcon('CalendarIcon')
|
||||
export const ClockIcon = createMockIcon('ClockIcon')
|
||||
export const UserIcon = createMockIcon('UserIcon')
|
||||
export const SettingsIcon = createMockIcon('SettingsIcon')
|
||||
export const InfoIcon = createMockIcon('InfoIcon')
|
||||
export const WarningIcon = createMockIcon('WarningIcon')
|
||||
export const ErrorIcon = createMockIcon('ErrorIcon')
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '../../../testing/test-utils';
|
||||
import { ProjectCard } from '../ProjectCard';
|
||||
import type { Project } from '../../types';
|
||||
|
||||
describe('ProjectCard', () => {
|
||||
const mockProject: Project = {
|
||||
id: 'project-1',
|
||||
title: 'Test Project',
|
||||
description: 'Test Description',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
pinned: false,
|
||||
features: [],
|
||||
docs: [],
|
||||
};
|
||||
|
||||
const mockTaskCounts = {
|
||||
todo: 5,
|
||||
doing: 3,
|
||||
review: 2,
|
||||
done: 10,
|
||||
};
|
||||
|
||||
const mockHandlers = {
|
||||
onSelect: vi.fn(),
|
||||
onPin: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render project title', () => {
|
||||
render(
|
||||
<ProjectCard
|
||||
project={mockProject}
|
||||
isSelected={false}
|
||||
taskCounts={mockTaskCounts}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Test Project')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display task counts', () => {
|
||||
render(
|
||||
<ProjectCard
|
||||
project={mockProject}
|
||||
isSelected={false}
|
||||
taskCounts={mockTaskCounts}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
// Task count badges should be visible
|
||||
// Note: Component only shows todo, doing, and done (not review)
|
||||
const fives = screen.getAllByText('5');
|
||||
expect(fives.length).toBeGreaterThan(0); // todo count
|
||||
expect(screen.getByText('10')).toBeInTheDocument(); // done
|
||||
// Doing count might be displayed as 3 or duplicated - implementation detail
|
||||
});
|
||||
|
||||
it('should call onSelect when clicked', () => {
|
||||
render(
|
||||
<ProjectCard
|
||||
project={mockProject}
|
||||
isSelected={false}
|
||||
taskCounts={mockTaskCounts}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
const card = screen.getByRole('listitem');
|
||||
fireEvent.click(card);
|
||||
|
||||
expect(mockHandlers.onSelect).toHaveBeenCalledWith(mockProject);
|
||||
expect(mockHandlers.onSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should apply selected styles when isSelected is true', () => {
|
||||
const { container } = render(
|
||||
<ProjectCard
|
||||
project={mockProject}
|
||||
isSelected={true}
|
||||
taskCounts={mockTaskCounts}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
const card = container.querySelector('[role="listitem"]');
|
||||
// Check for selected-specific classes
|
||||
expect(card?.className).toContain('scale-[1.02]');
|
||||
expect(card?.className).toContain('border-purple');
|
||||
});
|
||||
|
||||
it('should apply pinned styles when project is pinned', () => {
|
||||
const pinnedProject = { ...mockProject, pinned: true };
|
||||
|
||||
const { container } = render(
|
||||
<ProjectCard
|
||||
project={pinnedProject}
|
||||
isSelected={false}
|
||||
taskCounts={mockTaskCounts}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
const card = container.querySelector('[role="listitem"]');
|
||||
// Check for pinned-specific classes
|
||||
expect(card?.className).toContain('from-purple');
|
||||
expect(card?.className).toContain('border-purple-500');
|
||||
});
|
||||
|
||||
it('should render aurora glow effect when selected', () => {
|
||||
const { container } = render(
|
||||
<ProjectCard
|
||||
project={mockProject}
|
||||
isSelected={true}
|
||||
taskCounts={mockTaskCounts}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
// Aurora glow div should exist when selected
|
||||
const glowEffect = container.querySelector('.animate-\\[pulse_8s_ease-in-out_infinite\\]');
|
||||
expect(glowEffect).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render aurora glow effect when not selected', () => {
|
||||
const { container } = render(
|
||||
<ProjectCard
|
||||
project={mockProject}
|
||||
isSelected={false}
|
||||
taskCounts={mockTaskCounts}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
// Aurora glow div should not exist when not selected
|
||||
const glowEffect = container.querySelector('.animate-\\[pulse_8s_ease-in-out_infinite\\]');
|
||||
expect(glowEffect).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show zero task counts correctly', () => {
|
||||
const zeroTaskCounts = {
|
||||
todo: 0,
|
||||
doing: 0,
|
||||
review: 0,
|
||||
done: 0,
|
||||
};
|
||||
|
||||
render(
|
||||
<ProjectCard
|
||||
project={mockProject}
|
||||
isSelected={false}
|
||||
taskCounts={zeroTaskCounts}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
// All counts should show 0 (ProjectCard may not show review count)
|
||||
const zeros = screen.getAllByText('0');
|
||||
expect(zeros.length).toBeGreaterThanOrEqual(3); // At least todo, doing, done
|
||||
});
|
||||
|
||||
it('should handle very long project titles', () => {
|
||||
const longTitleProject = {
|
||||
...mockProject,
|
||||
title: 'This is an extremely long project title that should be truncated properly to avoid breaking the layout of the card component',
|
||||
};
|
||||
|
||||
render(
|
||||
<ProjectCard
|
||||
project={longTitleProject}
|
||||
isSelected={false}
|
||||
taskCounts={mockTaskCounts}
|
||||
{...mockHandlers}
|
||||
/>
|
||||
);
|
||||
|
||||
const title = screen.getByText(/This is an extremely long project title/);
|
||||
expect(title).toBeInTheDocument();
|
||||
// Title should have line-clamp-2 class
|
||||
expect(title.className).toContain('line-clamp-2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { projectKeys, useProjects, useCreateProject, useUpdateProject, useDeleteProject } from '../useProjectQueries';
|
||||
import type { Project } from '../../types';
|
||||
import React from 'react';
|
||||
|
||||
// Mock the services
|
||||
vi.mock('../../services', () => ({
|
||||
projectService: {
|
||||
listProjects: vi.fn(),
|
||||
createProject: vi.fn(),
|
||||
updateProject: vi.fn(),
|
||||
deleteProject: vi.fn(),
|
||||
getProjectFeatures: vi.fn(),
|
||||
},
|
||||
taskService: {
|
||||
getTaskCountsForAllProjects: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the toast hook
|
||||
vi.mock('../../../ui/hooks/useToast', () => ({
|
||||
useToast: () => ({
|
||||
showToast: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock smart polling
|
||||
vi.mock('../../../ui/hooks', () => ({
|
||||
useSmartPolling: () => ({
|
||||
refetchInterval: 5000,
|
||||
isPaused: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Test wrapper with QueryClient
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
};
|
||||
|
||||
describe('useProjectQueries', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('projectKeys', () => {
|
||||
it('should generate correct query keys', () => {
|
||||
expect(projectKeys.all).toEqual(['projects']);
|
||||
expect(projectKeys.lists()).toEqual(['projects', 'list']);
|
||||
expect(projectKeys.detail('123')).toEqual(['projects', 'detail', '123']);
|
||||
expect(projectKeys.tasks('123')).toEqual(['projects', 'detail', '123', 'tasks']);
|
||||
expect(projectKeys.features('123')).toEqual(['projects', 'detail', '123', 'features']);
|
||||
expect(projectKeys.documents('123')).toEqual(['projects', 'detail', '123', 'documents']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useProjects', () => {
|
||||
it('should fetch projects list', async () => {
|
||||
const mockProjects: Project[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Test Project',
|
||||
description: 'Test Description',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
pinned: false,
|
||||
features: [],
|
||||
docs: [],
|
||||
},
|
||||
];
|
||||
|
||||
const { projectService } = await import('../../services');
|
||||
vi.mocked(projectService.listProjects).mockResolvedValue(mockProjects);
|
||||
|
||||
const { result } = renderHook(() => useProjects(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
expect(result.current.data).toEqual(mockProjects);
|
||||
});
|
||||
|
||||
expect(projectService.listProjects).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateProject', () => {
|
||||
it('should optimistically add project and replace with server response', async () => {
|
||||
const newProject: Project = {
|
||||
id: 'real-id',
|
||||
title: 'New Project',
|
||||
description: 'New Description',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
pinned: false,
|
||||
features: [],
|
||||
docs: [],
|
||||
};
|
||||
|
||||
const { projectService } = await import('../../services');
|
||||
vi.mocked(projectService.createProject).mockResolvedValue({
|
||||
project: newProject,
|
||||
message: 'Created',
|
||||
});
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useCreateProject(), { wrapper });
|
||||
|
||||
await result.current.mutateAsync({
|
||||
title: 'New Project',
|
||||
description: 'New Description',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
expect(projectService.createProject).toHaveBeenCalledWith({
|
||||
title: 'New Project',
|
||||
description: 'New Description',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should rollback on error', async () => {
|
||||
const { projectService } = await import('../../services');
|
||||
vi.mocked(projectService.createProject).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useCreateProject(), { wrapper });
|
||||
|
||||
await expect(
|
||||
result.current.mutateAsync({
|
||||
title: 'New Project',
|
||||
description: 'New Description',
|
||||
})
|
||||
).rejects.toThrow('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useUpdateProject', () => {
|
||||
it('should handle pinning a project', async () => {
|
||||
const updatedProject: Project = {
|
||||
id: '1',
|
||||
title: 'Test Project',
|
||||
description: 'Test Description',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
pinned: true,
|
||||
features: [],
|
||||
docs: [],
|
||||
};
|
||||
|
||||
const { projectService } = await import('../../services');
|
||||
vi.mocked(projectService.updateProject).mockResolvedValue(updatedProject);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useUpdateProject(), { wrapper });
|
||||
|
||||
await result.current.mutateAsync({
|
||||
projectId: '1',
|
||||
updates: { pinned: true },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
expect(projectService.updateProject).toHaveBeenCalledWith('1', { pinned: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDeleteProject', () => {
|
||||
it('should optimistically remove project', async () => {
|
||||
const { projectService } = await import('../../services');
|
||||
vi.mocked(projectService.deleteProject).mockResolvedValue(undefined);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useDeleteProject(), { wrapper });
|
||||
|
||||
await result.current.mutateAsync('project-to-delete');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
expect(projectService.deleteProject).toHaveBeenCalledWith('project-to-delete');
|
||||
});
|
||||
});
|
||||
|
||||
it('should rollback on delete error', async () => {
|
||||
const { projectService } = await import('../../services');
|
||||
vi.mocked(projectService.deleteProject).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useDeleteProject(), { wrapper });
|
||||
|
||||
await expect(
|
||||
result.current.mutateAsync('project-to-delete')
|
||||
).rejects.toThrow('Permission denied');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { taskKeys, useProjectTasks, useCreateTask } from '../useTaskQueries';
|
||||
import type { Task } from '../../types';
|
||||
import React from 'react';
|
||||
|
||||
// Mock the services
|
||||
vi.mock('../../services', () => ({
|
||||
taskService: {
|
||||
getTasksByProject: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the toast hook
|
||||
vi.mock('../../../../ui/hooks/useToast', () => ({
|
||||
useToast: () => ({
|
||||
showToast: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock smart polling
|
||||
vi.mock('../../../../ui/hooks', () => ({
|
||||
useSmartPolling: () => ({
|
||||
refetchInterval: 5000,
|
||||
isPaused: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Test wrapper with QueryClient
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
};
|
||||
|
||||
describe('useTaskQueries', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('taskKeys', () => {
|
||||
it('should generate correct query keys', () => {
|
||||
expect(taskKeys.all('project-123')).toEqual(['projects', 'project-123', 'tasks']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useProjectTasks', () => {
|
||||
it('should fetch tasks for a project', async () => {
|
||||
const mockTasks: Task[] = [
|
||||
{
|
||||
id: 'task-1',
|
||||
project_id: 'project-123',
|
||||
title: 'Test Task',
|
||||
description: 'Test Description',
|
||||
status: 'todo',
|
||||
assignee: 'User',
|
||||
task_order: 100,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const { taskService } = await import('../../services');
|
||||
vi.mocked(taskService.getTasksByProject).mockResolvedValue(mockTasks);
|
||||
|
||||
const { result } = renderHook(() => useProjectTasks('project-123'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
expect(result.current.data).toEqual(mockTasks);
|
||||
});
|
||||
|
||||
expect(taskService.getTasksByProject).toHaveBeenCalledWith('project-123');
|
||||
});
|
||||
|
||||
it('should not fetch tasks when projectId is undefined', () => {
|
||||
const { result } = renderHook(() => useProjectTasks(undefined), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
expect(result.current.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should respect enabled flag', () => {
|
||||
const { result } = renderHook(() => useProjectTasks('project-123', false), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
expect(result.current.data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateTask', () => {
|
||||
it('should optimistically add task and replace with server response', async () => {
|
||||
const newTask: Task = {
|
||||
id: 'real-task-id',
|
||||
project_id: 'project-123',
|
||||
title: 'New Task',
|
||||
description: 'New Description',
|
||||
status: 'todo',
|
||||
assignee: 'User',
|
||||
task_order: 100,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const { taskService } = await import('../../services');
|
||||
vi.mocked(taskService.createTask).mockResolvedValue(newTask);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useCreateTask(), { wrapper });
|
||||
|
||||
await result.current.mutateAsync({
|
||||
project_id: 'project-123',
|
||||
title: 'New Task',
|
||||
description: 'New Description',
|
||||
status: 'todo',
|
||||
assignee: 'User',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
expect(taskService.createTask).toHaveBeenCalledWith({
|
||||
project_id: 'project-123',
|
||||
title: 'New Task',
|
||||
description: 'New Description',
|
||||
status: 'todo',
|
||||
assignee: 'User',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should provide default values for optional fields', async () => {
|
||||
const newTask: Task = {
|
||||
id: 'real-task-id',
|
||||
project_id: 'project-123',
|
||||
title: 'Minimal Task',
|
||||
description: '',
|
||||
status: 'todo',
|
||||
assignee: 'User',
|
||||
task_order: 100,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const { taskService } = await import('../../services');
|
||||
vi.mocked(taskService.createTask).mockResolvedValue(newTask);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useCreateTask(), { wrapper });
|
||||
|
||||
await result.current.mutateAsync({
|
||||
project_id: 'project-123',
|
||||
title: 'Minimal Task',
|
||||
description: '',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should rollback on error', async () => {
|
||||
const { taskService } = await import('../../services');
|
||||
vi.mocked(taskService.createTask).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useCreateTask(), { wrapper });
|
||||
|
||||
await expect(
|
||||
result.current.mutateAsync({
|
||||
project_id: 'project-123',
|
||||
title: 'Failed Task',
|
||||
description: 'This will fail',
|
||||
})
|
||||
).rejects.toThrow('Network error');
|
||||
});
|
||||
});
|
||||
});
|
||||
42
archon-ui-main/src/features/testing/test-utils.tsx
Normal file
42
archon-ui-main/src/features/testing/test-utils.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { render as rtlRender } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ToastProvider } from '../ui/components/ToastProvider';
|
||||
import { TooltipProvider } from '../ui/primitives/tooltip';
|
||||
|
||||
/**
|
||||
* Custom render function that wraps components with all necessary providers
|
||||
* This follows the best practice of having a centralized test render utility
|
||||
*/
|
||||
export function renderWithProviders(
|
||||
ui: React.ReactElement,
|
||||
{
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
}),
|
||||
...renderOptions
|
||||
} = {}
|
||||
) {
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<ToastProvider>
|
||||
{children}
|
||||
</ToastProvider>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return rtlRender(ui, { wrapper: Wrapper, ...renderOptions });
|
||||
}
|
||||
|
||||
// Re-export everything from React Testing Library
|
||||
export * from '@testing-library/react';
|
||||
|
||||
// Override the default render with our custom one
|
||||
export { renderWithProviders as render };
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useSmartPolling } from '../useSmartPolling';
|
||||
|
||||
describe('useSmartPolling', () => {
|
||||
beforeEach(() => {
|
||||
// Reset document visibility state
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
value: 'visible',
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
value: false,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
// Mock document.hasFocus
|
||||
document.hasFocus = vi.fn(() => true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return the base interval when document is visible and focused', () => {
|
||||
const { result } = renderHook(() => useSmartPolling(5000));
|
||||
|
||||
expect(result.current.refetchInterval).toBe(5000);
|
||||
expect(result.current.isActive).toBe(true);
|
||||
expect(result.current.isVisible).toBe(true);
|
||||
expect(result.current.hasFocus).toBe(true);
|
||||
});
|
||||
|
||||
it('should disable polling when document is hidden', () => {
|
||||
const { result } = renderHook(() => useSmartPolling(5000));
|
||||
|
||||
// Initially should be active
|
||||
expect(result.current.isActive).toBe(true);
|
||||
expect(result.current.refetchInterval).toBe(5000);
|
||||
|
||||
// Simulate tab becoming hidden
|
||||
act(() => {
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
value: true,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
|
||||
// Should be disabled (returns false)
|
||||
expect(result.current.isVisible).toBe(false);
|
||||
expect(result.current.isActive).toBe(false);
|
||||
expect(result.current.refetchInterval).toBe(false);
|
||||
});
|
||||
|
||||
it('should resume polling when document becomes visible again', () => {
|
||||
const { result } = renderHook(() => useSmartPolling(5000));
|
||||
|
||||
// Make hidden
|
||||
act(() => {
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
value: true,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
|
||||
expect(result.current.refetchInterval).toBe(false);
|
||||
|
||||
// Make visible again
|
||||
act(() => {
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
value: false,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
|
||||
expect(result.current.isVisible).toBe(true);
|
||||
expect(result.current.isActive).toBe(true);
|
||||
expect(result.current.refetchInterval).toBe(5000);
|
||||
});
|
||||
|
||||
it('should slow down to 60 seconds when window loses focus', () => {
|
||||
const { result } = renderHook(() => useSmartPolling(5000));
|
||||
|
||||
// Initially focused
|
||||
expect(result.current.refetchInterval).toBe(5000);
|
||||
expect(result.current.hasFocus).toBe(true);
|
||||
|
||||
// Simulate window blur
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('blur'));
|
||||
});
|
||||
|
||||
// Should be slowed down to 60 seconds
|
||||
expect(result.current.hasFocus).toBe(false);
|
||||
expect(result.current.isActive).toBe(false);
|
||||
expect(result.current.refetchInterval).toBe(60000);
|
||||
});
|
||||
|
||||
it('should resume normal speed when window regains focus', () => {
|
||||
const { result } = renderHook(() => useSmartPolling(5000));
|
||||
|
||||
// Blur window
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('blur'));
|
||||
});
|
||||
|
||||
expect(result.current.refetchInterval).toBe(60000);
|
||||
|
||||
// Focus window again
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
});
|
||||
|
||||
expect(result.current.hasFocus).toBe(true);
|
||||
expect(result.current.isActive).toBe(true);
|
||||
expect(result.current.refetchInterval).toBe(5000);
|
||||
});
|
||||
|
||||
it('should handle different base intervals', () => {
|
||||
const { result: result1 } = renderHook(() => useSmartPolling(1000));
|
||||
const { result: result2 } = renderHook(() => useSmartPolling(10000));
|
||||
|
||||
expect(result1.current.refetchInterval).toBe(1000);
|
||||
expect(result2.current.refetchInterval).toBe(10000);
|
||||
|
||||
// When blurred, both should be 60 seconds
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('blur'));
|
||||
});
|
||||
|
||||
expect(result1.current.refetchInterval).toBe(60000);
|
||||
expect(result2.current.refetchInterval).toBe(60000);
|
||||
});
|
||||
|
||||
it('should use default interval of 10000ms when not specified', () => {
|
||||
const { result } = renderHook(() => useSmartPolling());
|
||||
|
||||
expect(result.current.refetchInterval).toBe(10000);
|
||||
});
|
||||
|
||||
it('should cleanup event listeners on unmount', () => {
|
||||
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
|
||||
const windowRemoveEventListenerSpy = vi.spyOn(window, 'removeEventListener');
|
||||
|
||||
const { unmount } = renderHook(() => useSmartPolling(5000));
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
|
||||
expect(windowRemoveEventListenerSpy).toHaveBeenCalledWith('focus', expect.any(Function));
|
||||
expect(windowRemoveEventListenerSpy).toHaveBeenCalledWith('blur', expect.any(Function));
|
||||
|
||||
removeEventListenerSpy.mockRestore();
|
||||
windowRemoveEventListenerSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should correctly report isActive state', () => {
|
||||
const { result } = renderHook(() => useSmartPolling(5000));
|
||||
|
||||
// Active when both visible and focused
|
||||
expect(result.current.isActive).toBe(true);
|
||||
|
||||
// Not active when not focused
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('blur'));
|
||||
});
|
||||
expect(result.current.isActive).toBe(false);
|
||||
|
||||
// Not active when hidden
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('focus')); // Focus first
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
value: true,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
expect(result.current.isActive).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,294 +0,0 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, test, expect, vi } from 'vitest'
|
||||
import React from 'react'
|
||||
|
||||
describe('Component Tests', () => {
|
||||
test('button component works', () => {
|
||||
const onClick = vi.fn()
|
||||
const MockButton = ({ children, ...props }: any) => (
|
||||
<button {...props}>{children}</button>
|
||||
)
|
||||
|
||||
render(<MockButton onClick={onClick}>Click me</MockButton>)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('input component works', () => {
|
||||
const MockInput = () => {
|
||||
const [value, setValue] = React.useState('')
|
||||
return (
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Test input"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockInput />)
|
||||
const input = screen.getByPlaceholderText('Test input')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'test' } })
|
||||
expect((input as HTMLInputElement).value).toBe('test')
|
||||
})
|
||||
|
||||
test('modal component works', () => {
|
||||
const MockModal = () => {
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setIsOpen(true)}>Open Modal</button>
|
||||
{isOpen && (
|
||||
<div role="dialog">
|
||||
<h2>Modal Title</h2>
|
||||
<button onClick={() => setIsOpen(false)}>Close</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockModal />)
|
||||
|
||||
// Modal not visible initially
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
|
||||
// Open modal
|
||||
fireEvent.click(screen.getByText('Open Modal'))
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
|
||||
// Close modal
|
||||
fireEvent.click(screen.getByText('Close'))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('progress bar component works', () => {
|
||||
const MockProgressBar = ({ value, max }: { value: number; max: number }) => (
|
||||
<div>
|
||||
<div>Progress: {Math.round((value / max) * 100)}%</div>
|
||||
<div style={{ width: `${(value / max) * 100}%` }}>Bar</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const { rerender } = render(<MockProgressBar value={0} max={100} />)
|
||||
expect(screen.getByText('Progress: 0%')).toBeInTheDocument()
|
||||
|
||||
rerender(<MockProgressBar value={50} max={100} />)
|
||||
expect(screen.getByText('Progress: 50%')).toBeInTheDocument()
|
||||
|
||||
rerender(<MockProgressBar value={100} max={100} />)
|
||||
expect(screen.getByText('Progress: 100%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('tooltip component works', () => {
|
||||
const MockTooltip = ({ children, tooltip }: any) => {
|
||||
const [show, setShow] = React.useState(false)
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onMouseEnter={() => setShow(true)}
|
||||
onMouseLeave={() => setShow(false)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
{show && <div role="tooltip">{tooltip}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockTooltip tooltip="This is a tooltip">Hover me</MockTooltip>)
|
||||
|
||||
const button = screen.getByText('Hover me')
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseEnter(button)
|
||||
expect(screen.getByRole('tooltip')).toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseLeave(button)
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('accordion component works', () => {
|
||||
const MockAccordion = () => {
|
||||
const [expanded, setExpanded] = React.useState(false)
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setExpanded(!expanded)}>
|
||||
Section 1 {expanded ? '−' : '+'}
|
||||
</button>
|
||||
{expanded && <div>Section content</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockAccordion />)
|
||||
|
||||
expect(screen.queryByText('Section content')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Section 1 +'))
|
||||
expect(screen.getByText('Section content')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Section 1 −'))
|
||||
expect(screen.queryByText('Section content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('table sorting works', () => {
|
||||
const MockTable = () => {
|
||||
const [data, setData] = React.useState([
|
||||
{ name: 'Alice', age: 30 },
|
||||
{ name: 'Bob', age: 25 },
|
||||
{ name: 'Charlie', age: 35 }
|
||||
])
|
||||
|
||||
const sortByName = () => {
|
||||
setData([...data].sort((a, b) => a.name.localeCompare(b.name)))
|
||||
}
|
||||
|
||||
return (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th onClick={sortByName} style={{ cursor: 'pointer' }}>
|
||||
Name
|
||||
</th>
|
||||
<th>Age</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row, index) => (
|
||||
<tr key={index}>
|
||||
<td>{row.name}</td>
|
||||
<td>{row.age}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockTable />)
|
||||
|
||||
const cells = screen.getAllByRole('cell')
|
||||
expect(cells[0]).toHaveTextContent('Alice')
|
||||
|
||||
fireEvent.click(screen.getByText('Name'))
|
||||
|
||||
// After sorting, Alice should still be first (already sorted)
|
||||
const sortedCells = screen.getAllByRole('cell')
|
||||
expect(sortedCells[0]).toHaveTextContent('Alice')
|
||||
})
|
||||
|
||||
test('pagination works', () => {
|
||||
const MockPagination = () => {
|
||||
const [page, setPage] = React.useState(1)
|
||||
return (
|
||||
<div>
|
||||
<div>Page {page}</div>
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button onClick={() => setPage(page + 1)}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockPagination />)
|
||||
|
||||
expect(screen.getByText('Page 1')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Next'))
|
||||
expect(screen.getByText('Page 2')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Previous'))
|
||||
expect(screen.getByText('Page 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('form validation works', () => {
|
||||
const MockForm = () => {
|
||||
const [email, setEmail] = React.useState('')
|
||||
const [error, setError] = React.useState('')
|
||||
|
||||
const validate = (value: string) => {
|
||||
if (!value) {
|
||||
setError('Email is required')
|
||||
} else if (!value.includes('@')) {
|
||||
setError('Invalid email format')
|
||||
} else {
|
||||
setError('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value)
|
||||
validate(e.target.value)
|
||||
}}
|
||||
/>
|
||||
{error && <div role="alert">{error}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockForm />)
|
||||
|
||||
const input = screen.getByPlaceholderText('Email')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'invalid' } })
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Invalid email format')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'valid@email.com' } })
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('search filtering works', () => {
|
||||
const MockSearch = () => {
|
||||
const [query, setQuery] = React.useState('')
|
||||
const items = ['Apple', 'Banana', 'Cherry', 'Date']
|
||||
const filtered = items.filter(item =>
|
||||
item.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
placeholder="Search items"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<ul>
|
||||
{filtered.map((item, index) => (
|
||||
<li key={index}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockSearch />)
|
||||
|
||||
// All items visible initially
|
||||
expect(screen.getByText('Apple')).toBeInTheDocument()
|
||||
expect(screen.getByText('Banana')).toBeInTheDocument()
|
||||
|
||||
// Filter items
|
||||
const input = screen.getByPlaceholderText('Search items')
|
||||
fireEvent.change(input, { target: { value: 'a' } })
|
||||
|
||||
expect(screen.getByText('Apple')).toBeInTheDocument()
|
||||
expect(screen.getByText('Banana')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Cherry')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,107 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { DeleteConfirmModal } from '../../../src/components/common/DeleteConfirmModal';
|
||||
|
||||
describe('DeleteConfirmModal', () => {
|
||||
const defaultProps = {
|
||||
itemName: 'Test Item',
|
||||
onConfirm: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
type: 'task' as const,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders with correct title and message for task type', () => {
|
||||
render(<DeleteConfirmModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Delete Task')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Are you sure you want to delete the "Test Item" task/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with correct title and message for project type', () => {
|
||||
render(<DeleteConfirmModal {...defaultProps} type="project" />);
|
||||
|
||||
expect(screen.getByText('Delete Project')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Are you sure you want to delete the "Test Item" project/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with correct title and message for client type', () => {
|
||||
render(<DeleteConfirmModal {...defaultProps} type="client" />);
|
||||
|
||||
expect(screen.getByText('Delete MCP Client')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Are you sure you want to delete the "Test Item" client/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onConfirm when Delete button is clicked', () => {
|
||||
render(<DeleteConfirmModal {...defaultProps} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Delete'));
|
||||
|
||||
expect(defaultProps.onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onCancel when Cancel button is clicked', () => {
|
||||
render(<DeleteConfirmModal {...defaultProps} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
|
||||
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onCancel when Escape key is pressed', () => {
|
||||
render(<DeleteConfirmModal {...defaultProps} />);
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' });
|
||||
|
||||
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onCancel when backdrop is clicked', () => {
|
||||
render(<DeleteConfirmModal {...defaultProps} />);
|
||||
|
||||
// Click the backdrop
|
||||
const backdrop = screen.getByTestId('modal-backdrop');
|
||||
fireEvent.click(backdrop);
|
||||
|
||||
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not call onCancel when modal content is clicked', () => {
|
||||
render(<DeleteConfirmModal {...defaultProps} />);
|
||||
|
||||
// Click the modal dialog itself
|
||||
fireEvent.click(screen.getByRole('dialog'));
|
||||
|
||||
expect(defaultProps.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('has proper accessibility attributes', () => {
|
||||
render(<DeleteConfirmModal {...defaultProps} />);
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true');
|
||||
expect(dialog).toHaveAttribute('aria-labelledby');
|
||||
expect(dialog).toHaveAttribute('aria-describedby');
|
||||
});
|
||||
|
||||
it('focuses Cancel button by default', () => {
|
||||
render(<DeleteConfirmModal {...defaultProps} />);
|
||||
|
||||
const cancelButton = screen.getByText('Cancel');
|
||||
expect(cancelButton).toHaveFocus();
|
||||
});
|
||||
|
||||
it('has proper button types', () => {
|
||||
render(<DeleteConfirmModal {...defaultProps} />);
|
||||
|
||||
const cancelButton = screen.getByText('Cancel');
|
||||
const deleteButton = screen.getByText('Delete');
|
||||
|
||||
expect(cancelButton).toHaveAttribute('type', 'button');
|
||||
expect(deleteButton).toHaveAttribute('type', 'button');
|
||||
});
|
||||
});
|
||||
@@ -1,407 +0,0 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest'
|
||||
import React from 'react'
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock('../../../src/contexts/ToastContext', () => ({
|
||||
useToast: () => ({
|
||||
showToast: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/services/projectService', () => ({
|
||||
projectService: {
|
||||
getProjectDocuments: vi.fn().mockResolvedValue([]),
|
||||
deleteDocument: vi.fn().mockResolvedValue(undefined),
|
||||
updateDocument: vi.fn().mockResolvedValue({ id: 'doc-1', title: 'Updated' }),
|
||||
getDocument: vi.fn().mockResolvedValue({ id: 'doc-1', title: 'Document 1' })
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/services/knowledgeBaseService', () => ({
|
||||
knowledgeBaseService: {
|
||||
getItems: vi.fn().mockResolvedValue([])
|
||||
}
|
||||
}))
|
||||
|
||||
// Create a minimal DocsTab component for testing
|
||||
const DocsTabTest = () => {
|
||||
const [documents, setDocuments] = React.useState([
|
||||
{
|
||||
id: 'doc-1',
|
||||
title: 'Document 1',
|
||||
content: { type: 'prp' },
|
||||
document_type: 'prp',
|
||||
updated_at: '2025-07-30T12:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'doc-2',
|
||||
title: 'Document 2',
|
||||
content: { type: 'technical' },
|
||||
document_type: 'technical',
|
||||
updated_at: '2025-07-30T13:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'doc-3',
|
||||
title: 'Document 3',
|
||||
content: { type: 'business' },
|
||||
document_type: 'business',
|
||||
updated_at: '2025-07-30T14:00:00Z'
|
||||
}
|
||||
])
|
||||
|
||||
const [selectedDocument, setSelectedDocument] = React.useState(documents[0])
|
||||
const { showToast } = { showToast: vi.fn() }
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-700">
|
||||
{documents.map(doc => (
|
||||
<div
|
||||
key={doc.id}
|
||||
data-testid={`document-card-${doc.id}`}
|
||||
className={`flex-shrink-0 w-48 p-4 rounded-lg cursor-pointer ${
|
||||
selectedDocument?.id === doc.id ? 'border-2 border-blue-500' : 'border border-gray-200'
|
||||
}`}
|
||||
onClick={() => setSelectedDocument(doc)}
|
||||
>
|
||||
<div className={`text-xs ${doc.document_type}`}>{doc.document_type}</div>
|
||||
<h4>{doc.title}</h4>
|
||||
{selectedDocument?.id !== doc.id && (
|
||||
<button
|
||||
data-testid={`delete-${doc.id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (confirm(`Delete "${doc.title}"?`)) {
|
||||
setDocuments(prev => prev.filter(d => d.id !== doc.id))
|
||||
if (selectedDocument?.id === doc.id) {
|
||||
setSelectedDocument(documents.find(d => d.id !== doc.id) || null)
|
||||
}
|
||||
showToast('Document deleted', 'success')
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
data-testid="new-document-card"
|
||||
className="flex-shrink-0 w-48 h-32 rounded-lg border-2 border-dashed"
|
||||
onClick={() => console.log('New document')}
|
||||
>
|
||||
New Document
|
||||
</div>
|
||||
</div>
|
||||
{selectedDocument && (
|
||||
<div data-testid="selected-document">
|
||||
Selected: {selectedDocument.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('DocsTab Document Cards Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test('renders all document cards', () => {
|
||||
render(<DocsTabTest />)
|
||||
|
||||
expect(screen.getByTestId('document-card-doc-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('document-card-doc-2')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('document-card-doc-3')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('new-document-card')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('shows active state on selected document', () => {
|
||||
render(<DocsTabTest />)
|
||||
|
||||
const doc1 = screen.getByTestId('document-card-doc-1')
|
||||
expect(doc1.className).toContain('border-blue-500')
|
||||
|
||||
const doc2 = screen.getByTestId('document-card-doc-2')
|
||||
expect(doc2.className).not.toContain('border-blue-500')
|
||||
})
|
||||
|
||||
test('switches between documents', () => {
|
||||
render(<DocsTabTest />)
|
||||
|
||||
// Initially doc-1 is selected
|
||||
expect(screen.getByTestId('selected-document')).toHaveTextContent('Selected: Document 1')
|
||||
|
||||
// Click on doc-2
|
||||
fireEvent.click(screen.getByTestId('document-card-doc-2'))
|
||||
|
||||
// Now doc-2 should be selected
|
||||
expect(screen.getByTestId('selected-document')).toHaveTextContent('Selected: Document 2')
|
||||
|
||||
// Check active states
|
||||
expect(screen.getByTestId('document-card-doc-1').className).not.toContain('border-blue-500')
|
||||
expect(screen.getByTestId('document-card-doc-2').className).toContain('border-blue-500')
|
||||
})
|
||||
|
||||
test('deletes document with confirmation', () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
|
||||
render(<DocsTabTest />)
|
||||
|
||||
// Click delete on doc-2
|
||||
const deleteButton = screen.getByTestId('delete-doc-2')
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('Delete "Document 2"?')
|
||||
|
||||
// Document should be removed
|
||||
expect(screen.queryByTestId('document-card-doc-2')).not.toBeInTheDocument()
|
||||
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
test('cancels delete when user declines', () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
|
||||
render(<DocsTabTest />)
|
||||
|
||||
// Click delete on doc-2
|
||||
const deleteButton = screen.getByTestId('delete-doc-2')
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
// Document should still be there
|
||||
expect(screen.getByTestId('document-card-doc-2')).toBeInTheDocument()
|
||||
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
test('selects next document when deleting active document', () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
|
||||
render(<DocsTabTest />)
|
||||
|
||||
// doc-1 is initially selected
|
||||
expect(screen.getByTestId('selected-document')).toHaveTextContent('Selected: Document 1')
|
||||
|
||||
// Switch to doc-2
|
||||
fireEvent.click(screen.getByTestId('document-card-doc-2'))
|
||||
expect(screen.getByTestId('selected-document')).toHaveTextContent('Selected: Document 2')
|
||||
|
||||
// Switch to doc-1 to delete a non-selected document
|
||||
fireEvent.click(screen.getByTestId('document-card-doc-1'))
|
||||
|
||||
// Delete doc-2 (not currently selected - it should have delete button)
|
||||
const deleteButton = screen.getByTestId('delete-doc-2')
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
// Should automatically select another document
|
||||
expect(screen.getByTestId('selected-document')).toHaveTextContent('Selected: Document')
|
||||
expect(screen.queryByTestId('document-card-doc-2')).not.toBeInTheDocument()
|
||||
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
test('does not show delete button on active card', () => {
|
||||
render(<DocsTabTest />)
|
||||
|
||||
// doc-1 is active, should not have delete button
|
||||
expect(screen.queryByTestId('delete-doc-1')).not.toBeInTheDocument()
|
||||
|
||||
// doc-2 is not active, should have delete button
|
||||
expect(screen.getByTestId('delete-doc-2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('horizontal scroll container has correct classes', () => {
|
||||
const { container } = render(<DocsTabTest />)
|
||||
|
||||
const scrollContainer = container.querySelector('.overflow-x-auto')
|
||||
expect(scrollContainer).toBeInTheDocument()
|
||||
expect(scrollContainer?.className).toContain('scrollbar-thin')
|
||||
expect(scrollContainer?.className).toContain('scrollbar-thumb-gray-300')
|
||||
})
|
||||
|
||||
test('document cards maintain fixed width', () => {
|
||||
render(<DocsTabTest />)
|
||||
|
||||
const cards = screen.getAllByTestId(/document-card-doc-/)
|
||||
cards.forEach(card => {
|
||||
expect(card.className).toContain('flex-shrink-0')
|
||||
expect(card.className).toContain('w-48')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('DocsTab Document API Integration', () => {
|
||||
test('calls deleteDocument API when deleting a document', async () => {
|
||||
const { projectService } = await import('../../../src/services/projectService')
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
|
||||
// Create a test component that uses the actual deletion logic
|
||||
const DocsTabWithAPI = () => {
|
||||
const [documents, setDocuments] = React.useState([
|
||||
{ id: 'doc-1', title: 'Document 1', content: {}, document_type: 'prp', updated_at: '2025-07-30' },
|
||||
{ id: 'doc-2', title: 'Document 2', content: {}, document_type: 'spec', updated_at: '2025-07-30' }
|
||||
])
|
||||
const [selectedDocument, setSelectedDocument] = React.useState(documents[0])
|
||||
const project = { id: 'proj-123', title: 'Test Project' }
|
||||
const { showToast } = { showToast: vi.fn() }
|
||||
|
||||
const handleDelete = async (docId: string) => {
|
||||
try {
|
||||
// This mirrors the actual DocsTab deletion logic
|
||||
await projectService.deleteDocument(project.id, docId)
|
||||
setDocuments(prev => prev.filter(d => d.id !== docId))
|
||||
if (selectedDocument?.id === docId) {
|
||||
setSelectedDocument(documents.find(d => d.id !== docId) || null)
|
||||
}
|
||||
showToast('Document deleted', 'success')
|
||||
} catch (error) {
|
||||
console.error('Failed to delete document:', error)
|
||||
showToast('Failed to delete document', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{documents.map(doc => (
|
||||
<div key={doc.id} data-testid={`doc-${doc.id}`}>
|
||||
<span>{doc.title}</span>
|
||||
<button
|
||||
data-testid={`delete-${doc.id}`}
|
||||
onClick={() => {
|
||||
if (confirm(`Delete "${doc.title}"?`)) {
|
||||
handleDelete(doc.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<DocsTabWithAPI />)
|
||||
|
||||
// Click delete button
|
||||
fireEvent.click(screen.getByTestId('delete-doc-2'))
|
||||
|
||||
// Wait for async operations
|
||||
await waitFor(() => {
|
||||
expect(projectService.deleteDocument).toHaveBeenCalledWith('proj-123', 'doc-2')
|
||||
})
|
||||
|
||||
// Verify document is removed from UI
|
||||
expect(screen.queryByTestId('doc-doc-2')).not.toBeInTheDocument()
|
||||
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
test('handles deletion API errors gracefully', async () => {
|
||||
const { projectService } = await import('../../../src/services/projectService')
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
// Make deleteDocument reject
|
||||
projectService.deleteDocument = vi.fn().mockRejectedValue(new Error('API Error'))
|
||||
|
||||
const DocsTabWithError = () => {
|
||||
const [documents, setDocuments] = React.useState([
|
||||
{ id: 'doc-1', title: 'Document 1', content: {}, document_type: 'prp', updated_at: '2025-07-30' }
|
||||
])
|
||||
const project = { id: 'proj-123', title: 'Test Project' }
|
||||
const showToast = vi.fn()
|
||||
|
||||
const handleDelete = async (docId: string) => {
|
||||
try {
|
||||
await projectService.deleteDocument(project.id, docId)
|
||||
setDocuments(prev => prev.filter(d => d.id !== docId))
|
||||
showToast('Document deleted', 'success')
|
||||
} catch (error) {
|
||||
console.error('Failed to delete document:', error)
|
||||
showToast('Failed to delete document', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{documents.map(doc => (
|
||||
<div key={doc.id} data-testid={`doc-${doc.id}`}>
|
||||
<button
|
||||
data-testid={`delete-${doc.id}`}
|
||||
onClick={() => {
|
||||
if (confirm(`Delete "${doc.title}"?`)) {
|
||||
handleDelete(doc.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div data-testid="toast-container" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<DocsTabWithError />)
|
||||
|
||||
// Click delete button
|
||||
fireEvent.click(screen.getByTestId('delete-doc-1'))
|
||||
|
||||
// Wait for async operations
|
||||
await waitFor(() => {
|
||||
expect(projectService.deleteDocument).toHaveBeenCalledWith('proj-123', 'doc-1')
|
||||
})
|
||||
|
||||
// Document should still be in UI due to error
|
||||
expect(screen.getByTestId('doc-doc-1')).toBeInTheDocument()
|
||||
|
||||
// Error should be logged
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Failed to delete document:', expect.any(Error))
|
||||
|
||||
confirmSpy.mockRestore()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
test('deletion persists after page refresh', async () => {
|
||||
const { projectService } = await import('../../../src/services/projectService')
|
||||
|
||||
// Simulate documents before deletion
|
||||
let mockDocuments = [
|
||||
{ id: 'doc-1', title: 'Document 1', content: {}, document_type: 'prp', updated_at: '2025-07-30' },
|
||||
{ id: 'doc-2', title: 'Document 2', content: {}, document_type: 'spec', updated_at: '2025-07-30' }
|
||||
]
|
||||
|
||||
// First render - before deletion
|
||||
const { rerender } = render(<div data-testid="docs-count">{mockDocuments.length}</div>)
|
||||
expect(screen.getByTestId('docs-count')).toHaveTextContent('2')
|
||||
|
||||
// Mock deleteDocument to also update the mock data
|
||||
projectService.deleteDocument = vi.fn().mockImplementation(async (projectId, docId) => {
|
||||
mockDocuments = mockDocuments.filter(d => d.id !== docId)
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
// Mock the list function to return current state
|
||||
projectService.listProjectDocuments = vi.fn().mockImplementation(async () => {
|
||||
return mockDocuments
|
||||
})
|
||||
|
||||
// Perform deletion
|
||||
await projectService.deleteDocument('proj-123', 'doc-2')
|
||||
|
||||
// Simulate page refresh by re-fetching documents
|
||||
const refreshedDocs = await projectService.listProjectDocuments('proj-123')
|
||||
|
||||
// Re-render with refreshed data
|
||||
rerender(<div data-testid="docs-count">{refreshedDocs.length}</div>)
|
||||
|
||||
// Should only have 1 document after refresh
|
||||
expect(screen.getByTestId('docs-count')).toHaveTextContent('1')
|
||||
expect(refreshedDocs).toHaveLength(1)
|
||||
expect(refreshedDocs[0].id).toBe('doc-1')
|
||||
})
|
||||
})
|
||||
@@ -1,227 +0,0 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, test, expect, vi } from 'vitest'
|
||||
import React from 'react'
|
||||
import { DocumentCard, NewDocumentCard } from '../../../src/components/project-tasks/DocumentCard'
|
||||
import type { ProjectDoc } from '../../../src/components/project-tasks/DocumentCard'
|
||||
|
||||
describe('DocumentCard', () => {
|
||||
const mockDocument: ProjectDoc = {
|
||||
id: 'doc-1',
|
||||
title: 'Test Document',
|
||||
content: { test: 'content' },
|
||||
document_type: 'prp',
|
||||
updated_at: '2025-07-30T12:00:00Z',
|
||||
}
|
||||
|
||||
const mockHandlers = {
|
||||
onSelect: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test('renders document card with correct content', () => {
|
||||
render(
|
||||
<DocumentCard
|
||||
document={mockDocument}
|
||||
isActive={false}
|
||||
onSelect={mockHandlers.onSelect}
|
||||
onDelete={mockHandlers.onDelete}
|
||||
isDarkMode={false}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test Document')).toBeInTheDocument()
|
||||
expect(screen.getByText('prp')).toBeInTheDocument()
|
||||
expect(screen.getByText('7/30/2025')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('shows correct icon and color for different document types', () => {
|
||||
const documentTypes = [
|
||||
{ type: 'prp', expectedClass: 'text-blue-600' },
|
||||
{ type: 'technical', expectedClass: 'text-green-600' },
|
||||
{ type: 'business', expectedClass: 'text-purple-600' },
|
||||
{ type: 'meeting_notes', expectedClass: 'text-orange-600' },
|
||||
]
|
||||
|
||||
documentTypes.forEach(({ type, expectedClass }) => {
|
||||
const { container, rerender } = render(
|
||||
<DocumentCard
|
||||
document={{ ...mockDocument, document_type: type }}
|
||||
isActive={false}
|
||||
onSelect={mockHandlers.onSelect}
|
||||
onDelete={mockHandlers.onDelete}
|
||||
isDarkMode={false}
|
||||
/>
|
||||
)
|
||||
|
||||
const badge = container.querySelector(`.${expectedClass}`)
|
||||
expect(badge).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
test('applies active styles when selected', () => {
|
||||
const { container } = render(
|
||||
<DocumentCard
|
||||
document={mockDocument}
|
||||
isActive={true}
|
||||
onSelect={mockHandlers.onSelect}
|
||||
onDelete={mockHandlers.onDelete}
|
||||
isDarkMode={false}
|
||||
/>
|
||||
)
|
||||
|
||||
const card = container.firstChild as HTMLElement
|
||||
expect(card.className).toContain('border-blue-500')
|
||||
expect(card.className).toContain('scale-105')
|
||||
})
|
||||
|
||||
test('calls onSelect when clicked', () => {
|
||||
render(
|
||||
<DocumentCard
|
||||
document={mockDocument}
|
||||
isActive={false}
|
||||
onSelect={mockHandlers.onSelect}
|
||||
onDelete={mockHandlers.onDelete}
|
||||
isDarkMode={false}
|
||||
/>
|
||||
)
|
||||
|
||||
const card = screen.getByText('Test Document').closest('div')
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(mockHandlers.onSelect).toHaveBeenCalledWith(mockDocument)
|
||||
})
|
||||
|
||||
test('shows delete button on hover', () => {
|
||||
const { container } = render(
|
||||
<DocumentCard
|
||||
document={mockDocument}
|
||||
isActive={false}
|
||||
onSelect={mockHandlers.onSelect}
|
||||
onDelete={mockHandlers.onDelete}
|
||||
isDarkMode={false}
|
||||
/>
|
||||
)
|
||||
|
||||
const card = container.firstChild as HTMLElement
|
||||
|
||||
// Delete button should not be visible initially
|
||||
expect(screen.queryByLabelText('Delete Test Document')).not.toBeInTheDocument()
|
||||
|
||||
// Hover over the card
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
// Delete button should now be visible
|
||||
expect(screen.getByLabelText('Delete Test Document')).toBeInTheDocument()
|
||||
|
||||
// Mouse leave
|
||||
fireEvent.mouseLeave(card)
|
||||
|
||||
// Delete button should be hidden again
|
||||
expect(screen.queryByLabelText('Delete Test Document')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('does not show delete button on active card', () => {
|
||||
const { container } = render(
|
||||
<DocumentCard
|
||||
document={mockDocument}
|
||||
isActive={true}
|
||||
onSelect={mockHandlers.onSelect}
|
||||
onDelete={mockHandlers.onDelete}
|
||||
isDarkMode={false}
|
||||
/>
|
||||
)
|
||||
|
||||
const card = container.firstChild as HTMLElement
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
expect(screen.queryByLabelText('Delete Test Document')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('confirms before deleting', () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
|
||||
const { container } = render(
|
||||
<DocumentCard
|
||||
document={mockDocument}
|
||||
isActive={false}
|
||||
onSelect={mockHandlers.onSelect}
|
||||
onDelete={mockHandlers.onDelete}
|
||||
isDarkMode={false}
|
||||
/>
|
||||
)
|
||||
|
||||
const card = container.firstChild as HTMLElement
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
const deleteButton = screen.getByLabelText('Delete Test Document')
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('Delete "Test Document"?')
|
||||
expect(mockHandlers.onDelete).toHaveBeenCalledWith('doc-1')
|
||||
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
test('cancels delete when user declines', () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
|
||||
const { container } = render(
|
||||
<DocumentCard
|
||||
document={mockDocument}
|
||||
isActive={false}
|
||||
onSelect={mockHandlers.onSelect}
|
||||
onDelete={mockHandlers.onDelete}
|
||||
isDarkMode={false}
|
||||
/>
|
||||
)
|
||||
|
||||
const card = container.firstChild as HTMLElement
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
const deleteButton = screen.getByLabelText('Delete Test Document')
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalled()
|
||||
expect(mockHandlers.onDelete).not.toHaveBeenCalled()
|
||||
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
test('applies dark mode styles correctly', () => {
|
||||
const { container } = render(
|
||||
<DocumentCard
|
||||
document={mockDocument}
|
||||
isActive={false}
|
||||
onSelect={mockHandlers.onSelect}
|
||||
onDelete={mockHandlers.onDelete}
|
||||
isDarkMode={true}
|
||||
/>
|
||||
)
|
||||
|
||||
const card = container.firstChild as HTMLElement
|
||||
expect(card.className).toContain('dark:')
|
||||
})
|
||||
})
|
||||
|
||||
describe('NewDocumentCard', () => {
|
||||
test('renders new document card', () => {
|
||||
const onClick = vi.fn()
|
||||
render(<NewDocumentCard onClick={onClick} />)
|
||||
|
||||
expect(screen.getByText('New Document')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('calls onClick when clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
render(<NewDocumentCard onClick={onClick} />)
|
||||
|
||||
const card = screen.getByText('New Document').closest('div')
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,272 +0,0 @@
|
||||
import { describe, test, expect } from 'vitest'
|
||||
|
||||
// Test the PRP to Markdown conversion logic
|
||||
describe('MilkdownEditor PRP Conversion', () => {
|
||||
// Helper function to format values (extracted from component)
|
||||
const formatValue = (value: any, indent = ''): string => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(item => `${indent}- ${formatValue(item, indent + ' ')}`).join('\n') + '\n'
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
let result = ''
|
||||
Object.entries(value).forEach(([key, val]) => {
|
||||
const formattedKey = key.replace(/_/g, ' ')
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
|
||||
if (typeof val === 'string' || typeof val === 'number') {
|
||||
result += `${indent}**${formattedKey}:** ${val}\n\n`
|
||||
} else {
|
||||
result += `${indent}### ${formattedKey}\n\n${formatValue(val, indent)}`
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
// Simplified version of convertPRPToMarkdown for testing
|
||||
const convertPRPToMarkdown = (content: any, docTitle = 'Test Doc'): string => {
|
||||
let markdown = `# ${content.title || docTitle}\n\n`
|
||||
|
||||
// Metadata section
|
||||
if (content.version || content.author || content.date || content.status) {
|
||||
markdown += `## Metadata\n\n`
|
||||
if (content.version) markdown += `- **Version:** ${content.version}\n`
|
||||
if (content.author) markdown += `- **Author:** ${content.author}\n`
|
||||
if (content.date) markdown += `- **Date:** ${content.date}\n`
|
||||
if (content.status) markdown += `- **Status:** ${content.status}\n`
|
||||
markdown += '\n'
|
||||
}
|
||||
|
||||
// Goal section
|
||||
if (content.goal) {
|
||||
markdown += `## Goal\n\n${content.goal}\n\n`
|
||||
}
|
||||
|
||||
// Why section
|
||||
if (content.why) {
|
||||
markdown += `## Why\n\n`
|
||||
if (Array.isArray(content.why)) {
|
||||
content.why.forEach(item => markdown += `- ${item}\n`)
|
||||
} else {
|
||||
markdown += `${content.why}\n`
|
||||
}
|
||||
markdown += '\n'
|
||||
}
|
||||
|
||||
// What section
|
||||
if (content.what) {
|
||||
markdown += `## What\n\n`
|
||||
if (typeof content.what === 'string') {
|
||||
markdown += `${content.what}\n\n`
|
||||
} else if (content.what.description) {
|
||||
markdown += `${content.what.description}\n\n`
|
||||
|
||||
if (content.what.success_criteria) {
|
||||
markdown += `### Success Criteria\n\n`
|
||||
content.what.success_criteria.forEach((criterion: string) => {
|
||||
markdown += `- [ ] ${criterion}\n`
|
||||
})
|
||||
markdown += '\n'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle all other sections dynamically
|
||||
const handledKeys = [
|
||||
'title', 'version', 'author', 'date', 'status', 'goal', 'why', 'what',
|
||||
'document_type'
|
||||
]
|
||||
|
||||
Object.entries(content).forEach(([key, value]) => {
|
||||
if (!handledKeys.includes(key) && value) {
|
||||
const sectionTitle = key.replace(/_/g, ' ')
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
|
||||
markdown += `## ${sectionTitle}\n\n`
|
||||
markdown += formatValue(value)
|
||||
markdown += '\n'
|
||||
}
|
||||
})
|
||||
|
||||
return markdown
|
||||
}
|
||||
|
||||
test('converts basic PRP structure to markdown', () => {
|
||||
const prp = {
|
||||
title: 'Test PRP',
|
||||
version: '1.0',
|
||||
author: 'Test Author',
|
||||
date: '2025-07-30',
|
||||
status: 'draft',
|
||||
goal: 'Test goal'
|
||||
}
|
||||
|
||||
const markdown = convertPRPToMarkdown(prp)
|
||||
|
||||
expect(markdown).toContain('# Test PRP')
|
||||
expect(markdown).toContain('## Metadata')
|
||||
expect(markdown).toContain('- **Version:** 1.0')
|
||||
expect(markdown).toContain('- **Author:** Test Author')
|
||||
expect(markdown).toContain('- **Date:** 2025-07-30')
|
||||
expect(markdown).toContain('- **Status:** draft')
|
||||
expect(markdown).toContain('## Goal\n\nTest goal')
|
||||
})
|
||||
|
||||
test('handles array why section', () => {
|
||||
const prp = {
|
||||
title: 'Test PRP',
|
||||
why: ['Reason 1', 'Reason 2', 'Reason 3']
|
||||
}
|
||||
|
||||
const markdown = convertPRPToMarkdown(prp)
|
||||
|
||||
expect(markdown).toContain('## Why')
|
||||
expect(markdown).toContain('- Reason 1')
|
||||
expect(markdown).toContain('- Reason 2')
|
||||
expect(markdown).toContain('- Reason 3')
|
||||
})
|
||||
|
||||
test('handles string why section', () => {
|
||||
const prp = {
|
||||
title: 'Test PRP',
|
||||
why: 'Single reason for the change'
|
||||
}
|
||||
|
||||
const markdown = convertPRPToMarkdown(prp)
|
||||
|
||||
expect(markdown).toContain('## Why')
|
||||
expect(markdown).toContain('Single reason for the change')
|
||||
})
|
||||
|
||||
test('handles complex what section with success criteria', () => {
|
||||
const prp = {
|
||||
title: 'Test PRP',
|
||||
what: {
|
||||
description: 'Main description of what we are building',
|
||||
success_criteria: [
|
||||
'Criterion 1',
|
||||
'Criterion 2',
|
||||
'Criterion 3'
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const markdown = convertPRPToMarkdown(prp)
|
||||
|
||||
expect(markdown).toContain('## What')
|
||||
expect(markdown).toContain('Main description of what we are building')
|
||||
expect(markdown).toContain('### Success Criteria')
|
||||
expect(markdown).toContain('- [ ] Criterion 1')
|
||||
expect(markdown).toContain('- [ ] Criterion 2')
|
||||
expect(markdown).toContain('- [ ] Criterion 3')
|
||||
})
|
||||
|
||||
test('handles dynamic sections', () => {
|
||||
const prp = {
|
||||
title: 'Test PRP',
|
||||
user_personas: {
|
||||
developer: {
|
||||
name: 'Developer Dan',
|
||||
goals: ['Write clean code', 'Ship features fast']
|
||||
}
|
||||
},
|
||||
technical_requirements: {
|
||||
frontend: 'React 18',
|
||||
backend: 'FastAPI',
|
||||
database: 'PostgreSQL'
|
||||
}
|
||||
}
|
||||
|
||||
const markdown = convertPRPToMarkdown(prp)
|
||||
|
||||
expect(markdown).toContain('## User Personas')
|
||||
expect(markdown).toContain('### Developer')
|
||||
expect(markdown).toContain('**Name:** Developer Dan')
|
||||
expect(markdown).toContain('## Technical Requirements')
|
||||
expect(markdown).toContain('**Frontend:** React 18')
|
||||
expect(markdown).toContain('**Backend:** FastAPI')
|
||||
})
|
||||
|
||||
test('formats nested objects correctly', () => {
|
||||
const value = {
|
||||
level1: {
|
||||
level2: {
|
||||
level3: 'Deep value'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const formatted = formatValue(value)
|
||||
|
||||
expect(formatted).toContain('### Level1')
|
||||
expect(formatted).toContain('### Level2')
|
||||
expect(formatted).toContain('**Level3:** Deep value')
|
||||
})
|
||||
|
||||
test('formats arrays correctly', () => {
|
||||
const value = ['Item 1', 'Item 2', { nested: 'Nested item' }]
|
||||
|
||||
const formatted = formatValue(value)
|
||||
|
||||
expect(formatted).toContain('- Item 1')
|
||||
expect(formatted).toContain('- Item 2')
|
||||
expect(formatted).toContain('**Nested:** Nested item')
|
||||
})
|
||||
|
||||
test('handles empty content', () => {
|
||||
const prp = {}
|
||||
|
||||
const markdown = convertPRPToMarkdown(prp, 'Default Title')
|
||||
|
||||
expect(markdown).toBe('# Default Title\n\n')
|
||||
})
|
||||
|
||||
test('skips null and undefined values', () => {
|
||||
const prp = {
|
||||
title: 'Test PRP',
|
||||
null_field: null,
|
||||
undefined_field: undefined,
|
||||
empty_string: '',
|
||||
valid_field: 'Valid content'
|
||||
}
|
||||
|
||||
const markdown = convertPRPToMarkdown(prp)
|
||||
|
||||
expect(markdown).not.toContain('Null Field')
|
||||
expect(markdown).not.toContain('Undefined Field')
|
||||
expect(markdown).not.toContain('Empty String')
|
||||
expect(markdown).toContain('## Valid Field')
|
||||
expect(markdown).toContain('Valid content')
|
||||
})
|
||||
|
||||
test('converts snake_case to Title Case', () => {
|
||||
const prp = {
|
||||
title: 'Test PRP',
|
||||
user_journey_mapping: 'Content',
|
||||
api_endpoint_design: 'More content'
|
||||
}
|
||||
|
||||
const markdown = convertPRPToMarkdown(prp)
|
||||
|
||||
expect(markdown).toContain('## User Journey Mapping')
|
||||
expect(markdown).toContain('## Api Endpoint Design')
|
||||
})
|
||||
|
||||
test('preserves markdown formatting in content', () => {
|
||||
const prp = {
|
||||
title: 'Test PRP',
|
||||
description: '**Bold text** and *italic text* with `code`'
|
||||
}
|
||||
|
||||
const markdown = convertPRPToMarkdown(prp)
|
||||
|
||||
expect(markdown).toContain('**Bold text** and *italic text* with `code`')
|
||||
})
|
||||
})
|
||||
@@ -1,60 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
describe('TasksTab Drag and Drop Integration', () => {
|
||||
it('should properly manage movingTaskIds during drag operations', () => {
|
||||
const tasksTabPath = join(process.cwd(), 'src/components/project-tasks/TasksTab.tsx');
|
||||
const fileContent = readFileSync(tasksTabPath, 'utf-8');
|
||||
|
||||
// Check that moveTask adds task to movingTaskIds
|
||||
expect(fileContent).toContain('setMovingTaskIds(prev => new Set([...prev, taskId]))');
|
||||
|
||||
// Check that moveTask removes task from movingTaskIds in finally block
|
||||
expect(fileContent).toContain('finally {');
|
||||
expect(fileContent).toMatch(/finally\s*{\s*\/\/\s*Remove from loading set\s*setMovingTaskIds/);
|
||||
|
||||
// Check that the cleanup happens even on error
|
||||
const moveTaskMatch = fileContent.match(/const moveTask[\s\S]*?\n{2}\};/);
|
||||
expect(moveTaskMatch).toBeTruthy();
|
||||
if (moveTaskMatch) {
|
||||
const moveTaskFunction = moveTaskMatch[0];
|
||||
expect(moveTaskFunction).toContain('try {');
|
||||
expect(moveTaskFunction).toContain('catch (error)');
|
||||
expect(moveTaskFunction).toContain('finally {');
|
||||
}
|
||||
});
|
||||
|
||||
it('should pass movingTaskIds to TaskBoardView', () => {
|
||||
const tasksTabPath = join(process.cwd(), 'src/components/project-tasks/TasksTab.tsx');
|
||||
const fileContent = readFileSync(tasksTabPath, 'utf-8');
|
||||
|
||||
// Check that movingTaskIds is passed to TaskBoardView
|
||||
expect(fileContent).toContain('movingTaskIds={movingTaskIds}');
|
||||
});
|
||||
|
||||
it('should handle task completion through moveTask', () => {
|
||||
const tasksTabPath = join(process.cwd(), 'src/components/project-tasks/TasksTab.tsx');
|
||||
const fileContent = readFileSync(tasksTabPath, 'utf-8');
|
||||
|
||||
// Check that completeTask calls moveTask
|
||||
expect(fileContent).toMatch(/completeTask.*moveTask\(taskId, 'done'\)/s);
|
||||
});
|
||||
|
||||
it('should have optimistic updates in moveTask', () => {
|
||||
const tasksTabPath = join(process.cwd(), 'src/components/project-tasks/TasksTab.tsx');
|
||||
const fileContent = readFileSync(tasksTabPath, 'utf-8');
|
||||
|
||||
// Check for optimistic update comment and implementation
|
||||
expect(fileContent).toContain('// Optimistically update UI for immediate feedback');
|
||||
expect(fileContent).toContain('setTasks(prev => prev.map(task =>');
|
||||
});
|
||||
|
||||
it('should revert on error as indicated by comment', () => {
|
||||
const tasksTabPath = join(process.cwd(), 'src/components/project-tasks/TasksTab.tsx');
|
||||
const fileContent = readFileSync(tasksTabPath, 'utf-8');
|
||||
|
||||
// Check for revert comment
|
||||
expect(fileContent).toContain('// Revert optimistic update - polling will sync correct state');
|
||||
});
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, test, expect, vi } from 'vitest'
|
||||
import React from 'react'
|
||||
import { PRPViewer } from '../../../src/components/prp/PRPViewer'
|
||||
import type { PRPContent } from '../../../src/components/prp/types/prp.types'
|
||||
|
||||
describe('PRPViewer', () => {
|
||||
const mockContent: PRPContent = {
|
||||
title: 'Test PRP',
|
||||
version: '1.0',
|
||||
author: 'Test Author',
|
||||
date: '2025-07-30',
|
||||
status: 'draft',
|
||||
goal: 'Test goal with [Image #1] placeholder',
|
||||
why: 'Test reason with [Image #2] reference',
|
||||
what: {
|
||||
description: 'Test description with [Image #3] and [Image #4]',
|
||||
success_criteria: ['Criterion 1', 'Criterion 2 with [Image #5]']
|
||||
},
|
||||
context: {
|
||||
background: 'Background with [Image #6]',
|
||||
objectives: ['Objective 1', 'Objective 2']
|
||||
}
|
||||
}
|
||||
|
||||
test('renders without [Image #N] placeholders', () => {
|
||||
render(<PRPViewer content={mockContent} />)
|
||||
|
||||
// Check that [Image #N] placeholders are replaced
|
||||
expect(screen.queryByText(/\[Image #\d+\]/)).not.toBeInTheDocument()
|
||||
|
||||
// Check that content is present
|
||||
expect(screen.getByText(/Test goal/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Test reason/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Test description/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('processes nested content with image placeholders', () => {
|
||||
const { container } = render(<PRPViewer content={mockContent} />)
|
||||
|
||||
// Check that the content has been processed
|
||||
const htmlContent = container.innerHTML
|
||||
|
||||
// Should not contain raw [Image #N] text
|
||||
expect(htmlContent).not.toMatch(/\[Image #\d+\]/)
|
||||
|
||||
// Should contain processed markdown image syntax
|
||||
expect(htmlContent).toContain('Image 1')
|
||||
expect(htmlContent).toContain('Image 2')
|
||||
})
|
||||
|
||||
test('renders metadata section correctly', () => {
|
||||
render(<PRPViewer content={mockContent} />)
|
||||
|
||||
expect(screen.getByText('Test PRP')).toBeInTheDocument()
|
||||
expect(screen.getByText('1.0')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test Author')).toBeInTheDocument()
|
||||
expect(screen.getByText('draft')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('handles empty content gracefully', () => {
|
||||
render(<PRPViewer content={{} as PRPContent} />)
|
||||
|
||||
// Should render without errors
|
||||
expect(screen.getByText(/Metadata/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('handles null content', () => {
|
||||
render(<PRPViewer content={null as any} />)
|
||||
|
||||
expect(screen.getByText('No PRP content available')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('handles string content in objects', () => {
|
||||
const stringContent = {
|
||||
title: 'String Test',
|
||||
description: 'This has [Image #1] in it'
|
||||
}
|
||||
|
||||
render(<PRPViewer content={stringContent as any} />)
|
||||
|
||||
// Should process the image placeholder
|
||||
expect(screen.queryByText(/\[Image #1\]/)).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/This has/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('handles array content with image placeholders', () => {
|
||||
const arrayContent = {
|
||||
title: 'Array Test',
|
||||
items: [
|
||||
'Item 1 with [Image #1]',
|
||||
'Item 2 with [Image #2]',
|
||||
{ nested: 'Nested with [Image #3]' }
|
||||
]
|
||||
}
|
||||
|
||||
render(<PRPViewer content={arrayContent as any} />)
|
||||
|
||||
// Should process all image placeholders
|
||||
expect(screen.queryByText(/\[Image #\d+\]/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('renders collapsible sections', () => {
|
||||
render(<PRPViewer content={mockContent} />)
|
||||
|
||||
// Find collapsible sections
|
||||
const contextSection = screen.getByText('Context').closest('div')
|
||||
expect(contextSection).toBeInTheDocument()
|
||||
|
||||
// Should have chevron icon for collapsible sections
|
||||
const chevrons = screen.getAllByTestId('chevron-icon')
|
||||
expect(chevrons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('toggles section visibility', () => {
|
||||
render(<PRPViewer content={mockContent} />)
|
||||
|
||||
// Find a collapsible section header
|
||||
const contextHeader = screen.getByText('Context').closest('button')
|
||||
|
||||
// The section should be visible initially (defaultOpen for first 5 sections)
|
||||
expect(screen.getByText(/Background with/)).toBeInTheDocument()
|
||||
|
||||
// Click to collapse
|
||||
fireEvent.click(contextHeader!)
|
||||
|
||||
// Content should be hidden
|
||||
expect(screen.queryByText(/Background with/)).not.toBeInTheDocument()
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(contextHeader!)
|
||||
|
||||
// Content should be visible again
|
||||
expect(screen.getByText(/Background with/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('applies dark mode styles', () => {
|
||||
const { container } = render(<PRPViewer content={mockContent} isDarkMode={true} />)
|
||||
|
||||
const viewer = container.querySelector('.prp-viewer')
|
||||
expect(viewer?.className).toContain('dark')
|
||||
})
|
||||
|
||||
test('uses section overrides when provided', () => {
|
||||
const CustomSection = ({ data, title }: any) => (
|
||||
<div data-testid="custom-section">
|
||||
<h3>{title}</h3>
|
||||
<p>Custom rendering of: {JSON.stringify(data)}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
const overrides = {
|
||||
context: CustomSection
|
||||
}
|
||||
|
||||
render(<PRPViewer content={mockContent} sectionOverrides={overrides} />)
|
||||
|
||||
expect(screen.getByTestId('custom-section')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Custom rendering of/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('sorts sections by group', () => {
|
||||
const complexContent = {
|
||||
title: 'Complex PRP',
|
||||
// These should be sorted in a specific order
|
||||
validation_gates: { test: 'validation' },
|
||||
user_personas: { test: 'personas' },
|
||||
context: { test: 'context' },
|
||||
user_flows: { test: 'flows' },
|
||||
success_metrics: { test: 'metrics' }
|
||||
}
|
||||
|
||||
const { container } = render(<PRPViewer content={complexContent as any} />)
|
||||
|
||||
// Get all section titles in order
|
||||
const sectionTitles = Array.from(
|
||||
container.querySelectorAll('h3')
|
||||
).map(el => el.textContent)
|
||||
|
||||
// Context should come before personas
|
||||
const contextIndex = sectionTitles.findIndex(t => t?.includes('Context'))
|
||||
const personasIndex = sectionTitles.findIndex(t => t?.includes('Personas'))
|
||||
|
||||
expect(contextIndex).toBeLessThan(personasIndex)
|
||||
})
|
||||
})
|
||||
@@ -1,206 +0,0 @@
|
||||
/**
|
||||
* Tests for API configuration port requirements
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
describe('API Configuration', () => {
|
||||
let originalEnv: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// Save original environment
|
||||
originalEnv = { ...import.meta.env };
|
||||
|
||||
// Clear the module cache to ensure fresh imports
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original environment
|
||||
Object.keys(import.meta.env).forEach(key => {
|
||||
delete (import.meta.env as any)[key];
|
||||
});
|
||||
Object.assign(import.meta.env, originalEnv);
|
||||
});
|
||||
|
||||
describe('getApiUrl', () => {
|
||||
it('should use VITE_API_URL when provided', async () => {
|
||||
// Set VITE_API_URL
|
||||
(import.meta.env as any).VITE_API_URL = 'http://custom-api:9999';
|
||||
|
||||
const { getApiUrl } = await import('../../src/config/api');
|
||||
expect(getApiUrl()).toBe('http://custom-api:9999');
|
||||
});
|
||||
|
||||
it('should return empty string in production mode', async () => {
|
||||
// Set production mode
|
||||
(import.meta.env as any).PROD = true;
|
||||
|
||||
// It should not use VITE_API_URL
|
||||
(import.meta.env as any).VITE_API_URL = 'http://custom-api:9999';
|
||||
|
||||
const { getApiUrl } = await import('../../src/config/api');
|
||||
expect(getApiUrl()).toBe('');
|
||||
});
|
||||
|
||||
it('should use default port 8181 when no port environment variables are set in development', async () => {
|
||||
// Development mode without any port variables
|
||||
delete (import.meta.env as any).PROD;
|
||||
delete (import.meta.env as any).VITE_API_URL;
|
||||
delete (import.meta.env as any).VITE_ARCHON_SERVER_PORT;
|
||||
delete (import.meta.env as any).VITE_PORT;
|
||||
delete (import.meta.env as any).ARCHON_SERVER_PORT;
|
||||
|
||||
// Mock window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
protocol: 'http:',
|
||||
hostname: 'localhost'
|
||||
},
|
||||
writable: true
|
||||
});
|
||||
|
||||
const { getApiUrl } = await import('../../src/config/api');
|
||||
|
||||
expect(getApiUrl()).toBe('http://localhost:8181');
|
||||
});
|
||||
|
||||
it('should use VITE_ARCHON_SERVER_PORT when set in development', async () => {
|
||||
// Development mode with custom port via VITE_ prefix
|
||||
delete (import.meta.env as any).PROD;
|
||||
delete (import.meta.env as any).VITE_API_URL;
|
||||
(import.meta.env as any).VITE_ARCHON_SERVER_PORT = '9191';
|
||||
|
||||
// Mock window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
protocol: 'http:',
|
||||
hostname: 'localhost'
|
||||
},
|
||||
writable: true
|
||||
});
|
||||
|
||||
const { getApiUrl } = await import('../../src/config/api');
|
||||
expect(getApiUrl()).toBe('http://localhost:9191');
|
||||
});
|
||||
|
||||
it('should use custom port with https protocol', async () => {
|
||||
// Development mode with custom port and https via VITE_ prefix
|
||||
delete (import.meta.env as any).PROD;
|
||||
delete (import.meta.env as any).VITE_API_URL;
|
||||
(import.meta.env as any).VITE_ARCHON_SERVER_PORT = '8443';
|
||||
|
||||
// Mock window.location with https
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
protocol: 'https:',
|
||||
hostname: 'example.com'
|
||||
},
|
||||
writable: true
|
||||
});
|
||||
|
||||
const { getApiUrl } = await import('../../src/config/api');
|
||||
expect(getApiUrl()).toBe('https://example.com:8443');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('Port validation', () => {
|
||||
it('should handle various port formats', async () => {
|
||||
const testCases = [
|
||||
{ port: '80', expected: 'http://localhost:80' },
|
||||
{ port: '443', expected: 'http://localhost:443' },
|
||||
{ port: '3000', expected: 'http://localhost:3000' },
|
||||
{ port: '8080', expected: 'http://localhost:8080' },
|
||||
{ port: '65535', expected: 'http://localhost:65535' },
|
||||
];
|
||||
|
||||
for (const { port, expected } of testCases) {
|
||||
vi.resetModules();
|
||||
delete (import.meta.env as any).PROD;
|
||||
delete (import.meta.env as any).VITE_API_URL;
|
||||
(import.meta.env as any).VITE_ARCHON_SERVER_PORT = port;
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
protocol: 'http:',
|
||||
hostname: 'localhost'
|
||||
},
|
||||
writable: true
|
||||
});
|
||||
|
||||
const { getApiUrl } = await import('../../src/config/api');
|
||||
expect(getApiUrl()).toBe(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP Client Service Configuration', () => {
|
||||
let originalEnv: any;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = { ...import.meta.env };
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.keys(import.meta.env).forEach(key => {
|
||||
delete (import.meta.env as any)[key];
|
||||
});
|
||||
Object.assign(import.meta.env, originalEnv);
|
||||
});
|
||||
|
||||
it('should throw error when ARCHON_MCP_PORT is not set', async () => {
|
||||
delete (import.meta.env as any).ARCHON_MCP_PORT;
|
||||
|
||||
const { mcpClientService } = await import('../../src/services/mcpClientService');
|
||||
|
||||
await expect(mcpClientService.createArchonClient()).rejects.toThrow('ARCHON_MCP_PORT environment variable is required');
|
||||
await expect(mcpClientService.createArchonClient()).rejects.toThrow('Default value: 8051');
|
||||
});
|
||||
|
||||
it('should use ARCHON_MCP_PORT when set', async () => {
|
||||
(import.meta.env as any).ARCHON_MCP_PORT = '9051';
|
||||
(import.meta.env as any).ARCHON_SERVER_PORT = '8181';
|
||||
|
||||
// Mock window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
protocol: 'http:',
|
||||
hostname: 'localhost'
|
||||
},
|
||||
writable: true
|
||||
});
|
||||
|
||||
// Mock the API call
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: 'test-id',
|
||||
name: 'Archon',
|
||||
transport_type: 'http',
|
||||
connection_status: 'connected'
|
||||
})
|
||||
});
|
||||
|
||||
const { mcpClientService } = await import('../../src/services/mcpClientService');
|
||||
|
||||
try {
|
||||
await mcpClientService.createArchonClient();
|
||||
|
||||
// Verify the fetch was called with the correct URL
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/mcp/clients'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.stringContaining('9051')
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
// If it fails due to actual API call, that's okay for this test
|
||||
// We're mainly testing that it constructs the URL correctly
|
||||
expect(error).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,236 +0,0 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import React from 'react'
|
||||
import { credentialsService } from '../src/services/credentialsService'
|
||||
|
||||
describe('Error Handling Tests', () => {
|
||||
test('api error simulation', () => {
|
||||
const MockApiComponent = () => {
|
||||
const [error, setError] = React.useState('')
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Simulate API error
|
||||
throw new Error('Network error')
|
||||
} catch (err) {
|
||||
setError('Failed to load data')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={fetchData}>Load Data</button>
|
||||
{loading && <div>Loading...</div>}
|
||||
{error && <div role="alert">{error}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockApiComponent />)
|
||||
|
||||
fireEvent.click(screen.getByText('Load Data'))
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Failed to load data')
|
||||
})
|
||||
|
||||
test('timeout error simulation', () => {
|
||||
const MockTimeoutComponent = () => {
|
||||
const [status, setStatus] = React.useState('idle')
|
||||
|
||||
const handleTimeout = () => {
|
||||
setStatus('loading')
|
||||
setTimeout(() => {
|
||||
setStatus('timeout')
|
||||
}, 100)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={handleTimeout}>Start Request</button>
|
||||
{status === 'loading' && <div>Loading...</div>}
|
||||
{status === 'timeout' && <div role="alert">Request timed out</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockTimeoutComponent />)
|
||||
|
||||
fireEvent.click(screen.getByText('Start Request'))
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
||||
|
||||
// Wait for timeout
|
||||
setTimeout(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Request timed out')
|
||||
}, 150)
|
||||
})
|
||||
|
||||
test('form validation errors', () => {
|
||||
const MockFormErrors = () => {
|
||||
const [values, setValues] = React.useState({ name: '', email: '' })
|
||||
const [errors, setErrors] = React.useState<string[]>([])
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: string[] = []
|
||||
if (!values.name) newErrors.push('Name is required')
|
||||
if (!values.email) newErrors.push('Email is required')
|
||||
if (values.email && !values.email.includes('@')) {
|
||||
newErrors.push('Invalid email format')
|
||||
}
|
||||
setErrors(newErrors)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
placeholder="Name"
|
||||
value={values.name}
|
||||
onChange={(e) => setValues({ ...values, name: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
placeholder="Email"
|
||||
value={values.email}
|
||||
onChange={(e) => setValues({ ...values, email: e.target.value })}
|
||||
/>
|
||||
<button onClick={validate}>Submit</button>
|
||||
{errors.length > 0 && (
|
||||
<div role="alert">
|
||||
{errors.map((error, index) => (
|
||||
<div key={index}>{error}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockFormErrors />)
|
||||
|
||||
// Submit empty form
|
||||
fireEvent.click(screen.getByText('Submit'))
|
||||
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert).toHaveTextContent('Name is required')
|
||||
expect(alert).toHaveTextContent('Email is required')
|
||||
})
|
||||
|
||||
test('connection error recovery', () => {
|
||||
const MockConnection = () => {
|
||||
const [connected, setConnected] = React.useState(true)
|
||||
const [error, setError] = React.useState('')
|
||||
|
||||
const handleDisconnect = () => {
|
||||
setConnected(false)
|
||||
setError('Connection lost')
|
||||
}
|
||||
|
||||
const handleReconnect = () => {
|
||||
setConnected(true)
|
||||
setError('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>Status: {connected ? 'Connected' : 'Disconnected'}</div>
|
||||
{error && <div role="alert">{error}</div>}
|
||||
<button onClick={handleDisconnect}>Simulate Disconnect</button>
|
||||
<button onClick={handleReconnect}>Reconnect</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockConnection />)
|
||||
|
||||
expect(screen.getByText('Status: Connected')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Simulate Disconnect'))
|
||||
expect(screen.getByText('Status: Disconnected')).toBeInTheDocument()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Connection lost')
|
||||
|
||||
fireEvent.click(screen.getByText('Reconnect'))
|
||||
expect(screen.getByText('Status: Connected')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('user friendly error messages', () => {
|
||||
const MockErrorMessages = () => {
|
||||
const [errorType, setErrorType] = React.useState('')
|
||||
|
||||
const getErrorMessage = (type: string) => {
|
||||
switch (type) {
|
||||
case '401':
|
||||
return 'Please log in to continue'
|
||||
case '403':
|
||||
return "You don't have permission to access this"
|
||||
case '404':
|
||||
return "We couldn't find what you're looking for"
|
||||
case '500':
|
||||
return 'Something went wrong on our end'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setErrorType('401')}>401 Error</button>
|
||||
<button onClick={() => setErrorType('403')}>403 Error</button>
|
||||
<button onClick={() => setErrorType('404')}>404 Error</button>
|
||||
<button onClick={() => setErrorType('500')}>500 Error</button>
|
||||
{errorType && (
|
||||
<div role="alert">{getErrorMessage(errorType)}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockErrorMessages />)
|
||||
|
||||
fireEvent.click(screen.getByText('401 Error'))
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Please log in to continue')
|
||||
|
||||
fireEvent.click(screen.getByText('404 Error'))
|
||||
expect(screen.getByRole('alert')).toHaveTextContent("We couldn't find what you're looking for")
|
||||
|
||||
fireEvent.click(screen.getByText('500 Error'))
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Something went wrong on our end')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CredentialsService Error Handling', () => {
|
||||
const originalFetch = global.fetch
|
||||
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn() as any
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch
|
||||
})
|
||||
|
||||
test('should handle network errors with context', async () => {
|
||||
const mockError = new Error('Network request failed')
|
||||
;(global.fetch as any).mockRejectedValueOnce(mockError)
|
||||
|
||||
await expect(credentialsService.createCredential({
|
||||
key: 'TEST_KEY',
|
||||
value: 'test',
|
||||
is_encrypted: false,
|
||||
category: 'test'
|
||||
})).rejects.toThrow(/Network error while creating credential 'test_key'/)
|
||||
})
|
||||
|
||||
test('should preserve context in error messages', async () => {
|
||||
const mockError = new Error('database error')
|
||||
;(global.fetch as any).mockRejectedValueOnce(mockError)
|
||||
|
||||
await expect(credentialsService.updateCredential({
|
||||
key: 'OPENAI_API_KEY',
|
||||
value: 'sk-test',
|
||||
is_encrypted: true,
|
||||
category: 'api_keys'
|
||||
})).rejects.toThrow(/Updating credential 'OPENAI_API_KEY' failed/)
|
||||
})
|
||||
})
|
||||
@@ -1,246 +0,0 @@
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { usePolling } from '../../src/hooks/usePolling';
|
||||
|
||||
describe('usePolling Hook - REAL Tests', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
// Mock fetch globally
|
||||
global.fetch = vi.fn();
|
||||
// Reset document visibility state
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
value: false,
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
value: 'visible',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should poll the endpoint at specified intervals', async () => {
|
||||
const mockResponse = { data: 'test' };
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockResponse,
|
||||
headers: new Headers({ 'etag': '"v1"' })
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePolling('/api/test', { interval: 1000 })
|
||||
);
|
||||
|
||||
// Initially loading
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
expect(result.current.data).toBeUndefined();
|
||||
|
||||
// Wait for first fetch to complete
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual(mockResponse);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
}, { timeout: 5000 });
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance timer to trigger second poll
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
}, { timeout: 5000 });
|
||||
|
||||
// Check ETag header was sent on second request
|
||||
const secondCall = (global.fetch as any).mock.calls[1];
|
||||
expect(secondCall[1].headers['If-None-Match']).toBe('"v1"');
|
||||
}, 15000);
|
||||
|
||||
it('should handle 304 Not Modified responses correctly', async () => {
|
||||
const initialData = { value: 'initial' };
|
||||
|
||||
// First call returns data
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => initialData,
|
||||
headers: new Headers({ 'etag': '"v1"' })
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePolling('/api/test', { interval: 1000 })
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual(initialData);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
}, { timeout: 5000 });
|
||||
|
||||
// Second call returns 304 Not Modified
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 304,
|
||||
json: async () => null,
|
||||
headers: new Headers({ 'etag': '"v1"' })
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
}, { timeout: 5000 });
|
||||
|
||||
// Data should remain unchanged after 304
|
||||
expect(result.current.data).toEqual(initialData);
|
||||
}, 15000);
|
||||
|
||||
it('should pause polling when tab becomes inactive', async () => {
|
||||
// This test verifies that polling stops when the tab is hidden
|
||||
// The hook behavior is complex due to multiple useEffect hooks
|
||||
// so we'll just verify the key behavior: no excessive polling when hidden
|
||||
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: 'test' }),
|
||||
headers: new Headers()
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => usePolling('/api/test', { interval: 1000 }));
|
||||
|
||||
// Wait for initial fetch
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual({ data: 'test' });
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
}, { timeout: 5000 });
|
||||
|
||||
// Clear the mock to start fresh
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Simulate tab becoming hidden
|
||||
await act(async () => {
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
value: 'hidden',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
value: true,
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
|
||||
// Advance timers significantly while hidden
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
// Should have minimal or no calls while hidden (allowing for edge cases)
|
||||
const hiddenCallCount = (global.fetch as any).mock.calls.length;
|
||||
expect(hiddenCallCount).toBeLessThanOrEqual(1);
|
||||
|
||||
// Simulate tab becoming visible again
|
||||
await act(async () => {
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
value: 'visible',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
value: false,
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
|
||||
// Should trigger immediate refetch when becoming visible
|
||||
await waitFor(() => {
|
||||
expect((global.fetch as any).mock.calls.length).toBeGreaterThan(hiddenCallCount);
|
||||
}, { timeout: 5000 });
|
||||
}, 15000);
|
||||
|
||||
it('should handle errors and retry with backoff', async () => {
|
||||
// First call fails
|
||||
(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePolling('/api/test', { interval: 1000 })
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBeInstanceOf(Error);
|
||||
expect(result.current.error?.message).toBe('Network error');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
}, { timeout: 5000 });
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second call succeeds
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: 'recovered' }),
|
||||
headers: new Headers()
|
||||
});
|
||||
|
||||
// Advance timer for retry
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual({ data: 'recovered' });
|
||||
expect(result.current.error).toBeNull();
|
||||
}, { timeout: 5000 });
|
||||
}, 15000);
|
||||
|
||||
it('should cleanup on unmount', async () => {
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: 'test' }),
|
||||
headers: new Headers()
|
||||
});
|
||||
|
||||
const { unmount, result } = renderHook(() =>
|
||||
usePolling('/api/test', { interval: 1000 })
|
||||
);
|
||||
|
||||
// Wait for initial fetch to complete
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
}, { timeout: 5000 });
|
||||
|
||||
// Clear any pending timers before unmount
|
||||
vi.clearAllTimers();
|
||||
|
||||
unmount();
|
||||
|
||||
// Reset mocks to clear call count
|
||||
const callCountBeforeAdvance = (global.fetch as any).mock.calls.length;
|
||||
|
||||
// Advance timers after unmount
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
// No additional calls should be made after unmount
|
||||
expect((global.fetch as any).mock.calls.length).toBe(callCountBeforeAdvance);
|
||||
}, 15000);
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, test, expect, vi } from 'vitest'
|
||||
import React from 'react'
|
||||
import { isLmConfigured } from '../src/utils/onboarding'
|
||||
import type { NormalizedCredential } from '../src/utils/onboarding'
|
||||
|
||||
// Mock useNavigate for onboarding page test
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => vi.fn()
|
||||
}))
|
||||
|
||||
describe('Page Load Tests', () => {
|
||||
test('simple page component renders', () => {
|
||||
const MockPage = () => <h1>Projects</h1>
|
||||
render(<MockPage />)
|
||||
expect(screen.getByText('Projects')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('knowledge base mock renders', () => {
|
||||
const MockKnowledgePage = () => <h1>Knowledge Base</h1>
|
||||
render(<MockKnowledgePage />)
|
||||
expect(screen.getByText('Knowledge Base')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('settings mock renders', () => {
|
||||
const MockSettingsPage = () => <h1>Settings</h1>
|
||||
render(<MockSettingsPage />)
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('mcp mock renders', () => {
|
||||
const MockMCPPage = () => <h1>MCP Servers</h1>
|
||||
render(<MockMCPPage />)
|
||||
expect(screen.getByText('MCP Servers')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('tasks mock renders', () => {
|
||||
const MockTasksPage = () => (
|
||||
<div>
|
||||
<h1>Tasks</h1>
|
||||
<div>TODO</div>
|
||||
<div>In Progress</div>
|
||||
<div>Done</div>
|
||||
</div>
|
||||
)
|
||||
render(<MockTasksPage />)
|
||||
expect(screen.getByText('Tasks')).toBeInTheDocument()
|
||||
expect(screen.getByText('TODO')).toBeInTheDocument()
|
||||
expect(screen.getByText('In Progress')).toBeInTheDocument()
|
||||
expect(screen.getByText('Done')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('onboarding page renders', () => {
|
||||
const MockOnboardingPage = () => <h1>Welcome to Archon</h1>
|
||||
render(<MockOnboardingPage />)
|
||||
expect(screen.getByText('Welcome to Archon')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Onboarding Detection Tests', () => {
|
||||
test('isLmConfigured returns true when provider is openai and OPENAI_API_KEY exists', () => {
|
||||
const ragCreds: NormalizedCredential[] = [
|
||||
{ key: 'LLM_PROVIDER', value: 'openai', category: 'rag_strategy' }
|
||||
]
|
||||
const apiKeyCreds: NormalizedCredential[] = [
|
||||
{ key: 'OPENAI_API_KEY', value: 'sk-test123', category: 'api_keys' }
|
||||
]
|
||||
|
||||
expect(isLmConfigured(ragCreds, apiKeyCreds)).toBe(true)
|
||||
})
|
||||
|
||||
test('isLmConfigured returns true when provider is openai and OPENAI_API_KEY is encrypted', () => {
|
||||
const ragCreds: NormalizedCredential[] = [
|
||||
{ key: 'LLM_PROVIDER', value: 'openai', category: 'rag_strategy' }
|
||||
]
|
||||
const apiKeyCreds: NormalizedCredential[] = [
|
||||
{ key: 'OPENAI_API_KEY', is_encrypted: true, encrypted_value: 'encrypted_sk-test123', category: 'api_keys' }
|
||||
]
|
||||
|
||||
expect(isLmConfigured(ragCreds, apiKeyCreds)).toBe(true)
|
||||
})
|
||||
|
||||
test('isLmConfigured returns false when provider is openai and no OPENAI_API_KEY', () => {
|
||||
const ragCreds: NormalizedCredential[] = [
|
||||
{ key: 'LLM_PROVIDER', value: 'openai', category: 'rag_strategy' }
|
||||
]
|
||||
const apiKeyCreds: NormalizedCredential[] = []
|
||||
|
||||
expect(isLmConfigured(ragCreds, apiKeyCreds)).toBe(false)
|
||||
})
|
||||
|
||||
test('isLmConfigured returns true when provider is ollama regardless of API keys', () => {
|
||||
const ragCreds: NormalizedCredential[] = [
|
||||
{ key: 'LLM_PROVIDER', value: 'ollama', category: 'rag_strategy' }
|
||||
]
|
||||
const apiKeyCreds: NormalizedCredential[] = []
|
||||
|
||||
expect(isLmConfigured(ragCreds, apiKeyCreds)).toBe(true)
|
||||
})
|
||||
|
||||
test('isLmConfigured returns true when no provider but OPENAI_API_KEY exists', () => {
|
||||
const ragCreds: NormalizedCredential[] = []
|
||||
const apiKeyCreds: NormalizedCredential[] = [
|
||||
{ key: 'OPENAI_API_KEY', value: 'sk-test123', category: 'api_keys' }
|
||||
]
|
||||
|
||||
expect(isLmConfigured(ragCreds, apiKeyCreds)).toBe(true)
|
||||
})
|
||||
|
||||
test('isLmConfigured returns false when no provider and no OPENAI_API_KEY', () => {
|
||||
const ragCreds: NormalizedCredential[] = []
|
||||
const apiKeyCreds: NormalizedCredential[] = []
|
||||
|
||||
expect(isLmConfigured(ragCreds, apiKeyCreds)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,124 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
describe('ProjectPage Performance Optimizations', () => {
|
||||
const projectPagePath = join(process.cwd(), 'src/pages/ProjectPage.tsx');
|
||||
const projectServicePath = join(process.cwd(), 'src/services/projectService.ts');
|
||||
|
||||
it('should use batch API call for task counts instead of N+1 queries', () => {
|
||||
const fileContent = readFileSync(projectPagePath, 'utf-8');
|
||||
|
||||
// Verify batch endpoint is being used
|
||||
expect(fileContent).toContain('getTaskCountsForAllProjects');
|
||||
|
||||
// Verify we're NOT using Promise.allSettled for parallel fetching
|
||||
expect(fileContent).not.toContain('Promise.allSettled');
|
||||
|
||||
// Verify single batch API call pattern
|
||||
expect(fileContent).toContain('await projectService.getTaskCountsForAllProjects()');
|
||||
});
|
||||
|
||||
it('should have memoized handleProjectSelect to prevent duplicate calls', () => {
|
||||
const fileContent = readFileSync(projectPagePath, 'utf-8');
|
||||
|
||||
// Check that handleProjectSelect is wrapped with useCallback
|
||||
expect(fileContent).toMatch(/const handleProjectSelect = useCallback\(/);
|
||||
|
||||
// Check for early return if same project
|
||||
expect(fileContent).toContain('if (selectedProject?.id === project.id) return');
|
||||
|
||||
// Check dependency array includes selectedProject?.id
|
||||
expect(fileContent).toMatch(/\}, \[.*selectedProject\?\.id.*\]\)/);
|
||||
});
|
||||
|
||||
it('should implement task counts cache with TTL', () => {
|
||||
const fileContent = readFileSync(projectPagePath, 'utf-8');
|
||||
|
||||
// Check cache ref is defined
|
||||
expect(fileContent).toContain('const taskCountsCache = useRef');
|
||||
|
||||
// Check cache structure includes timestamp
|
||||
expect(fileContent).toContain('timestamp: number');
|
||||
|
||||
// Check cache is checked before API call (5-minute TTL = 300000ms)
|
||||
expect(fileContent).toContain('(now - taskCountsCache.current.timestamp) < 300000');
|
||||
|
||||
// Check cache is updated after successful API call
|
||||
expect(fileContent).toContain('taskCountsCache.current = {');
|
||||
});
|
||||
|
||||
it('should disable polling during project switching and drag operations', () => {
|
||||
const fileContent = readFileSync(projectPagePath, 'utf-8');
|
||||
|
||||
// Check useTaskPolling enabled parameter includes conditions
|
||||
expect(fileContent).toMatch(/enabled:.*!isSwitchingProject.*movingTaskIds\.size === 0/);
|
||||
|
||||
// Verify isSwitchingProject state exists
|
||||
expect(fileContent).toContain('const [isSwitchingProject, setIsSwitchingProject]');
|
||||
});
|
||||
|
||||
it('should have debounce utility implemented', () => {
|
||||
const debouncePath = join(process.cwd(), 'src/utils/debounce.ts');
|
||||
const fileContent = readFileSync(debouncePath, 'utf-8');
|
||||
|
||||
// Check debounce function exists
|
||||
expect(fileContent).toContain('export function debounce');
|
||||
|
||||
// Check it has proper TypeScript types
|
||||
expect(fileContent).toContain('T extends (...args: any[]) => any');
|
||||
|
||||
// Check timeout clearing logic
|
||||
expect(fileContent).toContain('clearTimeout(timeoutId)');
|
||||
});
|
||||
|
||||
it('should apply debouncing to loadTaskCountsForAllProjects', () => {
|
||||
const fileContent = readFileSync(projectPagePath, 'utf-8');
|
||||
|
||||
// Check debounce is imported
|
||||
expect(fileContent).toContain('import { debounce } from "../utils/debounce"');
|
||||
|
||||
// Check debounced version is created
|
||||
expect(fileContent).toContain('const debouncedLoadTaskCounts = useMemo');
|
||||
expect(fileContent).toContain('debounce((projectIds: string[])');
|
||||
|
||||
// Check debounced version is used instead of direct calls
|
||||
expect(fileContent).toContain('debouncedLoadTaskCounts(projectIds)');
|
||||
|
||||
// Verify 1000ms delay
|
||||
expect(fileContent).toContain('}, 1000)');
|
||||
});
|
||||
|
||||
it('should have batch task counts endpoint in backend service', () => {
|
||||
const serviceContent = readFileSync(projectServicePath, 'utf-8');
|
||||
|
||||
// Check the service method exists
|
||||
expect(serviceContent).toContain('async getTaskCountsForAllProjects()');
|
||||
|
||||
// Check it calls the correct endpoint
|
||||
expect(serviceContent).toContain('/api/projects/task-counts');
|
||||
|
||||
// Check return type
|
||||
expect(serviceContent).toContain('Promise<Record<string, TaskCounts>>');
|
||||
});
|
||||
|
||||
it('should not make duplicate API calls on project switch', () => {
|
||||
const fileContent = readFileSync(projectPagePath, 'utf-8');
|
||||
|
||||
// Check that tasks are cleared immediately on switch
|
||||
expect(fileContent).toContain('setTasks([]); // Clear stale tasks immediately');
|
||||
|
||||
// Check loading state is managed properly
|
||||
expect(fileContent).toContain('setIsSwitchingProject(true)');
|
||||
expect(fileContent).toContain('setIsSwitchingProject(false)');
|
||||
});
|
||||
|
||||
it('should have correct import statements for performance utilities', () => {
|
||||
const fileContent = readFileSync(projectPagePath, 'utf-8');
|
||||
|
||||
// Check all necessary React hooks are imported
|
||||
expect(fileContent).toContain('useCallback');
|
||||
expect(fileContent).toContain('useMemo');
|
||||
expect(fileContent).toContain('useRef');
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
describe('ProjectPage Polling Conflict Prevention', () => {
|
||||
it('should have movingTaskIds check in polling useEffect', () => {
|
||||
// Read the actual source file to verify the implementation
|
||||
const projectPagePath = join(process.cwd(), 'src/pages/ProjectPage.tsx');
|
||||
const fileContent = readFileSync(projectPagePath, 'utf-8');
|
||||
|
||||
// Check that movingTaskIds state is declared
|
||||
expect(fileContent).toContain('const [movingTaskIds, setMovingTaskIds] = useState<Set<string>>(new Set())');
|
||||
|
||||
// Check that movingTaskIds is checked before updating tasks
|
||||
expect(fileContent).toContain('if (movingTaskIds.size === 0)');
|
||||
|
||||
// Check that merge logic is present for non-moving tasks
|
||||
expect(fileContent).toContain('if (movingTaskIds.has(task.id))');
|
||||
expect(fileContent).toContain('return task; // Preserve local state for moving tasks');
|
||||
|
||||
// Check that movingTaskIds is in the dependency array
|
||||
expect(fileContent).toMatch(/\}, \[.*movingTaskIds.*\]\)/);
|
||||
});
|
||||
|
||||
it('should pass movingTaskIds props to TasksTab', () => {
|
||||
const projectPagePath = join(process.cwd(), 'src/pages/ProjectPage.tsx');
|
||||
const fileContent = readFileSync(projectPagePath, 'utf-8');
|
||||
|
||||
// Check that movingTaskIds is passed as prop
|
||||
expect(fileContent).toContain('movingTaskIds={movingTaskIds}');
|
||||
expect(fileContent).toContain('setMovingTaskIds={setMovingTaskIds}');
|
||||
});
|
||||
|
||||
it('should have TasksTab accept movingTaskIds props', () => {
|
||||
const tasksTabPath = join(process.cwd(), 'src/components/project-tasks/TasksTab.tsx');
|
||||
const fileContent = readFileSync(tasksTabPath, 'utf-8');
|
||||
|
||||
// Check that TasksTab accepts the props
|
||||
expect(fileContent).toContain('movingTaskIds: Set<string>');
|
||||
expect(fileContent).toContain('setMovingTaskIds: (ids: Set<string>) => void');
|
||||
});
|
||||
});
|
||||
@@ -1,393 +0,0 @@
|
||||
/**
|
||||
* Unit tests for projectService document CRUD operations
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import type { Document } from '../../src/services/projectService';
|
||||
|
||||
// Mock fetch globally
|
||||
global.fetch = vi.fn();
|
||||
|
||||
describe('projectService Document Operations', () => {
|
||||
let projectService: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset all mocks
|
||||
vi.resetAllMocks();
|
||||
vi.resetModules();
|
||||
|
||||
// Import fresh instance of projectService
|
||||
const module = await import('../../src/services/projectService');
|
||||
projectService = module.projectService;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getDocument', () => {
|
||||
const mockDocument: Document = {
|
||||
id: 'doc-123',
|
||||
project_id: 'proj-456',
|
||||
title: 'Test Document',
|
||||
content: { type: 'markdown', text: 'Test content' },
|
||||
document_type: 'prp',
|
||||
metadata: { version: '1.0' },
|
||||
tags: ['test', 'sample'],
|
||||
author: 'test-user',
|
||||
created_at: '2025-08-18T10:00:00Z',
|
||||
updated_at: '2025-08-18T10:00:00Z'
|
||||
};
|
||||
|
||||
it('should successfully fetch a document', async () => {
|
||||
// Mock successful response
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ document: mockDocument })
|
||||
});
|
||||
|
||||
const result = await projectService.getDocument('proj-456', 'doc-123');
|
||||
|
||||
expect(result).toEqual(mockDocument);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/projects/proj-456/docs/doc-123',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should include projectId in error message when fetch fails', async () => {
|
||||
// Mock failed response
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => '{"error": "Document not found"}'
|
||||
});
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await expect(projectService.getDocument('proj-456', 'doc-123')).rejects.toThrow();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to get document doc-123 from project proj-456:',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle network errors', async () => {
|
||||
// Mock network error
|
||||
(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await expect(projectService.getDocument('proj-456', 'doc-123')).rejects.toThrow('Network error');
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to get document doc-123 from project proj-456:',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateDocument', () => {
|
||||
const mockUpdatedDocument: Document = {
|
||||
id: 'doc-123',
|
||||
project_id: 'proj-456',
|
||||
title: 'Updated Document',
|
||||
content: { type: 'markdown', text: 'Updated content' },
|
||||
document_type: 'prp',
|
||||
metadata: { version: '2.0' },
|
||||
tags: ['updated', 'test'],
|
||||
author: 'test-user',
|
||||
created_at: '2025-08-18T10:00:00Z',
|
||||
updated_at: '2025-08-18T11:00:00Z'
|
||||
};
|
||||
|
||||
const updates = {
|
||||
title: 'Updated Document',
|
||||
content: { type: 'markdown', text: 'Updated content' },
|
||||
tags: ['updated', 'test']
|
||||
};
|
||||
|
||||
it('should successfully update a document', async () => {
|
||||
// Mock successful response
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ document: mockUpdatedDocument })
|
||||
});
|
||||
|
||||
const result = await projectService.updateDocument('proj-456', 'doc-123', updates);
|
||||
|
||||
expect(result).toEqual(mockUpdatedDocument);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/projects/proj-456/docs/doc-123',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json'
|
||||
}),
|
||||
body: JSON.stringify(updates)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should include projectId in error message when update fails', async () => {
|
||||
// Mock failed response
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: async () => '{"error": "Invalid update data"}'
|
||||
});
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await expect(projectService.updateDocument('proj-456', 'doc-123', updates)).rejects.toThrow();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to update document doc-123 in project proj-456:',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle partial updates', async () => {
|
||||
const partialUpdate = { title: 'Only Title Updated' };
|
||||
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ document: { ...mockUpdatedDocument, title: 'Only Title Updated' } })
|
||||
});
|
||||
|
||||
const result = await projectService.updateDocument('proj-456', 'doc-123', partialUpdate);
|
||||
|
||||
expect(result.title).toBe('Only Title Updated');
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/projects/proj-456/docs/doc-123',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify(partialUpdate)
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteDocument', () => {
|
||||
it('should successfully delete a document', async () => {
|
||||
// Mock successful response
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({})
|
||||
});
|
||||
|
||||
await expect(projectService.deleteDocument('proj-456', 'doc-123')).resolves.toBeUndefined();
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/projects/proj-456/docs/doc-123',
|
||||
expect.objectContaining({
|
||||
method: 'DELETE',
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should include projectId in error message when deletion fails', async () => {
|
||||
// Mock failed response
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 403,
|
||||
text: async () => '{"error": "Permission denied"}'
|
||||
});
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await expect(projectService.deleteDocument('proj-456', 'doc-123')).rejects.toThrow();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to delete document doc-123 from project proj-456:',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle 404 errors appropriately', async () => {
|
||||
// Mock 404 response
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => '{"error": "Document not found"}'
|
||||
});
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await expect(projectService.deleteDocument('proj-456', 'doc-123')).rejects.toThrow();
|
||||
|
||||
// Verify the error is logged with project context
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
const errorLog = consoleSpy.mock.calls[0];
|
||||
expect(errorLog[0]).toContain('proj-456');
|
||||
expect(errorLog[0]).toContain('doc-123');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle network timeouts', async () => {
|
||||
// Mock timeout error
|
||||
const timeoutError = new Error('Request timeout');
|
||||
(global.fetch as any).mockRejectedValueOnce(timeoutError);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await expect(projectService.deleteDocument('proj-456', 'doc-123')).rejects.toThrow('Failed to call API');
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to delete document doc-123 from project proj-456:',
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('Request timeout')
|
||||
})
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listProjectDocuments', () => {
|
||||
const mockDocuments: Document[] = [
|
||||
{
|
||||
id: 'doc-1',
|
||||
project_id: 'proj-456',
|
||||
title: 'Document 1',
|
||||
content: { type: 'markdown', text: 'Content 1' },
|
||||
document_type: 'prp',
|
||||
created_at: '2025-08-18T10:00:00Z',
|
||||
updated_at: '2025-08-18T10:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'doc-2',
|
||||
project_id: 'proj-456',
|
||||
title: 'Document 2',
|
||||
content: { type: 'markdown', text: 'Content 2' },
|
||||
document_type: 'spec',
|
||||
created_at: '2025-08-18T11:00:00Z',
|
||||
updated_at: '2025-08-18T11:00:00Z'
|
||||
}
|
||||
];
|
||||
|
||||
it('should successfully list all project documents', async () => {
|
||||
// Mock successful response
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ documents: mockDocuments })
|
||||
});
|
||||
|
||||
const result = await projectService.listProjectDocuments('proj-456');
|
||||
|
||||
expect(result).toEqual(mockDocuments);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/projects/proj-456/docs',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when no documents exist', async () => {
|
||||
// Mock response with no documents
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ documents: [] })
|
||||
});
|
||||
|
||||
const result = await projectService.listProjectDocuments('proj-456');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle null documents field gracefully', async () => {
|
||||
// Mock response with null documents
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ documents: null })
|
||||
});
|
||||
|
||||
const result = await projectService.listProjectDocuments('proj-456');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDocument', () => {
|
||||
const newDocumentData = {
|
||||
title: 'New Document',
|
||||
content: { type: 'markdown', text: 'New content' },
|
||||
document_type: 'prp',
|
||||
tags: ['new', 'test']
|
||||
};
|
||||
|
||||
const mockCreatedDocument: Document = {
|
||||
id: 'doc-new',
|
||||
project_id: 'proj-456',
|
||||
...newDocumentData,
|
||||
author: 'test-user',
|
||||
created_at: '2025-08-18T12:00:00Z',
|
||||
updated_at: '2025-08-18T12:00:00Z'
|
||||
};
|
||||
|
||||
it('should successfully create a new document', async () => {
|
||||
// Mock successful response
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ document: mockCreatedDocument })
|
||||
});
|
||||
|
||||
const result = await projectService.createDocument('proj-456', newDocumentData);
|
||||
|
||||
expect(result).toEqual(mockCreatedDocument);
|
||||
expect(result.id).toBeDefined();
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/projects/proj-456/docs',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json'
|
||||
}),
|
||||
body: JSON.stringify(newDocumentData)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle validation errors', async () => {
|
||||
// Mock validation error response
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 422,
|
||||
text: async () => '{"error": "Title is required"}'
|
||||
});
|
||||
|
||||
const invalidData = { content: 'Missing title' };
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await expect(projectService.createDocument('proj-456', invalidData)).rejects.toThrow();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to create document for project proj-456:',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,243 +0,0 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, test, expect, vi } from 'vitest'
|
||||
import React from 'react'
|
||||
|
||||
describe('User Flow Tests', () => {
|
||||
test('create project flow mock', () => {
|
||||
const MockCreateProject = () => {
|
||||
const [project, setProject] = React.useState('')
|
||||
return (
|
||||
<div>
|
||||
<h1>Create Project</h1>
|
||||
<input
|
||||
placeholder="Project title"
|
||||
value={project}
|
||||
onChange={(e) => setProject(e.target.value)}
|
||||
/>
|
||||
<button>Create</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockCreateProject />)
|
||||
expect(screen.getByText('Create Project')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Project title')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('search functionality mock', () => {
|
||||
const MockSearch = () => {
|
||||
const [query, setQuery] = React.useState('')
|
||||
return (
|
||||
<div>
|
||||
<h1>Search</h1>
|
||||
<input
|
||||
placeholder="Search knowledge base"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
{query && <div>Results for: {query}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockSearch />)
|
||||
const input = screen.getByPlaceholderText('Search knowledge base')
|
||||
fireEvent.change(input, { target: { value: 'test query' } })
|
||||
expect(screen.getByText('Results for: test query')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('settings toggle mock', () => {
|
||||
const MockSettings = () => {
|
||||
const [theme, setTheme] = React.useState('light')
|
||||
return (
|
||||
<div>
|
||||
<h1>Settings</h1>
|
||||
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
|
||||
Theme: {theme}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockSettings />)
|
||||
const button = screen.getByText('Theme: light')
|
||||
fireEvent.click(button)
|
||||
expect(screen.getByText('Theme: dark')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('file upload mock', () => {
|
||||
const MockUpload = () => {
|
||||
const [uploaded, setUploaded] = React.useState(false)
|
||||
return (
|
||||
<div>
|
||||
<h1>Upload Documents</h1>
|
||||
<input type="file" onChange={() => setUploaded(true)} data-testid="file-input" />
|
||||
{uploaded && <div>File uploaded successfully</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockUpload />)
|
||||
const input = screen.getByTestId('file-input')
|
||||
fireEvent.change(input)
|
||||
expect(screen.getByText('File uploaded successfully')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('connection status mock', () => {
|
||||
const MockConnection = () => {
|
||||
const [connected, setConnected] = React.useState(true)
|
||||
return (
|
||||
<div>
|
||||
<h1>Connection Status</h1>
|
||||
<div>{connected ? 'Connected' : 'Disconnected'}</div>
|
||||
<button onClick={() => setConnected(!connected)}>
|
||||
Toggle Connection
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockConnection />)
|
||||
expect(screen.getByText('Connected')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Toggle Connection'))
|
||||
expect(screen.getByText('Disconnected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('task management mock', () => {
|
||||
const MockTasks = () => {
|
||||
const [tasks, setTasks] = React.useState(['Task 1', 'Task 2'])
|
||||
const addTask = () => setTasks([...tasks, `Task ${tasks.length + 1}`])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Task Management</h1>
|
||||
<button onClick={addTask}>Add Task</button>
|
||||
<ul>
|
||||
{tasks.map((task, index) => (
|
||||
<li key={index}>{task}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockTasks />)
|
||||
expect(screen.getByText('Task 1')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Add Task'))
|
||||
expect(screen.getByText('Task 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('navigation mock', () => {
|
||||
const MockNav = () => {
|
||||
const [currentPage, setCurrentPage] = React.useState('home')
|
||||
return (
|
||||
<div>
|
||||
<nav>
|
||||
<button onClick={() => setCurrentPage('projects')}>Projects</button>
|
||||
<button onClick={() => setCurrentPage('settings')}>Settings</button>
|
||||
</nav>
|
||||
<main>
|
||||
<h1>Current page: {currentPage}</h1>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockNav />)
|
||||
expect(screen.getByText('Current page: home')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Projects'))
|
||||
expect(screen.getByText('Current page: projects')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('form validation mock', () => {
|
||||
const MockForm = () => {
|
||||
const [email, setEmail] = React.useState('')
|
||||
const [error, setError] = React.useState('')
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!email.includes('@')) {
|
||||
setError('Invalid email')
|
||||
} else {
|
||||
setError('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Form Validation</h1>
|
||||
<input
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<button onClick={handleSubmit}>Submit</button>
|
||||
{error && <div role="alert">{error}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockForm />)
|
||||
const input = screen.getByPlaceholderText('Email')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'invalid' } })
|
||||
fireEvent.click(screen.getByText('Submit'))
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Invalid email')
|
||||
})
|
||||
|
||||
test('theme switching mock', () => {
|
||||
const MockTheme = () => {
|
||||
const [isDark, setIsDark] = React.useState(false)
|
||||
return (
|
||||
<div className={isDark ? 'dark' : 'light'}>
|
||||
<h1>Theme Test</h1>
|
||||
<button onClick={() => setIsDark(!isDark)}>
|
||||
Switch to {isDark ? 'Light' : 'Dark'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockTheme />)
|
||||
const button = screen.getByText('Switch to Dark')
|
||||
fireEvent.click(button)
|
||||
expect(screen.getByText('Switch to Light')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('data filtering mock', () => {
|
||||
const MockFilter = () => {
|
||||
const [filter, setFilter] = React.useState('')
|
||||
const items = ['Apple', 'Banana', 'Cherry']
|
||||
const filtered = items.filter(item =>
|
||||
item.toLowerCase().includes(filter.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Filter Test</h1>
|
||||
<input
|
||||
placeholder="Filter items"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
<ul>
|
||||
{filtered.map((item, index) => (
|
||||
<li key={index}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<MockFilter />)
|
||||
const input = screen.getByPlaceholderText('Filter items')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'a' } })
|
||||
expect(screen.getByText('Apple')).toBeInTheDocument()
|
||||
expect(screen.getByText('Banana')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Cherry')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,138 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { calculateTaskOrder, calculateReorderPosition, getDefaultTaskOrder } from '../../src/utils/taskOrdering';
|
||||
import { Task } from '../../src/types/project';
|
||||
|
||||
// Mock task factory
|
||||
const createMockTask = (id: string, task_order: number): Task => ({
|
||||
id,
|
||||
title: `Task ${id}`,
|
||||
description: '',
|
||||
status: 'todo',
|
||||
assignee: { name: 'Test User', avatar: '' },
|
||||
feature: '',
|
||||
featureColor: '#3b82f6',
|
||||
task_order,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
project_id: 'test-project'
|
||||
});
|
||||
|
||||
describe('taskOrdering utilities', () => {
|
||||
describe('calculateTaskOrder', () => {
|
||||
it('does not mutate existingTasks', () => {
|
||||
const existingTasks = [createMockTask('1', 200), createMockTask('2', 100)];
|
||||
const snapshot = existingTasks.map(t => t.task_order);
|
||||
calculateTaskOrder({ position: 'first', existingTasks });
|
||||
expect(existingTasks.map(t => t.task_order)).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it('should return seed value for first task when no existing tasks', () => {
|
||||
const result = calculateTaskOrder({
|
||||
position: 'first',
|
||||
existingTasks: []
|
||||
});
|
||||
expect(result).toBe(65536);
|
||||
});
|
||||
|
||||
it('should calculate first position correctly', () => {
|
||||
const existingTasks = [createMockTask('1', 100), createMockTask('2', 200)];
|
||||
const result = calculateTaskOrder({
|
||||
position: 'first',
|
||||
existingTasks
|
||||
});
|
||||
expect(result).toBe(50); // Math.floor(100 / 2)
|
||||
});
|
||||
|
||||
it('should calculate last position correctly', () => {
|
||||
const existingTasks = [createMockTask('1', 100), createMockTask('2', 200)];
|
||||
const result = calculateTaskOrder({
|
||||
position: 'last',
|
||||
existingTasks
|
||||
});
|
||||
expect(result).toBe(1224); // 200 + 1024
|
||||
});
|
||||
|
||||
it('should calculate between position correctly', () => {
|
||||
const result = calculateTaskOrder({
|
||||
position: 'between',
|
||||
existingTasks: [],
|
||||
beforeTaskOrder: 100,
|
||||
afterTaskOrder: 200
|
||||
});
|
||||
expect(result).toBe(150); // Math.floor((100 + 200) / 2)
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultTaskOrder', () => {
|
||||
it('should return seed value when no existing tasks', () => {
|
||||
const result = getDefaultTaskOrder([]);
|
||||
expect(result).toBe(65536);
|
||||
});
|
||||
|
||||
it('should return first position when existing tasks present', () => {
|
||||
const existingTasks = [createMockTask('1', 100), createMockTask('2', 200)];
|
||||
const result = getDefaultTaskOrder(existingTasks);
|
||||
expect(result).toBe(50); // Math.floor(100 / 2)
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateReorderPosition', () => {
|
||||
const statusTasks = [
|
||||
createMockTask('1', 100),
|
||||
createMockTask('2', 200),
|
||||
createMockTask('3', 300)
|
||||
];
|
||||
|
||||
it('should calculate position for moving to first', () => {
|
||||
const result = calculateReorderPosition(statusTasks, 1, 0);
|
||||
expect(result).toBeLessThan(statusTasks[0].task_order);
|
||||
});
|
||||
|
||||
it('should calculate position for moving to last', () => {
|
||||
const result = calculateReorderPosition(statusTasks, 0, 2);
|
||||
expect(result).toBeGreaterThan(statusTasks[2].task_order);
|
||||
});
|
||||
|
||||
it('should calculate position for moving down within middle (1 -> 2)', () => {
|
||||
const result = calculateReorderPosition(statusTasks, 1, 2);
|
||||
// After excluding moving index 1, insert between 300 and end => should be >300 (or handled by "last" path)
|
||||
expect(result).toBeGreaterThan(statusTasks[2].task_order);
|
||||
});
|
||||
|
||||
it('should calculate position for moving up within middle (2 -> 1)', () => {
|
||||
const result = calculateReorderPosition(statusTasks, 2, 1);
|
||||
// With fixed neighbor calculation, this should work correctly
|
||||
expect(result).toBeGreaterThan(statusTasks[0].task_order); // > 100
|
||||
expect(result).toBeLessThan(statusTasks[1].task_order); // < 200
|
||||
});
|
||||
|
||||
it('should calculate position for moving between items', () => {
|
||||
const result = calculateReorderPosition(statusTasks, 0, 1);
|
||||
// Moving task 0 (order 100) to position 1 should place it before task 1 (order 200)
|
||||
// Since we removed the moving task, it should be between start and 200
|
||||
expect(result).toBeLessThan(statusTasks[1].task_order); // < 200
|
||||
expect(result).toBeGreaterThan(0); // > 0
|
||||
});
|
||||
|
||||
it('should return integer values only', () => {
|
||||
const result1 = calculateReorderPosition(statusTasks, 1, 0);
|
||||
const result2 = calculateReorderPosition(statusTasks, 0, 2);
|
||||
const result3 = calculateReorderPosition(statusTasks, 2, 1);
|
||||
|
||||
expect(Number.isInteger(result1)).toBe(true);
|
||||
expect(Number.isInteger(result2)).toBe(true);
|
||||
expect(Number.isInteger(result3)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle bounds checking correctly', () => {
|
||||
// Test with tasks that have equal order values (edge case)
|
||||
const equalTasks = [
|
||||
createMockTask('1', 100),
|
||||
createMockTask('2', 100)
|
||||
];
|
||||
const result = calculateReorderPosition(equalTasks, 0, 1);
|
||||
expect(Number.isInteger(result)).toBe(true);
|
||||
expect(result).toBeGreaterThan(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
58
archon-ui-main/tests/README.md
Normal file
58
archon-ui-main/tests/README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Test Structure
|
||||
|
||||
## Test Organization
|
||||
|
||||
We follow a hybrid testing strategy:
|
||||
|
||||
### Unit Tests (Colocated)
|
||||
Unit tests live next to the code they test in the `src/features` directory:
|
||||
```
|
||||
src/features/projects/
|
||||
├── components/
|
||||
│ ├── ProjectCard.tsx
|
||||
│ └── ProjectCard.test.tsx
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
Tests that cross multiple features/systems:
|
||||
```
|
||||
tests/integration/
|
||||
└── api.integration.test.ts
|
||||
```
|
||||
|
||||
### E2E Tests
|
||||
Full user flow tests:
|
||||
```
|
||||
tests/e2e/
|
||||
└── user-flows.e2e.test.ts
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm run test
|
||||
|
||||
# Run tests in watch mode
|
||||
npm run test:watch
|
||||
|
||||
# Run with coverage
|
||||
npm run test:coverage
|
||||
|
||||
# Run specific test file
|
||||
npx vitest run src/features/ui/hooks/useSmartPolling.test.ts
|
||||
```
|
||||
|
||||
## Test Naming Conventions
|
||||
|
||||
- **Unit tests**: `ComponentName.test.tsx` or `hookName.test.ts`
|
||||
- **Integration tests**: `feature.integration.test.ts`
|
||||
- **E2E tests**: `flow-name.e2e.test.ts`
|
||||
|
||||
## Test Setup
|
||||
|
||||
Global test setup is in `tests/setup.ts` which:
|
||||
- Sets environment variables
|
||||
- Mocks fetch and localStorage
|
||||
- Mocks DOM APIs
|
||||
- Mocks external libraries (lucide-react)
|
||||
@@ -35,17 +35,28 @@ Object.defineProperty(window, 'localStorage', {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
window.HTMLElement.prototype.scrollIntoView = vi.fn()
|
||||
|
||||
// Mock lucide-react icons - create a proxy that returns icon name for any icon
|
||||
vi.mock('lucide-react', () => {
|
||||
return new Proxy({}, {
|
||||
get: (target, prop) => {
|
||||
if (typeof prop === 'string') {
|
||||
return () => prop
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
})
|
||||
// Mock lucide-react icons - simple implementation
|
||||
vi.mock('lucide-react', () => ({
|
||||
Trash2: () => 'Trash2',
|
||||
X: () => 'X',
|
||||
AlertCircle: () => 'AlertCircle',
|
||||
Loader2: () => 'Loader2',
|
||||
BookOpen: () => 'BookOpen',
|
||||
Settings: () => 'Settings',
|
||||
WifiOff: () => 'WifiOff',
|
||||
ChevronDown: () => 'ChevronDown',
|
||||
ChevronRight: () => 'ChevronRight',
|
||||
Plus: () => 'Plus',
|
||||
Search: () => 'Search',
|
||||
Activity: () => 'Activity',
|
||||
CheckCircle2: () => 'CheckCircle2',
|
||||
ListTodo: () => 'ListTodo',
|
||||
MoreHorizontal: () => 'MoreHorizontal',
|
||||
Pin: () => 'Pin',
|
||||
PinOff: () => 'PinOff',
|
||||
Clipboard: () => 'Clipboard',
|
||||
// Add more icons as needed
|
||||
}))
|
||||
|
||||
// Mock ResizeObserver
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
@@ -23,6 +23,6 @@
|
||||
/* Path mapping */
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["src", "test"],
|
||||
"include": ["src", "tests"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
16
archon-ui-main/tsconfig.prod.json
Normal file
16
archon-ui-main/tsconfig.prod.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": [
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.ts",
|
||||
"**/*.spec.tsx",
|
||||
"**/__tests__/**",
|
||||
"**/tests/**",
|
||||
"src/features/testing/**",
|
||||
"test/**",
|
||||
"tests/**",
|
||||
"coverage/**",
|
||||
"**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -321,15 +321,18 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './test/setup.ts',
|
||||
setupFiles: './tests/setup.ts',
|
||||
css: true,
|
||||
include: [
|
||||
'src/**/*.{test,spec}.{ts,tsx}', // Tests colocated in features
|
||||
'tests/**/*.{test,spec}.{ts,tsx}' // Tests in tests directory
|
||||
],
|
||||
exclude: [
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/cypress/**',
|
||||
'**/.{idea,git,cache,output,temp}/**',
|
||||
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*',
|
||||
'**/*.test.{ts,tsx}',
|
||||
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*'
|
||||
],
|
||||
env: {
|
||||
VITE_HOST: host,
|
||||
|
||||
@@ -8,10 +8,12 @@ export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './test/setup.ts',
|
||||
setupFiles: './tests/setup.ts',
|
||||
include: [
|
||||
'test/**/*.test.{ts,tsx}',
|
||||
'test/**/*.spec.{ts,tsx}'
|
||||
'src/**/*.test.{ts,tsx}', // Colocated tests in features
|
||||
'src/**/*.spec.{ts,tsx}',
|
||||
'tests/**/*.test.{ts,tsx}', // Tests in tests directory
|
||||
'tests/**/*.spec.{ts,tsx}'
|
||||
],
|
||||
exclude: ['node_modules', 'dist', '.git', '.cache', 'test.backup', '*.backup/**', 'test-backups'],
|
||||
reporters: ['dot', 'json'],
|
||||
@@ -35,7 +37,7 @@ export default defineConfig({
|
||||
reportOnFailure: true, // Generate coverage reports even when tests fail
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'test/',
|
||||
'tests/',
|
||||
'**/*.d.ts',
|
||||
'**/*.config.*',
|
||||
'**/mockData.ts',
|
||||
|
||||
Reference in New Issue
Block a user