diff --git a/web-ui/__tests__/components/Dashboard.test.tsx b/web-ui/__tests__/components/Dashboard.test.tsx index 37d8bbf4..39dd0628 100644 --- a/web-ui/__tests__/components/Dashboard.test.tsx +++ b/web-ui/__tests__/components/Dashboard.test.tsx @@ -11,34 +11,63 @@ import * as api from '@/lib/api'; import * as websocket from '@/lib/websocket'; import * as agentAssignment from '@/api/agentAssignment'; -// Mock Hugeicons (used by PhaseProgress, Dashboard, and SessionStatus components) -jest.mock('@hugeicons/react', () => ({ - // PhaseProgress icons - Search01Icon: ({ className }: { className?: string }) => , - TaskEdit01Icon: ({ className }: { className?: string }) => , - Wrench01Icon: ({ className }: { className?: string }) => , - CheckmarkCircle01Icon: ({ className }: { className?: string }) => , - Award01Icon: ({ className }: { className?: string }) => , - RocketIcon: ({ className }: { className?: string }) => , - HelpCircleIcon: ({ className }: { className?: string }) => , - Idea01Icon: ({ className }: { className?: string }) => , - // Dashboard section header icons - UserGroupIcon: ({ className }: { className?: string }) => , - Loading03Icon: ({ className }: { className?: string }) => , - WorkHistoryIcon: ({ className }: { className?: string }) => , - AnalyticsUpIcon: ({ className }: { className?: string }) => , - ClipboardIcon: ({ className }: { className?: string }) => , - CheckListIcon: ({ className }: { className?: string }) => , - Target02Icon: ({ className }: { className?: string }) => , - FloppyDiskIcon: ({ className }: { className?: string }) => , - // Activity item icons - TestTube01Icon: ({ className }: { className?: string }) => , - Alert02Icon: ({ className }: { className?: string }) => , - CheckmarkSquare01Icon: ({ className }: { className?: string }) => , - BotIcon: ({ className }: { className?: string }) => , - Logout02Icon: ({ className }: { className?: string }) => , - GitCommitIcon: ({ className }: { className?: string }) => , -})); +// Mock Hugeicons (used by PhaseProgress, Dashboard, SessionStatus, and UI components) +// This mock must include ALL icons used by Dashboard and its children +jest.mock('@hugeicons/react', () => { + const createMockIcon = (name: string) => { + const Icon = ({ className }: { className?: string }) => ( + + ); + Icon.displayName = name; + return Icon; + }; + + return { + // PhaseProgress icons + Search01Icon: createMockIcon('search-icon'), + TaskEdit01Icon: createMockIcon('task-edit-icon'), + Wrench01Icon: createMockIcon('wrench-icon'), + Award01Icon: createMockIcon('award-icon'), + RocketIcon: createMockIcon('rocket-icon'), + HelpCircleIcon: createMockIcon('help-icon'), + Idea01Icon: createMockIcon('idea-icon'), + // Dashboard section header icons + UserGroupIcon: createMockIcon('user-group-icon'), + Loading03Icon: createMockIcon('loading-icon'), + WorkHistoryIcon: createMockIcon('work-history-icon'), + AnalyticsUpIcon: createMockIcon('analytics-icon'), + ClipboardIcon: createMockIcon('clipboard-icon'), + CheckListIcon: createMockIcon('checklist-icon'), + Target02Icon: createMockIcon('target-icon'), + FloppyDiskIcon: createMockIcon('floppy-icon'), + GitPullRequestIcon: createMockIcon('git-pull-request-icon'), + // Activity item icons + TestTube01Icon: createMockIcon('test-tube-icon'), + Alert02Icon: createMockIcon('alert-icon'), + CheckmarkSquare01Icon: createMockIcon('checkmark-square-icon'), + CheckmarkCircle01Icon: createMockIcon('checkmark-circle-icon'), + BotIcon: createMockIcon('bot-icon'), + Logout02Icon: createMockIcon('logout-icon'), + GitCommitIcon: createMockIcon('git-commit-icon'), + // UI component icons (select, checkbox, dialog, radio-group) + Tick01Icon: createMockIcon('tick-icon'), + ArrowDown01Icon: createMockIcon('arrow-down-icon'), + ArrowUp01Icon: createMockIcon('arrow-up-icon'), + Cancel01Icon: createMockIcon('cancel-icon'), + CircleIcon: createMockIcon('circle-icon'), + // PR component icons + AlertCircleIcon: createMockIcon('alert-circle-icon'), + GitBranchIcon: createMockIcon('git-branch-icon'), + ArrowRight01Icon: createMockIcon('arrow-right-icon'), + Link01Icon: createMockIcon('link-icon'), + Add01Icon: createMockIcon('add-icon'), + Cancel02Icon: createMockIcon('cancel02-icon'), + Time01Icon: createMockIcon('time-icon'), + // Other icons + Download01Icon: createMockIcon('download-icon'), + AlertDiamondIcon: createMockIcon('alert-diamond-icon'), + }; +}); // Create a shared mock WebSocket client that will be used across all tests const sharedMockWsClient = { diff --git a/web-ui/__tests__/components/PRCreationDialog.test.tsx b/web-ui/__tests__/components/PRCreationDialog.test.tsx new file mode 100644 index 00000000..366ca4b5 --- /dev/null +++ b/web-ui/__tests__/components/PRCreationDialog.test.tsx @@ -0,0 +1,247 @@ +/** + * Tests for PRCreationDialog Component + * TDD: Tests written first to define expected behavior + */ + +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import PRCreationDialog from '@/components/pr/PRCreationDialog'; + +// Mock the API +jest.mock('@/lib/api', () => ({ + pullRequestsApi: { + create: jest.fn().mockResolvedValue({ + data: { + pr_id: 1, + pr_number: 42, + pr_url: 'https://github.com/org/repo/pull/42', + status: 'open', + }, + }), + }, +})); + +// Mock git API for branch list +jest.mock('@/api/git', () => ({ + gitApi: { + getBranches: jest.fn().mockResolvedValue({ + data: { + branches: [ + { id: 1, branch_name: 'feature/auth', status: 'active' }, + { id: 2, branch_name: 'feature/dashboard', status: 'active' }, + { id: 3, branch_name: 'main', status: 'active' }, + ], + }, + }), + }, +})); + +describe('PRCreationDialog', () => { + const defaultProps = { + projectId: 1, + isOpen: true, + onClose: jest.fn(), + onSuccess: jest.fn(), + defaultBranch: 'feature/auth', + defaultTitle: 'Add authentication feature', + defaultDescription: 'Implements OAuth 2.0 login flow', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Rendering', () => { + it('should render the dialog when open', () => { + render(); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText(/create pull request/i)).toBeInTheDocument(); + }); + + it('should not render when closed', () => { + render(); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('should pre-fill form with default values', () => { + render(); + + expect(screen.getByDisplayValue('Add authentication feature')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Implements OAuth 2.0 login flow')).toBeInTheDocument(); + }); + }); + + describe('Form Fields', () => { + it('should have title input field', () => { + render(); + + const titleInput = screen.getByLabelText(/title/i); + expect(titleInput).toBeInTheDocument(); + expect(titleInput).toHaveAttribute('type', 'text'); + }); + + it('should have description textarea', () => { + render(); + + const descriptionInput = screen.getByLabelText(/description/i); + expect(descriptionInput).toBeInTheDocument(); + expect(descriptionInput.tagName.toLowerCase()).toBe('textarea'); + }); + + it('should have branch selector', () => { + render(); + + expect(screen.getByLabelText(/source branch/i)).toBeInTheDocument(); + }); + + it('should have base branch selector with main as default', () => { + render(); + + const baseBranchSelect = screen.getByLabelText(/target branch/i); + expect(baseBranchSelect).toBeInTheDocument(); + }); + }); + + describe('Form Validation', () => { + it('should require title', async () => { + const user = userEvent.setup(); + render(); + + const submitButton = screen.getByRole('button', { name: /create/i }); + await user.click(submitButton); + + expect(screen.getByText(/title is required/i)).toBeInTheDocument(); + }); + + it('should require branch selection', async () => { + const user = userEvent.setup(); + render(); + + const submitButton = screen.getByRole('button', { name: /create/i }); + await user.click(submitButton); + + expect(screen.getByText(/branch is required/i)).toBeInTheDocument(); + }); + }); + + describe('Form Submission', () => { + it('should call API with form data on submit', async () => { + const user = userEvent.setup(); + const { pullRequestsApi } = jest.requireMock('@/lib/api'); + + render(); + + const submitButton = screen.getByRole('button', { name: /create/i }); + await user.click(submitButton); + + await waitFor(() => { + expect(pullRequestsApi.create).toHaveBeenCalledWith(1, { + branch: 'feature/auth', + title: 'Add authentication feature', + body: 'Implements OAuth 2.0 login flow', + base: 'main', + }); + }); + }); + + it('should call onSuccess after successful creation', async () => { + const user = userEvent.setup(); + render(); + + const submitButton = screen.getByRole('button', { name: /create/i }); + await user.click(submitButton); + + await waitFor(() => { + expect(defaultProps.onSuccess).toHaveBeenCalled(); + }); + }); + + it('should close dialog after successful creation', async () => { + const user = userEvent.setup(); + render(); + + const submitButton = screen.getByRole('button', { name: /create/i }); + await user.click(submitButton); + + await waitFor(() => { + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + }); + + it('should show loading state during submission', async () => { + // Make the API call take time to show loading state + const { pullRequestsApi } = jest.requireMock('@/lib/api'); + let resolvePromise: (value: unknown) => void; + pullRequestsApi.create.mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePromise = resolve; + }) + ); + + const user = userEvent.setup(); + render(); + + const submitButton = screen.getByRole('button', { name: /create/i }); + await user.click(submitButton); + + // Button should show loading state + await waitFor(() => { + expect(screen.getByRole('button', { name: /creating/i })).toBeInTheDocument(); + }); + + // Cleanup: resolve the promise + resolvePromise!({ + data: { pr_id: 1, pr_number: 42, pr_url: 'https://github.com/org/repo/pull/42', status: 'open' }, + }); + }); + }); + + describe('Error Handling', () => { + it('should display error message on API failure', async () => { + const user = userEvent.setup(); + const { pullRequestsApi } = jest.requireMock('@/lib/api'); + pullRequestsApi.create.mockRejectedValueOnce(new Error('Branch already has an open PR')); + + render(); + + const submitButton = screen.getByRole('button', { name: /create/i }); + await user.click(submitButton); + + await waitFor(() => { + expect(screen.getByText(/branch already has an open pr/i)).toBeInTheDocument(); + }); + }); + }); + + describe('Cancel Action', () => { + it('should call onClose when cancel button is clicked', async () => { + const user = userEvent.setup(); + render(); + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + await user.click(cancelButton); + + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it('should reset form when reopened', async () => { + const user = userEvent.setup(); + const { rerender } = render(); + + // Modify title + const titleInput = screen.getByLabelText(/title/i); + await user.clear(titleInput); + await user.type(titleInput, 'Modified title'); + + // Close and reopen + rerender(); + rerender(); + + // Should have original default value + expect(screen.getByDisplayValue('Add authentication feature')).toBeInTheDocument(); + }); + }); +}); diff --git a/web-ui/__tests__/components/PRList.test.tsx b/web-ui/__tests__/components/PRList.test.tsx new file mode 100644 index 00000000..d62e66cb --- /dev/null +++ b/web-ui/__tests__/components/PRList.test.tsx @@ -0,0 +1,350 @@ +/** + * Tests for PRList Component + * TDD: Tests written first to define expected behavior + * + * PRList displays pull requests for a project with: + * - List view with status badges + * - Status filtering (all, open, merged, closed) + * - Create PR button + * - Quick actions (view, merge, close) + * - Real-time updates via WebSocket + */ + +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import PRList from '@/components/pr/PRList'; +import type { PullRequest, PRStatus } from '@/types/pullRequest'; + +// Mock Hugeicons to prevent import issues in tests +jest.mock('@hugeicons/react', () => ({ + GitPullRequestIcon: () => , + CheckmarkCircle01Icon: () => , + Cancel01Icon: () => , + Loading03Icon: () => , + AlertCircleIcon: () => , + GitBranchIcon: () => , + Link01Icon: () => , + Add01Icon: () => , + ArrowRight01Icon: () => , + CheckmarkSquare01Icon: () => , + Cancel02Icon: () => , + Time01Icon: () => , +})); + +// Mock data +const mockPRs: PullRequest[] = [ + { + id: 1, + pr_number: 42, + title: 'Add user authentication', + body: 'Implements OAuth 2.0 flow', + status: 'open', + head_branch: 'feature/auth', + base_branch: 'main', + pr_url: 'https://github.com/org/repo/pull/42', + issue_id: null, + created_at: '2024-01-15T10:00:00Z', + updated_at: '2024-01-15T12:00:00Z', + merged_at: null, + merge_commit_sha: null, + files_changed: 15, + additions: 450, + deletions: 50, + ci_status: 'success', + review_status: 'approved', + author: 'developer', + }, + { + id: 2, + pr_number: 41, + title: 'Fix login bug', + body: 'Fixes issue with session timeout', + status: 'merged', + head_branch: 'fix/login-bug', + base_branch: 'main', + pr_url: 'https://github.com/org/repo/pull/41', + issue_id: 5, + created_at: '2024-01-14T09:00:00Z', + updated_at: '2024-01-14T15:00:00Z', + merged_at: '2024-01-14T15:00:00Z', + merge_commit_sha: 'abc123', + files_changed: 3, + additions: 25, + deletions: 10, + ci_status: 'success', + review_status: 'approved', + author: 'developer', + }, + { + id: 3, + pr_number: 40, + title: 'Refactor database layer', + body: 'Cleanup and optimization', + status: 'closed', + head_branch: 'refactor/db', + base_branch: 'main', + pr_url: 'https://github.com/org/repo/pull/40', + issue_id: null, + created_at: '2024-01-13T08:00:00Z', + updated_at: '2024-01-13T16:00:00Z', + merged_at: null, + merge_commit_sha: null, + files_changed: 20, + additions: 200, + deletions: 300, + ci_status: 'failure', + review_status: 'changes_requested', + author: 'developer', + }, +]; + +// SWR mock state +let mockSWRData: { prs: PullRequest[]; total: number } | null = { prs: mockPRs, total: 3 }; +let mockSWRError: Error | null = null; +let mockSWRIsLoading = false; + +// Mock SWR with configurable state +jest.mock('swr', () => ({ + __esModule: true, + default: jest.fn(() => ({ + data: mockSWRData, + error: mockSWRError, + isLoading: mockSWRIsLoading, + mutate: jest.fn(), + })), +})); + +// Mock WebSocket client +const mockOnMessage = jest.fn(); +const mockUnsubscribe = jest.fn(); +jest.mock('@/lib/websocket', () => ({ + getWebSocketClient: () => ({ + onMessage: (handler: (msg: unknown) => void) => { + mockOnMessage.mockImplementation(handler); + return mockUnsubscribe; + }, + }), +})); + +// Mock the API +jest.mock('@/lib/api', () => ({ + pullRequestsApi: { + list: jest.fn().mockResolvedValue({ data: { prs: [], total: 0 } }), + }, +})); + +describe('PRList', () => { + const defaultProps = { + projectId: 1, + onCreatePR: jest.fn(), + onViewPR: jest.fn(), + onMergePR: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + // Reset mock state to defaults + mockSWRData = { prs: mockPRs, total: 3 }; + mockSWRError = null; + mockSWRIsLoading = false; + }); + + describe('Rendering', () => { + it('should render the PR list with all PRs', () => { + render(); + + expect(screen.getByText('Add user authentication')).toBeInTheDocument(); + expect(screen.getByText('Fix login bug')).toBeInTheDocument(); + expect(screen.getByText('Refactor database layer')).toBeInTheDocument(); + }); + + it('should display PR numbers', () => { + render(); + + expect(screen.getByText('#42')).toBeInTheDocument(); + expect(screen.getByText('#41')).toBeInTheDocument(); + expect(screen.getByText('#40')).toBeInTheDocument(); + }); + + it('should display status badges', () => { + render(); + + const prCards = screen.getAllByTestId('pr-card'); + expect(prCards.length).toBe(3); + + // Check status badges exist + expect(screen.getByText('open')).toBeInTheDocument(); + expect(screen.getByText('merged')).toBeInTheDocument(); + expect(screen.getByText('closed')).toBeInTheDocument(); + }); + + it('should display branch information', () => { + render(); + + expect(screen.getByText('feature/auth')).toBeInTheDocument(); + expect(screen.getByText('fix/login-bug')).toBeInTheDocument(); + }); + + it('should display file change counts', () => { + render(); + + expect(screen.getByText(/15 files/)).toBeInTheDocument(); + expect(screen.getByText(/\+450/)).toBeInTheDocument(); + expect(screen.getByText(/-50/)).toBeInTheDocument(); + }); + + it('should display Create PR button', () => { + render(); + + expect(screen.getByRole('button', { name: /create pr/i })).toBeInTheDocument(); + }); + }); + + describe('Filtering', () => { + it('should display filter buttons', () => { + render(); + + expect(screen.getByRole('tab', { name: /all/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /open/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /merged/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /closed/i })).toBeInTheDocument(); + }); + + it('should have "All" filter selected by default', () => { + render(); + + const allButton = screen.getByRole('tab', { name: /all/i }); + expect(allButton).toHaveAttribute('data-active', 'true'); + }); + + it('should call filter change handler when filter is clicked', async () => { + const user = userEvent.setup(); + render(); + + const openButton = screen.getByRole('tab', { name: /^open$/i }); + await user.click(openButton); + + expect(openButton).toHaveAttribute('data-active', 'true'); + }); + }); + + describe('Actions', () => { + it('should call onCreatePR when Create PR button is clicked', async () => { + const user = userEvent.setup(); + render(); + + const createButton = screen.getByRole('button', { name: /create pr/i }); + await user.click(createButton); + + expect(defaultProps.onCreatePR).toHaveBeenCalled(); + }); + + it('should call onViewPR when View button is clicked', async () => { + const user = userEvent.setup(); + render(); + + const viewButtons = screen.getAllByRole('button', { name: /view/i }); + await user.click(viewButtons[0]); + + expect(defaultProps.onViewPR).toHaveBeenCalledWith(42); + }); + + it('should show Merge button only for open PRs', () => { + render(); + + const prCards = screen.getAllByTestId('pr-card'); + + // First PR (open) should have Merge button + const openPRCard = prCards[0]; + expect(within(openPRCard).getByRole('button', { name: /merge/i })).toBeInTheDocument(); + + // Second PR (merged) should not have Merge button + const mergedPRCard = prCards[1]; + expect(within(mergedPRCard).queryByRole('button', { name: /merge/i })).not.toBeInTheDocument(); + }); + + it('should call onMergePR when Merge button is clicked', async () => { + const user = userEvent.setup(); + render(); + + const mergeButton = screen.getByRole('button', { name: /merge/i }); + await user.click(mergeButton); + + expect(defaultProps.onMergePR).toHaveBeenCalledWith(mockPRs[0]); + }); + }); + + describe('CI/Review Status', () => { + it('should display CI status indicator', () => { + render(); + + // Should show success indicator for first PR + const prCards = screen.getAllByTestId('pr-card'); + expect(within(prCards[0]).getByTestId('ci-status')).toHaveAttribute('data-status', 'success'); + }); + + it('should display review status indicator', () => { + render(); + + const prCards = screen.getAllByTestId('pr-card'); + expect(within(prCards[0]).getByTestId('review-status')).toHaveAttribute('data-status', 'approved'); + }); + }); + + describe('Empty State', () => { + it('should display empty state when no PRs exist', () => { + mockSWRData = { prs: [], total: 0 }; + render(); + + expect(screen.getByText(/no pull requests/i)).toBeInTheDocument(); + }); + + it('should still show Create PR button in empty state', () => { + mockSWRData = { prs: [], total: 0 }; + render(); + + // There should be at least one Create PR button + const createButtons = screen.getAllByRole('button', { name: /create pr/i }); + expect(createButtons.length).toBeGreaterThan(0); + }); + }); + + describe('Loading State', () => { + it('should display loading skeleton when loading', () => { + mockSWRData = null; + mockSWRIsLoading = true; + render(); + + expect(screen.getByTestId('pr-list-loading')).toBeInTheDocument(); + }); + }); + + describe('Error State', () => { + it('should display error message when fetch fails', () => { + mockSWRData = null; + mockSWRError = new Error('Failed to load PRs'); + render(); + + expect(screen.getByText(/failed to load/i)).toBeInTheDocument(); + }); + + it('should show retry button on error', () => { + mockSWRData = null; + mockSWRError = new Error('Failed to load PRs'); + render(); + + expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument(); + }); + }); + + describe('External Links', () => { + it('should render GitHub link with correct URL', () => { + render(); + + const githubLinks = screen.getAllByRole('link', { name: /github/i }); + expect(githubLinks[0]).toHaveAttribute('href', 'https://github.com/org/repo/pull/42'); + expect(githubLinks[0]).toHaveAttribute('target', '_blank'); + expect(githubLinks[0]).toHaveAttribute('rel', 'noopener noreferrer'); + }); + }); +}); diff --git a/web-ui/__tests__/components/PRMergeDialog.test.tsx b/web-ui/__tests__/components/PRMergeDialog.test.tsx new file mode 100644 index 00000000..bf4c43a7 --- /dev/null +++ b/web-ui/__tests__/components/PRMergeDialog.test.tsx @@ -0,0 +1,235 @@ +/** + * Tests for PRMergeDialog Component + * TDD: Tests written first to define expected behavior + */ + +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import PRMergeDialog from '@/components/pr/PRMergeDialog'; +import type { PullRequest } from '@/types/pullRequest'; + +// Mock PR data +const mockPR: PullRequest = { + id: 1, + pr_number: 42, + title: 'Add user authentication', + body: 'Implements OAuth 2.0 flow', + status: 'open', + head_branch: 'feature/auth', + base_branch: 'main', + pr_url: 'https://github.com/org/repo/pull/42', + issue_id: null, + created_at: '2024-01-15T10:00:00Z', + updated_at: '2024-01-15T12:00:00Z', + merged_at: null, + merge_commit_sha: null, + files_changed: 15, + additions: 450, + deletions: 50, + ci_status: 'success', + review_status: 'approved', +}; + +// Mock the API +jest.mock('@/lib/api', () => ({ + pullRequestsApi: { + merge: jest.fn().mockResolvedValue({ + data: { + merged: true, + merge_commit_sha: 'def456', + }, + }), + }, +})); + +describe('PRMergeDialog', () => { + const defaultProps = { + pr: mockPR, + projectId: 1, + isOpen: true, + onClose: jest.fn(), + onSuccess: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Rendering', () => { + it('should render the dialog when open', () => { + render(); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText(/merge pull request/i)).toBeInTheDocument(); + }); + + it('should not render when closed', () => { + render(); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('should display PR information', () => { + render(); + + expect(screen.getByText(/#42/)).toBeInTheDocument(); + expect(screen.getByText('Add user authentication')).toBeInTheDocument(); + expect(screen.getByText(/feature\/auth/)).toBeInTheDocument(); + // Use getAllByText since 'main' appears multiple times (in branch info and description) + const mainElements = screen.getAllByText(/main/); + expect(mainElements.length).toBeGreaterThan(0); + }); + }); + + describe('Merge Method Selection', () => { + it('should display all merge method options', () => { + render(); + + expect(screen.getByLabelText(/squash and merge/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/create merge commit/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/rebase and merge/i)).toBeInTheDocument(); + }); + + it('should have squash selected by default', () => { + render(); + + const squashRadio = screen.getByLabelText(/squash and merge/i); + expect(squashRadio).toBeChecked(); + }); + + it('should allow changing merge method', async () => { + const user = userEvent.setup(); + render(); + + const rebaseRadio = screen.getByLabelText(/rebase and merge/i); + await user.click(rebaseRadio); + + expect(rebaseRadio).toBeChecked(); + }); + }); + + describe('Delete Branch Option', () => { + it('should have delete branch checkbox checked by default', () => { + render(); + + const checkbox = screen.getByLabelText(/delete branch after merge/i); + expect(checkbox).toBeChecked(); + }); + + it('should allow unchecking delete branch option', async () => { + const user = userEvent.setup(); + render(); + + const checkbox = screen.getByLabelText(/delete branch after merge/i); + await user.click(checkbox); + + expect(checkbox).not.toBeChecked(); + }); + }); + + describe('Form Submission', () => { + it('should call API with selected options on confirm', async () => { + const user = userEvent.setup(); + const { pullRequestsApi } = jest.requireMock('@/lib/api'); + + render(); + + const confirmButton = screen.getByRole('button', { name: /confirm merge/i }); + await user.click(confirmButton); + + await waitFor(() => { + expect(pullRequestsApi.merge).toHaveBeenCalledWith(1, 42, { + method: 'squash', + delete_branch: true, + }); + }); + }); + + it('should call onSuccess after successful merge', async () => { + const user = userEvent.setup(); + render(); + + const confirmButton = screen.getByRole('button', { name: /confirm merge/i }); + await user.click(confirmButton); + + await waitFor(() => { + expect(defaultProps.onSuccess).toHaveBeenCalled(); + }); + }); + + it('should close dialog after successful merge', async () => { + const user = userEvent.setup(); + render(); + + const confirmButton = screen.getByRole('button', { name: /confirm merge/i }); + await user.click(confirmButton); + + await waitFor(() => { + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + }); + + it('should show loading state during submission', async () => { + // Make the API call take time to show loading state + const { pullRequestsApi } = jest.requireMock('@/lib/api'); + let resolvePromise: (value: unknown) => void; + pullRequestsApi.merge.mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePromise = resolve; + }) + ); + + const user = userEvent.setup(); + render(); + + const confirmButton = screen.getByRole('button', { name: /confirm merge/i }); + await user.click(confirmButton); + + // Button should show loading state + await waitFor(() => { + expect(screen.getByRole('button', { name: /merging/i })).toBeInTheDocument(); + }); + + // Cleanup: resolve the promise + resolvePromise!({ data: { merged: true, merge_commit_sha: 'def456' } }); + }); + }); + + describe('Error Handling', () => { + it('should display error message on API failure', async () => { + const user = userEvent.setup(); + const { pullRequestsApi } = jest.requireMock('@/lib/api'); + pullRequestsApi.merge.mockRejectedValueOnce(new Error('Merge conflicts detected')); + + render(); + + const confirmButton = screen.getByRole('button', { name: /confirm merge/i }); + await user.click(confirmButton); + + await waitFor(() => { + expect(screen.getByText(/merge conflicts detected/i)).toBeInTheDocument(); + }); + }); + }); + + describe('Cancel Action', () => { + it('should call onClose when cancel button is clicked', async () => { + const user = userEvent.setup(); + render(); + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + await user.click(cancelButton); + + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + }); + + describe('Warning Message', () => { + it('should display warning about irreversibility', () => { + render(); + + expect(screen.getByText(/this action cannot be undone/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/web-ui/__tests__/integration/dashboard-realtime-updates.test.tsx b/web-ui/__tests__/integration/dashboard-realtime-updates.test.tsx index eeaa38ce..b1c7ef89 100644 --- a/web-ui/__tests__/integration/dashboard-realtime-updates.test.tsx +++ b/web-ui/__tests__/integration/dashboard-realtime-updates.test.tsx @@ -13,34 +13,62 @@ import * as websocket from '@/lib/websocket'; jest.mock('@/lib/api'); jest.mock('@/lib/websocket'); -// Mock Hugeicons (used by PhaseProgress, Dashboard, and SessionStatus components) -jest.mock('@hugeicons/react', () => ({ - // PhaseProgress icons - Search01Icon: ({ className }: { className?: string }) => , - TaskEdit01Icon: ({ className }: { className?: string }) => , - Wrench01Icon: ({ className }: { className?: string }) => , - CheckmarkCircle01Icon: ({ className }: { className?: string }) => , - Award01Icon: ({ className }: { className?: string }) => , - RocketIcon: ({ className }: { className?: string }) => , - HelpCircleIcon: ({ className }: { className?: string }) => , - Idea01Icon: ({ className }: { className?: string }) => , - // Dashboard section header icons - UserGroupIcon: ({ className }: { className?: string }) => , - Loading03Icon: ({ className }: { className?: string }) => , - WorkHistoryIcon: ({ className }: { className?: string }) => , - AnalyticsUpIcon: ({ className }: { className?: string }) => , - ClipboardIcon: ({ className }: { className?: string }) => , - CheckListIcon: ({ className }: { className?: string }) => , - Target02Icon: ({ className }: { className?: string }) => , - FloppyDiskIcon: ({ className }: { className?: string }) => , - // Activity item icons - TestTube01Icon: ({ className }: { className?: string }) => , - Alert02Icon: ({ className }: { className?: string }) => , - CheckmarkSquare01Icon: ({ className }: { className?: string }) => , - BotIcon: ({ className }: { className?: string }) => , - Logout02Icon: ({ className }: { className?: string }) => , - GitCommitIcon: ({ className }: { className?: string }) => , -})); +// Mock Hugeicons (used by PhaseProgress, Dashboard, SessionStatus, and UI components) +jest.mock('@hugeicons/react', () => { + const createMockIcon = (name: string) => { + const Icon = ({ className }: { className?: string }) => ( + + ); + Icon.displayName = name; + return Icon; + }; + + return { + // PhaseProgress icons + Search01Icon: createMockIcon('search-icon'), + TaskEdit01Icon: createMockIcon('task-edit-icon'), + Wrench01Icon: createMockIcon('wrench-icon'), + Award01Icon: createMockIcon('award-icon'), + RocketIcon: createMockIcon('rocket-icon'), + HelpCircleIcon: createMockIcon('help-icon'), + Idea01Icon: createMockIcon('idea-icon'), + // Dashboard section header icons + UserGroupIcon: createMockIcon('user-group-icon'), + Loading03Icon: createMockIcon('loading-icon'), + WorkHistoryIcon: createMockIcon('work-history-icon'), + AnalyticsUpIcon: createMockIcon('analytics-icon'), + ClipboardIcon: createMockIcon('clipboard-icon'), + CheckListIcon: createMockIcon('checklist-icon'), + Target02Icon: createMockIcon('target-icon'), + FloppyDiskIcon: createMockIcon('floppy-icon'), + GitPullRequestIcon: createMockIcon('git-pull-request-icon'), + // Activity item icons + TestTube01Icon: createMockIcon('test-tube-icon'), + Alert02Icon: createMockIcon('alert-icon'), + CheckmarkSquare01Icon: createMockIcon('checkmark-square-icon'), + CheckmarkCircle01Icon: createMockIcon('checkmark-circle-icon'), + BotIcon: createMockIcon('bot-icon'), + Logout02Icon: createMockIcon('logout-icon'), + GitCommitIcon: createMockIcon('git-commit-icon'), + // UI component icons (select, checkbox, dialog, radio-group) + Tick01Icon: createMockIcon('tick-icon'), + ArrowDown01Icon: createMockIcon('arrow-down-icon'), + ArrowUp01Icon: createMockIcon('arrow-up-icon'), + Cancel01Icon: createMockIcon('cancel-icon'), + CircleIcon: createMockIcon('circle-icon'), + // PR component icons + AlertCircleIcon: createMockIcon('alert-circle-icon'), + GitBranchIcon: createMockIcon('git-branch-icon'), + ArrowRight01Icon: createMockIcon('arrow-right-icon'), + Link01Icon: createMockIcon('link-icon'), + Add01Icon: createMockIcon('add-icon'), + Cancel02Icon: createMockIcon('cancel02-icon'), + Time01Icon: createMockIcon('time-icon'), + // Other icons + Download01Icon: createMockIcon('download-icon'), + AlertDiamondIcon: createMockIcon('alert-diamond-icon'), + }; +}); jest.mock('@/components/ChatInterface', () => ({ __esModule: true, @@ -177,7 +205,7 @@ describe('Dashboard Real-Time Updates Integration', () => { // Verify Dashboard updates with new agent await waitFor(() => { - expect(screen.getByText(/1 agents active/i)).toBeInTheDocument(); + expect(screen.getByText(/1 agent active/i)).toBeInTheDocument(); expect(screen.getByText(/backend-worker-1/i)).toBeInTheDocument(); }); }); diff --git a/web-ui/__tests__/integration/prd-button-sync.test.tsx b/web-ui/__tests__/integration/prd-button-sync.test.tsx index d8601055..b26b7ee2 100644 --- a/web-ui/__tests__/integration/prd-button-sync.test.tsx +++ b/web-ui/__tests__/integration/prd-button-sync.test.tsx @@ -23,34 +23,61 @@ jest.mock('@/lib/websocket'); jest.mock('@/lib/api-client', () => ({ authFetch: jest.fn().mockRejectedValue(new Error('Not authenticated')), })); -jest.mock('@hugeicons/react', () => ({ - Cancel01Icon: () => ×, - CheckmarkCircle01Icon: ({ className }: { className?: string }) => , - Alert02Icon: () => !, - // PhaseProgress icons - Search01Icon: ({ className }: { className?: string }) => , - TaskEdit01Icon: ({ className }: { className?: string }) => , - Wrench01Icon: ({ className }: { className?: string }) => , - Award01Icon: ({ className }: { className?: string }) => , - RocketIcon: ({ className }: { className?: string }) => , - HelpCircleIcon: ({ className }: { className?: string }) => , - Idea01Icon: ({ className }: { className?: string }) => , - // Dashboard section header icons - UserGroupIcon: ({ className }: { className?: string }) => , - Loading03Icon: ({ className }: { className?: string }) => , - WorkHistoryIcon: ({ className }: { className?: string }) => , - AnalyticsUpIcon: ({ className }: { className?: string }) => , - ClipboardIcon: ({ className }: { className?: string }) => , - CheckListIcon: ({ className }: { className?: string }) => , - Target02Icon: ({ className }: { className?: string }) => , - FloppyDiskIcon: ({ className }: { className?: string }) => , - // Activity item icons - TestTube01Icon: ({ className }: { className?: string }) => , - CheckmarkSquare01Icon: ({ className }: { className?: string }) => , - BotIcon: ({ className }: { className?: string }) => , - Logout02Icon: ({ className }: { className?: string }) => , - GitCommitIcon: ({ className }: { className?: string }) => , -})); +jest.mock('@hugeicons/react', () => { + const createMockIcon = (name: string) => { + const Icon = ({ className }: { className?: string }) => ( + + ); + Icon.displayName = name; + return Icon; + }; + + return { + // PhaseProgress icons + Search01Icon: createMockIcon('search-icon'), + TaskEdit01Icon: createMockIcon('task-edit-icon'), + Wrench01Icon: createMockIcon('wrench-icon'), + Award01Icon: createMockIcon('award-icon'), + RocketIcon: createMockIcon('rocket-icon'), + HelpCircleIcon: createMockIcon('help-icon'), + Idea01Icon: createMockIcon('idea-icon'), + // Dashboard section header icons + UserGroupIcon: createMockIcon('user-group-icon'), + Loading03Icon: createMockIcon('loading-icon'), + WorkHistoryIcon: createMockIcon('work-history-icon'), + AnalyticsUpIcon: createMockIcon('analytics-icon'), + ClipboardIcon: createMockIcon('clipboard-icon'), + CheckListIcon: createMockIcon('checklist-icon'), + Target02Icon: createMockIcon('target-icon'), + FloppyDiskIcon: createMockIcon('floppy-icon'), + GitPullRequestIcon: createMockIcon('git-pull-request-icon'), + // Activity item icons + TestTube01Icon: createMockIcon('test-tube-icon'), + Alert02Icon: createMockIcon('alert-icon'), + CheckmarkSquare01Icon: createMockIcon('checkmark-square-icon'), + CheckmarkCircle01Icon: createMockIcon('checkmark-circle-icon'), + BotIcon: createMockIcon('bot-icon'), + Logout02Icon: createMockIcon('logout-icon'), + GitCommitIcon: createMockIcon('git-commit-icon'), + // UI component icons (select, checkbox, dialog, radio-group) + Tick01Icon: createMockIcon('tick-icon'), + ArrowDown01Icon: createMockIcon('arrow-down-icon'), + ArrowUp01Icon: createMockIcon('arrow-up-icon'), + Cancel01Icon: createMockIcon('cancel-icon'), + CircleIcon: createMockIcon('circle-icon'), + // PR component icons + AlertCircleIcon: createMockIcon('alert-circle-icon'), + GitBranchIcon: createMockIcon('git-branch-icon'), + ArrowRight01Icon: createMockIcon('arrow-right-icon'), + Link01Icon: createMockIcon('link-icon'), + Add01Icon: createMockIcon('add-icon'), + Cancel02Icon: createMockIcon('cancel02-icon'), + Time01Icon: createMockIcon('time-icon'), + // Other icons + Download01Icon: createMockIcon('download-icon'), + AlertDiamondIcon: createMockIcon('alert-diamond-icon'), + }; +}); jest.mock('@/components/ChatInterface', () => ({ __esModule: true, default: () =>
ChatInterface Mock
, diff --git a/web-ui/jest.setup.js b/web-ui/jest.setup.js index 3185c90d..6fe831b5 100644 --- a/web-ui/jest.setup.js +++ b/web-ui/jest.setup.js @@ -35,3 +35,72 @@ jest.mock('react-markdown', () => { return children } }) + +// Mock @hugeicons/react - create stub components for all icons +jest.mock('@hugeicons/react', () => { + const React = require('react') + + // Generic icon component factory + const createIconMock = (name) => { + const IconComponent = React.forwardRef(({ className, ...props }, ref) => + React.createElement('svg', { + ref, + className, + 'data-testid': name, + 'aria-hidden': 'true', + ...props + }) + ) + IconComponent.displayName = name + return IconComponent + } + + // List of all icons used in the codebase + const iconNames = [ + 'Add01Icon', + 'Alert02Icon', + 'AlertCircleIcon', + 'AlertDiamondIcon', + 'AnalyticsUpIcon', + 'ArrowDown01Icon', + 'ArrowRight01Icon', + 'ArrowUp01Icon', + 'Award01Icon', + 'BotIcon', + 'Cancel01Icon', + 'Cancel02Icon', + 'CheckListIcon', + 'CheckmarkCircle01Icon', + 'CheckmarkSquare01Icon', + 'CircleIcon', + 'ClipboardIcon', + 'Download01Icon', + 'FloppyDiskIcon', + 'GitBranchIcon', + 'GitCommitIcon', + 'GitPullRequestIcon', + 'HelpCircleIcon', + 'Idea01Icon', + 'Link01Icon', + 'Loading03Icon', + 'Logout02Icon', + 'RocketIcon', + 'Search01Icon', + 'Target02Icon', + 'TaskEdit01Icon', + 'TestTube01Icon', + 'Tick01Icon', + 'Time01Icon', + 'UserGroupIcon', + 'WorkHistoryIcon', + 'Wrench01Icon', + ] + + // Create mock object with all icons + const mocks = {} + iconNames.forEach(name => { + mocks[name] = createIconMock(name) + }) + + return mocks +}) diff --git a/web-ui/package-lock.json b/web-ui/package-lock.json index f6a7592f..4805c344 100644 --- a/web-ui/package-lock.json +++ b/web-ui/package-lock.json @@ -9,9 +9,12 @@ "version": "0.1.0", "dependencies": { "@hugeicons/react": "^0.3.0", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", @@ -2040,6 +2043,36 @@ } } }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", @@ -2297,6 +2330,52 @@ } } }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", + "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-menu": { "version": "2.1.16", "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", @@ -2538,6 +2617,38 @@ } } }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", diff --git a/web-ui/package.json b/web-ui/package.json index 331cf5b7..d662fe59 100644 --- a/web-ui/package.json +++ b/web-ui/package.json @@ -14,9 +14,12 @@ }, "dependencies": { "@hugeicons/react": "^0.3.0", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", diff --git a/web-ui/src/components/Dashboard.tsx b/web-ui/src/components/Dashboard.tsx index c2aaef17..1f49580b 100644 --- a/web-ui/src/components/Dashboard.tsx +++ b/web-ui/src/components/Dashboard.tsx @@ -38,6 +38,8 @@ import PhaseProgress from './PhaseProgress'; import TaskList from './TaskList'; import TaskReview from './TaskReview'; import { GitSection } from './git'; +import { PRList, PRCreationDialog, PRMergeDialog } from './pr'; +import type { PullRequest } from '@/types/pullRequest'; import { UserGroupIcon, Loading03Icon, @@ -55,6 +57,7 @@ import { CheckListIcon, Target02Icon, FloppyDiskIcon, + GitPullRequestIcon, } from '@hugeicons/react'; /** @@ -110,6 +113,12 @@ export default function Dashboard({ projectId }: DashboardProps) { const [showQualityGatesPanel, setShowQualityGatesPanel] = useState(true); const lastRetryTimeRef = useRef(0); + // Pull Request management state (Feature: PR Management UI) + const [showPRCreationDialog, setShowPRCreationDialog] = useState(false); + const [prToMerge, setPrToMerge] = useState(null); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_selectedPRNumber, setSelectedPRNumber] = useState(null); + // Memoize filtered agent lists for performance (T111) const _activeAgents = useMemo( () => agents.filter(a => a.status === 'working' || a.status === 'blocked'), @@ -519,6 +528,21 @@ export default function Dashboard({ projectId }: DashboardProps) { > Metrics +