diff --git a/.gitignore b/.gitignore index 622372e65a..7622368bbf 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ .sentryclirc .swc/ .turbo +.vitest/ .vscode app/ coverage diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4567868fe1..c51f57330d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ ### Prerequisites - [Git 2.10+](https://git-scm.com/downloads) -- [NodeJS LTS](https://nodejs.org/en/) +- [Node.js 24 LTS](https://nodejs.org/en/) (also used in CI) - [Podman](https://podman.io/docs/installation) - [podman-compose](https://github.com/containers/podman-compose) diff --git a/VITEST.md b/VITEST.md deleted file mode 100644 index e8b60acb2b..0000000000 --- a/VITEST.md +++ /dev/null @@ -1,236 +0,0 @@ -# Vitest Migration Plan - -## Goal - -Migrate all automated tests in this repository from Jest to latest Vitest with no feature regressions, stable CI reports, and same or better local developer experience - -## Current Baseline - -- Monorepo with three active workspaces: `client`, `server`, `nestjs` -- Jest is used in all three workspaces through local scripts and dedicated config files -- Current usage footprint is large (`421` Jest-related references in source and test files, across `97` files) -- Current CI uploads `jest-junit-*.xml` artifacts and relies on per-workspace `test:ci` -- Root ESLint setup loads `eslint-plugin-jest` test recommendations - -## Migration Strategy - -- Use phased migration with strict checkpoints -- Keep repository green after each phase -- Prefer adapter-compatible migration first (test runtime switch), then API cleanup -- Migrate workspace by workspace, not all at once -- Keep rollback path simple by limiting each pull request scope - -## Work Breakdown - -### Phase 0 - Planning and guardrails - -1. Create tracking issue and checklist for all steps in this document -2. Freeze non-critical test refactors during migration window -3. Define acceptance criteria - - all current test scripts have Vitest equivalents - - CI test jobs pass in all workspaces - - XML reports still published and consumed by report workflow - - no Jest runtime package remains in active dependencies -4. Define branch strategy - - one umbrella branch for the migration - - optional child branches by workspace if team parallelizes -5. Define rollback strategy - - each phase merged only after green CI - - keep isolated commits for dependency changes, config changes, and test API updates - -### Phase 1 - Repository inventory and mapping - -1. Build complete inventory of Jest touchpoints - - dependency graph (`jest`, `ts-jest`, `@types/jest`, `jest-environment-jsdom`, `jest-junit`, `jest-mock-axios`) - - config files (`client/jest.config.mjs`, `server/jest.config.mjs`, `nestjs/jest.config.mjs`, `nestjs/test/jest-e2e.json`) - - scripts in `package.json` files - - ESLint test plugin configuration - - CI workflow references to Jest output names -2. Build test API usage inventory - - global APIs (`jest.fn`, `jest.spyOn`, `jest.mock`, fake timers, reset helpers) - - type usages (`jest.Mock`, `jest.mocked`) - - setup files and global matchers -3. Build workspace-specific complexity score - - `client`: Next.js + jsdom + module mocks + `jest-mock-axios` - - `server`: Node + TypeScript transform + legacy backend - - `nestjs`: Node + Nest testing utilities + e2e config -4. Lock expected command matrix for final validation - - root: `npm run lint`, `npm run test`, `npm run compile`, `npm run format` - - plus workspace-level targeted test runs during migration - -### Phase 2 - Target architecture decisions - -1. Choose Vitest version and lockfile policy -2. Decide transform strategy for TypeScript in each workspace - - native Vitest + Vite transform where possible - - avoid keeping Jest-specific transform chain -3. Decide coverage provider and reporter format parity -4. Decide XML reporter replacement strategy for `jest-junit` - - select Vitest-compatible junit reporter - - preserve artifact naming or update workflows accordingly -5. Decide lint strategy - - replace `eslint-plugin-jest` with Vitest-aware ruleset, or mixed mode during transition -6. Decide test file naming policy - - keep current `.test`/`.spec` names for minimal churn -7. Decide future approach for Nest e2e tests - - keep in Vitest as integration tests - - or keep separate runner temporarily with clear deprecation date - -### Phase 3 - Foundation changes at root level - -1. Update root dependencies - - add Vitest core and shared helpers - - remove root Jest packages when no longer needed -2. Add shared base Vitest config pattern - - either reusable root config module or per-workspace local configs extending shared defaults -3. Update root lint config for test files - - switch from Jest plugin defaults to Vitest-aware defaults -4. Update root scripts only if needed for monorepo ergonomics - - keep `turbo run test` behavior unchanged for contributors -5. Regenerate lockfile and confirm deterministic install - -### Phase 4 - Migrate client workspace - -1. Replace Jest config with Vitest config compatible with Next.js client tests -2. Recreate jsdom environment setup and test globals -3. Migrate `setupJest` file naming and imports to Vitest-compatible setup -4. Replace Jest scripts in `client/package.json` - - `test`, `test:ci`, `test:watch`, `coverage` -5. Replace or adapt `jest-mock-axios` usage - - evaluate native module mocking and spies first - - if replacement package is needed, add it explicitly -6. Update test files in batches - - convert `jest.*` runtime calls to `vi.*` - - convert typed mocks from Jest types to Vitest types - - fix hoisting-sensitive mocks (`jest.mock` patterns) -7. Run client tests repeatedly until green -8. Ensure junit XML output generated for CI artifact upload - -### Phase 5 - Migrate server workspace - -1. Replace `server/jest.config.mjs` with Vitest config for Node environment -2. Replace Jest scripts in `server/package.json` -3. Convert server test runtime APIs from `jest.*` to `vi.*` -4. Validate module resolution parity (`moduleDirectories`, aliases, TS paths) -5. Validate timers and mocks behavior in legacy service tests -6. Run server test suite and coverage commands -7. Ensure CI XML report is emitted at expected path - -### Phase 6 - Migrate nestjs workspace - -1. Replace `nestjs/jest.config.mjs` with Vitest config -2. Replace `nestjs/test/jest-e2e.json` strategy - - either merge into Vitest project config - - or split unit/integration projects in a single Vitest config -3. Replace Jest scripts in `nestjs/package.json` -4. Convert Nest unit tests from `jest.*` to `vi.*` -5. Validate compatibility with Nest testing module patterns -6. Rework debug script strategy (`test:debug`) for Vitest runtime -7. Migrate e2e command to Vitest-equivalent workflow -8. Ensure junit XML report is emitted at expected path - -### Phase 7 - CI and reporting migration - -1. Update `.github/workflows/pull_request.yml` - - keep per-workspace test jobs - - update artifact paths and names if reporter file names changed -2. Update `.github/workflows/test_report.yml` - - replace any Jest-specific reporter assumptions - - ensure parsed reports still annotate PRs -3. Update deploy workflow test command assumptions where relevant -4. Validate that all workflows still run under current Node version policy - -### Phase 8 - TypeScript and tooling cleanup - -1. Remove Jest-only types from tsconfig include/types if present -2. Ensure Vitest globals typing configured per workspace or explicit imports used -3. Remove obsolete Jest dependencies and config files -4. Remove stale test setup filenames referencing Jest -5. Remove dead helper code or mocks created only for Jest behavior - -### Phase 9 - Verification and hardening - -1. Full local validation - - `npm run lint` - - `npm run test` - - `npm run compile` - - `npm run format` -2. Spot-check changed tests for deterministic behavior - - fake timers - - async expectations - - module mocks -3. Run CI on migration branch and verify all jobs green -4. Compare test duration before/after and capture regression notes -5. Compare coverage before/after and capture gaps - -### Phase 10 - Documentation and handoff - -1. Update contributor docs - - root `README.md` testing section if needed - - `CONTRIBUTING.md` test commands and local workflow - - workspace READMEs if they mention Jest -2. Add migration notes - - known incompatibilities - - new mocking patterns - - timer usage guidance -3. Create follow-up backlog for non-blocking improvements - - optimize slow tests - - improve test isolation - - enforce new lint rules for Vitest patterns - -## Execution Model for LLM Worker - -### Operating rules - -1. Work in small PRs with one phase or one workspace per PR -2. Keep PRs reviewable (target under ~400 changed lines unless mechanical rename) -3. Do not mix runtime migration with unrelated refactors -4. After each PR, run full required checks and attach outputs -5. If migration blocks on one workspace, ship completed workspace migrations first behind stable CI - -### Suggested PR sequence - -1. PR 1: root dependencies, lint plugin transition, shared Vitest base -2. PR 2: client migration -3. PR 3: server migration -4. PR 4: nestjs migration including e2e strategy -5. PR 5: CI/reporting cleanup and docs -6. PR 6: final Jest removal and dead-code cleanup - -### Definition of done - -- No active workspace uses Jest runtime commands -- No active workspace depends on Jest-only packages -- All local required commands pass at root -- CI test and reporting workflows pass with Vitest outputs -- Documentation reflects Vitest-based workflow - -## Risk Register and Mitigations - -1. Mock hoisting differences break tests - - mitigate with batch conversion and per-file verification -2. Timer behavior differences create flaky tests - - mitigate by standardizing fake timer lifecycle in setup/teardown -3. Next.js client transform differences cause import failures - - mitigate by validating module transform and alias mapping early in client phase -4. Nest e2e behavior differs from Jest config assumptions - - mitigate by isolating e2e migration and validating with dedicated command before merge -5. CI report parser mismatch after reporter switch - - mitigate by updating report workflow and testing artifact parsing in draft PR -6. Hidden Jest references survive in tooling - - mitigate with final repository-wide scan for `jest` tokens and config filenames - -## Final Migration Checklist - -- [ ] Root dependencies switched to Vitest stack -- [ ] ESLint test rules updated for Vitest -- [ ] Client tests green on Vitest -- [ ] Server tests green on Vitest -- [ ] NestJS tests green on Vitest -- [ ] NestJS e2e path migrated or formally isolated with deadline -- [ ] CI test jobs green -- [ ] CI test reports parsed and published -- [ ] Jest configs removed -- [ ] Jest packages removed from lockfile and package manifests -- [ ] Root validation commands pass -- [ ] Documentation updated \ No newline at end of file diff --git a/client/README.md b/client/README.md index 1564c517d6..4133f0a456 100644 --- a/client/README.md +++ b/client/README.md @@ -97,6 +97,27 @@ modules// - Convert styled-jsx to CSS modules during touch-based changes. - Do not initiate large-scale rewrites without approval. +## Unit Tests + +- Run all client tests: `npm test --workspace client` +- Run tests with combined coverage: `npm run test:ci --workspace client` +- Run DOM-free tests: `npm test --workspace client -- --project node` +- Run component and browser API tests: `npm test --workspace client -- --project dom` + +The `nodeTests` list in `vitest.config.mts` selects audited DOM-free tests. Add a file only if its tests and runtime +imports work without browser globals. All other tests use jsdom and `setupTests.ts`. Both projects retain file isolation +and contribute to the same coverage report and thresholds. + +Retries are disabled so flaky tests fail on their first attempt. Fix the async wait or state cleanup before adding a +test-specific retry. When checking one rendered state, group related assertions in one test to avoid repeated UI renders. +Keep separate tests for different inputs and interactions. + +For a timing comparison, use the same worker count and coverage options before and after the change: + +```sh +npm run test:ci --workspace client -- --maxWorkers=4 +``` + ## Enforcement (Planned) - ESLint boundary rules to prevent cross-module imports. diff --git a/client/package.json b/client/package.json index 180771a2e9..44cbc2dd36 100644 --- a/client/package.json +++ b/client/package.json @@ -43,15 +43,15 @@ }, "devDependencies": { "@playwright/test": "^1.51.1", - "@testing-library/jest-dom": "6.1.4", - "@testing-library/react": "14.1.2", - "@testing-library/user-event": "14.5.1", + "@testing-library/dom": "10.4.1", + "@testing-library/jest-dom": "7.0.1", + "@testing-library/react": "16.3.3", + "@testing-library/user-event": "14.6.7", "@types/aws-lambda": "8.10.126", "@types/cookie": "0.5.4", "@types/react": "18.3.18", "@types/react-dom": "18.3.5", "eslint-plugin-testing-library": "7.16.0", - "jsdom": "^29.0.2", "mq-polyfill": "1.1.8" }, "nextBundleAnalysis": { diff --git a/client/src/__tests__/setupUser.ts b/client/src/__tests__/setupUser.ts new file mode 100644 index 0000000000..f66fc64603 --- /dev/null +++ b/client/src/__tests__/setupUser.ts @@ -0,0 +1,25 @@ +import { act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +type User = ReturnType; + +export function setupUser(...options: Parameters): User { + const user = userEvent.setup(...options); + + return new Proxy(user, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver); + if (typeof value !== 'function') { + return value; + } + + return async (...args: unknown[]) => { + let result: unknown; + await act(async () => { + result = await Reflect.apply(value, target, args); + }); + return result; + }; + }, + }); +} diff --git a/client/src/components/Comment.test.tsx b/client/src/components/Comment.test.tsx index 8cf7eeeb1a..57eb1136e2 100644 --- a/client/src/components/Comment.test.tsx +++ b/client/src/components/Comment.test.tsx @@ -2,8 +2,8 @@ import { render, screen } from '@testing-library/react'; import { Comment } from './Comment'; describe('Comment', () => { - it('renders author, datetime, content and children', () => { - render( + it('renders populated and omitted comment sections', () => { + const { container, rerender } = render( A} @@ -19,30 +19,22 @@ describe('Comment', () => { expect(screen.getByText('This is the comment body')).toBeInTheDocument(); expect(screen.getByText('Nested reply')).toBeInTheDocument(); expect(screen.getByTestId('avatar')).toBeInTheDocument(); - }); - it('does not render the avatar when not provided', () => { - render(); + rerender(); expect(screen.queryByTestId('avatar')).not.toBeInTheDocument(); expect(screen.getByText('No avatar here')).toBeInTheDocument(); - }); - it('omits the header row when neither author nor datetime are provided', () => { - render(); + rerender(); expect(screen.getByText('Only content')).toBeInTheDocument(); expect(screen.queryByText('2 hours ago')).not.toBeInTheDocument(); - }); - it('renders the header row when only datetime is provided', () => { - render(); + rerender(); expect(screen.getByText('just now')).toBeInTheDocument(); - }); - it('renders nothing in the body areas when content and children are absent', () => { - const { container } = render(); + rerender(); expect(screen.getByText('Solo Author')).toBeInTheDocument(); // only the wrapper + author header should be present, no content/children divs diff --git a/client/src/components/CountBadge/CountBadge.test.tsx b/client/src/components/CountBadge/CountBadge.test.tsx index 34fcbef6ea..0c2f898643 100644 --- a/client/src/components/CountBadge/CountBadge.test.tsx +++ b/client/src/components/CountBadge/CountBadge.test.tsx @@ -9,37 +9,29 @@ function getBadgeCount(container: HTMLElement) { } describe('CountBadge', () => { - it('renders the provided count', () => { - const { container } = render(); + it('renders counts and applies mapped and unmapped status styles', () => { + const { container, rerender } = render(); expect(container.textContent).toContain('5'); - }); - it('applies the default-status styling', () => { - const { container } = render(); - const sup = getBadgeCount(container); + rerender(); + let sup = getBadgeCount(container); expect(sup).not.toBeNull(); expect(sup).toHaveStyle({ backgroundColor: '#f0f2f5', color: 'rgba(0, 0, 0, 0.45)' }); - }); - it('applies the processing-status styling', () => { - const { container } = render(); - const sup = getBadgeCount(container); + rerender(); + sup = getBadgeCount(container); expect(sup).not.toBeNull(); expect(sup).toHaveStyle({ backgroundColor: '#e6f7ff', color: '#1677ff' }); - }); - it('applies no extra styling for an unmapped status', () => { - const { container } = render(); - const sup = getBadgeCount(container); + rerender(); + sup = getBadgeCount(container); expect(sup).not.toBeNull(); // none of the preset colors should be applied expect(sup).not.toHaveStyle({ backgroundColor: '#f0f2f5' }); expect(sup).not.toHaveStyle({ backgroundColor: '#e6f7ff' }); - }); - it('renders showZero count of 0', () => { - const { container } = render(); - const sup = getBadgeCount(container); + rerender(); + sup = getBadgeCount(container); expect(sup).not.toBeNull(); expect(sup?.textContent).toContain('0'); }); diff --git a/client/src/components/DevTools/DevToolsContainer.test.tsx b/client/src/components/DevTools/DevToolsContainer.test.tsx index 332f963310..a41d56d922 100644 --- a/client/src/components/DevTools/DevToolsContainer.test.tsx +++ b/client/src/components/DevTools/DevToolsContainer.test.tsx @@ -1,6 +1,6 @@ /* eslint-disable testing-library/no-node-access */ import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { DevToolsContainer } from './DevToolsContainer'; // The two tab panes are covered by their own specs; stub them so the container @@ -11,7 +11,8 @@ vi.mock('./DevToolsCurrentUser', () => ({ })); describe('DevToolsContainer', () => { - it('renders children and the collapsed float button by default', () => { + it('renders children and supports opening, switching tabs, and closing', async () => { + const user = setupUser(); render(
app content
@@ -19,40 +20,20 @@ describe('DevToolsContainer', () => { ); expect(screen.getByTestId('app')).toBeInTheDocument(); - // FloatButton is shown, card is not expect(screen.queryByText('Dev tools')).not.toBeInTheDocument(); expect(screen.queryByTestId('users-pane')).not.toBeInTheDocument(); - }); - - it('opens the dev tools card on float button click showing the users tab', async () => { - const user = userEvent.setup(); - render(); await user.click(document.querySelector('.ant-float-btn') as HTMLElement); expect(screen.getByText('Dev tools')).toBeInTheDocument(); expect(screen.getByTestId('users-pane')).toBeInTheDocument(); expect(screen.queryByTestId('current-user-pane')).not.toBeInTheDocument(); - }); - - it('switches to the current user session tab', async () => { - const user = userEvent.setup(); - render(); - await user.click(document.querySelector('.ant-float-btn') as HTMLElement); await user.click(screen.getByText('Current user session')); expect(screen.getByTestId('current-user-pane')).toBeInTheDocument(); expect(screen.queryByTestId('users-pane')).not.toBeInTheDocument(); - }); - - it('closes the card via the close button', async () => { - const user = userEvent.setup(); - render(); - await user.click(document.querySelector('.ant-float-btn') as HTMLElement); - expect(screen.getByText('Dev tools')).toBeInTheDocument(); - // the close (icon-only) button lives in the card extra slot const closeButton = document.querySelector('.ant-card-extra .ant-btn') as HTMLElement; await user.click(closeButton); diff --git a/client/src/components/DevTools/DevToolsCurrentUser.test.tsx b/client/src/components/DevTools/DevToolsCurrentUser.test.tsx index 706ba7d7c3..234c0cdac4 100644 --- a/client/src/components/DevTools/DevToolsCurrentUser.test.tsx +++ b/client/src/components/DevTools/DevToolsCurrentUser.test.tsx @@ -10,6 +10,30 @@ vi.mock('@client/api', () => ({ }, })); +vi.mock('antd', () => { + const Descriptions = Object.assign( + ({ title, children }: { title: React.ReactNode; children: React.ReactNode }) => ( +
+

{title}

+ {children} +
+ ), + { + Item: ({ label, children }: { label: React.ReactNode; children: React.ReactNode }) => ( +
+ {label} + {children} +
+ ), + }, + ); + + return { + Descriptions, + Typography: { Text: ({ children }: { children: React.ReactNode }) => {children} }, + }; +}); + // Drive the real fetcher (so the session-api callback runs) through a tiny // useRequest stand-in backed by React state. This exercises the data path in // the component rather than stubbing its result wholesale. diff --git a/client/src/components/DevTools/DevToolsUsers.test.tsx b/client/src/components/DevTools/DevToolsUsers.test.tsx index 0700658156..eb5d57a456 100644 --- a/client/src/components/DevTools/DevToolsUsers.test.tsx +++ b/client/src/components/DevTools/DevToolsUsers.test.tsx @@ -1,5 +1,5 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import { setupUser } from '@client/__tests__/setupUser'; import { useRouter } from 'next/navigation'; import DevToolsUsers from './DevToolsUsers'; @@ -23,6 +23,14 @@ const users = [ { id: 2, githubId: 'bob', student: [], mentor: [20, 30] }, ]; +function getLoginButton(githubId: string) { + // Scope the action to its user instead of relying on table order. + // eslint-disable-next-line testing-library/no-node-access + const row = screen.getByText(githubId).closest('tr') as HTMLTableRowElement; + expect(row).toHaveRole('row'); + return within(row).getByRole('button', { name: 'Login' }); +} + describe('DevToolsUsers', () => { beforeEach(() => { vi.clearAllMocks(); @@ -31,21 +39,14 @@ describe('DevToolsUsers', () => { getDevUserLogin.mockResolvedValue({ data: {} }); }); - it('loads and renders the users table', async () => { + it('logs in as a user and redirects on the Login action', async () => { + const user = setupUser(); render(); expect(await screen.findByText('alice')).toBeInTheDocument(); expect(screen.getByText('bob')).toBeInTheDocument(); expect(getDevUsers).toHaveBeenCalledTimes(1); - }); - - it('logs in as a user and redirects on the Login action', async () => { - const user = userEvent.setup(); - render(); - - await screen.findByText('alice'); - const loginButtons = screen.getAllByRole('button', { name: 'Login' }); - await user.click(loginButtons[0]); + await user.click(getLoginButton('alice')); await waitFor(() => expect(getDevUserLogin).toHaveBeenCalledWith('alice')); expect(push).toHaveBeenCalledWith('/api/v2/auth/github/login'); @@ -54,11 +55,11 @@ describe('DevToolsUsers', () => { it('logs an error and does not redirect when login fails', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); getDevUserLogin.mockRejectedValueOnce(new Error('nope')); - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('alice'); - await user.click(screen.getAllByRole('button', { name: 'Login' })[0]); + await user.click(getLoginButton('alice')); await waitFor(() => expect(errorSpy).toHaveBeenCalledWith('Failed to login user')); expect(push).not.toHaveBeenCalled(); diff --git a/client/src/components/Footer/Donation.test.tsx b/client/src/components/Footer/Donation.test.tsx index 251e0ed2c7..73acf14b15 100644 --- a/client/src/components/Footer/Donation.test.tsx +++ b/client/src/components/Footer/Donation.test.tsx @@ -2,27 +2,29 @@ import { render, screen } from '@testing-library/react'; import { Donation } from './Donation'; +vi.mock('antd', () => ({ + Button: ({ children, href, target }: React.ComponentProps<'a'>) => ( + + {children} + + ), +})); + describe('Footer Donation', () => { - it('renders the heading and the configured donator count', () => { - render(); + it('renders configured donor details, widget, and donation link', () => { + const { container, rerender } = render(); expect(screen.getByText('Thank you for your support!')).toBeInTheDocument(); expect(screen.getByText('Top 21 donators:')).toBeInTheDocument(); - }); - it('renders the opencollective widget with the count in its url', () => { - const { container } = render(); + const button = screen.getByRole('link', { name: /Make a donation/ }); + expect(button).toHaveAttribute('href', 'https://opencollective.com/rsschool#section-contribute'); + expect(button).toHaveAttribute('target', '_blank'); + + rerender(); const widget = container.querySelector('object'); expect(widget).toHaveAttribute('data', expect.stringContaining('limit=15')); expect(widget).toHaveAttribute('type', 'image/svg+xml'); }); - - it('renders a donation button linking to opencollective', () => { - render(); - - const button = screen.getByRole('link', { name: /Make a donation/ }); - expect(button).toHaveAttribute('href', 'https://opencollective.com/rsschool#section-contribute'); - expect(button).toHaveAttribute('target', '_blank'); - }); }); diff --git a/client/src/components/Footer/Feedback.test.tsx b/client/src/components/Footer/Feedback.test.tsx index 4360b7d95f..a0049732b9 100644 --- a/client/src/components/Footer/Feedback.test.tsx +++ b/client/src/components/Footer/Feedback.test.tsx @@ -2,14 +2,10 @@ import { render, screen } from '@testing-library/react'; import { Feedback } from './Feedback'; describe('Footer Feedback', () => { - it('renders the Feedback section title', () => { - render(); - expect(screen.getByText('Feedback')).toBeInTheDocument(); - }); - - it('renders the gratitude, heroes and feedback links', () => { + it('renders the section title and feedback links', () => { render(); + expect(screen.getByText('Feedback')).toBeInTheDocument(); expect(screen.getByRole('link', { name: /Say Thank you/ })).toHaveAttribute('href', '/gratitude'); const heroes = screen.getByRole('link', { name: /Heroes page/ }); diff --git a/client/src/components/Footer/FooterLayout.test.tsx b/client/src/components/Footer/FooterLayout.test.tsx index 679dbffd25..01378d359b 100644 --- a/client/src/components/Footer/FooterLayout.test.tsx +++ b/client/src/components/Footer/FooterLayout.test.tsx @@ -2,27 +2,17 @@ import { render, screen } from '@testing-library/react'; import { FooterLayout } from './FooterLayout'; describe('FooterLayout', () => { - it('renders the Help, Feedback and Donation sections', () => { - render(); - - expect(screen.getByText('Help')).toBeInTheDocument(); - expect(screen.getByText('Feedback')).toBeInTheDocument(); - expect(screen.getByText('Thank you for your support!')).toBeInTheDocument(); - }); - - it('renders the social networks', () => { - render(); - expect(screen.getByRole('link', { name: /GitHub/ })).toBeInTheDocument(); - }); + afterEach(() => vi.useRealTimers()); - it('renders the copyright with the current year', () => { + it('renders its sections, social networks, and current-year copyright', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2031-05-10T00:00:00Z')); render(); - + expect(screen.getByText('Help')).toBeInTheDocument(); + expect(screen.getByText('Feedback')).toBeInTheDocument(); + expect(screen.getByText('Thank you for your support!')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /GitHub/ })).toBeInTheDocument(); expect(screen.getByText(/The Rolling Scopes 2031/)).toBeInTheDocument(); - - vi.useRealTimers(); }); }); diff --git a/client/src/components/Footer/Help.test.tsx b/client/src/components/Footer/Help.test.tsx index 3c597f8b6e..61f315e975 100644 --- a/client/src/components/Footer/Help.test.tsx +++ b/client/src/components/Footer/Help.test.tsx @@ -2,14 +2,10 @@ import { render, screen } from '@testing-library/react'; import { Help } from './Help'; describe('Footer Help', () => { - it('renders the Help section title', () => { - render(); - expect(screen.getByText('Help')).toBeInTheDocument(); - }); - - it('renders the documentation and bug report links', () => { + it('renders the section title and support links', () => { render(); + expect(screen.getByText('Help')).toBeInTheDocument(); const docs = screen.getByRole('link', { name: /Docs/ }); expect(docs).toHaveAttribute('href', 'https://rs.school/docs'); expect(docs).toHaveAttribute('target', '_blank'); diff --git a/client/src/components/Footer/Menu.test.tsx b/client/src/components/Footer/Menu.test.tsx index 4091ca86d7..2f7e393c1d 100644 --- a/client/src/components/Footer/Menu.test.tsx +++ b/client/src/components/Footer/Menu.test.tsx @@ -7,13 +7,10 @@ const data = [ ]; describe('Footer Menu', () => { - it('renders the title', () => { - render(); - expect(screen.getByText('Help')).toBeInTheDocument(); - }); + it('renders menu content and its empty variant', () => { + const { rerender } = render(); - it('renders a link per data entry with correct href and target', () => { - render(); + expect(screen.getByText('Help')).toBeInTheDocument(); const docs = screen.getByRole('link', { name: /Docs/ }); expect(docs).toHaveAttribute('href', 'https://docs.rs.school'); @@ -22,16 +19,10 @@ describe('Footer Menu', () => { const home = screen.getByRole('link', { name: /Home/ }); expect(home).toHaveAttribute('href', '/home'); expect(home).toHaveAttribute('target', '_self'); - }); - - it('renders the icons for each entry', () => { - render(); expect(screen.getByTestId('icon-docs')).toBeInTheDocument(); expect(screen.getByTestId('icon-home')).toBeInTheDocument(); - }); - it('renders nothing but the title for empty data', () => { - render(); + rerender(); expect(screen.getByText('Empty')).toBeInTheDocument(); expect(screen.queryByRole('link')).not.toBeInTheDocument(); }); diff --git a/client/src/components/Footer/SocialNetworks.test.tsx b/client/src/components/Footer/SocialNetworks.test.tsx index 5ae219711a..380a5679a8 100644 --- a/client/src/components/Footer/SocialNetworks.test.tsx +++ b/client/src/components/Footer/SocialNetworks.test.tsx @@ -5,10 +5,10 @@ describe('Footer SocialNetworks', () => { it('renders all four social links opening in a new tab', () => { render(); - const github = screen.getByRole('link', { name: /GitHub/ }); - expect(github).toHaveAttribute('href', 'https://github.com/rolling-scopes/rsschool-app'); - expect(github).toHaveAttribute('target', '_blank'); - + expect(screen.getByRole('link', { name: /GitHub/ })).toHaveAttribute( + 'href', + 'https://github.com/rolling-scopes/rsschool-app', + ); expect(screen.getByRole('link', { name: /YouTube/ })).toHaveAttribute( 'href', 'https://www.youtube.com/c/rollingscopesschool', @@ -18,25 +18,9 @@ describe('Footer SocialNetworks', () => { 'href', 'https://www.linkedin.com/company/the-rolling-scopes-school/', ); - }); - - it('renders exactly four links', () => { - render(); - expect(screen.getAllByRole('link')).toHaveLength(4); - }); - - it('opens every link in a new tab (the only reachable newTab branch)', () => { - render(); - // `socialLinks` is a private module constant whose entries all set `newTab: true`, - // and `SocialNetworks` accepts no props, so only the `_blank` side of the - // `linkInfo.newTab ? '_blank' : '_self'` ternary (SocialNetworks.tsx:47) is reachable. - // unreachable: the `_self` branch requires an entry with `newTab: false`, which the - // hardcoded, non-exported, non-parameterized `socialLinks` array never provides. const links = screen.getAllByRole('link'); expect(links).toHaveLength(4); - links.forEach(link => { - expect(link).toHaveAttribute('target', '_blank'); - }); + links.forEach(link => expect(link).toHaveAttribute('target', '_blank')); }); }); diff --git a/client/src/components/HeaderMiniBannerCarousel.test.tsx b/client/src/components/HeaderMiniBannerCarousel.test.tsx index 1513eae031..3e116420ee 100644 --- a/client/src/components/HeaderMiniBannerCarousel.test.tsx +++ b/client/src/components/HeaderMiniBannerCarousel.test.tsx @@ -1,21 +1,32 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { act, render, screen } from '@testing-library/react'; import { HeaderMiniBannerCarousel } from './HeaderMiniBannerCarousel'; describe('HeaderMiniBannerCarousel', () => { - it('should render title when no banner is set', () => { - render(); + it('renders a title and applies a custom class', () => { + render(); expect(screen.getByText('Feature updates')).toBeInTheDocument(); + expect(screen.getByTestId('carouselContainer')).toHaveClass('custom-class'); }); - it('should render banner image when banner is set', () => { + it('renders a linked banner image', () => { const bannerPath = 'test-banner-xyz123.png'; - render(); + render( + , + ); const bannerImage = screen.getByRole('img'); expect(bannerImage).toBeInTheDocument(); expect(bannerImage).toHaveAttribute('src', bannerPath); + expect(screen.getByRole('link', { name: 'Logo banner' })).toHaveAttribute('href', 'https://rs.school/promo'); }); it('should render link when item has url', () => { @@ -24,11 +35,24 @@ describe('HeaderMiniBannerCarousel', () => { expect(screen.getByRole('link', { name: 'Open docs' })).toHaveAttribute('href', 'https://rs.school/docs'); }); - it('should render controls for multiple items', () => { - render(); + it('renders working controls for multiple items', () => { + vi.useFakeTimers(); + render(); + + const previousButton = screen.getByRole('button', { + name: 'Previous banner', + }); + const nextButton = screen.getByRole('button', { name: 'Next banner' }); + act(() => { + nextButton.click(); + vi.runOnlyPendingTimers(); + previousButton.click(); + vi.runOnlyPendingTimers(); + }); - expect(screen.getByRole('button', { name: 'Previous banner' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Next banner' })).toBeInTheDocument(); + expect(screen.getAllByText('First slide').length).toBeGreaterThan(0); + expect(screen.getAllByText('Second slide').length).toBeGreaterThan(0); + vi.useRealTimers(); }); it('should not render controls for single item', () => { @@ -38,64 +62,21 @@ describe('HeaderMiniBannerCarousel', () => { expect(screen.queryByRole('button', { name: 'Next banner' })).not.toBeInTheDocument(); }); - it('should not render when items array is empty', () => { - render(); + it('does not render without visible items', () => { + const { rerender } = render(); expect(screen.queryByTestId('carouselContainer')).not.toBeInTheDocument(); - }); - - it('should not render when items have no banner or title', () => { - render(); + rerender(); expect(screen.queryByTestId('carouselContainer')).not.toBeInTheDocument(); - }); - - it('should not render when items are empty objects', () => { - render(); + rerender(); expect(screen.queryByTestId('carouselContainer')).not.toBeInTheDocument(); }); - it('invokes the next and previous handlers when controls are clicked', async () => { - const user = userEvent.setup(); - render(); - - const nextButton = screen.getByRole('button', { name: 'Next banner' }); - const prevButton = screen.getByRole('button', { name: 'Previous banner' }); - - // Exercises goToNextItem -> carouselRef.current?.next() - await user.click(nextButton); - // Exercises goToPrevItem -> carouselRef.current?.prev() - await user.click(prevButton); - - // Carousel stays mounted; clicking the controls must not crash and keeps slides rendered. - // (infinite mode clones slides, so the text appears more than once.) - expect(screen.getAllByText('First slide').length).toBeGreaterThan(0); - expect(screen.getAllByText('Second slide').length).toBeGreaterThan(0); - }); - - it('renders a banner link when both banner and url are set', () => { - render( - , - ); - - expect(screen.getByRole('img')).toHaveAttribute('src', 'promo-banner.png'); - expect(screen.getByRole('link', { name: 'Promo' })).toHaveAttribute('href', 'https://rs.school/promo'); - }); - - it('applies the custom className to the carousel container', () => { - render(); - - expect(screen.getByTestId('carouselContainer')).toHaveClass('custom-class'); - }); - it('disables autoplay when intervalMs is zero', () => { render(); - // Both slides still render; the autoplay branch (intervalMs > 0) is the falsy side here. - // (infinite mode clones slides, so the text appears more than once.) expect(screen.getAllByText('First slide').length).toBeGreaterThan(0); expect(screen.getAllByText('Second slide').length).toBeGreaterThan(0); }); diff --git a/client/src/components/Heroes/HeroesCountBadge.test.tsx b/client/src/components/Heroes/HeroesCountBadge.test.tsx index 91b38556c3..ebf86a2a10 100644 --- a/client/src/components/Heroes/HeroesCountBadge.test.tsx +++ b/client/src/components/Heroes/HeroesCountBadge.test.tsx @@ -1,48 +1,32 @@ import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import HeroesCountBadge from './HeroesCountBadge'; describe('HeroesCountBadge', () => { - it('renders the badge avatar with the proper alt and src', () => { - render(); + it('renders known, unknown, zero-count, and counted badges', () => { + const { rerender } = render(); const avatar = screen.getByRole('img', { name: 'Hero badge' }); expect(avatar).toHaveAttribute('src', '/static/svg/badges/Hero.svg'); - }); - - it('falls back to an empty url for an unknown badge id', () => { - render(); - - const avatar = screen.getByRole('img', { name: 'Unknown badge' }); - expect(avatar).toHaveAttribute('src', '/static/svg/badges/'); - }); - - it('does not render a count badge superscript when count is 0', () => { - render(); expect(screen.queryByText('3')).not.toBeInTheDocument(); - }); - it('renders the numeric count when greater than 0', () => { - render(); + rerender(); + expect(screen.getByRole('img', { name: 'Unknown badge' })).toHaveAttribute('src', '/static/svg/badges/'); + + rerender(); expect(screen.getByText('3')).toBeInTheDocument(); }); - it('shows the badge name in a tooltip on hover', async () => { - const user = userEvent.setup(); - render(); + it('shows the badge name, comment, and formatted date in its tooltip', async () => { + const user = setupUser(); + render( + , + ); await user.hover(screen.getByRole('img', { name: 'Good_job badge' })); - expect(await screen.findByRole('tooltip')).toHaveTextContent('Good job'); - }); - - it('includes the comment and formatted date in the tooltip when provided', async () => { - const user = userEvent.setup(); - render(); - - await user.hover(screen.getByRole('img', { name: 'Hero badge' })); - const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toHaveTextContent('Good job'); expect(tooltip).toHaveTextContent('Great work!'); expect(tooltip).toHaveTextContent('2023-01-15 10:30'); }); diff --git a/client/src/components/Heroes/HeroesRadarTab.test.tsx b/client/src/components/Heroes/HeroesRadarTab.test.tsx index 48ec9ab606..0265136459 100644 --- a/client/src/components/Heroes/HeroesRadarTab.test.tsx +++ b/client/src/components/Heroes/HeroesRadarTab.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import dayjs from 'dayjs'; import type { Session } from '@client/components/withSession'; import { CountryDto, HeroesRadarDto } from '@client/api'; @@ -62,8 +62,7 @@ const { getHeroesRadar, getHeroesCountries } = vi.hoisted(() => ({ getHeroesCountries: vi.fn(), })); -vi.mock('@client/api', async () => ({ - ...(await vi.importActual('@client/api')), +vi.mock('@client/api', () => ({ GratitudesApi: function GratitudesApi() { return { getHeroesRadar, getHeroesCountries }; }, @@ -92,7 +91,13 @@ function renderTab({ isAdmin = false, setLoading = vi.fn() } = {}) { ); } +const originalLocation = Object.getOwnPropertyDescriptor(window, 'location')!; + describe('HeroesRadarTab', () => { + afterEach(() => { + Object.defineProperty(window, 'location', originalLocation); + }); + beforeEach(() => { vi.clearAllMocks(); getHeroesRadar.mockResolvedValue({ data: heroesResponse }); @@ -106,55 +111,29 @@ describe('HeroesRadarTab', () => { }); it('fetches the heroes radar on mount with the initial query params', async () => { - renderTab(); - - await waitFor(() => expect(getHeroesRadar).toHaveBeenCalled()); - expect(getHeroesRadar).toHaveBeenCalledWith(1, 20, undefined, undefined, undefined, undefined, undefined); - expect(await screen.findByTestId('radar-table')).toHaveTextContent('rows:1'); - }); - - it('toggles the loading flag around the fetch', async () => { const setLoading = vi.fn(); renderTab({ setLoading }); await waitFor(() => expect(getHeroesRadar).toHaveBeenCalled()); + expect(getHeroesRadar).toHaveBeenCalledWith(1, 20, undefined, undefined, undefined, undefined, undefined); + expect(await screen.findByTestId('radar-table')).toHaveTextContent('rows:1'); expect(setLoading).toHaveBeenCalledWith(true); await waitFor(() => expect(setLoading).toHaveBeenCalledWith(false)); - }); - - it('does not load countries, country filter or export for non-admins', async () => { - renderTab({ isAdmin: false }); - - await waitFor(() => expect(getHeroesRadar).toHaveBeenCalled()); expect(getHeroesCountries).not.toHaveBeenCalled(); expect(screen.queryByText('Countries')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Export CSV/ })).not.toBeInTheDocument(); }); - it('loads countries and shows admin-only controls for admins', async () => { - renderTab({ isAdmin: true }); - - await waitFor(() => expect(getHeroesCountries).toHaveBeenCalled()); - expect(screen.getByText('Countries')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Export CSV/ })).toBeInTheDocument(); - }); - - it('renders the course filter options', async () => { - renderTab(); - await waitFor(() => expect(getHeroesRadar).toHaveBeenCalled()); - - fireEvent.mouseDown(screen.getByText('Select course')); - expect(await screen.findByTitle('RS 2024')).toBeInTheDocument(); - expect(screen.getByTitle('RS 2023')).toBeInTheDocument(); - }); - it('refetches with selected filters on Filter submit', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderTab(); await waitFor(() => expect(getHeroesRadar).toHaveBeenCalledTimes(1)); fireEvent.mouseDown(screen.getByText('Select course')); - fireEvent.click(await screen.findByTitle('RS 2024')); + const courseOption = await screen.findByTitle('RS 2024'); + expect(courseOption).toBeInTheDocument(); + expect(screen.getByTitle('RS 2023')).toBeInTheDocument(); + fireEvent.click(courseOption); await user.click(screen.getByRole('button', { name: 'Filter' })); @@ -164,7 +143,7 @@ describe('HeroesRadarTab', () => { }); it('resets the form and refetches on Clear', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderTab(); await waitFor(() => expect(getHeroesRadar).toHaveBeenCalledTimes(1)); @@ -175,7 +154,7 @@ describe('HeroesRadarTab', () => { }); it('refetches the current page when the table pagination changes', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderTab(); await waitFor(() => expect(getHeroesRadar).toHaveBeenCalledTimes(1)); @@ -187,7 +166,7 @@ describe('HeroesRadarTab', () => { }); it('formats and forwards the selected date range when filtering', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderTab(); await waitFor(() => expect(getHeroesRadar).toHaveBeenCalledTimes(1)); @@ -208,7 +187,7 @@ describe('HeroesRadarTab', () => { }); it('includes the date range params in the csv export url', async () => { - const user = userEvent.setup(); + const user = setupUser(); const original = window.location.href; Object.defineProperty(window, 'location', { writable: true, value: { href: original } }); @@ -226,7 +205,7 @@ describe('HeroesRadarTab', () => { }); it('exports to csv by navigating to the csv endpoint', async () => { - const user = userEvent.setup(); + const user = setupUser(); const original = window.location.href; Object.defineProperty(window, 'location', { writable: true, @@ -236,6 +215,10 @@ describe('HeroesRadarTab', () => { renderTab({ isAdmin: true }); await waitFor(() => expect(getHeroesRadar).toHaveBeenCalled()); + await waitFor(() => expect(getHeroesCountries).toHaveBeenCalled()); + expect(screen.getByText('Countries')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Export CSV/ })).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /Export CSV/ })); expect(window.location.href).toContain('/api/v2/gratitudes/heroes/radar/csv?'); diff --git a/client/src/components/Heroes/HeroesRadarTable.test.tsx b/client/src/components/Heroes/HeroesRadarTable.test.tsx index 7cba0b4a79..c6c2c1f7d3 100644 --- a/client/src/components/Heroes/HeroesRadarTable.test.tsx +++ b/client/src/components/Heroes/HeroesRadarTable.test.tsx @@ -13,7 +13,7 @@ const baseHero: HeroRadarDto = { githubId: 'alice', name: 'Alice Smith', rank: 1, - total: 2, + total: 7, badges: [ { id: 'b1', badgeId: 'Hero', comment: 'nice', date: '2023-01-01T00:00:00.000Z' }, { id: 'b2', badgeId: 'Good_job', comment: 'great', date: '2023-02-01T00:00:00.000Z' }, @@ -23,21 +23,18 @@ const baseHero: HeroRadarDto = { describe('HeroesRadarTable', () => { const noop = vi.fn(); - it('renders a row per hero with github link and profile link', () => { - render(); + it('renders hero links, badge total and the desktop form layout', () => { + const setFormLayout = vi.fn(); + render(); const githubLink = screen.getByRole('link', { name: 'alice' }); expect(githubLink).toHaveAttribute('href', 'https://github.com/alice'); const profileLink = screen.getByRole('link', { name: 'Alice Smith' }); expect(profileLink).toHaveAttribute('href', '/profile?githubId=alice'); - }); - - it('renders the total badge count in bold', () => { - const hero: HeroRadarDto = { ...baseHero, total: 7 }; - render(); const totalCell = screen.getByText('7'); expect(totalCell.tagName).toBe('B'); + expect(setFormLayout).toHaveBeenCalledWith('inline'); }); it('renders "New" for a rank greater or equal to 999999', () => { @@ -57,13 +54,6 @@ describe('HeroesRadarTable', () => { expect(screen.getAllByText('No data').length).toBeGreaterThan(0); }); - it('sets the form layout based on the window width on mount', () => { - // jsdom default innerWidth is 1024 (>= XS breakpoint), so layout should be inline - const setFormLayout = vi.fn(); - render(); - expect(setFormLayout).toHaveBeenCalledWith('inline'); - }); - it('switches to vertical layout for narrow viewports', () => { const original = window.innerWidth; Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 400 }); diff --git a/client/src/components/MentorOptions.test.tsx b/client/src/components/MentorOptions.test.tsx index c3c303d5d9..c9a4247975 100644 --- a/client/src/components/MentorOptions.test.tsx +++ b/client/src/components/MentorOptions.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { Form } from 'antd'; import { MentorDetailsDtoStudentsPreferenceEnum } from '@client/api'; import { MentorOptions, Options } from './MentorOptions'; @@ -35,35 +35,13 @@ function Wrapper({ } describe('MentorOptions', () => { - it('renders the labels and the stubbed student search', () => { - render(); - + it('shows the confirm button by default and hides it when disabled', () => { + const { rerender } = render(); expect(screen.getByText('How many students are you ready to mentor per course?')).toBeInTheDocument(); expect(screen.getByText('Preferred students location')).toBeInTheDocument(); expect(screen.getByText('Predefined students (if any)')).toBeInTheDocument(); expect(screen.getByTestId('student-search')).toHaveTextContent('student-search-7'); - }); - - it('renders student-count options offset by the course minimum', () => { - render(); - - fireEvent.mouseDown(screen.getByText('Students count...')); - // first option = 0 + minStudentsPerMentor(2), last = 6 + 2 = 8 - expect(screen.getByTitle('2')).toBeInTheDocument(); - expect(screen.getByTitle('8')).toBeInTheDocument(); - }); - - it('renders the location options', () => { - render(); - - fireEvent.mouseDown(screen.getByText('Select a prefered option...')); - expect(screen.getByTitle('Any city or country')).toBeInTheDocument(); - expect(screen.getByTitle('My country only')).toBeInTheDocument(); - expect(screen.getByTitle('My city only')).toBeInTheDocument(); - }); - it('shows the confirm button by default and hides it when disabled', () => { - const { rerender } = render(); expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument(); rerender(); @@ -85,7 +63,7 @@ describe('MentorOptions', () => { }); it('validates required fields and blocks submit when empty', async () => { - const user = userEvent.setup(); + const user = setupUser(); const handleSubmit = vi.fn().mockResolvedValue(undefined); render(); @@ -96,14 +74,19 @@ describe('MentorOptions', () => { }); it('submits the selected values', async () => { - const user = userEvent.setup(); + const user = setupUser(); const handleSubmit = vi.fn().mockResolvedValue(undefined); render(); fireEvent.mouseDown(screen.getByText('Students count...')); + expect(screen.getByTitle('2')).toBeInTheDocument(); + expect(screen.getByTitle('8')).toBeInTheDocument(); fireEvent.click(screen.getByTitle('2')); fireEvent.mouseDown(screen.getByText('Select a prefered option...')); + expect(screen.getByTitle('Any city or country')).toBeInTheDocument(); + expect(screen.getByTitle('My country only')).toBeInTheDocument(); + expect(screen.getByTitle('My city only')).toBeInTheDocument(); fireEvent.click(screen.getByTitle('My country only')); await user.click(screen.getByRole('button', { name: 'Confirm' })); diff --git a/client/src/components/Profile/EducationCard.tsx b/client/src/components/Profile/EducationCard.tsx index c13b72a71b..30a7d09958 100644 --- a/client/src/components/Profile/EducationCard.tsx +++ b/client/src/components/Profile/EducationCard.tsx @@ -1,5 +1,6 @@ +import { List } from '@client/shared/components/List'; import { ChangeEvent, useMemo, useState } from 'react'; -import { Typography, List, Input, Button } from 'antd'; +import { Typography, Input, Button } from 'antd'; import { ReadOutlined, FileAddOutlined, DeleteOutlined } from '@ant-design/icons'; import isEqual from 'lodash/isEqual'; import CommonCardWithSettingsModal from './CommonCardWithSettingsModal'; @@ -145,13 +146,11 @@ const EducationCard = ({ isEditingModeEnabled, data, updateProfile }: Props) => cancelChanges={handleCancel} isSaveDisabled={isSaveDisabled} content={ - displayUniversities.length ? ( - - ) : null + displayUniversities.length ? : null } profileSettingsContent={ <> - + diff --git a/client/src/components/Profile/InterviewCard.tsx b/client/src/components/Profile/InterviewCard.tsx index 7a6b026a6b..30d7769028 100644 --- a/client/src/components/Profile/InterviewCard.tsx +++ b/client/src/components/Profile/InterviewCard.tsx @@ -1,7 +1,8 @@ import CommonCard from '@client/components/Profile/CommonCard'; +import { List } from '@client/shared/components/List'; import { QuestionCircleOutlined } from '@ant-design/icons'; import { CSSProperties, ReactNode, useState } from 'react'; -import { Empty, Flex, List, theme, Typography } from 'antd'; +import { Empty, Flex, theme, Typography } from 'antd'; import { DecisionTag, getRating } from '@client/domain/interview'; import { Decision } from '@client/data/interviews/technical-screening'; import { @@ -72,7 +73,6 @@ function renderCoreJsInterviews({ cardData, setModalData }: CardRenderProps @@ -108,7 +108,6 @@ function renderPrescreeningInterviewCard({ cardData, setModalData }: CardRenderP return ( ( diff --git a/client/src/components/Profile/MentorStatsCard.tsx b/client/src/components/Profile/MentorStatsCard.tsx index 272bd3b0f9..769e5b3218 100644 --- a/client/src/components/Profile/MentorStatsCard.tsx +++ b/client/src/components/Profile/MentorStatsCard.tsx @@ -1,5 +1,6 @@ +import { List } from '@client/shared/components/List'; import { useMemo, useState } from 'react'; -import { Button, Card, Flex, List, Space, Typography } from 'antd'; +import { Button, Card, Flex, Space, Typography } from 'antd'; import CommonCard from './CommonCard'; import MentorStatsModal from './MentorStatsModal'; import { MentorStats, Student } from '@common/models/profile'; @@ -90,7 +91,6 @@ export function MentorStatsCard(props: Props) { students ? ( idx === 0 ? ( ( diff --git a/client/src/components/Profile/StudentStatsCard.tsx b/client/src/components/Profile/StudentStatsCard.tsx index f3754fa544..742adafe90 100644 --- a/client/src/components/Profile/StudentStatsCard.tsx +++ b/client/src/components/Profile/StudentStatsCard.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import isEqual from 'lodash/isEqual'; -import { Typography, List, Button, Progress } from 'antd'; +import { List } from '@client/shared/components/List'; +import { Typography, Button, Progress } from 'antd'; import axios from 'axios'; import CommonCard from './CommonCard'; import StudentStatsModal from './StudentStatsModal'; @@ -177,7 +178,6 @@ class StudentStatsCard extends React.Component { icon={} content={ { @@ -18,7 +18,7 @@ describe('AboutCard', () => { }); it('edits, saves the about text and reflects the new value on success', async () => { - const user = userEvent.setup(); + const user = setupUser(); const updateProfile = vi.fn().mockResolvedValue(true); render(); @@ -26,7 +26,7 @@ describe('AboutCard', () => { const textarea = screen.getByRole('textbox'); await user.clear(textarea); - await user.type(textarea, 'new bio'); + await user.type(textarea, 'new bio', { skipClick: true }); await user.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => expect(updateProfile).toHaveBeenCalledWith({ aboutMyself: 'new bio' })); @@ -34,14 +34,14 @@ describe('AboutCard', () => { }); it('keeps the previous displayed value when the update fails', async () => { - const user = userEvent.setup(); + const user = setupUser(); const updateProfile = vi.fn().mockResolvedValue(false); render(); await user.click(screen.getByRole('img', { name: 'edit' })); const textarea = screen.getByRole('textbox'); await user.clear(textarea); - await user.type(textarea, 'failed bio'); + await user.type(textarea, 'failed bio', { skipClick: true }); await user.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => expect(updateProfile).toHaveBeenCalledWith({ aboutMyself: 'failed bio' })); @@ -50,14 +50,14 @@ describe('AboutCard', () => { }); it('restores the original value when the edit is cancelled', async () => { - const user = userEvent.setup(); + const user = setupUser(); const updateProfile = vi.fn().mockResolvedValue(true); render(); await user.click(screen.getByRole('img', { name: 'edit' })); const textarea = screen.getByRole('textbox'); await user.clear(textarea); - await user.type(textarea, 'discarded'); + await user.type(textarea, 'discarded', { skipClick: true }); await user.click(screen.getByRole('button', { name: 'Cancel' })); expect(updateProfile).not.toHaveBeenCalled(); @@ -69,7 +69,7 @@ describe('AboutCard', () => { }); it('disables Save until the text changes', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole('img', { name: 'edit' })); diff --git a/client/src/components/Profile/__test__/CommonCard.test.tsx b/client/src/components/Profile/__test__/CommonCard.test.tsx index 77d874aea4..9085d8fc83 100644 --- a/client/src/components/Profile/__test__/CommonCard.test.tsx +++ b/client/src/components/Profile/__test__/CommonCard.test.tsx @@ -1,15 +1,33 @@ -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; import CommonCard from '../CommonCard'; +vi.mock('@ant-design/icons/EditOutlined', () => ({ default: () => null })); +vi.mock('antd', () => { + const Empty = ({ description }: { description?: ReactNode }) =>
{description ?? 'No data'}
; + Empty.PRESENTED_IMAGE_SIMPLE = 'simple'; + + return { + Card: ({ title, children }: { title: ReactNode; children: ReactNode }) => ( +
+ {title} + {children} +
+ ), + Empty, + Typography: { Title: ({ children }: React.PropsWithChildren) =>

{children}

}, + }; +}); + describe('CommonCard', () => { - describe('Should render correctly', () => { - it('if just basic props is present', () => { - const { container } = render(Icon} content={

Card body

} />); - expect(container).toMatchSnapshot(); - }); - it('if is null content passed', () => { - const { container } = render(Icon} content={null} />); - expect(container).toMatchSnapshot(); - }); + it('renders content and the empty fallback', () => { + const { rerender } = render(Icon} content={

Card body

} />); + + expect(screen.getByRole('heading', { name: 'Icon Test' })).toBeInTheDocument(); + expect(screen.getByText('Card body')).toBeInTheDocument(); + + rerender(Icon} content={null} noDataDescription="Nothing here" />); + + expect(screen.getByText('Nothing here')).toBeInTheDocument(); }); }); diff --git a/client/src/components/Profile/__test__/CommonCardWithSettingsModal.test.tsx b/client/src/components/Profile/__test__/CommonCardWithSettingsModal.test.tsx index d787169c2a..7a4a6c5b64 100644 --- a/client/src/components/Profile/__test__/CommonCardWithSettingsModal.test.tsx +++ b/client/src/components/Profile/__test__/CommonCardWithSettingsModal.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import CommonCardWithSettingsModal from '../CommonCardWithSettingsModal'; function renderCard(overrides: Partial> = {}) { @@ -22,26 +22,24 @@ describe('CommonCardWithSettingsModal', () => { const { container } = renderCard(); expect(container).toMatchSnapshot(); }); - it('if null content is passed and editing mode is disabled', () => { - const { container } = renderCard({ content: null, isEditingModeEnabled: false }); - expect(container).toMatchSnapshot(); - }); }); it('does not render the edit affordance or modal when editing is disabled', () => { - renderCard({ isEditingModeEnabled: false }); + const { container } = renderCard({ content: null, isEditingModeEnabled: false }); + expect(container).toMatchSnapshot(); expect(screen.queryByRole('img', { name: 'edit' })).not.toBeInTheDocument(); expect(screen.queryByText('Settings content')).not.toBeInTheDocument(); }); it('opens the settings modal and saves changes', async () => { - const user = userEvent.setup(); + const user = setupUser(); const saveProfile = vi.fn(); - renderCard({ saveProfile }); + renderCard({ saveProfile, settingsTitle: 'Custom Settings' }); await user.click(screen.getByRole('img', { name: 'edit' })); expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByText('Settings content')).toBeInTheDocument(); + expect(screen.getByText('Custom Settings')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Save' })); expect(saveProfile).toHaveBeenCalledTimes(1); @@ -50,7 +48,7 @@ describe('CommonCardWithSettingsModal', () => { }); it('opens the settings modal and discards changes on cancel', async () => { - const user = userEvent.setup(); + const user = setupUser(); const cancelChanges = vi.fn(); const saveProfile = vi.fn(); renderCard({ cancelChanges, saveProfile }); @@ -64,18 +62,10 @@ describe('CommonCardWithSettingsModal', () => { }); it('disables the Save button when isSaveDisabled is set', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderCard({ isSaveDisabled: true }); await user.click(screen.getByRole('img', { name: 'edit' })); expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); }); - - it('uses a custom settings title when provided', async () => { - const user = userEvent.setup(); - renderCard({ settingsTitle: 'Custom Settings' }); - - await user.click(screen.getByRole('img', { name: 'edit' })); - expect(screen.getByText('Custom Settings')).toBeInTheDocument(); - }); }); diff --git a/client/src/components/Profile/__test__/ContactsCard.test.tsx b/client/src/components/Profile/__test__/ContactsCard.test.tsx index 34494aed16..13b809649d 100644 --- a/client/src/components/Profile/__test__/ContactsCard.test.tsx +++ b/client/src/components/Profile/__test__/ContactsCard.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import ContactsCard from '../ContactsCard'; // epamEmail must match /[^@]+_[^@]+@epam.com/ and email must be a valid email so that @@ -92,18 +92,13 @@ describe('ContactsCard', () => { expect(screen.getByText(/Contacts aren't filled in/)).toBeInTheDocument(); }); - it('shows the EmailConfirmation prompt for an unconfirmed email in editing mode', () => { - render(); - expect(screen.getByText('Send confirmation email?')).toBeInTheDocument(); - }); - it('does not show the EmailConfirmation prompt when the email connection is enabled', () => { render(); expect(screen.queryByText('Send confirmation email?')).not.toBeInTheDocument(); }); it('calls sendConfirmationEmail when the confirmation link is clicked', async () => { - const user = userEvent.setup(); + const user = setupUser(); const sendConfirmationEmail = vi.fn(); render( { />, ); - await user.click(screen.getByText('Send confirmation email?')); + const confirmation = screen.getByText('Send confirmation email?'); + expect(confirmation).toBeInTheDocument(); + await user.click(confirmation); expect(sendConfirmationEmail).toHaveBeenCalledTimes(1); }); it('edits a contact, saves and reflects the new value on success (handleSave)', async () => { - const user = userEvent.setup(); + const user = setupUser(); const updateProfile = vi.fn().mockResolvedValue(true); render(); @@ -126,7 +123,7 @@ describe('ContactsCard', () => { // Telegram is the 3rd contact field in the form const telegramInput = within(dialog).getByDisplayValue('televasya'); await user.clear(telegramInput); - await user.type(telegramInput, 'new_tg'); + await user.type(telegramInput, 'new_tg', { skipClick: true }); await user.click(screen.getByRole('button', { name: 'Save' })); @@ -137,7 +134,7 @@ describe('ContactsCard', () => { }); it('does not commit displayed values when the update fails (handleSave early return)', async () => { - const user = userEvent.setup(); + const user = setupUser(); const updateProfile = vi.fn().mockResolvedValue(false); render(); @@ -145,7 +142,7 @@ describe('ContactsCard', () => { const dialog = screen.getByRole('dialog'); const telegramInput = within(dialog).getByDisplayValue('televasya'); await user.clear(telegramInput); - await user.type(telegramInput, 'rejected_tg'); + await user.type(telegramInput, 'rejected_tg', { skipClick: true }); await user.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => expect(updateProfile).toHaveBeenCalled()); @@ -154,7 +151,7 @@ describe('ContactsCard', () => { }); it('restores the displayed contacts on cancel (handleCancel)', async () => { - const user = userEvent.setup(); + const user = setupUser(); const updateProfile = vi.fn(); render(); @@ -162,7 +159,7 @@ describe('ContactsCard', () => { const dialog = screen.getByRole('dialog'); const telegramInput = within(dialog).getByDisplayValue('televasya'); await user.clear(telegramInput); - await user.type(telegramInput, 'discarded_tg'); + await user.type(telegramInput, 'discarded_tg', { skipClick: true }); expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled(); await user.click(screen.getByRole('button', { name: 'Cancel' })); diff --git a/client/src/components/Profile/__test__/ContactsCardForm.test.tsx b/client/src/components/Profile/__test__/ContactsCardForm.test.tsx index 2d14233bdb..e3ef221948 100644 --- a/client/src/components/Profile/__test__/ContactsCardForm.test.tsx +++ b/client/src/components/Profile/__test__/ContactsCardForm.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import ContactsCardForm from '../ContactsCardForm'; import { Contact, ContactsKeys } from '@client/services/user'; @@ -31,13 +31,13 @@ describe('ContactsCardForm', () => { }); it('propagates changed values via setValues on input (handleChanges, valid input)', async () => { - const user = userEvent.setup(); + const user = setupUser(); const setValues = vi.fn(); const setHasError = vi.fn(); render(); - const inputs = screen.getAllByRole('textbox') as HTMLInputElement[]; - await user.type(inputs[1], 'valid@example.com'); + const email = screen.getByLabelText('E-mail:'); + await user.type(email, 'valid@example.com'); await waitFor(() => expect(setValues).toHaveBeenCalled()); const lastCall = setValues.mock.calls.at(-1)?.[0]; @@ -45,13 +45,13 @@ describe('ContactsCardForm', () => { }); it('flags an error via setHasError when an invalid email is entered (validation reject branch)', async () => { - const user = userEvent.setup(); + const user = setupUser(); const setValues = vi.fn(); const setHasError = vi.fn(); render(); - const inputs = screen.getAllByRole('textbox') as HTMLInputElement[]; - await user.type(inputs[1], 'not-an-email'); + const email = screen.getByLabelText('E-mail:'); + await user.type(email, 'not-an-email'); // validateFields rejects -> setHasError(true) eventually called await waitFor(() => expect(setHasError).toHaveBeenCalledWith(true)); diff --git a/client/src/components/Profile/__test__/DiscordCard.test.tsx b/client/src/components/Profile/__test__/DiscordCard.test.tsx index f8df72f31d..a0a2bcad45 100644 --- a/client/src/components/Profile/__test__/DiscordCard.test.tsx +++ b/client/src/components/Profile/__test__/DiscordCard.test.tsx @@ -4,44 +4,32 @@ import DiscordCard from '../DiscordCard'; const discord = { id: '12345', username: 'vasya', discriminator: '0' }; describe('DiscordCard', () => { - describe('when the user is authorized (data.id present)', () => { - it('shows the authorized message and a Reauthorize link for the profile owner', () => { - render(); - - expect(screen.getByText(/You are authorized as/)).toBeInTheDocument(); - expect(screen.getByText('Switch to another Discord account:')).toBeInTheDocument(); - - const link = screen.getByRole('link', { name: 'Reauthorize' }); - expect(link).toBeInTheDocument(); - expect(link).toHaveAttribute('href', expect.stringContaining('discord.com')); - // StudentDiscord renders the username link - expect(screen.getByText('@vasya')).toBeInTheDocument(); - }); - - it('shows the authorized message for a non-owner without the authorize link', () => { - render(); - - expect(screen.getByText(/The user is authorized as/)).toBeInTheDocument(); - expect(screen.queryByText('Switch to another Discord account:')).not.toBeInTheDocument(); - expect(screen.queryByRole('link', { name: /authorize/i })).not.toBeInTheDocument(); - }); - }); + it('renders authorized and unauthorized states for owners and other users', () => { + const { rerender } = render(); + + expect(screen.getByText(/You are authorized as/)).toBeInTheDocument(); + expect(screen.getByText('Switch to another Discord account:')).toBeInTheDocument(); + + const link = screen.getByRole('link', { name: 'Reauthorize' }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', expect.stringContaining('discord.com')); + expect(screen.getByText('@vasya')).toBeInTheDocument(); + + rerender(); + + expect(screen.getByText(/The user is authorized as/)).toBeInTheDocument(); + expect(screen.queryByText('Switch to another Discord account:')).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /authorize/i })).not.toBeInTheDocument(); - describe('when the user is not authorized (data null or no id)', () => { - it('shows the not-authorized message and an Authorize link for the profile owner', () => { - render(); + rerender(); - expect(screen.getByText(/You haven't authorized yet/)).toBeInTheDocument(); - // 'Switch to another Discord account:' is hidden because data?.id is falsy - expect(screen.queryByText('Switch to another Discord account:')).not.toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Authorize' })).toBeInTheDocument(); - }); + expect(screen.getByText(/You haven't authorized yet/)).toBeInTheDocument(); + expect(screen.queryByText('Switch to another Discord account:')).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Authorize' })).toBeInTheDocument(); - it('shows the not-authorized message for a non-owner without the authorize link', () => { - render(); + rerender(); - expect(screen.getByText(/The user hasn't authorized yet/)).toBeInTheDocument(); - expect(screen.queryByRole('link', { name: /authorize/i })).not.toBeInTheDocument(); - }); + expect(screen.getByText(/The user hasn't authorized yet/)).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /authorize/i })).not.toBeInTheDocument(); }); }); diff --git a/client/src/components/Profile/__test__/EducationCard.test.tsx b/client/src/components/Profile/__test__/EducationCard.test.tsx index f175087438..e8878228cd 100644 --- a/client/src/components/Profile/__test__/EducationCard.test.tsx +++ b/client/src/components/Profile/__test__/EducationCard.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import EducationCard from '../EducationCard'; describe('EducationCard', () => { @@ -40,12 +40,11 @@ describe('EducationCard', () => { }); }); - const openSettings = (user: ReturnType) => - user.click(screen.getByRole('img', { name: 'edit' })); + const openSettings = (user: ReturnType) => user.click(screen.getByRole('img', { name: 'edit' })); // Fill all three fields of the (single) university in an open dialog. const fillNewUniversity = async ( - user: ReturnType, + user: ReturnType, { university, faculty, graduationYear }: { university: string; faculty: string; graduationYear: string }, ) => { const dialog = screen.getByRole('dialog'); @@ -56,7 +55,7 @@ describe('EducationCard', () => { }; it('adds a new university, fills it, saves and reflects it on success', async () => { - const user = userEvent.setup(); + const user = setupUser(); const updateProfile = vi.fn().mockResolvedValue(true); render(); @@ -78,7 +77,7 @@ describe('EducationCard', () => { }); it('does not update the displayed list when the save fails (handleSave early return)', async () => { - const user = userEvent.setup(); + const user = setupUser(); const updateProfile = vi.fn().mockResolvedValue(false); render(); @@ -94,7 +93,7 @@ describe('EducationCard', () => { }); it('typing into an existing university field updates the input (handleChange)', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await openSettings(user); @@ -106,7 +105,7 @@ describe('EducationCard', () => { }); it('disables Add new university while an entry has empty fields (isAddDisabled)', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await openSettings(user); @@ -118,23 +117,12 @@ describe('EducationCard', () => { expect(screen.getByText('(Empty)')).toBeInTheDocument(); }); - it('deletes a university entry (handleDelete)', async () => { - const user = userEvent.setup(); + it('deletes a university and restores it on cancel', async () => { + const user = setupUser(); render(); await openSettings(user); expect(screen.getByDisplayValue('MIT')).toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: /Delete/ })); - - expect(screen.queryByDisplayValue('MIT')).not.toBeInTheDocument(); - }); - - it('restores the universities on cancel (handleCancel)', async () => { - const user = userEvent.setup(); - render(); - - await openSettings(user); // delete the only entry, then cancel to restore it await user.click(screen.getByRole('button', { name: /Delete/ })); expect(screen.queryByDisplayValue('MIT')).not.toBeInTheDocument(); @@ -148,7 +136,7 @@ describe('EducationCard', () => { }); it('renders the settings entry as "(Empty)" when a university is incomplete', async () => { - const user = userEvent.setup(); + const user = setupUser(); render( ({ @@ -11,26 +10,32 @@ vi.mock('@client/shared/components/Timer', () => ({ ), })); +vi.mock('antd', () => ({ + Alert: ({ title }: { title: React.ReactNode }) =>
{title}
, +})); + describe('EmailConfirmation', () => { - it('shows a clickable resend link when there is no lastLinkSentAt (allowedToResend)', async () => { - const user = userEvent.setup(); + it('handles resend, timer elapsed, recent, old, and changed connection states', () => { const sendConfirmationEmail = vi.fn(); - render(); + const { rerender } = render( + , + ); const link = screen.getByText('Send confirmation email?'); expect(link).toBeInTheDocument(); expect(screen.queryByText(/timer:/)).not.toBeInTheDocument(); - await user.click(link); + fireEvent.click(link); expect(sendConfirmationEmail).toHaveBeenCalledTimes(1); // After clicking, lastSent is set to now -> link replaced by the Timer branch expect(screen.queryByText('Send confirmation email?')).not.toBeInTheDocument(); expect(screen.getByText(/timer:/)).toBeInTheDocument(); - }); - it('shows the Timer branch when lastLinkSentAt is recent (not allowed to resend)', () => { - const sendConfirmationEmail = vi.fn(); - render( + fireEvent.click(screen.getByRole('button', { name: 'elapse' })); + expect(screen.getByText('Send confirmation email?')).toBeInTheDocument(); + expect(screen.queryByText(/timer:/)).not.toBeInTheDocument(); + + rerender( { expect(screen.queryByText('Send confirmation email?')).not.toBeInTheDocument(); expect(screen.getByText(/timer:/)).toBeInTheDocument(); expect(screen.getByText('Send confirmation email in')).toBeInTheDocument(); - }); - it('shows the resend link again when the Timer elapses (onElapsed resets lastSent)', async () => { - const user = userEvent.setup(); - const sendConfirmationEmail = vi.fn(); - render( - , - ); - - expect(screen.getByText(/timer:/)).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'elapse' })); - expect(screen.getByText('Send confirmation email?')).toBeInTheDocument(); - expect(screen.queryByText(/timer:/)).not.toBeInTheDocument(); - }); - - it('allows resend when lastLinkSentAt is older than 60 seconds', () => { - const sendConfirmationEmail = vi.fn(); const old = new Date(Date.now() - 120 * 1000).toISOString(); - render( + rerender( { expect(screen.getByText('Send confirmation email?')).toBeInTheDocument(); expect(screen.queryByText(/timer:/)).not.toBeInTheDocument(); - }); - - it('syncs lastSent when the connection prop changes (useEffect)', () => { - const sendConfirmationEmail = vi.fn(); - const { rerender } = render( - , - ); - expect(screen.getByText('Send confirmation email?')).toBeInTheDocument(); rerender( { const expandButton = screen.getByTestId('expand-button'); expect(expandButton).toBeInTheDocument(); - await userEvent.click(expandButton); + await setupUser().click(expandButton); expect( await screen.findByText(/Rolling Scopes School 2020 Q1 Pre-Screening Interview Feedback/), @@ -84,7 +84,7 @@ describe('InterviewCard', () => { const expandButton = screen.getByTestId('expand-button'); expect(expandButton).toBeInTheDocument(); - await userEvent.click(expandButton); + await setupUser().click(expandButton); expect(await screen.findByText(/JS Course 2021 CoreJS Interview Feedback/)).toBeInTheDocument(); }); diff --git a/client/src/components/Profile/__test__/InterviewModal.test.tsx b/client/src/components/Profile/__test__/InterviewModal.test.tsx index 3c754c2304..9e7f9da69f 100644 --- a/client/src/components/Profile/__test__/InterviewModal.test.tsx +++ b/client/src/components/Profile/__test__/InterviewModal.test.tsx @@ -1,5 +1,5 @@ import { render, screen, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import InterviewModal from '../InterviewModal'; import { CoreJsInterviewFeedback, LegacyFeedback, StageInterviewDetailedFeedback } from '@common/models/profile'; @@ -174,7 +174,7 @@ describe('InterviewModal', () => { it('calls onHide when the modal is cancelled', async () => { const onHide = vi.fn(); - const user = userEvent.setup(); + const user = setupUser(); const coreJsData: CoreJsInterviewFeedback = { courseName: 'JS Course', courseFullName: 'JS Course 2021', diff --git a/client/src/components/Profile/__test__/LanguagesCard.test.tsx b/client/src/components/Profile/__test__/LanguagesCard.test.tsx index 3e8efeaae3..0b75f013d6 100644 --- a/client/src/components/Profile/__test__/LanguagesCard.test.tsx +++ b/client/src/components/Profile/__test__/LanguagesCard.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { UpdateUserDtoLanguagesEnum } from '@client/api'; import { getLanguageName } from '@client/components/SelectLanguages'; import LanguagesCard from '../LanguagesCard'; @@ -17,27 +17,20 @@ function renderCard(overrides: Partial { - it('renders no tags when data is empty', () => { - renderCard({ data: [] }); + it('renders the empty state without an edit affordance when editing is disabled', () => { + renderCard({ data: [], isEditingModeEnabled: false }); expect(screen.queryByText(String(getLanguageName(lang)))).not.toBeInTheDocument(); expect(screen.getByText('Languages are not selected')).toBeInTheDocument(); - }); - - it('renders a tag for each language when data is populated', () => { - renderCard({ data: [lang] }); - expect(screen.getAllByText(String(getLanguageName(lang))).length).toBeGreaterThan(0); - }); - - it('does not show the edit affordance when editing is disabled', () => { - renderCard({ isEditingModeEnabled: false }); expect(screen.queryByRole('img', { name: 'edit' })).not.toBeInTheDocument(); }); it('opens the settings modal and saves languages when updateProfile resolves true', async () => { - const user = userEvent.setup(); + const user = setupUser(); const updateProfile = vi.fn().mockResolvedValue(true); renderCard({ data: [lang], updateProfile }); + expect(screen.getAllByText(String(getLanguageName(lang))).length).toBeGreaterThan(0); + await user.click(screen.getByRole('img', { name: 'edit' })); expect(screen.getByRole('dialog')).toBeInTheDocument(); @@ -49,7 +42,7 @@ describe('LanguagesCard', () => { }); it('does not update languages when updateProfile resolves false', async () => { - const user = userEvent.setup(); + const user = setupUser(); const updateProfile = vi.fn().mockResolvedValue(false); renderCard({ data: [lang], updateProfile }); @@ -62,7 +55,7 @@ describe('LanguagesCard', () => { }); it('resets the form on cancel', async () => { - const user = userEvent.setup(); + const user = setupUser(); const updateProfile = vi.fn().mockResolvedValue(true); renderCard({ data: [lang], updateProfile }); diff --git a/client/src/components/Profile/__test__/MainCard.test.tsx b/client/src/components/Profile/__test__/MainCard.test.tsx index 0ca564304f..f6d186905d 100644 --- a/client/src/components/Profile/__test__/MainCard.test.tsx +++ b/client/src/components/Profile/__test__/MainCard.test.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { ProfileMainCardData } from '@client/services/user'; +import { setupUser } from '@client/__tests__/setupUser'; import MainCard from '../MainCard'; // Stub the remote (Google Maps) LocationSelect with a simple value-emitting control so @@ -71,7 +71,7 @@ describe('MainCard', () => { }); it('edits the name, saves and reflects the new value on success', async () => { - const user = userEvent.setup(); + const user = setupUser(); const updateProfile = vi.fn().mockResolvedValue(true); render(); @@ -79,7 +79,7 @@ describe('MainCard', () => { const nameInput = screen.getByPlaceholderText('First-name Last-name'); await user.clear(nameInput); - await user.type(nameInput, 'Jane Roe'); + await user.type(nameInput, 'Jane Roe', { skipClick: true }); await user.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => expect(updateProfile).toHaveBeenCalledWith({ name: 'Jane Roe' })); @@ -87,7 +87,7 @@ describe('MainCard', () => { }); it('edits the location and includes city/country in the update payload', async () => { - const user = userEvent.setup(); + const user = setupUser(); const updateProfile = vi.fn().mockResolvedValue(true); render(); @@ -100,7 +100,7 @@ describe('MainCard', () => { }); it('sends null city/country when the location is cleared (hits the `?? null` branch)', async () => { - const user = userEvent.setup(); + const user = setupUser(); const updateProfile = vi.fn().mockResolvedValue(true); render(); @@ -112,14 +112,14 @@ describe('MainCard', () => { }); it('keeps the previous displayed values when the update fails', async () => { - const user = userEvent.setup(); + const user = setupUser(); const updateProfile = vi.fn().mockResolvedValue(false); render(); await user.click(screen.getByRole('img', { name: 'edit' })); const nameInput = screen.getByPlaceholderText('First-name Last-name'); await user.clear(nameInput); - await user.type(nameInput, 'Rejected Name'); + await user.type(nameInput, 'Rejected Name', { skipClick: true }); await user.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => expect(updateProfile).toHaveBeenCalled()); @@ -129,13 +129,13 @@ describe('MainCard', () => { }); it('restores the original name when the edit is cancelled', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole('img', { name: 'edit' })); const nameInput = screen.getByPlaceholderText('First-name Last-name'); await user.clear(nameInput); - await user.type(nameInput, 'Discarded'); + await user.type(nameInput, 'Discarded', { skipClick: true }); await user.click(screen.getByRole('button', { name: 'Cancel' })); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); @@ -146,23 +146,23 @@ describe('MainCard', () => { }); it('keeps Save disabled when the name is only whitespace and enables it on a real change', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole('img', { name: 'edit' })); const nameInput = screen.getByPlaceholderText('First-name Last-name'); await user.clear(nameInput); - await user.type(nameInput, ' '); + await user.type(nameInput, ' ', { skipClick: true }); expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); await user.clear(nameInput); - await user.type(nameInput, 'Real Name'); + await user.type(nameInput, 'Real Name', { skipClick: true }); expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled(); }); it('opens the obfuscate modal for admins', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole('button', { name: 'Obfuscate' })); diff --git a/client/src/components/Profile/__test__/MentorStatsCard.test.tsx b/client/src/components/Profile/__test__/MentorStatsCard.test.tsx index 47e7a876a6..4f65ffa378 100644 --- a/client/src/components/Profile/__test__/MentorStatsCard.test.tsx +++ b/client/src/components/Profile/__test__/MentorStatsCard.test.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from '@testing-library/react'; import { MentorStatsCard } from '../MentorStatsCard'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; vi.mock('@client/modules/Profile/components/MentorEndorsement', () => ({ MentorEndorsement: ({ open, onClose }: { open: boolean; onClose: () => void }) => ( @@ -37,31 +37,6 @@ describe('MentorStatsCard', () => { }, ]; - it('shows stats', () => { - render(); - expect(screen.getByText('Mentored Students:')).toBeInTheDocument(); - expect(screen.getByText('Courses as Mentor:')).toBeInTheDocument(); - }); - - it('shows all courses', () => { - const courseNames = mentorStats.map(course => course.courseName); - render(); - courseNames.forEach(course => expect(screen.getByText(course)).toBeInTheDocument()); - }); - - it('shows details button for courses with students', () => { - render(); - const coursesWithStudents = mentorStats.reduce((acc, c) => (c?.students?.length ? acc + 1 : acc), 0); - const openButtons = screen.queryAllByTitle('Open details'); - expect(openButtons.length).toBe(coursesWithStudents); - }); - - it('shows dedicated message if no there are no students in the course', () => { - render(); - expect(screen.getByText('rs-2020-q1')).toBeInTheDocument(); - expect(screen.getByText('Does not have students at this course yet')).toBeInTheDocument(); - }); - it('shows endorsement button for admins', () => { render(); expect(screen.getByRole('button', { name: /Get Endorsement/i })).toBeInTheDocument(); @@ -72,26 +47,24 @@ describe('MentorStatsCard', () => { expect(screen.queryByRole('button', { name: /Get Endorsement/i })).not.toBeInTheDocument(); }); - it('opens MentorStatsModal for a course with students when expand is clicked', async () => { + it('closes MentorStatsModal when Close is clicked', async () => { render(); - const user = userEvent.setup(); - - const expandBtn = screen.getByTestId('expand-button'); - await user.click(expandBtn); - - expect(screen.getByText('rs-2018-q1 statistics')).toBeInTheDocument(); - }); + const user = setupUser(); - it('closes MentorStatsModal when Close is clicked', async () => { - const { unmount } = render(); - const user = userEvent.setup(); + expect(screen.getByText('Mentored Students:')).toBeInTheDocument(); + expect(screen.getByText('Courses as Mentor:')).toBeInTheDocument(); + for (const course of mentorStats) { + expect(screen.getByText(course.courseName)).toBeInTheDocument(); + } + const coursesWithStudents = mentorStats.filter(course => course.students?.length).length; + expect(screen.queryAllByTitle('Open details')).toHaveLength(coursesWithStudents); + expect(screen.getByText('Does not have students at this course yet')).toBeInTheDocument(); await user.click(screen.getByTestId('expand-button')); expect(screen.getByText('rs-2018-q1 statistics')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Close' })); - unmount(); - await waitFor(() => expect(screen.queryByText('rs-2018-q1 statistics')).not.toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('rs-2018-q1 statistics')).not.toBeVisible()); }); it('renders students list for the first course when it has students', () => { @@ -104,7 +77,7 @@ describe('MentorStatsCard', () => { it('opens and closes MentorEndorsement modal via the admin button', async () => { render(); - const user = userEvent.setup(); + const user = setupUser(); await user.click(screen.getByRole('button', { name: /Get Endorsement/i })); expect(screen.getByTestId('endorsement-open')).toBeInTheDocument(); diff --git a/client/src/components/Profile/__test__/MentorStatsModal.test.tsx b/client/src/components/Profile/__test__/MentorStatsModal.test.tsx index 658491ca26..7c700be911 100644 --- a/client/src/components/Profile/__test__/MentorStatsModal.test.tsx +++ b/client/src/components/Profile/__test__/MentorStatsModal.test.tsx @@ -22,8 +22,9 @@ describe('MentorStatsModal', () => { ], } as const; - it('renders title and student items with proper links and score', () => { - render(); + it('renders student statistics and calls onHide when closed', () => { + const onHide = vi.fn(); + render(); expect(screen.getByText('RS 2018 Q1 statistics')).toBeInTheDocument(); @@ -36,14 +37,8 @@ describe('MentorStatsModal', () => { expect(screen.getByRole('link', { name: 'alex' })).toHaveAttribute('href', 'https://github.com/alex'); expect(screen.getByRole('link', { name: 'vasya' })).toHaveAttribute('href', 'https://github.com/vasya'); - }); - - it('calls onHide when close button is clicked', () => { - const onHide = vi.fn(); - render(); - const closeBtn = screen.getByRole('button', { name: 'Close' }); - fireEvent.click(closeBtn); + fireEvent.click(screen.getByRole('button', { name: 'Close' })); expect(onHide).toHaveBeenCalled(); }); }); diff --git a/client/src/components/Profile/__test__/ObfuscateConfirmationModal.test.tsx b/client/src/components/Profile/__test__/ObfuscateConfirmationModal.test.tsx index 20f9c4dc0b..e0e2966ea5 100644 --- a/client/src/components/Profile/__test__/ObfuscateConfirmationModal.test.tsx +++ b/client/src/components/Profile/__test__/ObfuscateConfirmationModal.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import ObfuscationModal from '../ObfuscateConfirmationModal'; const { obfuscateProfile } = vi.hoisted(() => ({ @@ -13,6 +13,7 @@ vi.mock('@client/api', () => ({ })); const reload = vi.fn(); +const originalLocation = Object.getOwnPropertyDescriptor(window, 'location')!; beforeAll(() => { Object.defineProperty(window, 'location', { @@ -21,6 +22,10 @@ beforeAll(() => { }); }); +afterAll(() => { + Object.defineProperty(window, 'location', originalLocation); +}); + beforeEach(() => { obfuscateProfile.mockClear(); reload.mockClear(); @@ -37,20 +42,8 @@ function renderModal(overrides: Partial { - it('shows an error and does not obfuscate when the nickname does not match', async () => { - const user = userEvent.setup(); - renderModal({ githubId: 'octocat' }); - - await user.type(screen.getByPlaceholderText('Enter GitHub nickname'), 'wrong'); - await user.click(screen.getByRole('button', { name: /OK/i })); - - expect(screen.getByText('Nickname does not match. Please try again.')).toBeInTheDocument(); - expect(obfuscateProfile).not.toHaveBeenCalled(); - expect(reload).not.toHaveBeenCalled(); - }); - it('obfuscates the profile and reloads when the nickname matches', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderModal({ githubId: 'octocat' }); await user.type(screen.getByPlaceholderText('Enter GitHub nickname'), 'octocat'); @@ -62,7 +55,7 @@ describe('ObfuscationModal', () => { }); it('does not obfuscate when githubId is null even if input matches text', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderModal({ githubId: null }); await user.type(screen.getByPlaceholderText('Enter GitHub nickname'), 'whatever'); @@ -73,7 +66,7 @@ describe('ObfuscationModal', () => { }); it('clears the validation error when the user types again', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderModal({ githubId: 'octocat' }); const input = screen.getByPlaceholderText('Enter GitHub nickname'); @@ -81,12 +74,15 @@ describe('ObfuscationModal', () => { await user.click(screen.getByRole('button', { name: /OK/i })); expect(screen.getByText('Nickname does not match. Please try again.')).toBeInTheDocument(); + expect(obfuscateProfile).not.toHaveBeenCalled(); + expect(reload).not.toHaveBeenCalled(); + await user.type(input, 'x'); expect(screen.queryByText('Nickname does not match. Please try again.')).not.toBeInTheDocument(); }); it('calls setIsModalVisible(false) and resets state on cancel', async () => { - const user = userEvent.setup(); + const user = setupUser(); const setIsModalVisible = vi.fn(); renderModal({ githubId: 'octocat', setIsModalVisible }); diff --git a/client/src/components/Profile/__test__/PublicFeedbackCard.test.tsx b/client/src/components/Profile/__test__/PublicFeedbackCard.test.tsx index 720fd4ffc5..dd7b6514fc 100644 --- a/client/src/components/Profile/__test__/PublicFeedbackCard.test.tsx +++ b/client/src/components/Profile/__test__/PublicFeedbackCard.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import PublicFeedbackCard from '../PublicFeedbackCard'; describe('PublicFeedbackCard', () => { @@ -70,43 +70,32 @@ describe('PublicFeedbackCard', () => { vi.useRealTimers(); }); - it('should render correctly', () => { + it('matches the feedback card snapshot', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2019-01-01')); const { container } = render(); expect(container).toMatchSnapshot(); }); - it('opens the public feedback modal when the fullscreen action is clicked, then closes it', async () => { - const user = userEvent.setup(); + it('renders feedback details and opens and closes the modal', async () => { + const user = setupUser(); render(); - // modal is not visible initially + expect(screen.getByText('Total badges:')).toBeInTheDocument(); + expect(screen.getByText('Last feedback:')).toBeInTheDocument(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - await user.click(screen.getByRole('img', { name: 'fullscreen' })); expect(screen.getByRole('dialog')).toBeInTheDocument(); - - // close via the modal Close button -> hidePublicFeedbackModal await user.click(screen.getByRole('button', { name: 'Close' })); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); }); - it('shows the total badge count and renders the last feedback', () => { - render(); - expect(screen.getByText('Total badges:')).toBeInTheDocument(); - expect(screen.getByText('Last feedback:')).toBeInTheDocument(); - }); - - it('handles feedback entries with and without a badgeId (badgeId branch)', () => { + it('handles feedback with no badge and empty feedback', () => { const mixed = [{ ...data[0], badgeId: '' }, { ...data[1] }]; - render(); - // last feedback is the first item which has no badgeId -> renders empty badge label, no crash + const { rerender } = render(); expect(screen.getByText('Total badges:')).toBeInTheDocument(); - }); - it('renders an empty list without badges (countBadges with empty data)', () => { - render(); + rerender(); expect(screen.getByText('Total badges:')).toBeInTheDocument(); }); }); diff --git a/client/src/components/Profile/__test__/PublicFeedbackModal.test.tsx b/client/src/components/Profile/__test__/PublicFeedbackModal.test.tsx index dde7fec52f..e3602c2d04 100644 --- a/client/src/components/Profile/__test__/PublicFeedbackModal.test.tsx +++ b/client/src/components/Profile/__test__/PublicFeedbackModal.test.tsx @@ -73,12 +73,11 @@ describe('PublicFeedbackModal', () => { vi.useRealTimers(); }); - it('Should render correctly', () => { - const { container } = render(); + it('renders and handles populated, empty, and hidden states', () => { + const onHide = vi.fn(); + const { container, rerender } = render(); expect(container).toMatchSnapshot(); - }); - it('renders known badge name, empty string for unknown badge, and nothing when badgeId is falsy', () => { const mixed = [ { feedbackDate: '2021-01-01T00:00:00.000Z', @@ -103,34 +102,22 @@ describe('PublicFeedbackModal', () => { }, ]; - render(); + rerender(); - // known badge resolves to its display name expect(screen.getByText('Congratulations')).toBeInTheDocument(); - // all comments rendered regardless of badge state expect(screen.getByText('Known badge comment')).toBeInTheDocument(); expect(screen.getByText('Unknown badge comment')).toBeInTheDocument(); expect(screen.getByText('No badge comment')).toBeInTheDocument(); - // author links present expect(screen.getByRole('link', { name: 'Known User' })).toHaveAttribute('href', '/profile?githubId=known'); - }); - - it('renders an empty list when there are no feedback items', () => { - render(); - expect(screen.getByText('Public Feedback')).toBeInTheDocument(); - }); - - it('calls onHide when the modal is cancelled', () => { - const onHide = vi.fn(); - render(); const dialog = screen.getByRole('dialog'); fireEvent.click(within(dialog).getByRole('button', { name: /close/i })); expect(onHide).toHaveBeenCalled(); - }); - it('does not render the dialog content when not visible', () => { - render(); + rerender(); + expect(screen.getByText('Public Feedback')).toBeInTheDocument(); + + rerender(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); }); diff --git a/client/src/components/Profile/__test__/StudentLeaveCourse.test.tsx b/client/src/components/Profile/__test__/StudentLeaveCourse.test.tsx index 375f28078b..35f999fab9 100644 --- a/client/src/components/Profile/__test__/StudentLeaveCourse.test.tsx +++ b/client/src/components/Profile/__test__/StudentLeaveCourse.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import StudentLeaveCourse from '../StudentLeaveCourse'; const reasonsOptions = [ @@ -19,16 +19,8 @@ function renderModal(overrides: Partial { - it('renders the confirmation messages and reason options', () => { - renderModal(); - expect(screen.getByText('Are you sure you want to leave the course?')).toBeInTheDocument(); - expect(screen.getByText('Your learning will be stopped.')).toBeInTheDocument(); - expect(screen.getByRole('checkbox', { name: /Too difficult/ })).toBeInTheDocument(); - expect(screen.getByRole('checkbox', { name: /No time/ })).toBeInTheDocument(); - }); - it('does not call onOk when no reason is selected (validation fails)', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onOk = vi.fn(); renderModal({ onOk }); @@ -39,7 +31,7 @@ describe('StudentLeaveCourse', () => { }); it('calls onOk with the selected reason when validation passes', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onOk = vi.fn(); renderModal({ onOk }); @@ -51,10 +43,15 @@ describe('StudentLeaveCourse', () => { }); it('calls onCancel when "Continue studying" is clicked', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onCancel = vi.fn(); renderModal({ onCancel }); + expect(screen.getByText('Are you sure you want to leave the course?')).toBeInTheDocument(); + expect(screen.getByText('Your learning will be stopped.')).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: /Too difficult/ })).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: /No time/ })).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Continue studying' })); expect(onCancel).toHaveBeenCalledTimes(1); }); diff --git a/client/src/components/Profile/__test__/StudentStatsCard.test.tsx b/client/src/components/Profile/__test__/StudentStatsCard.test.tsx index 58493b956c..632737e7ff 100644 --- a/client/src/components/Profile/__test__/StudentStatsCard.test.tsx +++ b/client/src/components/Profile/__test__/StudentStatsCard.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import axios from 'axios'; import StudentStatsCard from '../StudentStatsCard'; import { StudentStats } from '@common/models/profile'; @@ -174,21 +174,12 @@ describe('StudentStatsCard', () => { const makeData = (overrides: Partial = {}): StudentStats[] => [{ ...baseCourse, ...overrides }] as unknown as StudentStats[]; - it('renders the Leave Course button for an owner on an active course and opens the leave modal', async () => { - const user = userEvent.setup(); - render(); - - const leaveBtn = screen.getByRole('button', { name: /Leave Course/ }); - await user.click(leaveBtn); - - expect(screen.getByText('Confirm Leaving Course')).toBeInTheDocument(); - }); - it('cancels the leave confirmation modal (hideExpelConfirmationModal)', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole('button', { name: /Leave Course/ })); + expect(screen.getByText('Confirm Leaving Course')).toBeInTheDocument(); const dialog = screen.getByRole('dialog'); await user.click(within(dialog).getByRole('button', { name: /Continue studying/ })); @@ -197,7 +188,7 @@ describe('StudentStatsCard', () => { }); it('submits the leave survey and posts to the leave endpoint then reloads (selfExpelStudent)', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole('button', { name: /Leave Course/ })); @@ -212,7 +203,7 @@ describe('StudentStatsCard', () => { }); it('renders Back to Course for a self-expelled student and rejoins on click (rejoinAsStudent)', async () => { - const user = userEvent.setup(); + const user = setupUser(); render( { expect(screen.getByText('You expelled by Course Manager or Mentor')).toBeInTheDocument(); }); - it('renders no leave/back controls for a non-owner', () => { - render(); - - expect(screen.queryByRole('button', { name: /Leave Course/ })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /Back to Course/ })).not.toBeInTheDocument(); - }); - it('renders the certificate link, mentor link and rank when present', () => { render( { expect(screen.getByText('Position: 5')).toBeInTheDocument(); }); - it('opens the per-course stats modal from the expand button (showStudentStatsModal)', async () => { - const user = userEvent.setup(); + it('hides leave controls for a non-owner and opens and closes course statistics', async () => { + const user = setupUser(); render(); - await user.click(screen.getByRole('button', { name: 'Open details' })); - - expect(screen.getByText('RS Active statistics')).toBeInTheDocument(); - }); - - it('closes the per-course stats modal (hideStudentStatsModal)', async () => { - const user = userEvent.setup(); - render(); + expect(screen.queryByRole('button', { name: /Leave Course/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Back to Course/ })).not.toBeInTheDocument(); + expect(axios.post).not.toHaveBeenCalled(); await user.click(screen.getByRole('button', { name: 'Open details' })); + expect(screen.getByText('RS Active statistics')).toBeInTheDocument(); const dialog = screen.getByRole('dialog'); await user.click(within(dialog).getByRole('button', { name: 'Close' })); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); }); - - it('does not post when leaving with no courseId (selfExpelStudent guard)', async () => { - // unreachable in practice: the Leave button always passes a real courseId. - // Covered indirectly: the non-owner case renders no leave control, so the guard - // path (`if (!courseId) return`) cannot be triggered through the UI. - render(); - expect(axios.post).not.toHaveBeenCalled(); - }); }); diff --git a/client/src/components/Profile/__test__/StudentStatsModal.test.tsx b/client/src/components/Profile/__test__/StudentStatsModal.test.tsx index b5645139f3..10ab6f7f08 100644 --- a/client/src/components/Profile/__test__/StudentStatsModal.test.tsx +++ b/client/src/components/Profile/__test__/StudentStatsModal.test.tsx @@ -9,9 +9,9 @@ describe('StudentStatsModal', () => { courseName: 'rs-2018-q1', locationName: 'Minsk', courseFullName: 'Rolling Scopes School 2018 Q1', - isExpelled: false, + isExpelled: true, isSelfExpelled: false, - expellingReason: '', + expellingReason: 'No activity', isCourseCompleted: true, totalScore: 1201, certificateId: 'asd', @@ -53,6 +53,14 @@ describe('StudentStatsModal', () => { const { container } = render(); expect(container).toMatchSnapshot(); + expect(screen.getByRole('link', { name: 'Andrey Andreev' })).toHaveAttribute('href', '/profile?githubId=andrew123'); + expect(screen.getByText('Position:')).toBeInTheDocument(); + expect(screen.getByText('32')).toBeInTheDocument(); + expect(screen.getByText(/\/ 340\.0/)).toBeInTheDocument(); + expect(screen.getByText(/Expelling reason: No activity/)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Task 1' })).toHaveAttribute('href', 'https://description.com'); + expect(screen.getAllByRole('link', { name: 'PR' })).toHaveLength(3); + expect(screen.getByText('120.00')).toBeInTheDocument(); }); const baseStats = (overrides: Partial = {}): StudentStats => ({ @@ -85,23 +93,6 @@ describe('StudentStatsModal', () => { ...overrides, }); - it('renders mentor link, rank, total score with max and expelling reason when all present', () => { - render( - , - ); - - expect(screen.getByRole('link', { name: 'Andrey Andreev' })).toHaveAttribute('href', '/profile?githubId=andrew123'); - expect(screen.getByText('Position:')).toBeInTheDocument(); - expect(screen.getByText('32')).toBeInTheDocument(); - // maxScore present on every task -> max course score computed (130 * 1 = 130.0) - expect(screen.getByText(/\/ 130\.0/)).toBeInTheDocument(); - expect(screen.getByText(/Expelling reason: No activity/)).toBeInTheDocument(); - }); - it('hides mentor link, rank, max score and expelling reason when absent/falsy', () => { const stats = baseStats({ mentor: { githubId: '', name: 'No Github' }, @@ -158,30 +149,6 @@ describe('StudentStatsModal', () => { expect(within(dialog).queryByRole('link', { name: 'PR' })).not.toBeInTheDocument(); }); - it('renders task columns truthy branches: descriptionUri link, score weighted, PR link', () => { - const stats = baseStats({ - tasks: [ - { - maxScore: 100, - scoreWeight: 2, - name: 'Linked Task', - descriptionUri: 'https://task.example', - githubPrUri: 'https://pr.example', - score: 50, - comment: 'c', - }, - ], - }); - render(); - - expect(screen.getByRole('link', { name: 'Linked Task' })).toHaveAttribute('href', 'https://task.example'); - expect(screen.getByRole('link', { name: 'PR' })).toHaveAttribute('href', 'https://pr.example'); - // score 50 * weight 2 = 100.00 displayed - expect(screen.getByText('100.00')).toBeInTheDocument(); - // score / max cell shows the actual score and maxScore - expect(screen.getByText('50')).toBeInTheDocument(); - }); - it('calls onHide when modal cancel/close is triggered', () => { const onHide = vi.fn(); render(); diff --git a/client/src/components/Profile/__test__/__snapshots__/CommonCard.test.tsx.snap b/client/src/components/Profile/__test__/__snapshots__/CommonCard.test.tsx.snap deleted file mode 100644 index 494b3923ee..0000000000 --- a/client/src/components/Profile/__test__/__snapshots__/CommonCard.test.tsx.snap +++ /dev/null @@ -1,126 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`CommonCard > Should render correctly > if is null content passed 1`] = ` -
-
-
-
-
-

- - - Icon - - - Test - -

-
-
-
-
-
-
- - - No data - - - - - - - - - -
-
- No data -
-
-
-
-
-`; - -exports[`CommonCard > Should render correctly > if just basic props is present 1`] = ` -
-
-
-
-
-

- - - Icon - - - Test - -

-
-
-
-
-

- Card body -

-
-
-
-`; diff --git a/client/src/components/Profile/__test__/__snapshots__/CommonCardWithSettingsModal.test.tsx.snap b/client/src/components/Profile/__test__/__snapshots__/CommonCardWithSettingsModal.test.tsx.snap index 21f5db85df..2f8f9b3251 100644 --- a/client/src/components/Profile/__test__/__snapshots__/CommonCardWithSettingsModal.test.tsx.snap +++ b/client/src/components/Profile/__test__/__snapshots__/CommonCardWithSettingsModal.test.tsx.snap @@ -60,7 +60,7 @@ exports[`CommonCardWithSettingsModal > Should render correctly > if just basic p `; -exports[`CommonCardWithSettingsModal > Should render correctly > if null content is passed and editing mode is disabled 1`] = ` +exports[`CommonCardWithSettingsModal > does not render the edit affordance or modal when editing is disabled 1`] = `
Should render correctly > if "data" has element with "n class="ant-card-body" >
-
-
-
    -
  • -

    - (Empty) -

    -
  • -
-
-
+

+ (Empty) +

+
+ +
@@ -248,37 +244,33 @@ exports[`EducationCard > Should render correctly > if editing mode is disabled 1 class="ant-card-body" >
-
-
-
    -
  • -

    - - - 2002 - - - - MIT / POIT -

    -
  • -
-
-
+

+ + + 2002 + + + + MIT / POIT +

+
+ + @@ -354,37 +346,33 @@ exports[`EducationCard > Should render correctly > if editing mode is enabled 1` class="ant-card-body" >
-
-
-
    -
  • -

    - - - 2002 - - - - MIT / POIT -

    -
  • -
-
-
+

+ + + 2002 + + + + MIT / POIT +

+
+ + diff --git a/client/src/components/Profile/__test__/__snapshots__/PublicFeedbackCard.test.tsx.snap b/client/src/components/Profile/__test__/__snapshots__/PublicFeedbackCard.test.tsx.snap index 20df6d275d..6c48d40082 100644 --- a/client/src/components/Profile/__test__/__snapshots__/PublicFeedbackCard.test.tsx.snap +++ b/client/src/components/Profile/__test__/__snapshots__/PublicFeedbackCard.test.tsx.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`PublicFeedbackCard > should render correctly 1`] = ` +exports[`PublicFeedbackCard > matches the feedback card snapshot 1`] = `
Should render correctly 1`] = `
`; +exports[`PublicFeedbackModal > renders and handles populated, empty, and hidden states 1`] = `
`; diff --git a/client/src/components/Profile/__test__/__snapshots__/StudentStatsCard.test.tsx.snap b/client/src/components/Profile/__test__/__snapshots__/StudentStatsCard.test.tsx.snap index 14540308fb..d2f7228b9d 100644 --- a/client/src/components/Profile/__test__/__snapshots__/StudentStatsCard.test.tsx.snap +++ b/client/src/components/Profile/__test__/__snapshots__/StudentStatsCard.test.tsx.snap @@ -49,263 +49,264 @@ exports[`StudentStatsCard > should render correctly 1`] = ` class="ant-card-body" >
-
-
-
+ +
  • +
    +
    +

    - + + rs-2019-q1 + / Minsk + - -

  • -
  • +

    - -
  • - -
    -
    + Dima Testovich + +

    +

    + Position: + 32 +

    +

    + Score: + 101 +

    +
    + +
    + +
    diff --git a/client/src/components/Profile/ui/DateWidget.test.tsx b/client/src/components/Profile/ui/DateWidget.test.tsx index a006e5fa6e..50f9053d81 100644 --- a/client/src/components/Profile/ui/DateWidget.test.tsx +++ b/client/src/components/Profile/ui/DateWidget.test.tsx @@ -2,21 +2,24 @@ import { render, screen } from '@testing-library/react'; import { formatDate } from '@client/services/formatter'; import { DateWidget } from './DateWidget'; +vi.mock('antd', () => ({ + Flex: ({ children }: { children: React.ReactNode }) =>
    {children}
    , + Space: ({ children }: { children: React.ReactNode }) =>
    {children}
    , + theme: { useToken: () => ({ token: { colorTextTertiary: '#777' } }) }, + Typography: { Text: ({ children, ...props }: React.ComponentProps<'span'>) => {children} }, +})); + describe('DateWidget', () => { - it('renders the formatted date with a label', () => { - render(); + it('renders a formatted date and nothing for missing or empty dates', () => { + const { container, rerender } = render(); expect(screen.getByText('Date')).toBeInTheDocument(); expect(screen.getByTestId('date-widget')).toHaveTextContent(formatDate('2024-01-15T10:00:00.000Z')); - }); - it('renders nothing when date is missing', () => { - const { container } = render(); + rerender(); expect(container).toBeEmptyDOMElement(); - }); - it('renders nothing when date is an empty string', () => { - const { container } = render(); + rerender(); expect(container).toBeEmptyDOMElement(); }); }); diff --git a/client/src/components/Profile/ui/ExpandButtonWidget.test.tsx b/client/src/components/Profile/ui/ExpandButtonWidget.test.tsx index df566acb39..e6c4cb0eeb 100644 --- a/client/src/components/Profile/ui/ExpandButtonWidget.test.tsx +++ b/client/src/components/Profile/ui/ExpandButtonWidget.test.tsx @@ -1,19 +1,14 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { fireEvent, render, screen } from '@testing-library/react'; import { ExpandButtonWidget } from './ExpandButtonWidget'; describe('ExpandButtonWidget', () => { - it('renders an accessible expand button', () => { - render(); - expect(screen.getByRole('button', { name: 'Open details' })).toBeInTheDocument(); - }); - - it('calls onClick when pressed', async () => { - const user = userEvent.setup(); + it('renders an accessible button and calls onClick when pressed', () => { const onClick = vi.fn(); render(); + const button = screen.getByRole('button', { name: 'Open details' }); - await user.click(screen.getByRole('button', { name: 'Open details' })); + expect(button).toBeInTheDocument(); + fireEvent.click(button); expect(onClick).toHaveBeenCalledTimes(1); }); }); diff --git a/client/src/components/Profile/ui/InterviewerWidget.test.tsx b/client/src/components/Profile/ui/InterviewerWidget.test.tsx index d5563c13b4..276283c80b 100644 --- a/client/src/components/Profile/ui/InterviewerWidget.test.tsx +++ b/client/src/components/Profile/ui/InterviewerWidget.test.tsx @@ -5,25 +5,25 @@ import { InterviewerWidget } from './InterviewerWidget'; vi.mock('@client/shared/components/GithubAvatar', () => ({ GithubAvatar: ({ githubId }: { githubId: string }) => {githubId}, })); +vi.mock('antd', () => ({ + Flex: ({ children }: React.PropsWithChildren) =>
    {children}
    , + Space: ({ children }: React.PropsWithChildren) =>
    {children}
    , + theme: { useToken: () => ({ token: { colorTextTertiary: '#aaa', colorTextBase: '#000' } }) }, + Typography: { Text: ({ children }: React.PropsWithChildren) => {children} }, +})); const interviewer = { name: 'Jane Doe', githubId: 'jane' }; describe('InterviewerWidget', () => { - it('renders the interviewer name, avatar and profile link', () => { - render(); + it('renders interviewer details in horizontal and vertical layouts', () => { + const { rerender } = render(); expect(screen.getByText('Jane Doe')).toBeInTheDocument(); expect(screen.getByTestId('avatar')).toHaveTextContent('jane'); expect(screen.getByRole('link')).toHaveAttribute('href', '/profile?githubId=jane'); - }); - - it('renders the label with a colon in horizontal layout', () => { - render(); expect(screen.getByText(/Interviewer/)).toHaveTextContent('Interviewer :'); - }); - it('renders the label without a colon in vertical layout', () => { - render(); + rerender(); const label = screen.getByText(/Interviewer/); expect(label.textContent?.trim()).toBe('Interviewer'); }); diff --git a/client/src/components/Profile/ui/LegacyScreeningFeedback.test.tsx b/client/src/components/Profile/ui/LegacyScreeningFeedback.test.tsx index f0214a77b1..6c850ba5a8 100644 --- a/client/src/components/Profile/ui/LegacyScreeningFeedback.test.tsx +++ b/client/src/components/Profile/ui/LegacyScreeningFeedback.test.tsx @@ -13,10 +13,18 @@ function makeFeedback(overrides: Partial = {}): LegacyFeedback { } describe('LegacyScreeningFeedback', () => { - it('renders the comment when present', () => { + it('renders the default comment, task, resolution, English level and skills', () => { render(); expect(screen.getByText('Comment:')).toBeInTheDocument(); expect(screen.getByText('Great candidate')).toBeInTheDocument(); + expect(screen.getByText('sum two numbers')).toBeInTheDocument(); + expect(screen.getByText(/clean code/)).toBeInTheDocument(); + expect(screen.getByText('Yes')).toBeInTheDocument(); + expect(screen.getByText(/Estimated English level: B1/)).toBeInTheDocument(); + expect(screen.getByText('HTML/CSS')).toBeInTheDocument(); + expect(screen.getByText('Data structures')).toBeInTheDocument(); + expect(screen.getByText('Common of CS / Programming')).toBeInTheDocument(); + expect(screen.getByText('Code writing level')).toBeInTheDocument(); }); it('does not render the comment block when comment is empty', () => { @@ -24,21 +32,6 @@ describe('LegacyScreeningFeedback', () => { expect(screen.queryByText('Comment:')).not.toBeInTheDocument(); }); - it('renders the programming task and coding comment', () => { - render(); - expect(screen.getByText('sum two numbers')).toBeInTheDocument(); - expect(screen.getByText(/clean code/)).toBeInTheDocument(); - }); - - it('renders "Yes" tag when resolved === 1', () => { - render( - , - ); - expect(screen.getByText('Yes')).toBeInTheDocument(); - }); - it('renders "Yes (with tips)" tag when resolved === 2', () => { render( { expect(screen.getByText('No')).toBeInTheDocument(); }); - it('maps a numeric english level via ENGLISH_LEVELS', () => { - render(); - // ENGLISH_LEVELS[5] === 'B1' -> uppercased - expect(screen.getByText(/Estimated English level: B1/)).toBeInTheDocument(); - }); - it('renders a string english level as-is (uppercased)', () => { render(); expect(screen.getByText(/Estimated English level: B2/)).toBeInTheDocument(); }); - - it('renders the skills table rows', () => { - render(); - expect(screen.getByText('HTML/CSS')).toBeInTheDocument(); - expect(screen.getByText('Data structures')).toBeInTheDocument(); - expect(screen.getByText('Common of CS / Programming')).toBeInTheDocument(); - expect(screen.getByText('Code writing level')).toBeInTheDocument(); - }); }); diff --git a/client/src/components/Profile/ui/PrescreeningFeedback.test.tsx b/client/src/components/Profile/ui/PrescreeningFeedback.test.tsx index 3394ba0ccf..858a547c54 100644 --- a/client/src/components/Profile/ui/PrescreeningFeedback.test.tsx +++ b/client/src/components/Profile/ui/PrescreeningFeedback.test.tsx @@ -36,28 +36,20 @@ describe('PrescreeningFeedback', () => { }, }; - it('renders the non-rejected feedback items', () => { + it('renders feedback items and both skill sections', () => { render(); expect(screen.getByText('none observed')).toBeInTheDocument(); expect(screen.getByText('strong candidate')).toBeInTheDocument(); expect(screen.getByText('IELTS 7.0')).toBeInTheDocument(); expect(screen.getByText('B2 level')).toBeInTheDocument(); expect(screen.getByText('learned at university')).toBeInTheDocument(); - // intro comment (isRejectedInterviewItem) must NOT show expect(screen.queryByText('should not show')).not.toBeInTheDocument(); - }); - - it('renders both Theory and Practice skill sections with topic and no-topic rows', () => { - render(); expect(screen.getByText('Theory')).toBeInTheDocument(); expect(screen.getByText('Practice')).toBeInTheDocument(); - // SkillTable: row with topic expect(screen.getByText('Closures')).toBeInTheDocument(); expect(screen.getByText('Explain closures')).toBeInTheDocument(); - // SkillTable: row without topic still renders the title expect(screen.getByText('No topic question')).toBeInTheDocument(); expect(screen.getByText('Reverse an array')).toBeInTheDocument(); - // SkillSection comments expect(screen.getByText('good theory')).toBeInTheDocument(); expect(screen.getByText('good practice')).toBeInTheDocument(); }); diff --git a/client/src/components/Profile/ui/PrescreeningFeedback.tsx b/client/src/components/Profile/ui/PrescreeningFeedback.tsx index caaccde0cf..ee456c74ac 100644 --- a/client/src/components/Profile/ui/PrescreeningFeedback.tsx +++ b/client/src/components/Profile/ui/PrescreeningFeedback.tsx @@ -62,7 +62,7 @@ const FeedbackItem = ({ }) => { if (typeof value === 'string' && value) { return ( - + {label}: {value} @@ -89,7 +89,7 @@ export function PrescreeningFeedback({ feedback }: { feedback: StageInterviewDet ); return ( - + {displayItems.map(item => ( ))} @@ -115,7 +115,7 @@ function SkillSection({ if (!skills) return null; return ( - + {title} diff --git a/client/src/components/Profile/ui/ScoreWidget.test.tsx b/client/src/components/Profile/ui/ScoreWidget.test.tsx index 9b3ab71a04..1094d1119c 100644 --- a/client/src/components/Profile/ui/ScoreWidget.test.tsx +++ b/client/src/components/Profile/ui/ScoreWidget.test.tsx @@ -1,16 +1,20 @@ import { render, screen } from '@testing-library/react'; import { ScoreWidget } from './ScoreWidget'; +vi.mock('antd', () => ({ + Tag: ({ children }: React.PropsWithChildren) => {children}, + theme: { useToken: () => ({ token: { colorBgSpotlight: '#000' } }) }, + Typography: { Text: ({ children }: React.PropsWithChildren) => {children} }, +})); + describe('ScoreWidget', () => { - it('renders the score label and value', () => { - render(); + it('renders positive and zero scores', () => { + const { rerender } = render(); expect(screen.getByText('Score:')).toBeInTheDocument(); expect(screen.getByText('42')).toBeInTheDocument(); - }); - it('renders a zero score', () => { - render(); + rerender(); expect(screen.getByText('0')).toBeInTheDocument(); }); }); diff --git a/client/src/components/Profile/ui/__tests__/DateWidget.test.tsx b/client/src/components/Profile/ui/__tests__/DateWidget.test.tsx index 9d8da638f6..f7816974c4 100644 --- a/client/src/components/Profile/ui/__tests__/DateWidget.test.tsx +++ b/client/src/components/Profile/ui/__tests__/DateWidget.test.tsx @@ -1,23 +1,26 @@ import { render, screen } from '@testing-library/react'; import { DateWidget } from '@client/components/Profile/ui'; +vi.mock('@ant-design/icons/CalendarOutlined', () => ({ + default: () => , +})); +vi.mock('antd', () => ({ + Flex: ({ children }: React.PropsWithChildren) =>
    {children}
    , + Space: ({ children }: React.PropsWithChildren) =>
    {children}
    , + theme: { useToken: () => ({ token: { colorTextTertiary: '#000' } }) }, + Typography: { Text: ({ children, ...props }: React.PropsWithChildren) => {children} }, +})); + describe('DateWidget', () => { - it('returns null when no date provided', () => { - render(); + it('renders nothing without a date and formatted content otherwise', () => { + const { rerender } = render(); const element = screen.queryByTestId('date-widget'); expect(element).not.toBeInTheDocument(); - }); - it('renders formatted date and label when date provided', () => { - render(); + rerender(); expect(screen.getByTestId('date-widget')).toBeInTheDocument(); expect(screen.getByText('Date')).toBeInTheDocument(); expect(screen.getByText('2025-01-15')).toBeInTheDocument(); - }); - - it('renders calendar icon', () => { - render(); - expect(screen.getByTestId('date-widget')).toBeInTheDocument(); expect(screen.getByRole('img', { name: /calendar/i })).toBeInTheDocument(); }); }); diff --git a/client/src/components/Profile/ui/__tests__/ExpandButtonWidget.test.tsx b/client/src/components/Profile/ui/__tests__/ExpandButtonWidget.test.tsx index a89d2a94b4..baf82441a9 100644 --- a/client/src/components/Profile/ui/__tests__/ExpandButtonWidget.test.tsx +++ b/client/src/components/Profile/ui/__tests__/ExpandButtonWidget.test.tsx @@ -1,20 +1,15 @@ -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { ExpandButtonWidget } from '@client/components/Profile/ui'; -import userEvent from '@testing-library/user-event'; describe('ExpandButtonWidget', () => { - it('should render correctly', () => { - render( console.log('test')} />); - const button = screen.getByRole('button'); - expect(button).toBeInTheDocument(); - expect(button.title).toBe('Open details'); - }); - - it('should call onClick callback', async () => { + it('renders correctly and calls onClick', () => { const onClick = vi.fn(); render(); const button = screen.getByRole('button'); - await userEvent.click(button); + + expect(button).toBeInTheDocument(); + expect(button.title).toBe('Open details'); + fireEvent.click(button); expect(onClick).toHaveBeenCalledTimes(1); }); }); diff --git a/client/src/components/Profile/ui/__tests__/InterviewerWidget.test.tsx b/client/src/components/Profile/ui/__tests__/InterviewerWidget.test.tsx index 64cecc2ef7..a5a61ec726 100644 --- a/client/src/components/Profile/ui/__tests__/InterviewerWidget.test.tsx +++ b/client/src/components/Profile/ui/__tests__/InterviewerWidget.test.tsx @@ -2,27 +2,24 @@ import { render, screen } from '@testing-library/react'; import { InterviewerWidget } from '@client/components/Profile/ui'; describe('InterviewerWidget', () => { - it('renders interviewer name with link to profile', () => { + it('renders interviewer details in horizontal and vertical layouts', () => { const interviewer = { name: 'Alice', githubId: 'alice' }; - render(); + const { rerender } = render(); expect(screen.getByText(/Interviewer/)).toBeInTheDocument(); const link = screen.getByRole('link', { name: /Alice/ }); expect(link).toBeInTheDocument(); expect(link).toHaveAttribute('href', expect.stringContaining('/profile?githubId=alice')); - }); - - it('renders vertical layout without colon in the label', () => { - const interviewer = { name: 'Bob', githubId: 'bob' }; - render(); + rerender(); expect(screen.getByText('Interviewer')).toBeInTheDocument(); expect(screen.queryByText('Interviewer:')).not.toBeInTheDocument(); - - const link = screen.getByRole('link', { name: /Bob/ }); - expect(link).toHaveAttribute('href', expect.stringContaining('/profile?githubId=bob')); + expect(screen.getByRole('link', { name: /Alice/ })).toHaveAttribute( + 'href', + expect.stringContaining('/profile?githubId=alice'), + ); }); }); diff --git a/client/src/components/Profile/ui/__tests__/IsGoodCandidateWidget.test.tsx b/client/src/components/Profile/ui/__tests__/IsGoodCandidateWidget.test.tsx index 25630cbcc1..48861fa44a 100644 --- a/client/src/components/Profile/ui/__tests__/IsGoodCandidateWidget.test.tsx +++ b/client/src/components/Profile/ui/__tests__/IsGoodCandidateWidget.test.tsx @@ -1,21 +1,22 @@ import { render, screen } from '@testing-library/react'; import { IsGoodCandidateWidget } from '@client/components/Profile/ui'; +vi.mock('antd', () => ({ + Tag: ({ children }: React.PropsWithChildren) => {children}, + Typography: { Text: ({ children }: React.PropsWithChildren) => {children} }, +})); + describe('IsGoodCandidateWidget', () => { - it('renders Yes tag when isGoodCandidate is true', () => { - render(); + it('renders only for a true candidate value', () => { + const { rerender } = render(); expect(screen.getByText('Good candidate:')).toBeInTheDocument(); expect(screen.getByText('Yes')).toBeInTheDocument(); - }); - it('renders nothing when isGoodCandidate is false', () => { - render(); + rerender(); expect(screen.queryByText(/Good candidate:/i)).not.toBeInTheDocument(); - }); - it('renders nothing when isGoodCandidate is null', () => { - render(); + rerender(); expect(screen.queryByText(/Good candidate:/i)).not.toBeInTheDocument(); }); }); diff --git a/client/src/components/Profile/ui/__tests__/ScoreWidget.test.tsx b/client/src/components/Profile/ui/__tests__/ScoreWidget.test.tsx index 296713e453..28de10928b 100644 --- a/client/src/components/Profile/ui/__tests__/ScoreWidget.test.tsx +++ b/client/src/components/Profile/ui/__tests__/ScoreWidget.test.tsx @@ -2,23 +2,17 @@ import { render, screen } from '@testing-library/react'; import { ScoreWidget } from '@client/components/Profile/ui'; describe('ScoreWidget', () => { - it('renders label and score value', () => { - render(); + it('renders regular, zero, and large scores', () => { + const { rerender } = render(); expect(screen.getByText('Score:')).toBeInTheDocument(); expect(screen.getByText('85')).toBeInTheDocument(); - }); - - it('renders zero score', () => { - render(); + rerender(); expect(screen.getByText('Score:')).toBeInTheDocument(); expect(screen.getByText('0')).toBeInTheDocument(); - }); - - it('renders large score values', () => { - render(); + rerender(); expect(screen.getByText('123456')).toBeInTheDocument(); }); }); diff --git a/client/src/components/RegistrationPageLayout.test.tsx b/client/src/components/RegistrationPageLayout.test.tsx index f58354befe..984009aa51 100644 --- a/client/src/components/RegistrationPageLayout.test.tsx +++ b/client/src/components/RegistrationPageLayout.test.tsx @@ -25,30 +25,20 @@ describe('RegistrationPageLayout', () => { mapsApiKey.value = 'test-key'; }); - it('renders the header and children content', () => { - render( - + it('renders content, conditionally loads Maps, and reflects loading state', () => { + const { container, rerender } = render( +
    registration form
    , ); expect(screen.getByRole('banner')).toBeInTheDocument(); expect(screen.getByText('registration form')).toBeInTheDocument(); - }); - - it('loads the google maps script when an api key is configured', () => { - render( - -
    content
    -
    , - ); - expect(screen.getByTestId('gmaps-script')).toBeInTheDocument(); - }); + expect(container.querySelector('.ant-spin-spinning')).toBeInTheDocument(); - it('does not load the google maps script when no api key is configured', () => { mapsApiKey.value = ''; - render( + rerender(
    content
    , @@ -56,14 +46,4 @@ describe('RegistrationPageLayout', () => { expect(screen.queryByTestId('gmaps-script')).not.toBeInTheDocument(); }); - - it('shows a busy spinner while loading', () => { - const { container } = render( - -
    content
    -
    , - ); - - expect(container.querySelector('.ant-spin-spinning')).toBeInTheDocument(); - }); }); diff --git a/client/src/components/SelectLanguages.test.tsx b/client/src/components/SelectLanguages.test.tsx index c938b2f6d3..be1aff6001 100644 --- a/client/src/components/SelectLanguages.test.tsx +++ b/client/src/components/SelectLanguages.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { getLanguageName, SelectLanguages } from './SelectLanguages'; import { UpdateUserDtoLanguagesEnum } from '@client/api'; @@ -11,48 +11,25 @@ describe('getLanguageName', () => { }); describe('SelectLanguages', () => { - it('renders the default placeholder', () => { - render(); + it('renders a multiple selector with default and custom placeholders', () => { + const { rerender } = render(); expect(screen.getByText('Select languages')).toBeInTheDocument(); - }); + expect(screen.getByRole('combobox')).toBeInTheDocument(); - it('renders a custom placeholder when provided', () => { - render(); + rerender(); expect(screen.getByText('Pick a language')).toBeInTheDocument(); }); - it('renders a multiple-mode combobox', () => { - render(); - expect(screen.getByRole('combobox')).toBeInTheDocument(); - }); - - it('shows language options when opened', () => { - render(); + it('shows sorted language options and selects one', () => { + const handleChange = vi.fn(); + render(); fireEvent.mouseDown(screen.getByRole('combobox')); const options = screen.getAllByRole('option'); expect(options.length).toBeGreaterThan(0); - // English should be present as an option label expect(screen.getByTitle('English')).toBeInTheDocument(); - }); - - it('selects a language option', () => { - const handleChange = vi.fn(); - render(); - - fireEvent.mouseDown(screen.getByRole('combobox')); - fireEvent.click(screen.getByTitle('English')); - - expect(handleChange).toHaveBeenCalledWith([UpdateUserDtoLanguagesEnum.En], expect.anything()); - }); - - it('renders options sorted by their English language name', () => { - render(); - - fireEvent.mouseDown(screen.getByRole('combobox')); - - const optionLabels = screen.getAllByRole('option').map(option => option.textContent ?? ''); + const optionLabels = options.map(option => option.textContent ?? ''); const sortedLabels = [...optionLabels].sort((a, b) => a.localeCompare(b, 'en')); // Options are produced from `languages`, which is sorted at module load by languagesSorter. // This exercises the normal `localeCompare` path of the sorter. @@ -60,10 +37,13 @@ describe('SelectLanguages', () => { // cannot fire because Intl.DisplayNames resolves every UpdateUserDtoLanguagesEnum value to a // truthy name, and the sorter is a module-private function run once at import with no injection point. expect(optionLabels).toEqual(sortedLabels); + + fireEvent.click(screen.getByTitle('English')); + expect(handleChange).toHaveBeenCalledWith([UpdateUserDtoLanguagesEnum.En], expect.anything()); }); it('filters options by the typed language name via optionFilterProp="label"', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); const combobox = screen.getByRole('combobox'); diff --git a/client/src/components/SettingsItem.test.tsx b/client/src/components/SettingsItem.test.tsx index a7ec715810..eb19e955d0 100644 --- a/client/src/components/SettingsItem.test.tsx +++ b/client/src/components/SettingsItem.test.tsx @@ -1,6 +1,5 @@ /* eslint-disable testing-library/no-container, testing-library/no-node-access -- antd Divider has no role/text to query */ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import SettingsItem from './SettingsItem'; const Icon = ({ style }: { style?: React.CSSProperties }) => ( @@ -10,34 +9,28 @@ const Icon = ({ style }: { style?: React.CSSProperties }) => ( ); describe('SettingsItem', () => { - it('renders the header and the expand icon', () => { - render( - -
    panel body
    -
    , - ); - - expect(screen.getByText('My Settings')).toBeInTheDocument(); - expect(screen.getByTestId('settings-icon')).toBeInTheDocument(); - }); - - it('reveals children content after expanding the panel', async () => { - const user = userEvent.setup(); - render( + it('renders and expands a panel without actions', () => { + vi.useFakeTimers(); + const { container } = render(
    hidden child content
    , ); + expect(screen.getByText('Expandable')).toBeInTheDocument(); + expect(screen.getByTestId('settings-icon')).toBeInTheDocument(); expect(screen.queryByText('hidden child content')).not.toBeInTheDocument(); - await user.click(screen.getByText('Expandable')); + fireEvent.click(screen.getByText('Expandable')); + act(() => vi.runOnlyPendingTimers()); - expect(await screen.findByText('hidden child content')).toBeInTheDocument(); + expect(screen.getByText('hidden child content')).toBeInTheDocument(); + expect(container.querySelector('.ant-divider')).not.toBeInTheDocument(); + vi.useRealTimers(); }); - it('renders the actions and a divider once expanded', async () => { - const user = userEvent.setup(); + it('renders the actions and a divider once expanded', () => { + vi.useFakeTimers(); render( { , ); - await user.click(screen.getByText('With actions')); + fireEvent.click(screen.getByText('With actions')); + act(() => vi.runOnlyPendingTimers()); - expect(await screen.findByRole('button', { name: 'Save' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Reset' })).toBeInTheDocument(); - }); - - it('does not render a divider when no actions are provided', async () => { - const user = userEvent.setup(); - const { container } = render( - -
    body content
    -
    , - ); - - await user.click(screen.getByText('No actions')); - await screen.findByText('body content'); - - expect(container.querySelector('.ant-divider')).not.toBeInTheDocument(); + vi.useRealTimers(); }); }); diff --git a/client/src/components/SettingsItem.tsx b/client/src/components/SettingsItem.tsx index 275b65a858..0f48630f7a 100644 --- a/client/src/components/SettingsItem.tsx +++ b/client/src/components/SettingsItem.tsx @@ -2,7 +2,6 @@ import { PropsWithChildren, ReactNode, CSSProperties, ComponentType } from 'reac import { Collapse, Divider, Flex, theme, Typography } from 'antd'; const { Text } = Typography; -const { Panel } = Collapse; type SettingsItemProps = PropsWithChildren & { header: string; @@ -17,17 +16,22 @@ const SettingsItem = ({ children, header, IconComponent, actions }: SettingsItem } - > - {header}} key={header}> - - - {children} - - {actions && } - {actions?.map(action => action)} - - - + items={[ + { + key: header, + label: {header}, + children: ( + + + {children} + + {actions && } + {actions} + + ), + }, + ]} + /> ); }; diff --git a/client/src/components/SlothImage.test.tsx b/client/src/components/SlothImage.test.tsx index 136312b13c..5fc7f7dd3f 100644 --- a/client/src/components/SlothImage.test.tsx +++ b/client/src/components/SlothImage.test.tsx @@ -3,28 +3,24 @@ import { render, screen } from '@testing-library/react'; import { SlothImage } from './SlothImage'; describe('SlothImage', () => { - it('renders an image with the default svg extension', () => { - render(); + it('renders default, custom-extension, and sized images', () => { + const { rerender } = render(); const img = screen.getByRole('img', { name: 'welcome' }); expect(img).toBeInTheDocument(); expect(img).toHaveAttribute('src', 'https://cdn.rs.school/sloths/stickers/welcome/image.svg'); - }); - it('renders with a png extension when requested', () => { - render(); + rerender(); - const img = screen.getByRole('img', { name: 'hero' }); - expect(img).toHaveAttribute('src', 'https://cdn.rs.school/sloths/stickers/hero/image.png'); - }); + const pngImg = screen.getByRole('img', { name: 'hero' }); + expect(pngImg).toHaveAttribute('src', 'https://cdn.rs.school/sloths/stickers/hero/image.png'); - it('forwards extra image props such as width', () => { - render(); + rerender(); - const img = screen.getByRole('img', { name: 'mentor' }); - expect(img).toHaveAttribute('alt', 'mentor'); + const sizedImg = screen.getByRole('img', { name: 'mentor' }); + expect(sizedImg).toHaveAttribute('alt', 'mentor'); // antd applies the width to the image wrapper element - const wrapper = img.closest('.ant-image'); + const wrapper = sizedImg.closest('.ant-image'); expect(wrapper).toHaveStyle({ width: '120px' }); }); }); diff --git a/client/src/components/Student/AssignStudentModal.test.tsx b/client/src/components/Student/AssignStudentModal.test.tsx index 6f5bc516a7..57aa6e666e 100644 --- a/client/src/components/Student/AssignStudentModal.test.tsx +++ b/client/src/components/Student/AssignStudentModal.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { AssignStudentModal } from './AssignStudentModal'; // --- boundary mocks --- @@ -39,53 +39,29 @@ const baseProps = { describe('AssignStudentModal', () => { beforeEach(() => vi.clearAllMocks()); - it('renders the title with the mentor github id and the student search', () => { - render(); + it('renders and handles guard, success, error, and cancel paths', async () => { + const user = setupUser(); + const onClose = vi.fn(); + updateStudent.mockResolvedValueOnce(undefined); + render(); expect(screen.getByText('Assign Student to')).toBeInTheDocument(); expect(screen.getByText('mentor-1')).toBeInTheDocument(); expect(screen.getByTestId('pick-student')).toBeInTheDocument(); - }); - - it('does nothing on OK when no student was selected', async () => { - const user = userEvent.setup(); - render(); await user.click(screen.getByRole('button', { name: 'OK' })); - expect(updateStudent).not.toHaveBeenCalled(); - expect(baseProps.onClose).not.toHaveBeenCalled(); - }); - - it('assigns the selected student and shows a success message', async () => { - const user = userEvent.setup(); - updateStudent.mockResolvedValue(undefined); - const onClose = vi.fn(); - render(); + expect(onClose).not.toHaveBeenCalled(); await user.click(screen.getByTestId('pick-student')); await user.click(screen.getByRole('button', { name: 'OK' })); - await waitFor(() => expect(updateStudent).toHaveBeenCalledWith('student-1', { mentorGithuId: 'mentor-1' })); expect(onClose).toHaveBeenCalled(); expect(success).toHaveBeenCalledWith('Student has been added to mentor'); - }); - - it('shows an error message when the assignment fails', async () => { - const user = userEvent.setup(); - updateStudent.mockRejectedValue(new Error('failed')); - render(); - await user.click(screen.getByTestId('pick-student')); + updateStudent.mockRejectedValueOnce(new Error('failed')); await user.click(screen.getByRole('button', { name: 'OK' })); - await waitFor(() => expect(error).toHaveBeenCalledWith('Error: failed')); - }); - - it('calls onClose when cancelled', async () => { - const user = userEvent.setup(); - const onClose = vi.fn(); - render(); await user.click(screen.getByRole('button', { name: 'Cancel' })); expect(onClose).toHaveBeenCalled(); diff --git a/client/src/components/Student/DashboardDetails.test.tsx b/client/src/components/Student/DashboardDetails.test.tsx index 6b2c079441..db26ae62c2 100644 --- a/client/src/components/Student/DashboardDetails.test.tsx +++ b/client/src/components/Student/DashboardDetails.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { DashboardDetails } from './DashboardDetails'; import type { StudentDetails } from '@client/services/course'; @@ -78,15 +78,13 @@ describe('DashboardDetails', () => { expect(container).toBeEmptyDOMElement(); }); - it('renders the drawer title with name and github id', () => { - render(); - expect(screen.getByText('Student One , student-1')).toBeInTheDocument(); - }); - it('shows the Expel button for an active student and opens the comment modal', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onExpelStudent = vi.fn(); render(); + expect(screen.getByText('Student One , student-1')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Issue Certificate/ })).not.toBeInTheDocument(); + expect(screen.queryByTestId('pick-mentor')).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: /Expel/ })); expect(screen.getByTestId('comment-modal')).toBeInTheDocument(); @@ -96,7 +94,7 @@ describe('DashboardDetails', () => { }); it('shows the Restore button for an inactive student', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onRestoreStudent = vi.fn(); const inactive = { ...activeDetails, isActive: false } as StudentDetails; render(); @@ -106,14 +104,8 @@ describe('DashboardDetails', () => { expect(onRestoreStudent).toHaveBeenCalled(); }); - it('hides manager controls when not a manager/supervisor', () => { - render(); - expect(screen.queryByRole('button', { name: /Issue Certificate/ })).not.toBeInTheDocument(); - expect(screen.queryByTestId('pick-mentor')).not.toBeInTheDocument(); - }); - it('shows manager controls and updates the mentor', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onUpdateMentor = vi.fn(); render(); @@ -131,7 +123,7 @@ describe('DashboardDetails', () => { }); it('issues a certificate and closes the modal on success', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onIssueCertificate = vi.fn().mockResolvedValue(true); render(); @@ -144,7 +136,7 @@ describe('DashboardDetails', () => { }); it('keeps the issue modal open when issuing fails', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onIssueCertificate = vi.fn().mockResolvedValue(false); render(); @@ -156,7 +148,7 @@ describe('DashboardDetails', () => { }); it('closes the comment modal on cancel without expelling', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onExpelStudent = vi.fn(); render(); @@ -169,7 +161,7 @@ describe('DashboardDetails', () => { }); it('closes the issue-certificate modal on cancel', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onIssueCertificate = vi.fn(); render(); @@ -182,7 +174,7 @@ describe('DashboardDetails', () => { }); it('shows the Remove Certificate confirm for admins and fires onRemoveCertificate', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onRemoveCertificate = vi.fn(); render( , diff --git a/client/src/components/Student/DashboardDetails.tsx b/client/src/components/Student/DashboardDetails.tsx index dc856a88c4..c7f4dc9391 100644 --- a/client/src/components/Student/DashboardDetails.tsx +++ b/client/src/components/Student/DashboardDetails.tsx @@ -42,7 +42,7 @@ export function DashboardDetails(props: Props) { return ( <> ({ })); describe('StudentDiscord', () => { - it('renders a discord link with discriminator and copy button', () => { - render(); + it('renders Discord details for discriminator, modern username, missing account, and prefix variants', () => { + const { rerender } = render(); const link = screen.getByRole('link', { name: '@johnny#4567' }); expect(link).toHaveAttribute('href', 'https://discordapp.com/users/123'); expect(link).toHaveAttribute('target', '_blank'); expect(screen.getByTestId('copy')).toHaveTextContent('@johnny#4567'); - }); - it('omits the discriminator when it is "0"', () => { - render(); + rerender(); expect(screen.getByRole('link', { name: '@plainuser' })).toBeInTheDocument(); expect(screen.getByTestId('copy')).toHaveTextContent('@plainuser'); - }); - it('renders "unknown" when discord is null', () => { - render(); + rerender(); expect(screen.getByText('unknown')).toBeInTheDocument(); expect(screen.queryByRole('link')).not.toBeInTheDocument(); expect(screen.queryByTestId('copy')).not.toBeInTheDocument(); - }); - it('renders the optional text prefix', () => { - render(); + rerender(); expect(screen.getByText(/Discord:/)).toBeInTheDocument(); }); diff --git a/client/src/components/TabsWithCounter/renderers.test.tsx b/client/src/components/TabsWithCounter/renderers.test.tsx index b6247d1ec1..924e6cc507 100644 --- a/client/src/components/TabsWithCounter/renderers.test.tsx +++ b/client/src/components/TabsWithCounter/renderers.test.tsx @@ -1,7 +1,16 @@ -/* eslint-disable testing-library/no-container, testing-library/no-node-access */ +/* eslint-disable testing-library/no-node-access */ import { render, screen } from '@testing-library/react'; import { tabRenderer, LabelItem } from './renderers'; +vi.mock('antd', () => ({ + Badge: ({ count, style }: { count: number; style: React.CSSProperties }) => ( + + {count} + + ), + Space: ({ children }: React.PropsWithChildren) => {children}, +})); + // Indirection so the testing-library lint heuristic does not treat the // `tabRenderer` result as a `render()` return value. function buildTab(item: LabelItem, activeTab?: string) { @@ -9,38 +18,26 @@ function buildTab(item: LabelItem, activeTab?: string) { } describe('tabRenderer', () => { - it('returns the key unchanged', () => { + it('returns the key and renders each count and active state', () => { expect(buildTab({ key: 'mytab', label: 'My Tab', count: 0 }).key).toBe('mytab'); - }); - it('renders just the label when count is zero', () => { - render(
    {buildTab({ key: 'a', label: 'Plain', count: 0 }).label}
    ); + render( +
    + {buildTab({ key: 'a', label: 'Plain', count: 0 }).label} + {buildTab({ key: 'b', label: 'With Count', count: 7 }).label} + {buildTab({ key: 'c', label: 'Active', count: 3 }, 'c').label} + {buildTab({ key: 'd', label: 'Inactive', count: 3 }, 'other').label} +
    , + ); expect(screen.getByText('Plain')).toBeInTheDocument(); - // no badge sup rendered - expect(document.querySelector('.ant-badge-count')).toBeNull(); - }); - - it('renders a count badge alongside the label when count > 0', () => { - const { container } = render(
    {buildTab({ key: 'a', label: 'With Count', count: 7 }).label}
    ); - expect(screen.getByText('With Count')).toBeInTheDocument(); - const badge = container.querySelector('.ant-badge-count'); - expect(badge).not.toBeNull(); - expect(badge?.textContent).toContain('7'); - }); - - it('uses processing status when the tab is active', () => { - const { container } = render(
    {buildTab({ key: 'a', label: 'Active', count: 3 }, 'a').label}
    ); - - const badge = container.querySelector('.ant-badge-count') as HTMLElement; - expect(badge).toHaveStyle({ backgroundColor: '#e6f7ff' }); - }); - - it('uses default status when the tab is not active', () => { - const { container } = render(
    {buildTab({ key: 'a', label: 'Inactive', count: 3 }, 'b').label}
    ); - - const badge = container.querySelector('.ant-badge-count') as HTMLElement; - expect(badge).toHaveStyle({ backgroundColor: '#f0f2f5' }); + expect(screen.getByText('7')).toBeInTheDocument(); + expect(screen.getByText('Active').querySelector('.ant-badge-count')).toHaveStyle({ + backgroundColor: '#e6f7ff', + }); + expect(screen.getByText('Inactive').querySelector('.ant-badge-count')).toHaveStyle({ + backgroundColor: '#f0f2f5', + }); }); }); diff --git a/client/src/components/Warning/Warning.test.tsx b/client/src/components/Warning/Warning.test.tsx index 0700048563..93c6e6e53c 100644 --- a/client/src/components/Warning/Warning.test.tsx +++ b/client/src/components/Warning/Warning.test.tsx @@ -19,18 +19,19 @@ vi.mock('@client/shared/components/PageLayout', () => ({ })); describe('Warning', () => { - it('renders the image (prefixed with /static) and a text message', () => { - render(); + it('renders text and JSX messages and forwards loading state', () => { + const { rerender } = render( + , + ); const img = screen.getByRole('img', { name: 'Sad sloth' }); expect(img).toHaveAttribute('src', '/static/svg/sloth.svg'); expect(img).toHaveAttribute('width', '175'); expect(img).toHaveAttribute('height', '175'); expect(screen.getByRole('heading', { name: 'Page not found' })).toBeInTheDocument(); - }); + expect(screen.getByTestId('page-layout')).toHaveAttribute('data-loading', 'false'); - it('renders a JSX text message', () => { - render( + rerender( { ); expect(screen.getByTestId('custom-message')).toHaveTextContent('Custom error'); - }); - - it('defaults loading to false', () => { - render(); - expect(screen.getByTestId('page-layout')).toHaveAttribute('data-loading', 'false'); - }); - it('forwards the loading flag to PageLayout', () => { - render(); + rerender(); expect(screen.getByTestId('page-layout')).toHaveAttribute('data-loading', 'true'); }); }); diff --git a/client/src/components/WelcomeCard.test.tsx b/client/src/components/WelcomeCard.test.tsx index 16a5152db9..749e2fee29 100644 --- a/client/src/components/WelcomeCard.test.tsx +++ b/client/src/components/WelcomeCard.test.tsx @@ -2,26 +2,13 @@ import { render, screen } from '@testing-library/react'; import { WelcomeCard } from './WelcomeCard'; describe('WelcomeCard', () => { - it('renders the welcome alert', () => { + it('renders the welcome content and registration links', () => { render(); expect(screen.getByText('Welcome to RS School App! Please register to continue')).toBeInTheDocument(); - }); - - it('renders the welcome sticker image', () => { - render(); const img = screen.getByRole('img', { name: 'welcome' }); expect(img).toHaveAttribute('src', 'https://cdn.rs.school/sloths/stickers/welcome/image.png'); - }); - - it('links to the student and mentor registration pages', () => { - render(); - expect(screen.getByRole('link', { name: /Register as a student/ })).toHaveAttribute('href', '/registry/student'); expect(screen.getByRole('link', { name: /Register as a mentor/ })).toHaveAttribute('href', '/registry/mentor'); - }); - - it('links to the login page to switch accounts', () => { - render(); expect(screen.getByRole('link', { name: /Log in with another GitHub account/ })).toHaveAttribute('href', '/login'); }); }); diff --git a/client/src/components/__tests__/CopyToClipboardButton.test.tsx b/client/src/components/__tests__/CopyToClipboardButton.test.tsx index 82f49043f9..cd7a7125d8 100644 --- a/client/src/components/__tests__/CopyToClipboardButton.test.tsx +++ b/client/src/components/__tests__/CopyToClipboardButton.test.tsx @@ -1,4 +1,4 @@ -import { act, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import CopyToClipboardButton from '@client/shared/components/CopyToClipboardButton'; import { useCopyToClipboard } from 'react-use'; @@ -22,32 +22,20 @@ describe('CopyToClipboardButton', () => { vi.mocked(useCopyToClipboard).mockReturnValue([{ noUserInteraction: true }, mockCopyToClipboard]); }); - it('should render with default style', () => { - render(); + it('should render, copy, and apply default and custom button types', () => { + const { rerender } = render(); const button = screen.getByTestId('copy-to-clipboard'); expect(button).toBeInTheDocument(); expect(button).toHaveClass('ant-btn-dashed'); - }); - - it('should render button with copy icon', () => { - render(); const icon = screen.getByRole('img'); expect(icon).toBeInTheDocument(); expect(icon).toHaveClass('anticon anticon-copy'); - }); - it('should copy text to clipboard on click', async () => { - render(); - const button = screen.getByTestId('copy-to-clipboard'); - - act(() => button.click()); + fireEvent.click(button); expect(mockCopyToClipboard).toHaveBeenCalledWith(TEST_VALUE); - }); - it('should render with custom button type', () => { - render(); - const button = screen.getByTestId('copy-to-clipboard'); + rerender(); expect(button).toHaveAttribute('type', 'button'); expect(button).toHaveClass('ant-btn-primary'); }); diff --git a/client/src/components/__tests__/GithubUserLink.test.tsx b/client/src/components/__tests__/GithubUserLink.test.tsx index 10efdfe519..033210e0d7 100644 --- a/client/src/components/__tests__/GithubUserLink.test.tsx +++ b/client/src/components/__tests__/GithubUserLink.test.tsx @@ -1,4 +1,4 @@ -import { act, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { GithubUserLink } from '@client/shared/components/GithubUserLink'; import { useCopyToClipboard } from 'react-use'; @@ -23,50 +23,27 @@ describe('GithubUserLink', () => { vi.mocked(useCopyToClipboard).mockReturnValue([{ noUserInteraction: true }, mockCopyToClipboard]); }); - it('should render correct links', () => { + it('should render links, labels, avatar, copy control, and hidden variants', () => { const DEFAULT_NUMBER_OF_LINKS = 2; - render(); + const { rerender } = render(); const links = screen.getAllByRole('link'); expect(links.length).toBe(DEFAULT_NUMBER_OF_LINKS); expect(links.some(l => l.getAttribute('href') === `/profile?githubId=${TEST_VALUE}`)).toBe(true); expect(links.some(l => l.getAttribute('href') === `https://github.com/${TEST_VALUE}`)).toBe(true); - }); - - it('should render provided full name', () => { - render(); - expect(screen.getByText(TEST_FULL_NAME)).toBeInTheDocument(); - }); - - it('should not render user avatar by default', () => { - render(); - const imgs = screen.getAllByRole('img'); - const avatar = imgs.some(img => img.getAttribute('src')?.includes('avatars')); - expect(avatar).toBe(true); - }); - - it('should not render user avatar if isUserIconHidden === false', () => { - render(); - const imgs = screen.getAllByRole('img'); - const avatar = imgs.some(img => img.getAttribute('src')?.includes('avatars')); - expect(avatar).toBe(false); - }); + expect(screen.getAllByRole('img').some(img => img.getAttribute('src')?.includes('avatars'))).toBe(true); - it('should render copy button be default', () => { - render(); const copyButton = screen.getByTitle('Copy GitHub name to clipboard'); expect(copyButton).toBeInTheDocument(); - }); + fireEvent.click(copyButton); + expect(mockCopyToClipboard).toHaveBeenCalledWith(TEST_VALUE); - it('should no render copy button if copyable === false', () => { - render(); - const copyButton = screen.queryByTitle('Copy GitHub name to clipboard'); - expect(copyButton).not.toBeInTheDocument(); - }); + rerender(); + expect(screen.getByText(TEST_FULL_NAME)).toBeInTheDocument(); - it('should copy value to clipboard on click', async () => { - render(); - const copyButton = screen.getByTitle('Copy GitHub name to clipboard'); - await act(async () => copyButton.click()); - expect(mockCopyToClipboard).toHaveBeenCalledWith(TEST_VALUE); + rerender(); + expect(screen.getAllByRole('img').some(img => img.getAttribute('src')?.includes('avatars'))).toBe(false); + + rerender(); + expect(screen.queryByTitle('Copy GitHub name to clipboard')).not.toBeInTheDocument(); }); }); diff --git a/client/src/components/__tests__/Rating.test.tsx b/client/src/components/__tests__/Rating.test.tsx index 08d8bbd4a6..d7e3d7ee73 100644 --- a/client/src/components/__tests__/Rating.test.tsx +++ b/client/src/components/__tests__/Rating.test.tsx @@ -2,16 +2,14 @@ import { render, screen } from '@testing-library/react'; import { Rating } from '@client/shared/components/Rating'; describe('Rating', () => { - it('renders tooltip label based on rounded integer value when tooltips provided', () => { + it('renders the appropriate label with and without tooltips', () => { const tooltips = ['terrible', 'bad', 'normal', 'good', 'wonderful']; - render(); + const { rerender } = render(); expect(screen.getByText('good')).toBeInTheDocument(); - }); - it('renders numeric value with two decimals when tooltips are not provided', () => { - render(); + rerender(); expect(screen.getByText('4.17')).toBeInTheDocument(); }); diff --git a/client/src/components/__tests__/StudenDiscrod.test.tsx b/client/src/components/__tests__/StudenDiscrod.test.tsx index 1ccc5fe89d..5509a3b15b 100644 --- a/client/src/components/__tests__/StudenDiscrod.test.tsx +++ b/client/src/components/__tests__/StudenDiscrod.test.tsx @@ -3,33 +3,23 @@ import { Discord } from '@client/api'; import { StudentDiscord } from '@client/components/StudentDiscord'; describe('StudentDiscord', () => { - test('renders a Discord user correctly', () => { + test('renders populated, empty, and prefixed Discord data', () => { const discord: Discord = { id: '123456', username: 'TestUser', discriminator: '1234', }; - render(); + const { rerender } = render(); const userLink = screen.getByText('@TestUser#1234'); expect(userLink).toBeInTheDocument(); expect(userLink).toHaveAttribute('href', 'https://discordapp.com/users/123456'); - }); - test('renders "unknown" for null Discord data', () => { - render(); + rerender(); expect(screen.getByText('unknown')).toBeInTheDocument(); - }); - - test('renders the text prefix if provided', () => { - const discord: Discord = { - id: '123456', - username: 'TestUser', - discriminator: '1234', - }; - render(); + rerender(); expect(screen.getByText('Discord user')).toBeInTheDocument(); }); }); diff --git a/client/src/components/common/CustomPopconfirm.test.tsx b/client/src/components/common/CustomPopconfirm.test.tsx index 92aaf3e419..7f7faefc9d 100644 --- a/client/src/components/common/CustomPopconfirm.test.tsx +++ b/client/src/components/common/CustomPopconfirm.test.tsx @@ -1,21 +1,10 @@ /* eslint-disable testing-library/no-node-access */ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { CustomPopconfirm } from './CustomPopconfirm'; describe('CustomPopconfirm', () => { - it('renders its trigger children', () => { - render( - - - , - ); - - expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument(); - }); - - it('opens the confirmation popup on click and fires onConfirm', async () => { - const user = userEvent.setup(); + it('renders, opens, and confirms through the default placement', async () => { + vi.useFakeTimers(); const onConfirm = vi.fn(); render( @@ -23,28 +12,34 @@ describe('CustomPopconfirm', () => { , ); - await user.click(screen.getByRole('button', { name: 'Delete' })); - - expect(await screen.findByText('Remove item?')).toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: /yes|ok/i })); - - await waitFor(() => expect(onConfirm).toHaveBeenCalled()); + const trigger = screen.getByRole('button', { name: 'Delete' }); + expect(trigger).toBeInTheDocument(); + fireEvent.click(trigger); + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); + expect(screen.getByText('Remove item?')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /yes|ok/i })); + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); + expect(onConfirm).toHaveBeenCalled(); + vi.useRealTimers(); }); it('honors an explicitly provided placement', async () => { - const user = userEvent.setup(); + vi.useFakeTimers(); render( , ); - await user.click(screen.getByRole('button', { name: 'Trigger' })); - - // popup renders with the placement reflected in the overlay class - await waitFor(() => { - expect(document.querySelector('.ant-popover-placement-bottomLeft')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Trigger' })); + await act(async () => { + await vi.runOnlyPendingTimersAsync(); }); + expect(document.querySelector('.ant-popover-placement-bottomLeft')).not.toBeNull(); + vi.useRealTimers(); }); }); diff --git a/client/src/components/withGoogleMaps.test.tsx b/client/src/components/withGoogleMaps.test.tsx index 56c327dae6..52a4f902d7 100644 --- a/client/src/components/withGoogleMaps.test.tsx +++ b/client/src/components/withGoogleMaps.test.tsx @@ -41,15 +41,6 @@ describe('withGoogleMaps', () => { expect(script?.getAttribute('src')).toContain('key=test-key'); }); - it('passes props through to the wrapped component', async () => { - mapsApiKeyRef.value = 'k'; - const { withGoogleMaps } = await import('./withGoogleMaps'); - const Wrapped = withGoogleMaps(Dummy); - - render(); - expect(screen.getByTestId('wrapped')).toHaveTextContent('forwarded'); - }); - it('does not inject the script when no api key is configured', async () => { mapsApiKeyRef.value = undefined; const { withGoogleMaps } = await import('./withGoogleMaps'); diff --git a/client/src/domain/interview.helpers.test.tsx b/client/src/domain/interview.helpers.test.tsx index e30c8f2512..a0cf60f725 100644 --- a/client/src/domain/interview.helpers.test.tsx +++ b/client/src/domain/interview.helpers.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import { StageInterviewFeedbackVerdict } from '@common/models'; import { Decision } from '@client/data/interviews/technical-screening'; import { initializeFeatures } from '@client/services/features'; @@ -16,6 +16,11 @@ import { InterviewPeriod, } from './interview'; +vi.mock('antd', () => ({ + Tag: ({ children }: { children: React.ReactNode }) => {children}, + Typography: { Text: ({ children }: { children: React.ReactNode }) => {children} }, +})); + describe('friendlyStageInterviewVerdict', () => { it.each` value | expected @@ -129,53 +134,46 @@ describe('isRegistrationNotStarted', () => { }); describe('DecisionTag', () => { - it('renders Completed (green) when no decision but status is Completed', () => { - render(); - expect(screen.getByText('Completed')).toBeInTheDocument(); - }); - - it('renders Uncompleted when no decision and not completed', () => { - render(); - expect(screen.getByText('Uncompleted')).toBeInTheDocument(); - }); - - it.each` - decision | label - ${Decision.Yes} | ${'Completed'} - ${Decision.No} | ${'Completed'} - ${Decision.Draft} | ${'Unfilled form'} - ${Decision.SeparateStudy} | ${'Separate study'} - ${Decision.MissedIgnoresMentor} | ${'Ignored mentor'} - ${Decision.MissedWithReason} | ${'Missed with a reason'} - `('renders "$label" for $decision', ({ decision, label }) => { - render(); - expect(screen.getByText(label)).toBeInTheDocument(); - }); - - it('renders Completed for the legacy noButGoodCandidate value', () => { - render(); - expect(screen.getByText('Completed')).toBeInTheDocument(); - }); - - it('renders Unfilled form for the legacy didNotDecideYet value', () => { - render(); - expect(screen.getByText('Unfilled form')).toBeInTheDocument(); - }); + it('renders every current and legacy decision label', () => { + const cases = [ + { element: , label: 'Completed' }, + { element: , label: 'Uncompleted' }, + { element: , label: 'Completed' }, + { element: , label: 'Completed' }, + { element: , label: 'Unfilled form' }, + { element: , label: 'Separate study' }, + { element: , label: 'Ignored mentor' }, + { element: , label: 'Missed with a reason' }, + { element: , label: 'Completed' }, + { element: , label: 'Unfilled form' }, + { element: , label: 'Uncompleted' }, + ]; + + render( + <> + {cases.map(({ element }, index) => ( +
    + {element} +
    + ))} + , + ); - it('renders Uncompleted for an unknown legacy decision value', () => { - render(); - expect(screen.getByText('Uncompleted')).toBeInTheDocument(); + cases.forEach(({ label }, index) => { + expect(within(screen.getByTestId(`decision-${index}`)).getByText(label)).toBeInTheDocument(); + }); }); }); describe('InterviewPeriod', () => { - it('renders the full date range by default', () => { - render(); + it('renders full and short date ranges', () => { + render( + <> + + + , + ); expect(screen.getByText('2023-03-09 - 2023-03-20')).toBeInTheDocument(); - }); - - it('renders the short date range when shortDate is set', () => { - render(); expect(screen.getByText('Mar 09 - Mar 20')).toBeInTheDocument(); }); }); diff --git a/client/src/modules/AutoTest/components/AttemptsAnswers/AttemptsAnswers.test.tsx b/client/src/modules/AutoTest/components/AttemptsAnswers/AttemptsAnswers.test.tsx index 896584fdb9..0a5c19f24b 100644 --- a/client/src/modules/AutoTest/components/AttemptsAnswers/AttemptsAnswers.test.tsx +++ b/client/src/modules/AutoTest/components/AttemptsAnswers/AttemptsAnswers.test.tsx @@ -1,5 +1,4 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { fireEvent, render, screen } from '@testing-library/react'; import { TaskVerificationAttemptDto } from '@client/api'; import AttemptsAnswers from './AttemptsAnswers'; @@ -22,33 +21,16 @@ function generateAttempt(overrides: Partial = {}): T } describe('AttemptsAnswers', () => { - it('should render the heading per attempt counting down from the total', () => { - render(); + it('renders every attempt and returns to the table', () => { + const hideAnswers = vi.fn(); + render(); expect(screen.getByRole('heading', { name: 'Attempt #2' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Attempt #1' })).toBeInTheDocument(); - }); - - it('should render the score and the formatted date for an attempt', () => { - render(); - expect(screen.getByText('Score: 7 / 10')).toBeInTheDocument(); - expect(screen.getByText('2022-10-10 12:00')).toBeInTheDocument(); - }); - - it('should render the questions of every attempt', () => { - render(); - - expect(screen.getByRole('heading', { name: 'What is 2 + 2?' })).toBeInTheDocument(); - }); - - it('should call hideAnswers when the "Back to table" button is clicked', async () => { - const user = userEvent.setup(); - const hideAnswers = vi.fn(); - render(); - - await user.click(screen.getByRole('button', { name: /back to table/i })); - + expect(screen.getAllByText('2022-10-10 12:00')).toHaveLength(2); + expect(screen.getAllByRole('heading', { name: 'What is 2 + 2?' })).toHaveLength(2); + fireEvent.click(screen.getByRole('button', { name: /back to table/i })); expect(hideAnswers).toHaveBeenCalledTimes(1); }); }); diff --git a/client/src/modules/AutoTest/components/AutoTestTaskCard/AutoTestTaskCard.test.tsx b/client/src/modules/AutoTest/components/AutoTestTaskCard/AutoTestTaskCard.test.tsx index 749ac34e6a..eb5c302acb 100644 --- a/client/src/modules/AutoTest/components/AutoTestTaskCard/AutoTestTaskCard.test.tsx +++ b/client/src/modules/AutoTest/components/AutoTestTaskCard/AutoTestTaskCard.test.tsx @@ -21,44 +21,34 @@ function renderCard(courseTask: Partial = {}) { } describe('AutoTestTaskCard', () => { - it('should render the task name', () => { - renderCard(); + it('renders task details, value fallbacks, switch states, and preview link', () => { + const { rerender } = renderCard(); expect(screen.getByText(/Self Education Task/)).toBeInTheDocument(); - }); - - it('should render the column labels and their values', () => { - renderCard(); - expect(screen.getByText('Max attempts number')).toBeInTheDocument(); expect(screen.getByText('3')).toBeInTheDocument(); expect(screen.getByText('Number of Questions')).toBeInTheDocument(); expect(screen.getByText('10')).toBeInTheDocument(); expect(screen.getByText('Threshold percentage')).toBeInTheDocument(); expect(screen.getByText('80')).toBeInTheDocument(); - }); - - it('should render a checked switch when strict attempts mode is enabled', () => { - renderCard({ strictAttemptsMode: 1 }); - expect(screen.getByRole('switch')).toBeChecked(); - }); - - it('should render an unchecked switch when strict attempts mode is disabled', () => { - renderCard({ strictAttemptsMode: null }); + rerender( + , + ); expect(screen.getByRole('switch')).not.toBeChecked(); - }); - - it('should render a dash placeholder when numeric values are missing', () => { - renderCard({ maxAttemptsNumber: null, numberOfQuestions: null, thresholdPercentage: null }); - expect(screen.getAllByText('–')).toHaveLength(3); - }); - - it('should link the preview button to the admin task page', () => { - renderCard({ id: 99 }); - const link = screen.getByRole('link', { name: /preview task/i }); expect(link).toHaveAttribute('href', '/admin/auto-test-task/99'); }); diff --git a/client/src/modules/AutoTest/components/Coding/Coding.test.tsx b/client/src/modules/AutoTest/components/Coding/Coding.test.tsx index f7213f3fca..513482a12c 100644 --- a/client/src/modules/AutoTest/components/Coding/Coding.test.tsx +++ b/client/src/modules/AutoTest/components/Coding/Coding.test.tsx @@ -26,21 +26,31 @@ function renderCoding(type: CourseTaskDetailedDtoTypeEnum) { } describe('Coding', () => { - it.each` - type | text - ${CourseTaskDetailedDtoTypeEnum.Codewars} | ${/Please use the next username in your/i} - ${CourseTaskDetailedDtoTypeEnum.Codewars} | ${/codewars profile/i} - ${CourseTaskDetailedDtoTypeEnum.Jstask} | ${/Tests run on Node.js version 22. Please make sure your solution works on Node.js version 22./i} - ${CourseTaskDetailedDtoTypeEnum.Jstask} | ${/The system will run tests in the following repository and will update the score based on the result:/i} - ${CourseTaskDetailedDtoTypeEnum.Jstask} | ${/https:\/\/github.com\/github-id\/github-repo-name/i} - ${CourseTaskDetailedDtoTypeEnum.Kotlintask} | ${/The system will run tests in the following repository and will update the score based on the result:/i} - ${CourseTaskDetailedDtoTypeEnum.Kotlintask} | ${/https:\/\/github.com\/github-id\/github-repo-name/i} - `( - 'should render $type task with $text', - async ({ type, text }: { type: CourseTaskDetailedDtoTypeEnum; text: RegExp | string }) => { - renderCoding(type); + it.each([ + { + type: CourseTaskDetailedDtoTypeEnum.Codewars, + texts: [/Please use the next username in your/i, /codewars profile/i], + }, + { + type: CourseTaskDetailedDtoTypeEnum.Jstask, + texts: [ + /Tests run on Node.js version 22. Please make sure your solution works on Node.js version 22./i, + /The system will run tests in the following repository and will update the score based on the result:/i, + /https:\/\/github.com\/github-id\/github-repo-name/i, + ], + }, + { + type: CourseTaskDetailedDtoTypeEnum.Kotlintask, + texts: [ + /The system will run tests in the following repository and will update the score based on the result:/i, + /https:\/\/github.com\/github-id\/github-repo-name/i, + ], + }, + ])('should render $type task instructions', async ({ type, texts }) => { + renderCoding(type); + for (const text of texts) { expect(await screen.findByText(text)).toBeInTheDocument(); - }, - ); + } + }); }); diff --git a/client/src/modules/AutoTest/components/Exercise/Exercise.test.tsx b/client/src/modules/AutoTest/components/Exercise/Exercise.test.tsx index e7af780de5..a77d2451dd 100644 --- a/client/src/modules/AutoTest/components/Exercise/Exercise.test.tsx +++ b/client/src/modules/AutoTest/components/Exercise/Exercise.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { Form } from 'antd'; import { CheckerEnum, CourseTaskDetailedDtoTypeEnum } from '@client/api'; import { CourseTaskVerifications } from '@client/modules/AutoTest/types'; @@ -45,18 +45,6 @@ describe('Exercise', () => { changeMock.mockClear(); }); - it('should render the Coding exercise for a jstask', () => { - renderExercise(CourseTaskDetailedDtoTypeEnum.Jstask); - - expect(screen.getByText(/will run tests in the following repository/i)).toBeInTheDocument(); - }); - - it('should render the SelfEducation exercise', () => { - renderExercise(CourseTaskDetailedDtoTypeEnum.Selfeducation); - - expect(screen.getByRole('heading', { name: /Q1/ })).toBeInTheDocument(); - }); - it('should render the Jupyter upload exercise for an ipynb task', () => { renderExercise(CourseTaskDetailedDtoTypeEnum.Ipynb); @@ -69,15 +57,12 @@ describe('Exercise', () => { expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument(); }); - it('should render the submit button', () => { + it('should call submit when the form is submitted for a coding task', async () => { + const user = setupUser(); renderExercise(CourseTaskDetailedDtoTypeEnum.Jstask); + expect(screen.getByText(/will run tests in the following repository/i)).toBeInTheDocument(); expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument(); - }); - - it('should call submit when the form is submitted for a coding task', async () => { - const user = userEvent.setup(); - renderExercise(CourseTaskDetailedDtoTypeEnum.Jstask); await user.click(screen.getByRole('button', { name: /submit/i })); @@ -85,43 +70,35 @@ describe('Exercise', () => { }); it('should call change when the self-education answer is selected', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderExercise(CourseTaskDetailedDtoTypeEnum.Selfeducation); + expect(screen.getByRole('heading', { name: /Q1/ })).toBeInTheDocument(); + await user.click(screen.getAllByRole('radio')[0] as HTMLElement); await waitFor(() => expect(changeMock).toHaveBeenCalled()); + expect( + screen.queryByText(/Form has validation errors! Check that all required fields are filled!/i), + ).not.toBeInTheDocument(); }); - it('should surface the validation-error tooltip when submitting the self-education form with no answer', async () => { - const user = userEvent.setup(); + it('should show the missing-answer error and clear it after a valid answer', async () => { + const user = setupUser(); renderExercise(CourseTaskDetailedDtoTypeEnum.Selfeducation); await user.click(screen.getByRole('button', { name: /submit/i })); // onFinishFailed fires because the required answer field is empty. - expect( - await screen.findByText(/Form has validation errors! Check that all required fields are filled!/i), - ).toBeInTheDocument(); - }); - - it('should clear the validation error once every required field is filled and valid', async () => { - const user = userEvent.setup(); - renderExercise(CourseTaskDetailedDtoTypeEnum.Selfeducation); - - // pick a valid answer so all watched values become truthy and validateFields resolves, - // hitting the success callback that sets validationError back to false. - await user.click(screen.getAllByRole('radio')[0] as HTMLElement); + const error = await screen.findByText(/Form has validation errors! Check that all required fields are filled!/i); + await waitFor(() => expect(error).toBeVisible()); - await waitFor(() => { - expect( - screen.queryByText(/Form has validation errors! Check that all required fields are filled!/i), - ).not.toBeInTheDocument(); - }); + await user.click(screen.getAllByRole('radio')[1] as HTMLElement); + await waitFor(() => expect(error).not.toBeVisible()); }); it('should set a validation error when a watched field is truthy but fails validation', async () => { - const user = userEvent.setup(); + const user = setupUser(); // Checkbox.Group value is an array; toggling on then off leaves `[]`, which is // truthy (passing the every(Boolean) guard) yet fails the `required` rule — // exercising the validateFields rejection callback. diff --git a/client/src/modules/AutoTest/components/JupyterNotebook/JupyterNotebook.test.tsx b/client/src/modules/AutoTest/components/JupyterNotebook/JupyterNotebook.test.tsx index c19d5102e5..cb520bd884 100644 --- a/client/src/modules/AutoTest/components/JupyterNotebook/JupyterNotebook.test.tsx +++ b/client/src/modules/AutoTest/components/JupyterNotebook/JupyterNotebook.test.tsx @@ -1,5 +1,5 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { act, render, screen } from '@testing-library/react'; +import { setupUser } from '@client/__tests__/setupUser'; import type { UploadFile, UploadProps } from 'antd'; import { Button, Form } from 'antd'; import JupyterNotebook from './JupyterNotebook'; @@ -35,31 +35,21 @@ describe('JupyterNotebook', () => { capturedFileList = undefined; }); - it('should render the upload button', () => { - renderJupyterNotebook(); - - expect(screen.getByRole('button', { name: /select jupyter notebook/i })).toBeInTheDocument(); - }); - - it('should show the required validation message when submitting without a file', async () => { - const user = userEvent.setup(); + it('renders, validates, and stores a selected notebook', async () => { + const user = setupUser(); const onFinish = vi.fn(); renderJupyterNotebook(onFinish); + expect(screen.getByRole('button', { name: /select jupyter notebook/i })).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: /submit/i })); - expect(await screen.findByText('Please upload the file')).toBeInTheDocument(); expect(onFinish).not.toHaveBeenCalled(); - }); - - it('should add the chosen file to the upload list on change', async () => { - renderJupyterNotebook(); const file = { uid: '1', name: 'notebook.ipynb' } as UploadFile; - // Simulate antd firing onChange with a selected file. - capturedOnChange?.({ file, fileList: [file] } as Parameters>[0]); + await act(async () => { + capturedOnChange?.({ file, fileList: [file] } as Parameters>[0]); + }); - // The component stores the file and passes it back as the Upload fileList. expect(await screen.findByText('Select Jupyter Notebook')).toBeInTheDocument(); expect(capturedFileList?.[0]?.name).toBe('notebook.ipynb'); }); diff --git a/client/src/modules/AutoTest/components/Question/Question.test.tsx b/client/src/modules/AutoTest/components/Question/Question.test.tsx index 89981f0ffe..2b37ad9429 100644 --- a/client/src/modules/AutoTest/components/Question/Question.test.tsx +++ b/client/src/modules/AutoTest/components/Question/Question.test.tsx @@ -22,23 +22,20 @@ function renderQuestion(question: Partial { - it('should render the question title and its answers', () => { + it('should render the question title, answers and radio inputs', () => { renderQuestion({}); expect(screen.getByRole('heading', { name: 'What is 2 + 2?' })).toBeInTheDocument(); expect(screen.getByText('3')).toBeInTheDocument(); expect(screen.getByText('4')).toBeInTheDocument(); expect(screen.getByText('5')).toBeInTheDocument(); + expect(screen.getAllByRole('radio')).toHaveLength(3); }); - it.each` - multiple | role - ${false} | ${'radio'} - ${true} | ${'checkbox'} - `('should render $role inputs when multiple is $multiple', ({ multiple, role }) => { - renderQuestion({ multiple }); + it('should render checkbox inputs when multiple is true', () => { + renderQuestion({ multiple: true }); - expect(screen.getAllByRole(role)).toHaveLength(3); + expect(screen.getAllByRole('checkbox')).toHaveLength(3); }); it('should mark the selected answer as checked', () => { diff --git a/client/src/modules/AutoTest/components/StatusTabs/StatusTabs.test.tsx b/client/src/modules/AutoTest/components/StatusTabs/StatusTabs.test.tsx index 50527bbe53..1550f8124a 100644 --- a/client/src/modules/AutoTest/components/StatusTabs/StatusTabs.test.tsx +++ b/client/src/modules/AutoTest/components/StatusTabs/StatusTabs.test.tsx @@ -5,51 +5,27 @@ import { CourseTaskStatus } from '@client/modules/AutoTest/types'; describe('StatusTabs', () => { const onTabChangeMock = vi.fn(); - it('should render status tabs', () => { - const statuses = generateStatuses(); - - render(); + it('renders counts, handles empty statuses, and reports tab changes', () => { + const statuses = generateStatuses(undefined, { + [CourseTaskStatus.Available]: 2, + [CourseTaskStatus.Missed]: 3, + [CourseTaskStatus.Done]: 4, + }); + const { rerender } = render(); expect(screen.getAllByRole('tab')).toHaveLength(3); - }); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.getByText('4')).toBeInTheDocument(); - it('should render status tabs when statuses were not provided', () => { - render(); + fireEvent.click(screen.getByText(new RegExp(CourseTaskStatus.Missed, 'i'))); + fireEvent.click(screen.getByText(new RegExp(CourseTaskStatus.Done, 'i'))); + expect(onTabChangeMock).toHaveBeenCalledWith(CourseTaskStatus.Missed); + expect(onTabChangeMock).toHaveBeenCalledWith(CourseTaskStatus.Done); + rerender(); expect(screen.getAllByRole('tab')).toHaveLength(3); }); - - it.each` - status | count - ${CourseTaskStatus.Available} | ${2} - ${CourseTaskStatus.Missed} | ${3} - ${CourseTaskStatus.Done} | ${4} - `( - 'should render badge with count of $count for "$status" tab', - ({ status, count }: { status: string; count: number }) => { - const statuses = generateStatuses(undefined, { [status]: count }); - - render(); - - expect(screen.getByText(count)).toBeInTheDocument(); - }, - ); - - describe('when active tab was changed', () => { - it.each` - tabName - ${CourseTaskStatus.Missed} - ${CourseTaskStatus.Done} - `('should call onTabChange with tab name "$tabName"', ({ tabName }: { tabName: string }) => { - const statuses = generateStatuses(undefined, { [tabName]: 2 }); - render(); - - const selectedTab = screen.getByText(new RegExp(tabName, 'i')); - fireEvent.click(selectedTab); - - expect(onTabChangeMock).toHaveBeenCalledWith(tabName); - }); - }); }); function generateStatuses(count = 3, statusTypeAndCount: Record | null = null): CourseTaskStatus[] { diff --git a/client/src/modules/AutoTest/components/TaskCard/TaskCard.test.tsx b/client/src/modules/AutoTest/components/TaskCard/TaskCard.test.tsx index c85db4d0f7..cadc3ccd8a 100644 --- a/client/src/modules/AutoTest/components/TaskCard/TaskCard.test.tsx +++ b/client/src/modules/AutoTest/components/TaskCard/TaskCard.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { useRouter } from 'next/router'; import { TaskCard } from '..'; import { CheckerEnum } from '@client/api'; @@ -10,22 +10,6 @@ import { getAutoTestTaskRoute } from '@client/services/routes'; const COURSE_MOCK = { alias: 'course-alias', id: 100 } as Course; describe('TaskCard', () => { - it.each` - prop | value - ${'task name'} | ${'Course Task'} - ${'start date'} | ${'Sep 10'} - ${'end date'} | ${'Oct 10'} - ${'state'} | ${'Missed'} - ${'attempts count'} | ${'2 left'} - ${'score'} | ${'–'} - `('should render $prop', ({ value }: { value: string }) => { - const courseTask = generateCourseTask(2); - render(); - - const element = screen.getByText(new RegExp(value, 'i')); - expect(element).toBeInTheDocument(); - }); - it('should render attempts count as "No limits" when max attempts was not provided', () => { const courseTask = generateCourseTask(); render(); @@ -58,12 +42,16 @@ describe('TaskCard', () => { }); it('should navigate to the task route when "Open Task" is clicked', async () => { - const user = userEvent.setup(); + const user = setupUser(); const push = vi.fn(); (useRouter as unknown as ReturnType).mockReturnValue({ push }); const courseTask = generateCourseTask(2); render(); + for (const value of ['Course Task', 'Sep 10', 'Oct 10', 'Missed', '2 left', '–']) { + expect(screen.getByText(new RegExp(value, 'i'))).toBeInTheDocument(); + } + await user.click(screen.getByRole('button', { name: /open task/i })); expect(push).toHaveBeenCalledWith(getAutoTestTaskRoute(COURSE_MOCK.alias, courseTask.id)); @@ -77,7 +65,7 @@ describe('TaskCard', () => { }); it('enables "Done Task" and calls onMarkAsDone with the task id when the score reaches the threshold', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onMarkAsDone = vi.fn(); const courseTask = generateCourseTask(2, passingScore()); render(); diff --git a/client/src/modules/AutoTest/components/TaskCardColumn/TaskCardColumn.test.tsx b/client/src/modules/AutoTest/components/TaskCardColumn/TaskCardColumn.test.tsx index 57fdf01057..62e4a9dbc9 100644 --- a/client/src/modules/AutoTest/components/TaskCardColumn/TaskCardColumn.test.tsx +++ b/client/src/modules/AutoTest/components/TaskCardColumn/TaskCardColumn.test.tsx @@ -1,21 +1,22 @@ import { render, screen } from '@testing-library/react'; import TaskCardColumn from './TaskCardColumn'; +vi.mock('antd', () => ({ + Space: ({ children }: React.PropsWithChildren) =>
    {children}
    , + Typography: { Text: ({ children }: React.PropsWithChildren) => {children} }, +})); + describe('TaskCardColumn', () => { - it('should render the label', () => { - render(); + it('renders the label and each supported value type', () => { + const { rerender } = render(); expect(screen.getByText('Max attempts number')).toBeInTheDocument(); - }); - - it('should render a primitive value', () => { - render(); + expect(screen.getByText('5')).toBeInTheDocument(); + rerender(); expect(screen.getByText('90')).toBeInTheDocument(); - }); - it('should render a node value', () => { - render(enabled
    } />); + rerender(enabled} />); expect(screen.getByText('enabled')).toBeInTheDocument(); }); diff --git a/client/src/modules/AutoTest/components/TaskDeadlineDate/TaskDeadlineDate.test.tsx b/client/src/modules/AutoTest/components/TaskDeadlineDate/TaskDeadlineDate.test.tsx index 5745dbf3fb..24b5f0c84c 100644 --- a/client/src/modules/AutoTest/components/TaskDeadlineDate/TaskDeadlineDate.test.tsx +++ b/client/src/modules/AutoTest/components/TaskDeadlineDate/TaskDeadlineDate.test.tsx @@ -1,28 +1,35 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import dayjs from 'dayjs'; import { TaskDeadlineDate, TaskDeadlineDateProps } from '..'; import { CourseTaskState } from '@client/modules/AutoTest/types'; describe('TaskDeadlineDate', () => { - it.each` - type | when | state | daysCount - ${'secondary'} | ${'end date is not passed'} | ${CourseTaskState.Uncompleted} | ${9} - ${'secondary'} | ${'end date is not passed'} | ${CourseTaskState.Completed} | ${9} - ${'danger'} | ${'end date passed'} | ${CourseTaskState.Missed} | ${1} - `( - 'should render date as "$type" when $when', - ({ type, state, daysCount }: { type: string; state: CourseTaskState; daysCount: number }) => { - const date = dayjs(); - const startDate = date.subtract(2, 'd'); - const endDate = date.add(daysCount, 'd'); - const props: TaskDeadlineDateProps = { - startDate: startDate.format(), - endDate: endDate.format(), - state, - }; - render(); + it('renders future and missed deadlines with the expected emphasis', () => { + const date = dayjs(); + const startDate = date.subtract(2, 'd').format(); + const cases = [ + { state: CourseTaskState.Uncompleted, endDate: date.add(9, 'd'), type: 'secondary' }, + { state: CourseTaskState.Completed, endDate: date.add(9, 'd'), type: 'secondary' }, + { state: CourseTaskState.Missed, endDate: date.add(1, 'd'), type: 'danger' }, + ]; - expect(screen.getByText(new RegExp(endDate.format('MMM DD'), 'i'))).toHaveClass(`ant-typography-${type}`); - }, - ); + render( + <> + {cases.map(({ state, endDate }, index) => { + const props: TaskDeadlineDateProps = { startDate, endDate: endDate.format(), state }; + return ( +
    + +
    + ); + })} + , + ); + + cases.forEach(({ endDate, type }, index) => { + expect( + within(screen.getByTestId(`deadline-${index}`)).getByText(new RegExp(endDate.format('MMM DD'), 'i')), + ).toHaveClass(`ant-typography-${type}`); + }); + }); }); diff --git a/client/src/modules/AutoTest/components/TaskDescription/TaskDescription.test.tsx b/client/src/modules/AutoTest/components/TaskDescription/TaskDescription.test.tsx index c510e17db8..7e6a5abf11 100644 --- a/client/src/modules/AutoTest/components/TaskDescription/TaskDescription.test.tsx +++ b/client/src/modules/AutoTest/components/TaskDescription/TaskDescription.test.tsx @@ -3,8 +3,8 @@ import { CheckerEnum } from '@client/api'; import { CourseTaskState, CourseTaskVerifications } from '@client/modules/AutoTest/types'; import TaskDescription from './TaskDescription'; -function renderTaskDescription(courseTask: Partial = {}) { - return render( +function taskDescription(courseTask: Partial = {}) { + return ( = {} ...courseTask, } as CourseTaskVerifications } - />, + /> ); } describe('TaskDescription', () => { - it('should render the task name', () => { - renderTaskDescription(); + it('renders task details and hides the description when its URL is empty', () => { + const { rerender } = render(taskDescription()); expect(screen.getByText('Course Task')).toBeInTheDocument(); - }); - - it('should render the back link to the auto-test route', () => { - renderTaskDescription(); const [backLink] = screen.getAllByRole('link'); expect(backLink).toHaveAttribute('href', expect.stringContaining('my-course')); - }); - it('should render the description link when descriptionUrl is provided', () => { - renderTaskDescription({ descriptionUrl: 'https://example.com/task' }); - - const link = screen.getByRole('link', { name: 'https://example.com/task' }); - expect(link).toHaveAttribute('href', 'https://example.com/task'); + const link = screen.getByRole('link', { name: 'https://example.com/description' }); + expect(link).toHaveAttribute('href', 'https://example.com/description'); expect(link).toHaveAttribute('target', '_blank'); expect(screen.getByText('Description:')).toBeInTheDocument(); - }); - - it('should not render the description block when descriptionUrl is empty', () => { - renderTaskDescription({ descriptionUrl: '' }); - - expect(screen.queryByText('Description:')).not.toBeInTheDocument(); - }); - - it('should render the deadline dates in the YYYY-MM-DD HH:mm format', () => { - renderTaskDescription(); expect(screen.getByText(/2022-09-10/)).toBeInTheDocument(); expect(screen.getByText(/2022-10-10/)).toBeInTheDocument(); + + rerender(taskDescription({ descriptionUrl: '' })); + expect(screen.queryByText('Description:')).not.toBeInTheDocument(); }); }); diff --git a/client/src/modules/AutoTest/components/VerificationInformation/VerificationInformation.test.tsx b/client/src/modules/AutoTest/components/VerificationInformation/VerificationInformation.test.tsx index cfb09e5fc8..14fcbde8fb 100644 --- a/client/src/modules/AutoTest/components/VerificationInformation/VerificationInformation.test.tsx +++ b/client/src/modules/AutoTest/components/VerificationInformation/VerificationInformation.test.tsx @@ -50,7 +50,6 @@ describe('VerificationInformation', () => { ${CourseTaskDetailedDtoTypeEnum.Cvmarkdown} ${CourseTaskDetailedDtoTypeEnum.Htmltask} ${CourseTaskDetailedDtoTypeEnum.Ipynb} - ${CourseTaskDetailedDtoTypeEnum.Jstask} ${CourseTaskDetailedDtoTypeEnum.Kotlintask} ${CourseTaskDetailedDtoTypeEnum.Objctask} `( @@ -77,6 +76,7 @@ describe('VerificationInformation', () => { const refreshButton = screen.getByRole('button', { name: /refresh/i }); expect(startTaskButton).toBeInTheDocument(); expect(refreshButton).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /show answers/i })).not.toBeInTheDocument(); }); it('should not render start and refresh buttons if table is not visible', () => { @@ -104,7 +104,7 @@ describe('VerificationInformation', () => { renderVerificationInformation({ type: CourseTaskDetailedDtoTypeEnum.Selfeducation, studentEndDate: '2000-01-01 12:00', - verifications: [{ score: 50 }] as CourseTaskVerifications['verifications'], + verifications: [{ id: 1, score: 50 }] as CourseTaskVerifications['verifications'], }); const answersButton = screen.getByRole('button', { name: /show answers/i }); diff --git a/client/src/modules/AutoTest/components/VerificationsTable/VerificationsTable.test.tsx b/client/src/modules/AutoTest/components/VerificationsTable/VerificationsTable.test.tsx index feb3cfea49..db669d6894 100644 --- a/client/src/modules/AutoTest/components/VerificationsTable/VerificationsTable.test.tsx +++ b/client/src/modules/AutoTest/components/VerificationsTable/VerificationsTable.test.tsx @@ -23,20 +23,11 @@ const PROPS_MOCK: VerificationsTableProps = { }; describe('VerificationsTable', () => { - it.each` - item - ${'Date / Time'} - ${'Score / Max'} - ${'Accuracy'} - ${'Details'} - ${'20 / 100'} - ${'40%'} - ${'Your accuracy: 40%.'} - `('should render $item', ({ item }: { item: string }) => { + it('should render table headers, score, accuracy and details', () => { render(); - const element = screen.getByText(item); - expect(element).toBeInTheDocument(); + const items = ['Date / Time', 'Score / Max', 'Accuracy', 'Details', '20 / 100', '40%', 'Your accuracy: 40%.']; + items.forEach(item => expect(screen.getByText(item)).toBeInTheDocument()); }); it('should render metadata when it was provided', () => { diff --git a/client/src/modules/AutoTest/hooks/useCourseTaskSubmit/useCourseTaskSubmit.test.ts b/client/src/modules/AutoTest/hooks/useCourseTaskSubmit/useCourseTaskSubmit.test.ts index 8ce9266b46..a851848e58 100644 --- a/client/src/modules/AutoTest/hooks/useCourseTaskSubmit/useCourseTaskSubmit.test.ts +++ b/client/src/modules/AutoTest/hooks/useCourseTaskSubmit/useCourseTaskSubmit.test.ts @@ -2,7 +2,7 @@ import { renderHook } from '@testing-library/react'; import { CourseTaskDetailedDtoTypeEnum, CourseTaskVerificationsApi } from '@client/api'; import { IpynbFile, useCourseTaskSubmit } from './useCourseTaskSubmit'; import { FilesService } from '@client/services/files'; -import { act } from 'react-dom/test-utils'; +import { act } from 'react'; import { AxiosError } from 'axios'; import * as UserUtils from '@client/domain/user'; import { CourseTaskVerifications } from '@client/modules/AutoTest/types'; diff --git a/client/src/modules/AutoTest/hooks/useCourseTaskVerifications/useCourseTaskVerifications.test.ts b/client/src/modules/AutoTest/hooks/useCourseTaskVerifications/useCourseTaskVerifications.test.ts index e92c5fcd17..c122de771b 100644 --- a/client/src/modules/AutoTest/hooks/useCourseTaskVerifications/useCourseTaskVerifications.test.ts +++ b/client/src/modules/AutoTest/hooks/useCourseTaskVerifications/useCourseTaskVerifications.test.ts @@ -78,7 +78,7 @@ describe('useCourseTaskVerifications', () => { expect(result.current.tasks?.[0]?.verifications).toEqual([{ id: 11, courseTaskId: 1, score: 90 }]); }); - it('toggles isExerciseVisible via startTask and finishTask', async () => { + it('toggles exercise visibility and reloads verifications', async () => { getCourseTasksDetailed.mockResolvedValueOnce({ data: [] }); const { result } = renderHook(() => useCourseTaskVerifications(42)); @@ -90,39 +90,19 @@ describe('useCourseTaskVerifications', () => { act(() => result.current.finishTask()); expect(result.current.isExerciseVisible).toBe(false); - // finishTask reloads the verifications request await waitFor(() => expect(getTaskVerifications).toHaveBeenCalledTimes(2)); - }); - - it('reloads the verifications when reload is called', async () => { - getCourseTasksDetailed.mockResolvedValueOnce({ data: [] }); - const { result } = renderHook(() => useCourseTaskVerifications(42)); - - await waitFor(() => expect(getTaskVerifications).toHaveBeenCalledTimes(1)); act(() => result.current.reload()); - await waitFor(() => expect(getTaskVerifications).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(getTaskVerifications).toHaveBeenCalledTimes(3)); }); - it('marks a task as done, moving it to the Done status and persisting the id per course', async () => { + it('marks a task done and does not duplicate its persisted id', async () => { getCourseTasksDetailed.mockResolvedValueOnce({ data: [detailedTask({ id: 1, name: 'Available' })] }); const { result } = renderHook(() => useCourseTaskVerifications(42)); await waitFor(() => expect(result.current.tasks).toHaveLength(1)); expect(result.current.tasks?.[0]?.status).toBe('Available'); - act(() => result.current.markTaskAsDone(1)); - - await waitFor(() => expect(result.current.tasks?.[0]?.status).toBe('Done')); - expect(JSON.parse(localStorage.getItem('autotest-done-tasks-42') ?? '[]')).toContain(1); - }); - - it('does not duplicate an id that was already marked as done', async () => { - getCourseTasksDetailed.mockResolvedValueOnce({ data: [detailedTask({ id: 1 })] }); - const { result } = renderHook(() => useCourseTaskVerifications(42)); - - await waitFor(() => expect(result.current.tasks).toHaveLength(1)); - act(() => result.current.markTaskAsDone(1)); act(() => result.current.markTaskAsDone(1)); diff --git a/client/src/modules/AutoTest/hooks/useVerificationsAnswers/useVerificationsAnswers.test.ts b/client/src/modules/AutoTest/hooks/useVerificationsAnswers/useVerificationsAnswers.test.ts index e10d095c9f..82b8015cc1 100644 --- a/client/src/modules/AutoTest/hooks/useVerificationsAnswers/useVerificationsAnswers.test.ts +++ b/client/src/modules/AutoTest/hooks/useVerificationsAnswers/useVerificationsAnswers.test.ts @@ -1,4 +1,4 @@ -import { act, renderHook, waitFor } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react'; import { message } from 'antd'; import { useVerificationsAnswers } from './useVerificationsAnswers'; @@ -35,7 +35,7 @@ describe('useVerificationsAnswers', () => { }); expect(getAnswers).toHaveBeenCalledWith(10, 20); - await waitFor(() => expect(result.current.answers).toEqual(answers)); + expect(result.current.answers).toEqual(answers); }); it('clears the answers when hideAnswers is called', async () => { @@ -45,7 +45,7 @@ describe('useVerificationsAnswers', () => { await act(async () => { await result.current.showAnswers(); }); - await waitFor(() => expect(result.current.answers).not.toBeNull()); + expect(result.current.answers).not.toBeNull(); act(() => result.current.hideAnswers()); expect(result.current.answers).toBeNull(); diff --git a/client/src/modules/AutoTest/pages/AutoTests/AutoTests.test.tsx b/client/src/modules/AutoTest/pages/AutoTests/AutoTests.test.tsx index f2770b458b..e2bf72d0d6 100644 --- a/client/src/modules/AutoTest/pages/AutoTests/AutoTests.test.tsx +++ b/client/src/modules/AutoTest/pages/AutoTests/AutoTests.test.tsx @@ -1,5 +1,5 @@ import { render, screen, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { ReactNode } from 'react'; import { CheckerEnum } from '@client/api'; import { CourseTaskState, CourseTaskStatus, CourseTaskVerifications } from '@client/modules/AutoTest/types'; @@ -63,29 +63,17 @@ describe('AutoTests page', () => { }); }); - it('should render the page title', () => { + it('should render initial tabs and tasks, then switch the visible tasks', async () => { + const user = setupUser(); render(); expect(screen.getByRole('heading', { name: 'Auto-tests' })).toBeInTheDocument(); - }); - - it('should render the status tabs', () => { - render(); - expect(screen.getAllByRole('tab')).toHaveLength(3); - }); - - it('should show only the tasks of the active (Available) tab by default', () => { - render(); - expect(screen.getByText('Available Task')).toBeInTheDocument(); expect(screen.queryByText('Missed Task')).not.toBeInTheDocument(); expect(screen.queryByText('Completed Task')).not.toBeInTheDocument(); - }); - - it('should switch the visible tasks when another tab is selected', async () => { - const user = userEvent.setup(); - render(); + const availableTab = screen.getByRole('tab', { name: /available/i }); + expect(within(availableTab).getByText('1')).toBeInTheDocument(); const missedTab = screen.getByRole('tab', { name: /missed/i }); await user.click(missedTab); @@ -95,7 +83,7 @@ describe('AutoTests page', () => { }); it('calls markTaskAsDone with the task id when "Done Task" is clicked on the Available tab', async () => { - const user = userEvent.setup(); + const user = setupUser(); const passedTask = { ...task(1, 'Available Task', CourseTaskStatus.Available), verifications: [{ score: 90 }], @@ -117,13 +105,6 @@ describe('AutoTests page', () => { expect(screen.getAllByRole('tab')).toHaveLength(3); }); - it('should count statuses in the tab badges', () => { - render(); - - const availableTab = screen.getByRole('tab', { name: /available/i }); - expect(within(availableTab).getByText('1')).toBeInTheDocument(); - }); - it('should fall back to empty lists when the hook returns no tasks (undefined)', () => { // exercises the `|| []` fallbacks on `tasks?.map` / `tasks?.filter` useCourseTaskVerifications.mockReturnValue({ tasks: undefined }); diff --git a/client/src/modules/AutoTest/pages/Task/Task.test.tsx b/client/src/modules/AutoTest/pages/Task/Task.test.tsx index 7591c16cbe..142cb0793a 100644 --- a/client/src/modules/AutoTest/pages/Task/Task.test.tsx +++ b/client/src/modules/AutoTest/pages/Task/Task.test.tsx @@ -101,15 +101,10 @@ describe('Task page', () => { expect(container).toBeEmptyDOMElement(); }); - it('should render the task description with the task name', () => { + it('renders the task description and verification actions', () => { render(); expect(screen.getByText('My Auto Test')).toBeInTheDocument(); - }); - - it('should render the verification information (start/refresh) when the table is visible', () => { - render(); - expect(screen.getByRole('button', { name: /start task/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /refresh/i })).toBeInTheDocument(); }); diff --git a/client/src/modules/Contributor/components/ContributorModal.test.tsx b/client/src/modules/Contributor/components/ContributorModal.test.tsx index 7bcddc8002..d1127c4a79 100644 --- a/client/src/modules/Contributor/components/ContributorModal.test.tsx +++ b/client/src/modules/Contributor/components/ContributorModal.test.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { message } from 'antd'; +import { setupUser } from '@client/__tests__/setupUser'; import { ContributorModal } from './ContributorModal'; // --- Boundary mocks -------------------------------------------------------- @@ -13,8 +13,7 @@ const { getContributor, createContributor, updateContributor, searchUsers } = vi searchUsers: vi.fn(), })); -vi.mock('@client/api', async () => ({ - ...(await vi.importActual('@client/api')), +vi.mock('@client/api', () => ({ ContributorsApi: function ContributorsApi() { return { getContributor, createContributor, updateContributor }; }, @@ -45,25 +44,8 @@ describe('', () => { }); }); - it('renders the "Add Contributor" title and empty fields when creating', async () => { - render(); - - expect(await screen.findByText('Add Contributor')).toBeInTheDocument(); - // No fetch when there is no id. - expect(getContributor).not.toHaveBeenCalled(); - expect(await screen.findByLabelText('Description')).toHaveValue(''); - }); - - it('fetches and prefills the form when editing', async () => { - render(); - - expect(await screen.findByText('Edit Contributor')).toBeInTheDocument(); - await waitFor(() => expect(getContributor).toHaveBeenCalledWith(7)); - await waitFor(() => expect(screen.getByLabelText('Description')).toHaveValue('Existing description')); - }); - it('creates a contributor from the typed values', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onClose = vi.fn(); render(); @@ -79,14 +61,16 @@ describe('', () => { }); it('updates the existing contributor by id when editing', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onClose = vi.fn(); render(); + expect(await screen.findByText('Edit Contributor')).toBeInTheDocument(); + await waitFor(() => expect(getContributor).toHaveBeenCalledWith(7)); await waitFor(() => expect(screen.getByLabelText('Description')).toHaveValue('Existing description')); const desc = screen.getByLabelText('Description'); await user.clear(desc); - await user.type(desc, 'Updated description'); + await user.type(desc, 'Updated description', { skipClick: true }); await user.click(screen.getByRole('button', { name: /save/i })); await waitFor(() => @@ -96,7 +80,7 @@ describe('', () => { }); it('shows an error and stays open when validation fails (no user)', async () => { - const user = userEvent.setup(); + const user = setupUser(); const errorSpy = vi.spyOn(message, 'error').mockImplementation(() => ({}) as never); const onClose = vi.fn(); render(); @@ -112,11 +96,13 @@ describe('', () => { }); it('calls onClose when Cancel is clicked', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onClose = vi.fn(); render(); - await screen.findByText('Add Contributor'); + expect(await screen.findByText('Add Contributor')).toBeInTheDocument(); + expect(getContributor).not.toHaveBeenCalled(); + expect(screen.getByLabelText('Description')).toHaveValue(''); await user.click(screen.getByRole('button', { name: /cancel/i })); expect(onClose).toHaveBeenCalled(); diff --git a/client/src/modules/Contributor/components/ContributorsTable.test.tsx b/client/src/modules/Contributor/components/ContributorsTable.test.tsx index 4e40dba8e8..300d587810 100644 --- a/client/src/modules/Contributor/components/ContributorsTable.test.tsx +++ b/client/src/modules/Contributor/components/ContributorsTable.test.tsx @@ -1,5 +1,5 @@ import { render, screen, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { ContributorDto } from '@client/api'; import { ContributorsTable } from './ContributorsTable'; @@ -8,25 +8,26 @@ const data = [ { id: 2, description: 'Second', user: { githubId: 'gh-two' } }, ] as unknown as ContributorDto[]; +function getContributorRow(githubId: string) { + // eslint-disable-next-line testing-library/no-node-access -- Avoid computing accessible names for every table row. + const row = screen.getByText(githubId).closest('tr'); + expect(row).toHaveRole('row'); + return row!; +} + describe('', () => { - it('renders a row per contributor with github id and description', () => { + it('renders contributor details and calls handleUpdate for the selected row', async () => { + const user = setupUser(); + const handleUpdate = vi.fn(); render( - , + , ); expect(screen.getByText('gh-one')).toBeInTheDocument(); expect(screen.getByText('First')).toBeInTheDocument(); expect(screen.getByText('gh-two')).toBeInTheDocument(); - }); - - it('calls handleUpdate with the row record when the edit button is clicked', async () => { - const user = userEvent.setup(); - const handleUpdate = vi.fn(); - render( - , - ); - const row = screen.getByRole('row', { name: /gh-one/ }); + const row = getContributorRow('gh-one'); const [editBtn] = within(row).getAllByRole('button'); await user.click(editBtn); @@ -34,11 +35,11 @@ describe('', () => { }); it('calls handleDelete with the row record when the delete button is clicked', async () => { - const user = userEvent.setup(); + const user = setupUser(); const handleDelete = vi.fn().mockResolvedValue(undefined); render(); - const row = screen.getByRole('row', { name: /gh-two/ }); + const row = getContributorRow('gh-two'); const buttons = within(row).getAllByRole('button'); await user.click(buttons[1]); diff --git a/client/src/modules/Contributor/pages/ContributorPage.test.tsx b/client/src/modules/Contributor/pages/ContributorPage.test.tsx index c85d624ee3..313efce6d1 100644 --- a/client/src/modules/Contributor/pages/ContributorPage.test.tsx +++ b/client/src/modules/Contributor/pages/ContributorPage.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { ReactNode } from 'react'; import { ContributorPage } from './ContributorPage'; @@ -32,8 +32,7 @@ const { getContributors, deleteContributor, getContributor, createContributor, s searchUsers: vi.fn(), })); -vi.mock('@client/api', async () => ({ - ...(await vi.importActual('@client/api')), +vi.mock('@client/api', () => ({ ContributorsApi: function ContributorsApi() { return { getContributors, deleteContributor, getContributor, createContributor }; }, @@ -47,6 +46,13 @@ const contributors = [ { id: 2, description: 'Second', user: { githubId: 'gh-two' } }, ]; +function getContributorRow(githubId: string) { + // eslint-disable-next-line testing-library/no-node-access -- Avoid computing accessible names for every table row. + const row = screen.getByText(githubId).closest('tr'); + expect(row).toHaveRole('row'); + return row!; +} + describe('', () => { beforeEach(() => { vi.clearAllMocks(); @@ -66,25 +72,12 @@ describe('', () => { expect(screen.getByRole('heading', { name: /manage contributors/i })).toBeInTheDocument(); }); - it('opens the create modal when "Add Contributor" is clicked', async () => { - const user = userEvent.setup(); - render(); - await screen.findByText('gh-one'); - - await user.click(screen.getByRole('button', { name: /add contributor/i })); - - // "Add Contributor" is both the trigger button and the modal title; assert the - // modal one by scoping to the dialog. - const dialog = await screen.findByRole('dialog'); - expect(within(dialog).getByText('Add Contributor')).toBeInTheDocument(); - }); - it('opens the edit modal when a row edit button is clicked', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('gh-one'); - const row = screen.getByRole('row', { name: /gh-one/ }); + const row = getContributorRow('gh-one'); const [editBtn] = within(row).getAllByRole('button'); await user.click(editBtn); @@ -93,11 +86,11 @@ describe('', () => { }); it('deletes a contributor and reloads the list', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('gh-two'); - const row = screen.getByRole('row', { name: /gh-two/ }); + const row = getContributorRow('gh-two'); const buttons = within(row).getAllByRole('button'); await user.click(buttons[1]); @@ -105,13 +98,14 @@ describe('', () => { await waitFor(() => expect(getContributors).toHaveBeenCalledTimes(2)); }); - it('reloads the list after the modal closes', async () => { - const user = userEvent.setup(); + it('opens the create modal and reloads the list after it closes', async () => { + const user = setupUser(); render(); await screen.findByText('gh-one'); await user.click(screen.getByRole('button', { name: /add contributor/i })); const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('Add Contributor')).toBeInTheDocument(); await user.click(within(dialog).getByRole('button', { name: /cancel/i })); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); diff --git a/client/src/modules/Course/components/CourseNoAccess.test.tsx b/client/src/modules/Course/components/CourseNoAccess.test.tsx index 6b8a5a7c26..93e675fe06 100644 --- a/client/src/modules/Course/components/CourseNoAccess.test.tsx +++ b/client/src/modules/Course/components/CourseNoAccess.test.tsx @@ -2,14 +2,10 @@ import { render, screen } from '@testing-library/react'; import { CourseNoAccess } from './CourseNoAccess'; describe('', () => { - it('renders a 403 result explaining the user has no access', () => { + it('renders a 403 explanation and home link', () => { render(); expect(screen.getByText('You Have No Access to Course Page')).toBeInTheDocument(); expect(screen.getByText(/Please register or choose another course/i)).toBeInTheDocument(); - }); - - it('offers a link back to the home page', () => { - render(); const link = screen.getByRole('link', { name: /go home/i }); expect(link).toHaveAttribute('href', '/'); }); diff --git a/client/src/modules/Course/components/NoSubmissionAvailable/index.test.tsx b/client/src/modules/Course/components/NoSubmissionAvailable/index.test.tsx index 6f5836a42f..f878d97b21 100644 --- a/client/src/modules/Course/components/NoSubmissionAvailable/index.test.tsx +++ b/client/src/modules/Course/components/NoSubmissionAvailable/index.test.tsx @@ -2,13 +2,10 @@ import { render, screen } from '@testing-library/react'; import { NoSubmissionAvailable } from './'; describe('', () => { - it('tells the user no tasks are available', () => { + it('links to the course schedule when no tasks are available', () => { render(); expect(screen.getByRole('heading', { name: /no tasks available for submission now/i })).toBeInTheDocument(); - }); - it('links to the schedule for the given course alias', () => { - render(); const link = screen.getByRole('link', { name: /schedule/i }); expect(link).toHaveAttribute('href', '/course/schedule?course=rs-2024'); }); diff --git a/client/src/modules/Course/contexts/SessionContext.test.tsx b/client/src/modules/Course/contexts/SessionContext.test.tsx index b9c4eeebd3..67c9eb9f9f 100644 --- a/client/src/modules/Course/contexts/SessionContext.test.tsx +++ b/client/src/modules/Course/contexts/SessionContext.test.tsx @@ -28,10 +28,17 @@ describe('', () => { vi.mocked(useActiveCourseContext).mockReturnValue(mockActiveCourse); }); - it('should render loading screen', () => { + it('renders loading and uses the SessionApi fetcher', async () => { + const getSession = vi.spyOn(SessionApi.prototype, 'getSession').mockResolvedValue({ + data: mockSession, + } as never); vi.mocked(useRequest).mockReturnValue({ loading: true }); render({mockChildren}); + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + const fetcher = vi.mocked(useRequest).mock.calls[0][0] as () => Promise; + await expect(fetcher()).resolves.toEqual(mockSession); + expect(getSession).toHaveBeenCalledTimes(1); }); it('should handle error and redirect to login', () => { @@ -40,41 +47,38 @@ describe('', () => { expect(Router.push).toHaveBeenCalledWith('/login', expect.anything()); }); - it('should render children for admin user for admin-only pages', () => { + it('allows admin-only pages for admins and denies other users', () => { vi.mocked(useRequest).mockReturnValue({ data: mockSession }); - render({mockChildren}); + const { rerender } = render({mockChildren}); expect(screen.getByText('Child Component')).toBeInTheDocument(); - }); - it('should render warning for non-admin user for admin-only pages', () => { vi.mocked(useRequest).mockReturnValue({ data: { ...mockSession, isAdmin: false } }); - render({mockChildren}); + rerender({mockChildren}); expect(screen.getByText(/You don't have required role to access this page/)).toBeInTheDocument(); }); - it('should render children for user with allowed roles', () => { + it('checks active-course roles and any-course power-user access', () => { vi.mocked(useRequest).mockReturnValue({ data: mockSession }); - render({mockChildren}); + const { rerender } = render({mockChildren}); expect(screen.getByText('Child Component')).toBeInTheDocument(); - }); - it('should render warning for user without allowed roles', () => { vi.mocked(useRequest).mockReturnValue({ data: { ...mockSession, isAdmin: false } }); - render({mockChildren}); + rerender({mockChildren}); expect(screen.getByText(/You don't have required role to access this page/)).toBeInTheDocument(); - }); - it('fetches the session via SessionApi.getSession in the useRequest fetcher', async () => { - const getSession = vi.spyOn(SessionApi.prototype, 'getSession').mockResolvedValue({ - data: mockSession, - } as never); - vi.mocked(useRequest).mockReturnValue({ loading: true }); - render({mockChildren}); + vi.mocked(useRequest).mockReturnValue({ data: { isAdmin: false, courses: {} } }); + rerender({mockChildren}); + expect(screen.getByText(/You don't have required role to access this page/)).toBeInTheDocument(); - // Capture the fetcher passed to useRequest and invoke it to exercise getSession(). - const fetcher = vi.mocked(useRequest).mock.calls[0][0] as () => Promise; - await expect(fetcher()).resolves.toEqual(mockSession); - expect(getSession).toHaveBeenCalledTimes(1); + vi.mocked(useRequest).mockReturnValue({ + data: { isAdmin: false, courses: { 2: { roles: ['mentor'] } } }, + }); + rerender( + + {mockChildren} + , + ); + expect(screen.getByText('Child Component')).toBeInTheDocument(); }); it('renders the AccessDenied warning with a working "Go Back" button', async () => { @@ -86,37 +90,13 @@ describe('', () => { expect(back).toHaveBeenCalledTimes(1); }); - it('denies access to a hirer-only page for a non-hirer, non-admin user', () => { + it('checks hirer-only access for non-hirers and hirers', () => { vi.mocked(useRequest).mockReturnValue({ data: { ...mockSession, isAdmin: false, isHirer: false } }); - render({mockChildren}); + const { rerender } = render({mockChildren}); expect(screen.getByText(/You don't have required role to access this page/)).toBeInTheDocument(); - }); - it('allows a hirer-only page for a hirer user', () => { vi.mocked(useRequest).mockReturnValue({ data: { ...mockSession, isAdmin: false, isHirer: true } }); - render({mockChildren}); - expect(screen.getByText('Child Component')).toBeInTheDocument(); - }); - - it('falls back to no roles when the current course is absent from the session', () => { - // Non-admin, allowedRoles set, but session.courses has no entry for the active course id - // → `courses?.[id]?.roles ?? []` resolves to [] and access is denied. - vi.mocked(useRequest).mockReturnValue({ data: { isAdmin: false, courses: {} } }); - render({mockChildren}); - expect(screen.getByText(/You don't have required role to access this page/)).toBeInTheDocument(); - }); - - it('grants access to a power user when anyCoursePowerUser is enabled (role in another course)', () => { - // No role in the active course (id 1), but the user is a mentor in another course (id 2), - // and anyCoursePowerUser lets that count → access granted. - vi.mocked(useRequest).mockReturnValue({ - data: { isAdmin: false, courses: { 2: { roles: ['mentor'] } } }, - }); - render( - - {mockChildren} - , - ); + rerender({mockChildren}); expect(screen.getByText('Child Component')).toBeInTheDocument(); }); }); diff --git a/client/src/modules/Course/pages/CouseNoAccess/index.test.tsx b/client/src/modules/Course/pages/CouseNoAccess/index.test.tsx index 5d088be50d..f72f6665ef 100644 --- a/client/src/modules/Course/pages/CouseNoAccess/index.test.tsx +++ b/client/src/modules/Course/pages/CouseNoAccess/index.test.tsx @@ -1,6 +1,27 @@ import { render, screen } from '@testing-library/react'; import { CouseNoAccessPage } from './'; +vi.mock('antd', () => ({ + Row: ({ children }: { children: React.ReactNode }) =>
    {children}
    , + Col: ({ children }: { children: React.ReactNode }) =>
    {children}
    , + Button: ({ children, href }: React.ComponentProps<'a'>) => {children}, + Result: ({ + title, + subTitle, + extra, + }: { + title: React.ReactNode; + subTitle: React.ReactNode; + extra: React.ReactNode; + }) => ( +
    +

    {title}

    +

    {subTitle}

    + {extra} +
    + ), +})); + describe('', () => { it('renders the no-access course result', () => { render(); diff --git a/client/src/modules/Course/pages/Student/CrossCheckSubmit/index.test.tsx b/client/src/modules/Course/pages/Student/CrossCheckSubmit/index.test.tsx index a1d4bdbd59..e229ae77cb 100644 --- a/client/src/modules/Course/pages/Student/CrossCheckSubmit/index.test.tsx +++ b/client/src/modules/Course/pages/Student/CrossCheckSubmit/index.test.tsx @@ -154,32 +154,18 @@ describe('', () => { setQueryTaskId(); }); - it('renders the page title and the task selector', () => { - render(); - expect(screen.getByRole('heading', { name: /cross-check submit/i })).toBeInTheDocument(); - expect(screen.getByLabelText('task')).toBeInTheDocument(); - }); - it('shows the no-submission message when there are no tasks', () => { setTasks([]); render(); expect(screen.getByText('No submission available')).toBeInTheDocument(); }); - it('loads task details when a task is selected and reveals the submit form', async () => { - setQueryTaskId(7); - render(); - - await waitFor(() => expect(getCrossCheckTaskDetails).toHaveBeenCalledWith(7)); - expect(getMyCrossCheckFeedbacks).toHaveBeenCalledWith(42, 7); - // Submit button appears because the task exists and the deadline is in the future. - expect(await screen.findByRole('button', { name: /^submit$/i })).toBeInTheDocument(); - }); - it('submits the solution url and shows a success message', async () => { setQueryTaskId(7); render(); - await screen.findByRole('button', { name: /^submit$/i }); + expect(await screen.findByRole('button', { name: /^submit$/i })).toBeInTheDocument(); + expect(getCrossCheckTaskDetails).toHaveBeenCalledWith(7); + expect(getMyCrossCheckFeedbacks).toHaveBeenCalledWith(42, 7); const input = screen.getByPlaceholderText(/link in the form of/i); fireEvent.change(input, { target: { value: 'https://github.com/octocat/pr/1' } }); @@ -264,6 +250,9 @@ describe('', () => { } as any); render(); + expect(screen.getByRole('heading', { name: /cross-check submit/i })).toBeInTheDocument(); + expect(screen.getByLabelText('task')).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('task'), { target: { value: '7' } }); expect(replace).toHaveBeenCalledWith(expect.stringContaining('taskId=7')); }); diff --git a/client/src/modules/CourseManagement/components/CertificateCriteriaModal/CertificateCriteriaModal.test.tsx b/client/src/modules/CourseManagement/components/CertificateCriteriaModal/CertificateCriteriaModal.test.tsx index 717f4a70fd..683801a14d 100644 --- a/client/src/modules/CourseManagement/components/CertificateCriteriaModal/CertificateCriteriaModal.test.tsx +++ b/client/src/modules/CourseManagement/components/CertificateCriteriaModal/CertificateCriteriaModal.test.tsx @@ -5,7 +5,7 @@ import { FormValues, hasValidCriteria, } from './CertificateCriteriaModal'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import * as ReactUse from 'react-use'; const props = { @@ -20,7 +20,10 @@ const renderCertificateCriteriaModal = () => { }; describe('CertificateCriteriaModal', () => { - beforeAll(() => { + let user: ReturnType; + + beforeEach(() => { + user = setupUser(); // mock CoursesTasksApi call vi.spyOn(ReactUse, 'useAsync').mockReturnValue({ value: [ @@ -35,37 +38,20 @@ describe('CertificateCriteriaModal', () => { }); afterEach(() => { - vi.clearAllMocks(); - }); - - const user = userEvent.setup(); - - test('should render modal title', async () => { - renderCertificateCriteriaModal(); - - const title = await screen.findByText('Certificate Criteria'); - expect(title).toBeInTheDocument(); - }); - - test('should render alert message', async () => { - renderCertificateCriteriaModal(); - - const alert = await screen.findByText(CERTIFICATE_ALERT_MESSAGE); - expect(alert).toBeInTheDocument(); - }); - - test('should render "add task" button', async () => { - renderCertificateCriteriaModal(); - - const button = await screen.findByRole('button', { name: /add task/i }); - expect(button).toBeInTheDocument(); + vi.restoreAllMocks(); }); - test('should render "minimum total score" field', async () => { + test('should render the initial form with certificate issuance disabled', async () => { renderCertificateCriteriaModal(); - const field = await screen.findByText('Minimum Total Score'); - expect(field).toBeInTheDocument(); + expect(await screen.findByText('Certificate Criteria')).toBeInTheDocument(); + expect(screen.getByText(CERTIFICATE_ALERT_MESSAGE)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /add task/i })).toBeInTheDocument(); + expect(screen.getByText('Minimum Total Score')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + const submitButton = screen.getByRole('button', { name: /issue certificates/i }); + expect(submitButton).toBeInTheDocument(); + expect(submitButton).toBeDisabled(); }); test('should render task criteria row on "add task" button click', async () => { @@ -76,7 +62,10 @@ describe('CertificateCriteriaModal', () => { expect(await screen.findByText('Task')).toBeInTheDocument(); expect(await screen.findByText('Minimum Score')).toBeInTheDocument(); - expect(await screen.findByRole('button', { name: /remove task/i })).toBeInTheDocument(); + const removeButton = await screen.findByLabelText('Remove task'); + expect(removeButton).toBeInTheDocument(); + expect(removeButton).toHaveRole('button'); + expect(removeButton).toBeVisible(); }); test('should remove task criteria row on "remove task" button click', async () => { @@ -85,27 +74,14 @@ describe('CertificateCriteriaModal', () => { const addButton = await screen.findByRole('button', { name: /add task/i }); await user.click(addButton); - const removeButton = await screen.findByRole('button', { name: /remove task/i }); + const removeButton = await screen.findByLabelText('Remove task'); + expect(removeButton).toHaveRole('button'); + expect(removeButton).toBeVisible(); await user.click(removeButton); expect(screen.queryByText('Minimum Score')).not.toBeInTheDocument(); }); - test('should render "cancel" button', async () => { - renderCertificateCriteriaModal(); - - const button = await screen.findByRole('button', { name: /cancel/i }); - expect(button).toBeInTheDocument(); - }); - - test('should render "issue certificates" button', async () => { - renderCertificateCriteriaModal(); - - const button = await screen.findByRole('button', { name: /issue certificates/i }); - expect(button).toBeInTheDocument(); - expect(button).toBeDisabled(); - }); - test('should enable "issue certificates" button on valid criteria', async () => { renderCertificateCriteriaModal(); diff --git a/client/src/modules/CourseManagement/components/CertificateTemplatePicker/CertificateTemplatePicker.test.tsx b/client/src/modules/CourseManagement/components/CertificateTemplatePicker/CertificateTemplatePicker.test.tsx index 9795e75b3f..5d609df180 100644 --- a/client/src/modules/CourseManagement/components/CertificateTemplatePicker/CertificateTemplatePicker.test.tsx +++ b/client/src/modules/CourseManagement/components/CertificateTemplatePicker/CertificateTemplatePicker.test.tsx @@ -1,5 +1,5 @@ /* eslint-disable testing-library/no-container, testing-library/no-node-access */ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import axios from 'axios'; import { CertificateTemplatePicker } from './CertificateTemplatePicker'; @@ -32,7 +32,7 @@ async function renderPicker(props: Parameters[ } describe('', () => { - it('shows a spinner while templates are loading', async () => { + it('fetches templates and replaces the loading spinner with their radios', async () => { let resolve!: (v: unknown) => void; mockedGet.mockReturnValue(new Promise(r => (resolve = r))); @@ -42,18 +42,7 @@ describe('', () => { resolve({ data: templates }); expect(await screen.findByText('Default')).toBeInTheDocument(); - }); - - it('fetches templates from the certificate templates endpoint', async () => { - await renderPicker(); - - await waitFor(() => expect(mockedGet).toHaveBeenCalledWith('/api/v2/certificate/templates')); - }); - - it('renders one radio per fetched template', async () => { - await renderPicker(); - - expect(await screen.findByText('Default')).toBeInTheDocument(); + expect(mockedGet).toHaveBeenCalledWith('/api/v2/certificate/templates'); expect(screen.getByText('Modern')).toBeInTheDocument(); expect(screen.getAllByRole('radio')).toHaveLength(2); }); @@ -96,6 +85,8 @@ describe('', () => { // open the preview (state) and NOT change the radio selection — openPreview calls // preventDefault/stopPropagation to swallow the surrounding Radio's toggle. const previewButtons = await screen.findAllByRole('button', { name: /view full preview/i }); + fireEvent.mouseDown(previewButtons[1]); + expect(onChange).not.toHaveBeenCalledWith('modern'); fireEvent.click(previewButtons[1]); // Selection onChange must NOT fire to 'modern' from the preview click. @@ -108,9 +99,10 @@ describe('', () => { it('renders an empty radio group when the fetch fails', async () => { mockedGet.mockRejectedValue(new Error('boom')); - await renderPicker(); + const { container } = await renderPicker(); - await waitFor(() => expect(screen.queryByRole('radio')).not.toBeInTheDocument()); + await waitFor(() => expect(container.querySelector('.ant-spin')).not.toBeInTheDocument()); + expect(screen.queryByRole('radio')).not.toBeInTheDocument(); }); it('serves templates from the module cache on a subsequent mount (no refetch)', async () => { @@ -123,7 +115,10 @@ describe('', () => { unmount(); // Second mount reads cachedTemplates: no spinner, no second network call. - render(); + // eslint-disable-next-line testing-library/no-unnecessary-act -- Settle image effects on the cached render + await act(async () => { + render(); + }); expect(screen.getByText('Default')).toBeInTheDocument(); expect(mockedGet).toHaveBeenCalledTimes(1); }); @@ -167,16 +162,6 @@ describe('', () => { }); }); - it('swallows mousedown on the fullscreen control so the radio is not toggled', async () => { - const onChange = vi.fn(); - await renderPicker({ value: 'default', onChange }); - - const previewButtons = await screen.findAllByRole('button', { name: /view full preview/i }); - fireEvent.mouseDown(previewButtons[1]); - - expect(onChange).not.toHaveBeenCalledWith('modern'); - }); - it('auto-selects safely when no onChange handler is provided', async () => { // value undefined + no onChange => the optional-chain `onChangeRef.current?.(fallback)` no-ops. const { CertificateTemplatePicker: Picker } = await import('./CertificateTemplatePicker'); diff --git a/client/src/modules/CourseManagement/components/CertificateTemplatePicker/IssueCertificateModal.test.tsx b/client/src/modules/CourseManagement/components/CertificateTemplatePicker/IssueCertificateModal.test.tsx index afea320655..b1c7a03ea3 100644 --- a/client/src/modules/CourseManagement/components/CertificateTemplatePicker/IssueCertificateModal.test.tsx +++ b/client/src/modules/CourseManagement/components/CertificateTemplatePicker/IssueCertificateModal.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { IssueCertificateModal } from './IssueCertificateModal'; // Stub the CertificateTemplatePicker (axios fetch + antd Image preview = brittle in @@ -28,78 +28,37 @@ function makeProps(overrides: Partial[0 describe('', () => { beforeEach(() => vi.clearAllMocks()); - it('does not render the dialog when closed', () => { - render(); + it('handles its closed, open, selected, reopened, and student states', async () => { + const user = setupUser(); + const props = makeProps({ open: false }); + const { rerender } = render(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - it('renders the generic title when no student name is provided', async () => { - render(); + rerender(); expect(await screen.findByText('Issue certificate')).toBeInTheDocument(); - }); - - it('renders a per-student title when a student name is provided', async () => { - render(); - - expect(await screen.findByText('Issue certificate — Ada Lovelace')).toBeInTheDocument(); - }); - - it('disables the Issue button until a template is chosen', async () => { - render(); - const issue = await screen.findByRole('button', { name: 'Issue' }); expect(issue).toBeDisabled(); - }); - - it('enables Issue and submits the chosen template id', async () => { - const user = userEvent.setup(); - const props = makeProps(); - render(); + await user.click(issue); + expect(props.onSubmit).not.toHaveBeenCalled(); + await user.click(screen.getByRole('button', { name: /cancel/i })); + expect(props.onCancel).toHaveBeenCalled(); await user.click(await screen.findByRole('button', { name: /pick modern/i })); - const issue = await screen.findByRole('button', { name: 'Issue' }); expect(issue).toBeEnabled(); - await user.click(issue); expect(props.onSubmit).toHaveBeenCalledWith('modern'); - }); - - it('does not call onSubmit when no template is selected (onOk guard)', async () => { - const user = userEvent.setup(); - const props = makeProps(); - render(); - - // Force a click on the disabled-looking guard path: even if clicked, templateId is undefined. - const issue = await screen.findByRole('button', { name: 'Issue' }); - await user.click(issue); - - expect(props.onSubmit).not.toHaveBeenCalled(); - }); - - it('calls onCancel when the cancel button is clicked', async () => { - const user = userEvent.setup(); - const props = makeProps(); - render(); - - await user.click(await screen.findByRole('button', { name: /cancel/i })); - expect(props.onCancel).toHaveBeenCalled(); - }); - - it('resets the selected template when the modal is reopened', async () => { - const user = userEvent.setup(); - const { rerender } = render(); - - await user.click(await screen.findByRole('button', { name: /pick modern/i })); expect(screen.getByTestId('picker-value')).toHaveTextContent('modern'); - // Close then reopen: the useEffect clears templateId so the Issue button is disabled again. - rerender(); - rerender(); + rerender(); + rerender(); expect(await screen.findByTestId('picker-value')).toHaveTextContent(''); expect(screen.getByRole('button', { name: 'Issue' })).toBeDisabled(); + + rerender(); + expect(await screen.findByText('Issue certificate — Ada Lovelace')).toBeInTheDocument(); }); }); diff --git a/client/src/modules/CourseManagement/components/CourseEventModal/index.test.tsx b/client/src/modules/CourseManagement/components/CourseEventModal/index.test.tsx index d84da58b3e..844abd1cc0 100644 --- a/client/src/modules/CourseManagement/components/CourseEventModal/index.test.tsx +++ b/client/src/modules/CourseManagement/components/CourseEventModal/index.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { CourseEventModal } from './index'; // --- Boundary mocks -------------------------------------------------------- @@ -136,16 +136,6 @@ describe('', () => { submitEvent.mockResolvedValue(undefined); }); - it('renders the modal with the event/type/discipline fields when adding a new event', async () => { - render(); - - expect(await screen.findByText('Course Event')).toBeInTheDocument(); - expect(screen.getByLabelText('Event')).toBeInTheDocument(); - expect(screen.getByLabelText('Type')).toBeInTheDocument(); - expect(screen.getByLabelText('Discipline')).toBeInTheDocument(); - expect(screen.getByLabelText('Description URL')).toBeInTheDocument(); - }); - it('renders the event name as a title (no Event select) when editing an existing event', async () => { render(); @@ -167,7 +157,7 @@ describe('', () => { }); it('shows a validation error and does not submit when required fields are empty', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); @@ -188,23 +178,8 @@ describe('', () => { expect(await screen.findByText('Please enter valid URL')).toBeInTheDocument(); }); - it('prefills description/type when picking an event template via onEventChange', async () => { - render(); - - const eventSelect = await screen.findByLabelText('Event'); - fireEvent.mouseDown(eventSelect); - - const option = await within(document.body).findByText('Intro Lecture'); - fireEvent.click(option); - - // The selected template's description URL flows into the URL input. - await waitFor(() => { - expect(screen.getByPlaceholderText('Enter description URL')).toHaveValue('https://example.com/intro'); - }); - }); - it('filters event template options by typed input via filterOption', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); const eventSelect = await screen.findByLabelText('Event'); @@ -219,7 +194,7 @@ describe('', () => { }); it('submits via submitEvent and then calls onSubmit when the form is valid', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); @@ -228,6 +203,10 @@ describe('', () => { fireEvent.mouseDown(eventSelect); fireEvent.click(await within(document.body).findByText('Intro Lecture')); + await waitFor(() => + expect(screen.getByPlaceholderText('Enter description URL')).toHaveValue('https://example.com/intro'), + ); + // Pick a discipline (required, not auto-filled). const disciplineSelect = screen.getByLabelText('Discipline'); fireEvent.mouseDown(disciplineSelect); @@ -243,12 +222,15 @@ describe('', () => { await waitFor(() => expect(props.onSubmit).toHaveBeenCalled()); }); - it('calls onCancel when the cancel button is clicked on a pristine form', async () => { - const user = userEvent.setup(); + it('renders the new-event fields and cancels a pristine form', async () => { + const user = setupUser(); const props = makeProps(); render(); - await screen.findByText('Course Event'); + expect(await screen.findByText('Course Event')).toBeInTheDocument(); + for (const label of ['Event', 'Type', 'Discipline', 'Description URL']) { + expect(screen.getByLabelText(label)).toBeInTheDocument(); + } await user.click(screen.getByRole('button', { name: /cancel/i })); expect(props.onCancel).toHaveBeenCalled(); diff --git a/client/src/modules/CourseManagement/components/CourseEventModal/index.tsx b/client/src/modules/CourseManagement/components/CourseEventModal/index.tsx index 8a06646ef2..e5d35d446d 100644 --- a/client/src/modules/CourseManagement/components/CourseEventModal/index.tsx +++ b/client/src/modules/CourseManagement/components/CourseEventModal/index.tsx @@ -93,7 +93,13 @@ export function CourseEventModal({ data, onCancel, courseId, onSubmit }: Props) {data.event?.id ? ( {data.event.name} ) : ( - + ({ value: value == null ? [] : [value] })} + getValueFromEvent={(values: string[]) => values[values.length - 1]} + > + diff --git a/client/src/modules/CourseManagement/components/CoursesListModal/index.test.tsx b/client/src/modules/CourseManagement/components/CoursesListModal/index.test.tsx index d457bfeae2..390b69330a 100644 --- a/client/src/modules/CourseManagement/components/CoursesListModal/index.test.tsx +++ b/client/src/modules/CourseManagement/components/CoursesListModal/index.test.tsx @@ -1,5 +1,5 @@ -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { setupUser } from '@client/__tests__/setupUser'; import { CoursesListModal } from './index'; // Mock only the API boundary: the generated CoursesApi class. The component @@ -8,8 +8,7 @@ const { getCourses } = vi.hoisted(() => ({ getCourses: vi.fn(), })); -vi.mock('@client/api', async () => ({ - ...(await vi.importActual('@client/api')), +vi.mock('@client/api', () => ({ CoursesApi: function CoursesApi() { return { getCourses }; }, @@ -35,20 +34,15 @@ describe('', () => { }); }); - it('returns null (renders no modal) when data is null', () => { - render(); + it('returns null (renders no modal) when data is null', async () => { + // eslint-disable-next-line testing-library/no-unnecessary-act -- Await mount effects after the synchronous render + await act(async () => { + render(); + }); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); - it('renders the modal with title and the course select field', async () => { - render(); - - expect(await screen.findByRole('dialog')).toBeInTheDocument(); - expect(screen.getByText('Courses')).toBeInTheDocument(); - expect(screen.getByLabelText('Course')).toBeInTheDocument(); - }); - it('uses the provided okText for the submit button', async () => { render(); @@ -65,20 +59,8 @@ describe('', () => { expect(await screen.findByText('extra child content')).toBeInTheDocument(); }); - it('renders the fetched courses as select options', async () => { - render(); - - const combobox = await screen.findByRole('combobox'); - fireEvent.mouseDown(combobox); - - await waitFor(() => { - expect(within(document.body).getByText('JavaScript')).toBeInTheDocument(); - expect(within(document.body).getByText('React')).toBeInTheDocument(); - }); - }); - it('does not submit and shows a validation message when no course is selected', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); @@ -90,7 +72,7 @@ describe('', () => { }); it('submits the selected course id mapped to { id } on save', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); @@ -98,6 +80,8 @@ describe('', () => { fireEvent.mouseDown(combobox); const option = await within(document.body).findByText('React'); + expect(option).toBeInTheDocument(); + expect(within(document.body).getByText('JavaScript')).toBeInTheDocument(); fireEvent.click(option); await user.click(screen.getByRole('button', { name: /save/i })); @@ -107,11 +91,15 @@ describe('', () => { }); }); - it('calls onCancel when the modal cancel button is clicked', async () => { - const user = userEvent.setup(); + it('renders the title and course selector and calls onCancel', async () => { + const user = setupUser(); const props = makeProps(); render(); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Courses')).toBeInTheDocument(); + expect(screen.getByLabelText('Course')).toBeInTheDocument(); + const cancel = await screen.findByRole('button', { name: /cancel/i }); await user.click(cancel); @@ -119,17 +107,17 @@ describe('', () => { }); it('filters options by typed input via filterOption', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); const combobox = await screen.findByRole('combobox'); - await user.click(combobox); - await user.type(combobox, 'react'); + fireEvent.mouseDown(combobox); + await user.type(combobox, 'react', { skipClick: true }); // "JavaScript" should be filtered out; only "React" remains visible. await waitFor(() => { expect(within(document.body).getByText('React')).toBeInTheDocument(); }); - expect(within(document.body).queryByText('JavaScript')).not.toBeInTheDocument(); + await waitFor(() => expect(within(document.body).queryByText('JavaScript')).not.toBeInTheDocument()); }); }); diff --git a/client/src/modules/CourseManagement/components/ExpelCriteriaModal/ExpelCriteriaModal.test.tsx b/client/src/modules/CourseManagement/components/ExpelCriteriaModal/ExpelCriteriaModal.test.tsx index e7786f2fbc..9899c256c7 100644 --- a/client/src/modules/CourseManagement/components/ExpelCriteriaModal/ExpelCriteriaModal.test.tsx +++ b/client/src/modules/CourseManagement/components/ExpelCriteriaModal/ExpelCriteriaModal.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { EXPEL_ALERT_MESSAGE, ExpelCriteriaModal, FormValues, hasValidCriteria } from './ExpelCriteriaModal'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import * as ReactUse from 'react-use'; const props = { @@ -32,72 +32,8 @@ describe('ExpelCriteriaModal', () => { vi.clearAllMocks(); }); - const user = userEvent.setup(); - - test('should render modal title', async () => { - renderExpelCriteriaModal(); - - const title = await screen.findByText('Expel Criteria'); - expect(title).toBeInTheDocument(); - }); - - test('should render alert message', async () => { - renderExpelCriteriaModal(); - - const alert = await screen.findByText(EXPEL_ALERT_MESSAGE); - expect(alert).toBeInTheDocument(); - }); - - test.each` - label - ${"Didn't Complete Following Tasks"} - ${'Minimum Total Score'} - ${'Expel Reason'} - `('should render field with $label label', async ({ label }) => { - renderExpelCriteriaModal(); - - const field = await screen.findByText(label); - expect(field).toBeInTheDocument(); - }); - - test('should render checkbox', async () => { - renderExpelCriteriaModal(); - - const checkbox = await screen.findByRole('checkbox'); - expect(checkbox).toBeInTheDocument(); - }); - - test('should render "cancel" button', async () => { - renderExpelCriteriaModal(); - - const button = await screen.findByRole('button', { name: /cancel/i }); - expect(button).toBeInTheDocument(); - }); - - test('should render "expel students" button', async () => { - renderExpelCriteriaModal(); - - const button = await screen.findByRole('button', { name: /expel students/i }); - expect(button).toBeInTheDocument(); - expect(button).toBeDisabled(); - }); - - test('should enable "expel students" button on valid criteria', async () => { - renderExpelCriteriaModal(); - - const button = await screen.findByRole('button', { name: /expel students/i }); - expect(button).toBeDisabled(); - - const minTotalScoreInput = await screen.findByLabelText('Minimum Total Score'); - fireEvent.change(minTotalScoreInput, { - target: { - value: 5, - }, - }); - expect(button).toBeEnabled(); - }); - test('should call "onClose" function on "cancel" button click', async () => { + const user = setupUser(); renderExpelCriteriaModal(); const button = await screen.findByRole('button', { name: /cancel/i }); @@ -106,9 +42,25 @@ describe('ExpelCriteriaModal', () => { expect(props.onClose).toHaveBeenCalled(); }); - test('should render error message when expel reason not provided', async () => { + test('renders the criteria form, enables submission for valid criteria and requires a reason', async () => { + const user = setupUser(); renderExpelCriteriaModal(); + for (const text of [ + 'Expel Criteria', + EXPEL_ALERT_MESSAGE, + "Didn't Complete Following Tasks", + 'Minimum Total Score', + 'Expel Reason', + ]) { + expect(screen.getByText(text)).toBeInTheDocument(); + } + expect(screen.getByRole('checkbox')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + const button = screen.getByRole('button', { name: /expel students/i }); + expect(button).toBeInTheDocument(); + expect(button).toBeDisabled(); + // Enable "expel students" button const minTotalScoreInput = await screen.findByLabelText('Minimum Total Score'); fireEvent.change(minTotalScoreInput, { @@ -117,7 +69,7 @@ describe('ExpelCriteriaModal', () => { }, }); - const button = await screen.findByRole('button', { name: /expel students/i }); + expect(button).toBeEnabled(); await user.click(button); const errorMessage = await screen.findByText('Please provide the expel reason'); @@ -127,6 +79,7 @@ describe('ExpelCriteriaModal', () => { }); test('should call "onSubmit" function on "expel students" button click', async () => { + const user = setupUser(); renderExpelCriteriaModal(); // Enable "expel students" button diff --git a/client/src/modules/CourseManagement/components/ExpelledStudentsStats.test.tsx b/client/src/modules/CourseManagement/components/ExpelledStudentsStats.test.tsx index 589fe17a50..667cac1317 100644 --- a/client/src/modules/CourseManagement/components/ExpelledStudentsStats.test.tsx +++ b/client/src/modules/CourseManagement/components/ExpelledStudentsStats.test.tsx @@ -1,6 +1,6 @@ /* eslint-disable testing-library/no-node-access */ import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import ExpelledStudentsStats from './ExpelledStudentsStats'; import type { ExpelledStatsDto } from '@client/api'; @@ -72,8 +72,10 @@ describe('', () => { expect(screen.queryByText('Detailed Statistics on Student Departures')).not.toBeInTheDocument(); }); - it('renders the heading, export button and data rows', () => { - useExpelledStats.mockReturnValue(makeHookState({ data: rows })); + it('calls handleDelete with the row id when a Delete button is clicked', async () => { + const handleDelete = vi.fn(); + useExpelledStats.mockReturnValue(makeHookState({ data: rows, handleDelete })); + const user = setupUser(); render(); @@ -93,14 +95,6 @@ describe('', () => { // fullName preferred, falls back to name when empty expect(screen.getByText('JavaScript Course')).toBeInTheDocument(); expect(screen.getByText('React Course')).toBeInTheDocument(); - }); - - it('calls handleDelete with the row id when a Delete button is clicked', async () => { - const handleDelete = vi.fn(); - useExpelledStats.mockReturnValue(makeHookState({ data: rows, handleDelete })); - const user = userEvent.setup(); - - render(); const firstRow = screen.getByText('js-2024').closest('tr')!; await user.click(within(firstRow).getByRole('button', { name: /delete/i })); @@ -111,7 +105,7 @@ describe('', () => { it('exports a CSV with headers and escaped values when Export CSV is clicked', async () => { // Include a sparse row so the CSV value extraction hits the null/undefined guards. useExpelledStats.mockReturnValue(makeHookState({ data: [...rows, sparseRow] })); - const user = userEvent.setup(); + const user = setupUser(); // Capture the blob contents and the programmatic download click. let capturedBlob: Blob | null = null; @@ -154,7 +148,7 @@ describe('', () => { it('does not export when there is no data', async () => { useExpelledStats.mockReturnValue(makeHookState({ data: [] })); - const user = userEvent.setup(); + const user = setupUser(); const createObjectURL = vi.fn(() => 'blob:none'); vi.stubGlobal('URL', { ...URL, createObjectURL }); diff --git a/client/src/modules/CourseManagement/components/SelectCourseTasks/SelectCourseTasks.test.tsx b/client/src/modules/CourseManagement/components/SelectCourseTasks/SelectCourseTasks.test.tsx index b35df4de20..b3ad135207 100644 --- a/client/src/modules/CourseManagement/components/SelectCourseTasks/SelectCourseTasks.test.tsx +++ b/client/src/modules/CourseManagement/components/SelectCourseTasks/SelectCourseTasks.test.tsx @@ -1,7 +1,7 @@ import { Form } from 'antd'; import { SelectCourseTasks } from './SelectCourseTasks'; import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; // Boundary mock: drive the real useAsync callback through a mocked CoursesTasksApi // so the data-fetch function (and the options mapping) actually run. @@ -22,7 +22,7 @@ const renderSelectCourseTasks = () => { }; describe('SelectCourseTasks', () => { - const user = userEvent.setup(); + const user = setupUser(); beforeEach(() => { vi.clearAllMocks(); @@ -34,25 +34,12 @@ describe('SelectCourseTasks', () => { }); }); - test('should render field with "Task" label', async () => { + test('renders fetched task options', async () => { renderSelectCourseTasks(); const field = await screen.findByLabelText('Task'); expect(field).toBeInTheDocument(); - }); - - test('should fetch the tasks for the given course', async () => { - renderSelectCourseTasks(); - - await screen.findByLabelText('Task'); expect(getCourseTasks).toHaveBeenCalledWith(1); - }); - - test('should render options on select click', async () => { - renderSelectCourseTasks(); - - const field = await screen.findByLabelText('Task'); - await user.click(field); const options = await screen.findAllByRole('option'); diff --git a/client/src/modules/CourseManagement/hooks/useExpelledStats.test.ts b/client/src/modules/CourseManagement/hooks/useExpelledStats.test.ts index ae08540015..2fbfb8df29 100644 --- a/client/src/modules/CourseManagement/hooks/useExpelledStats.test.ts +++ b/client/src/modules/CourseManagement/hooks/useExpelledStats.test.ts @@ -1,4 +1,4 @@ -import { act, renderHook, waitFor } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react'; import { useExpelledStats } from './useExpelledStats'; const { getCourseExpelledStats, deleteExpelledStat } = vi.hoisted(() => ({ @@ -24,24 +24,18 @@ describe('useExpelledStats', () => { it('does not fetch while there is no courseId (request not ready)', async () => { renderHook(() => useExpelledStats(undefined)); - await new Promise(r => setTimeout(r, 10)); + await act(async () => undefined); expect(getCourseExpelledStats).not.toHaveBeenCalled(); }); - it('fetches and exposes the expelled stats once a courseId is provided', async () => { + it('fetches, deletes a stat, and refreshes the list', async () => { const rows = [{ id: '1', githubId: 'student' }]; - getCourseExpelledStats.mockResolvedValueOnce({ data: rows }); + getCourseExpelledStats.mockResolvedValue({ data: rows }); const { result } = renderHook(() => useExpelledStats(100)); - await waitFor(() => expect(result.current.data).toEqual(rows)); + await act(async () => undefined); + expect(result.current.data).toEqual(rows); expect(getCourseExpelledStats).toHaveBeenCalledWith(100); - }); - - it('deletes a stat and refreshes the list afterwards', async () => { - getCourseExpelledStats.mockResolvedValue({ data: [{ id: '1' }] }); - const { result } = renderHook(() => useExpelledStats(100)); - - await waitFor(() => expect(result.current.data).toBeDefined()); expect(getCourseExpelledStats).toHaveBeenCalledTimes(1); await act(async () => { @@ -49,15 +43,15 @@ describe('useExpelledStats', () => { }); expect(deleteExpelledStat).toHaveBeenCalledWith('1'); - // onSuccess => refresh re-runs the fetch - await waitFor(() => expect(getCourseExpelledStats).toHaveBeenCalledTimes(2)); + expect(getCourseExpelledStats).toHaveBeenCalledTimes(2); }); it('exposes the error when the fetch rejects', async () => { getCourseExpelledStats.mockRejectedValueOnce(new Error('stats failed')); const { result } = renderHook(() => useExpelledStats(100)); - await waitFor(() => expect(result.current.error).toBeInstanceOf(Error)); + await act(async () => undefined); + expect(result.current.error).toBeInstanceOf(Error); expect(result.current.error?.message).toBe('stats failed'); }); }); diff --git a/client/src/modules/CourseStatistics/components/DonutChart/DonutChart.test.tsx b/client/src/modules/CourseStatistics/components/DonutChart/DonutChart.test.tsx index cac1954667..9976993087 100644 --- a/client/src/modules/CourseStatistics/components/DonutChart/DonutChart.test.tsx +++ b/client/src/modules/CourseStatistics/components/DonutChart/DonutChart.test.tsx @@ -34,8 +34,8 @@ const data = [ ]; describe('', () => { - it('renders the pie chart with the donut field configuration', () => { - render(); + it('renders populated, empty, and configured donut charts', () => { + const { rerender } = render(); const chart = screen.getByTestId('pie-chart'); expect(chart).toBeInTheDocument(); @@ -43,24 +43,15 @@ describe('', () => { expect(chart).toHaveAttribute('data-anglefield', 'value'); expect(chart).toHaveAttribute('data-colorfield', 'type'); expect(chart).toHaveAttribute('data-inner-radius', '0.6'); - }); - - it('renders the summed total as the centre annotation', () => { - render(); - - expect(screen.getByTestId('pie-chart')).toHaveAttribute('data-total', '10'); - }); + expect(chart).toHaveAttribute('data-total', '10'); - it('computes a zero total for empty data', () => { - render(); + rerender(); - const chart = screen.getByTestId('pie-chart'); - expect(chart).toHaveAttribute('data-length', '0'); - expect(chart).toHaveAttribute('data-total', '0'); - }); + const emptyChart = screen.getByTestId('pie-chart'); + expect(emptyChart).toHaveAttribute('data-length', '0'); + expect(emptyChart).toHaveAttribute('data-total', '0'); - it('merges a caller-supplied config (e.g. a tooltip)', () => { - render(); + rerender(); expect(screen.getByTestId('pie-chart')).toHaveAttribute('data-has-tooltip', 'true'); }); diff --git a/client/src/modules/CourseStatistics/components/EpamMentorsStatsCard/EpamMentorsStatsCard.test.tsx b/client/src/modules/CourseStatistics/components/EpamMentorsStatsCard/EpamMentorsStatsCard.test.tsx index ca041ff947..442c4f690c 100644 --- a/client/src/modules/CourseStatistics/components/EpamMentorsStatsCard/EpamMentorsStatsCard.test.tsx +++ b/client/src/modules/CourseStatistics/components/EpamMentorsStatsCard/EpamMentorsStatsCard.test.tsx @@ -1,19 +1,14 @@ import { render, screen } from '@testing-library/react'; -import { ReactNode, useEffect, useState } from 'react'; import { CourseMentorsStatsDto } from '@client/api'; import { EpamMentorsStatsCard } from './EpamMentorsStatsCard'; import { Colors } from '../../data'; vi.mock('next/dynamic', () => ({ - default: (loader: () => Promise<{ default: (p: Record) => ReactNode }>) => { - const Lazy = (props: Record) => { - const [Comp, setComp] = useState<((p: Record) => ReactNode) | null>(null); - useEffect(() => { - loader().then(m => setComp(() => m.default)); - }, []); - return Comp ? : null; - }; - return Lazy; + default: (loader: () => Promise) => { + void loader(); + return ({ count, total, color }: { count: number; total: number; color: string }) => ( +
    + ); }, })); @@ -31,17 +26,13 @@ const mentorsStats: CourseMentorsStatsDto = { }; describe('', () => { - it('renders the title and the epam/active mentors ratio', () => { + it('renders the title, ratio, and chart data', () => { render(); expect(screen.getByText('Epam Mentors')).toBeInTheDocument(); expect(screen.getByText('Epam Mentors: 12 / 40')).toBeInTheDocument(); - }); - - it('passes the epam count, active total and Purple color to the chart', async () => { - render(); - const chart = await screen.findByTestId('liquid-chart'); + const chart = screen.getByTestId('liquid-chart'); expect(chart).toHaveAttribute('data-count', '12'); expect(chart).toHaveAttribute('data-total', '40'); expect(chart).toHaveAttribute('data-color', Colors.Purple); diff --git a/client/src/modules/CourseStatistics/components/LiquidChart/LiquidChart.test.tsx b/client/src/modules/CourseStatistics/components/LiquidChart/LiquidChart.test.tsx index 75d15d191a..f778684732 100644 --- a/client/src/modules/CourseStatistics/components/LiquidChart/LiquidChart.test.tsx +++ b/client/src/modules/CourseStatistics/components/LiquidChart/LiquidChart.test.tsx @@ -9,7 +9,7 @@ vi.mock('@ant-design/plots', () => ({ Liquid: (config: { percent: number; style?: { fill?: string; contentText?: string } }) => (
    @@ -17,31 +17,25 @@ vi.mock('@ant-design/plots', () => ({ })); describe('', () => { - it('computes the percent ratio and formatted content text', () => { - render(); + it('renders default, custom-color, and zero-total chart data', () => { + const { rerender } = render(); const chart = screen.getByTestId('liquid-chart'); expect(chart).toHaveAttribute('data-percent', '0.25'); expect(chart).toHaveAttribute('data-content-text', '25.00%'); - }); - it('defaults the fill color to Blue', () => { - render(); + rerender(); expect(screen.getByTestId('liquid-chart')).toHaveAttribute('data-fill', Colors.Blue); - }); - it('forwards a custom color', () => { - render(); + rerender(); expect(screen.getByTestId('liquid-chart')).toHaveAttribute('data-fill', Colors.Gold); - }); - it('renders a NaN percent when total is zero (division guard not present)', () => { - render(); + rerender(); - const chart = screen.getByTestId('liquid-chart'); - expect(chart).toHaveAttribute('data-percent', 'NaN'); - expect(chart).toHaveAttribute('data-content-text', 'NaN%'); + const emptyChart = screen.getByTestId('liquid-chart'); + expect(emptyChart).toHaveAttribute('data-percent', 'NaN'); + expect(emptyChart).toHaveAttribute('data-content-text', 'NaN%'); }); }); diff --git a/client/src/modules/CourseStatistics/components/MentorsCountriesCard/MentorsCountriesCard.test.tsx b/client/src/modules/CourseStatistics/components/MentorsCountriesCard/MentorsCountriesCard.test.tsx index 00622a349d..9e52af63ae 100644 --- a/client/src/modules/CourseStatistics/components/MentorsCountriesCard/MentorsCountriesCard.test.tsx +++ b/client/src/modules/CourseStatistics/components/MentorsCountriesCard/MentorsCountriesCard.test.tsx @@ -49,14 +49,10 @@ const countriesStats: CountriesStatsDto = { }; describe('', () => { - it('renders the card title', () => { + it('renders the title and forwards chart data', async () => { render(); expect(screen.getByText('Mentors Countries')).toBeInTheDocument(); - }); - - it('forwards countries, active count, mentors axis title and Purple color', async () => { - render(); const chart = await screen.findByTestId('countries-chart'); expect(chart).toHaveAttribute('data-length', '3'); diff --git a/client/src/modules/CourseStatistics/components/StatCards/StatCards.test.tsx b/client/src/modules/CourseStatistics/components/StatCards/StatCards.test.tsx index a58fdccbb8..2aec9d3ba0 100644 --- a/client/src/modules/CourseStatistics/components/StatCards/StatCards.test.tsx +++ b/client/src/modules/CourseStatistics/components/StatCards/StatCards.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import { CourseAggregateStatsDto } from '@client/api'; import { StatCards } from './StatCards'; @@ -7,9 +7,10 @@ import { StatCards } from './StatCards'; // with a marker so we assert *which* cards are rendered for a given data shape and what // counts they receive — without pulling charts into jsdom. -vi.mock('@client/modules/Course/contexts', () => ({ - useActiveCourseContext: () => ({ course: { id: 42 } }), -})); +vi.mock('@client/modules/Course/contexts', () => { + const activeCourse = { course: { id: 42 } }; + return { useActiveCourseContext: () => activeCourse }; +}); const { getCourseTasks } = vi.hoisted(() => ({ getCourseTasks: vi.fn().mockResolvedValue({ data: [{ id: 1, name: 'T1' }] }), @@ -81,15 +82,21 @@ function makeData(overrides: Partial = {}): CourseAggre describe('', () => { beforeEach(() => vi.clearAllMocks()); - it('renders nothing-meaningful when no data is provided', () => { - render(); + it('renders nothing-meaningful when no data is provided', async () => { + // eslint-disable-next-line testing-library/no-unnecessary-act -- Await mount effects after the synchronous render + await act(async () => { + render(); + }); expect(screen.queryByTestId('card-students-stats')).not.toBeInTheDocument(); expect(screen.queryByTestId('card-epam-mentors')).not.toBeInTheDocument(); }); it('renders the full set of cards for fully-populated data with certified students', async () => { - render(); + // eslint-disable-next-line testing-library/no-unnecessary-act -- Await mount effects after the synchronous render + await act(async () => { + render(); + }); expect(screen.getByTestId('card-students-countries')).toHaveTextContent('80'); expect(screen.getByTestId('card-students-stats')).toBeInTheDocument(); @@ -108,7 +115,7 @@ describe('', () => { await waitFor(() => expect(getCourseTasks).toHaveBeenCalledWith(42)); }); - it('shows the eligible card (and hides certificate cards) when no students are certified', () => { + it('shows the eligible card (and hides certificate cards) when no students are certified', async () => { const data = makeData({ studentsStats: { activeStudentsCount: 80, @@ -118,18 +125,24 @@ describe('', () => { eligibleForCertificationCount: 40, }, }); - render(); + // eslint-disable-next-line testing-library/no-unnecessary-act -- Await mount effects after the synchronous render + await act(async () => { + render(); + }); expect(screen.getByTestId('card-eligible')).toBeInTheDocument(); expect(screen.queryByTestId('card-with-certificate')).not.toBeInTheDocument(); expect(screen.queryByTestId('card-certificates-countries')).not.toBeInTheDocument(); }); - it('hides the mentors-countries card when there are no active mentors', () => { + it('hides the mentors-countries card when there are no active mentors', async () => { const data = makeData({ mentorsStats: { mentorsActiveCount: 0, mentorsTotalCount: 0, epamMentorsCount: 0 }, }); - render(); + // eslint-disable-next-line testing-library/no-unnecessary-act -- Await mount effects after the synchronous render + await act(async () => { + render(); + }); expect(screen.queryByTestId('card-mentors-countries')).not.toBeInTheDocument(); expect(screen.queryByTestId('card-epam-mentors')).not.toBeInTheDocument(); diff --git a/client/src/modules/CourseStatistics/components/StatScopeSelector/StatScopeSelector.test.tsx b/client/src/modules/CourseStatistics/components/StatScopeSelector/StatScopeSelector.test.tsx index bb26374fbe..f439a5cfba 100644 --- a/client/src/modules/CourseStatistics/components/StatScopeSelector/StatScopeSelector.test.tsx +++ b/client/src/modules/CourseStatistics/components/StatScopeSelector/StatScopeSelector.test.tsx @@ -5,64 +5,54 @@ import { StatScope } from '@client/modules/CourseStatistics/constants'; // Brittle-widget stub: antd DatePicker (year picker) opens a panel that is heavy/flaky // in jsdom. Replace with a lightweight input that invokes onChange with a dayjs-like // object so we can assert the year-selection wiring without driving the real panel. -vi.mock('antd', async () => { - const actual = (await vi.importActual('antd')) as typeof import('antd'); +vi.mock('antd', () => { const DatePicker = ({ onChange }: { onChange?: (d: { year: () => number }) => void }) => ( ); - return { ...actual, DatePicker }; + const Switch = ({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }) => ( + })); + const dataCriteria = [ { key: '0', @@ -26,13 +29,11 @@ const dataCriteria = [ ] as CriteriaDto[]; describe('ExportJSONButton', () => { - test('contains following text', () => { - render(); + test('renders populated and empty criteria exports', () => { + const { rerender } = render(); expect(screen.getByText('Export JSON')).toBeInTheDocument(); - }); - test('should render correctly with empty dataCriteria', () => { - render(); + rerender(); const link = screen.getByRole('link'); expect(link).toHaveAttribute('download', 'crossCheckCriteria.json'); }); diff --git a/client/src/modules/CrossCheck/__tests__/UploadCriteriaJSON.test.tsx b/client/src/modules/CrossCheck/__tests__/UploadCriteriaJSON.test.tsx index d071165c49..4a349f7417 100644 --- a/client/src/modules/CrossCheck/__tests__/UploadCriteriaJSON.test.tsx +++ b/client/src/modules/CrossCheck/__tests__/UploadCriteriaJSON.test.tsx @@ -4,14 +4,10 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; const onLoad = vi.fn(); describe('UploadCriteriaJSON', () => { - test('contains following element', () => { + test('renders the upload control and accepts a JSON file', async () => { render(); const element = screen.getByText('Click to Upload Criteria (JSON)'); expect(element).toBeInTheDocument(); - }); - - test('upload file', async () => { - render(); global.URL.createObjectURL = vi.fn(); const file = new File(['{test: 1}'], 'test.json', { type: 'application/json' }); @@ -19,8 +15,6 @@ describe('UploadCriteriaJSON', () => { fireEvent.change(input, { target: { files: [file] } }); - await waitFor(() => { - expect(input.files).toHaveLength(1); - }); + await waitFor(() => expect(input.files).toHaveLength(1)); }); }); diff --git a/client/src/modules/CrossCheck/components/CriteriaForm.test.tsx b/client/src/modules/CrossCheck/components/CriteriaForm.test.tsx index 7121cbcffd..93d5efb9b6 100644 --- a/client/src/modules/CrossCheck/components/CriteriaForm.test.tsx +++ b/client/src/modules/CrossCheck/components/CriteriaForm.test.tsx @@ -1,6 +1,6 @@ /* eslint-disable testing-library/no-node-access */ import { render, screen, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { CrossCheckComment, CrossCheckCriteria } from '@client/services/course'; import { CriteriaForm } from './CriteriaForm'; @@ -32,33 +32,17 @@ describe('', () => { expect(container).toBeEmptyDOMElement(); }); - it('renders the title, criteria texts and max-score avatars', () => { - render(); + it('reports a percentage for each non-title criteria when the reviewer rates one', async () => { + const user = setupUser(); + const onChange = vi.fn(); + render(); expect(screen.getByRole('heading', { name: 'Section A' })).toBeInTheDocument(); expect(screen.getByText('Has tests')).toBeInTheDocument(); expect(screen.getByText('Has docs')).toBeInTheDocument(); - // max-score avatars expect(screen.getByText('5')).toBeInTheDocument(); expect(screen.getByText('3')).toBeInTheDocument(); - }); - - it('does not render a Self Review column when no self review is provided', () => { - render(); - expect(screen.queryByText('Self Review')).not.toBeInTheDocument(); - }); - - it('renders a Self Review column when a self review is provided', () => { - render(); - - expect(screen.getAllByText('Self Review').length).toBeGreaterThan(0); - }); - - it('reports a percentage for each non-title criteria when the reviewer rates one', async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - render(); // The first criteria's rate group; pick the third star ("Done" => 100%). const firstCard = screen.getByText('Has tests').closest('.ant-card') as HTMLElement; @@ -74,8 +58,14 @@ describe('', () => { ); }); + it('renders a Self Review column when a self review is provided', () => { + render(); + + expect(screen.getAllByText('Self Review').length).toBeGreaterThan(0); + }); + it('emits a partial percentage when the reviewer picks the middle rating', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onChange = vi.fn(); render(); @@ -90,7 +80,7 @@ describe('', () => { }); it('emits a review comment for the edited criteria', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onChange = vi.fn(); render(); @@ -105,7 +95,7 @@ describe('', () => { }); it('preserves the existing rating of other criteria when rating one', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onChange = vi.fn(); render( ', () => { }); it('preserves other criteria review comments and reuses their timestamp', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onChange = vi.fn(); const reviewComments: CrossCheckComment[] = [ { text: 'kept', criteriaId: 'c2', timestamp: 123, authorId: AUTHOR_ID }, @@ -171,7 +161,7 @@ describe('', () => { }); it('emits a zero percentage when the reviewer picks the lowest rating', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onChange = vi.fn(); // Start with a non-zero rating so re-selecting the first star is a real change to 0. render(); @@ -188,7 +178,7 @@ describe('', () => { }); it('emits comments with an empty review value when no value prop is provided', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onChange = vi.fn(); // value is omitted → `value ?? []` falls back to [] in onReviewCommentChange. render(); diff --git a/client/src/modules/CrossCheck/components/CrossCheckAssignmentLink.test.tsx b/client/src/modules/CrossCheck/components/CrossCheckAssignmentLink.test.tsx index a0247aa28d..595d9affac 100644 --- a/client/src/modules/CrossCheck/components/CrossCheckAssignmentLink.test.tsx +++ b/client/src/modules/CrossCheck/components/CrossCheckAssignmentLink.test.tsx @@ -10,24 +10,20 @@ const assignment: AssignmentLink = { }; describe('', () => { - it('renders nothing when there is no assignment', () => { - const { container } = render(); + it('renders empty, assigned, and missing-Discord states', () => { + const { container, rerender } = render(); expect(container).toBeEmptyDOMElement(); - }); - it('renders the student discord and the solution link', () => { - render(); + rerender(); expect(screen.getByText('Student Discord:', { exact: false })).toBeInTheDocument(); expect(screen.getByText('@octocat')).toBeInTheDocument(); const link = screen.getByRole('link', { name: assignment.url }); expect(link).toHaveAttribute('href', assignment.url); - }); - it('renders "unknown" discord when the student has no discord', () => { - render( + rerender( , diff --git a/client/src/modules/CrossCheck/components/CrossCheckCriteriaForm.test.tsx b/client/src/modules/CrossCheck/components/CrossCheckCriteriaForm.test.tsx index 12d2f04e16..c95984342f 100644 --- a/client/src/modules/CrossCheck/components/CrossCheckCriteriaForm.test.tsx +++ b/client/src/modules/CrossCheck/components/CrossCheckCriteriaForm.test.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { CrossCheckCriteriaDataDto, CrossCheckCriteriaDataDtoTypeEnum, CrossCheckSolutionReviewDto } from '@client/api'; import { CrossCheckCriteriaForm } from './CrossCheckCriteriaForm'; @@ -64,16 +64,6 @@ function Harness({ } describe('', () => { - it('renders criteria, penalty sections and the max-score label', () => { - render(); - - expect(screen.getByRole('heading', { name: 'Criteria' })).toBeInTheDocument(); - expect(screen.getByText('Layout section')).toBeInTheDocument(); - expect(screen.getByText('Implements the header')).toBeInTheDocument(); - expect(screen.getByText(/Broken layout/)).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: '(Max 100 points)' })).toBeInTheDocument(); - }); - it('recalculates the total score from the criteria points on mount', () => { const criteria = makeCriteria(); criteria[1].point = 7; @@ -97,12 +87,18 @@ describe('', () => { }); it('updates the running score as the reviewer scores a subtask', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); + expect(screen.getByRole('heading', { name: 'Criteria' })).toBeInTheDocument(); + expect(screen.getByText('Layout section')).toBeInTheDocument(); + expect(screen.getByText('Implements the header')).toBeInTheDocument(); + expect(screen.getByText(/Broken layout/)).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: '(Max 100 points)' })).toBeInTheDocument(); + const subtaskInput = screen.getAllByRole('spinbutton')[0]; await user.clear(subtaskInput); - await user.type(subtaskInput, '8'); + await user.type(subtaskInput, '8', { skipClick: true }); await waitFor(() => { expect(screen.getByTestId('score-value')).toHaveTextContent('8'); @@ -110,13 +106,13 @@ describe('', () => { }); it('lets the reviewer override the final score with the score input', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); // No criteria -> only the final-score InputNumber is rendered. const scoreInput = screen.getByRole('spinbutton'); await user.clear(scoreInput); - await user.type(scoreInput, '30'); + await user.type(scoreInput, '30', { skipClick: true }); await waitFor(() => { expect(screen.getByTestId('score-value')).toHaveTextContent('30'); @@ -124,7 +120,7 @@ describe('', () => { }); it('skips the form after confirming in the dialog and restores it on toggle', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole('button', { name: /Skip cross check form/ })); diff --git a/client/src/modules/CrossCheck/components/CrossCheckHistory.test.tsx b/client/src/modules/CrossCheck/components/CrossCheckHistory.test.tsx index 65955b844f..ffbc37f7df 100644 --- a/client/src/modules/CrossCheck/components/CrossCheckHistory.test.tsx +++ b/client/src/modules/CrossCheck/components/CrossCheckHistory.test.tsx @@ -46,13 +46,7 @@ function makeProps(overrides: Partial', () => { - it('renders the History heading', () => { - render(); - - expect(screen.getByRole('heading', { name: 'History' })).toBeInTheDocument(); - }); - - it('renders one SolutionReview per review in the timeline', () => { + it('renders the heading, reviews and active/outdated labels', () => { render( ', () => { expect(screen.getAllByTestId('solution-review')).toHaveLength(2); expect(screen.getByText('review-score-70')).toBeInTheDocument(); expect(screen.getByText('review-score-40')).toBeInTheDocument(); - }); - - it('marks the first review as the active review and the rest as outdated', () => { - render(); - + expect(screen.getByRole('heading', { name: 'History' })).toBeInTheDocument(); expect(screen.getByText('active review')).toBeInTheDocument(); expect(screen.getByText('outdated review')).toBeInTheDocument(); }); diff --git a/client/src/modules/CrossCheck/components/CrossCheckHistory.tsx b/client/src/modules/CrossCheck/components/CrossCheckHistory.tsx index 8da416c17a..fc89b3c836 100644 --- a/client/src/modules/CrossCheck/components/CrossCheckHistory.tsx +++ b/client/src/modules/CrossCheck/components/CrossCheckHistory.tsx @@ -49,61 +49,62 @@ export function CrossCheckHistory(props: Props) { )} - - {props.state.data.map((review, index) => { + { const isActiveReview = index === 0; - return ( - } - > - - {isActiveReview ? active review : outdated review} + return { + key: review.id, + color: isActiveReview ? 'green' : 'gray', + icon: , + content: ( + <> + + {isActiveReview ? active review : outdated review} - {review.author && ( - - your name is visible - - )} - + {review.author && ( + + your name is visible + + )} + - - - - - - - - - - - - - ); + + + + + + + + + + + + + ), + }; })} - + /> ); } diff --git a/client/src/modules/CrossCheck/components/DragSortTable.test.tsx b/client/src/modules/CrossCheck/components/DragSortTable.test.tsx index 28f7908e4e..76255859c1 100644 --- a/client/src/modules/CrossCheck/components/DragSortTable.test.tsx +++ b/client/src/modules/CrossCheck/components/DragSortTable.test.tsx @@ -20,7 +20,7 @@ function renderTable() { } describe('', () => { - it('renders rows through the custom draggable row component (not dragging)', () => { + it('applies styles based on the row drag state', () => { useSortable.mockReturnValue({ attributes: {}, setNodeRef: vi.fn(), @@ -28,16 +28,13 @@ describe('', () => { transition: undefined, isDragging: false, }); - renderTable(); + const { rerender } = renderTable(); const cell = screen.getByText('Alpha'); const row = cell.closest('tr')!; // Not dragging → no elevated z-index / relative positioning. expect(row.style.zIndex).toBe(''); expect(row.style.position).toBe(''); - }); - - it('applies the elevated dragging styles while a row is being dragged', () => { useSortable.mockReturnValue({ attributes: {}, setNodeRef: vi.fn(), @@ -45,10 +42,10 @@ describe('', () => { transition: undefined, isDragging: true, }); - renderTable(); + rerender( rowKey="key" columns={columns} dataSource={data} pagination={false} />); - const row = screen.getByText('Alpha').closest('tr')!; - expect(row.style.position).toBe('relative'); - expect(row.style.zIndex).toBe('9999'); + const draggingRow = screen.getByText('Alpha').closest('tr')!; + expect(draggingRow.style.position).toBe('relative'); + expect(draggingRow.style.zIndex).toBe('9999'); }); }); diff --git a/client/src/modules/CrossCheck/components/SolutionReview/Message/Message.test.tsx b/client/src/modules/CrossCheck/components/SolutionReview/Message/Message.test.tsx index eda0d8e8e1..73a596e1d3 100644 --- a/client/src/modules/CrossCheck/components/SolutionReview/Message/Message.test.tsx +++ b/client/src/modules/CrossCheck/components/SolutionReview/Message/Message.test.tsx @@ -19,57 +19,29 @@ const messageProps: MessageProps = { }; describe('Message', () => { - test('Should render author name', () => { - render(); + test('renders message details and read-state variants', () => { + const { rerender } = render(); expect(screen.getByText('John Doe')).toBeInTheDocument(); - }); - - test('Should render formatted timestamp', () => { - render(); - expect(screen.getByText('2022-03-15 00:00')).toBeInTheDocument(); - }); - - test('Should render role tag', () => { - render(); - expect(screen.getByText(messageProps.message.role)).toBeInTheDocument(); - }); - - test('Should render prepared comment with correct content', () => { - render(); - - const comment = screen.getByText('Lorem ipsum'); - expect(comment).toBeInTheDocument(); - }); + expect(screen.getByText('Lorem ipsum')).toBeInTheDocument(); - test('renders without a reviewNumber (defaults to 0)', () => { const { reviewNumber: _omit, ...rest } = messageProps; - render(); - - // Still renders the message; Username receives `reviewNumber ?? 0`. + rerender(); expect(screen.getByText('Lorem ipsum')).toBeInTheDocument(); expect(screen.getByText('John Doe')).toBeInTheDocument(); - }); - test('shows an unread badge for the reviewer when the reviewer has not read it', () => { - render( + rerender( , ); - - // Unread → the tooltip wrapper exposes the "Unread message" title. expect(screen.getByText('Lorem ipsum')).toBeInTheDocument(); - }); - - test('renders both read-receipt check marks when reviewer and student have read', () => { - render(); - // Both isReviewerRead and isStudentRead are true → two tooltip check icons. + rerender(); expect(screen.getByText('Lorem ipsum')).toBeInTheDocument(); }); }); diff --git a/client/src/modules/CrossCheck/components/SolutionReview/MessageSendingPanel/MessageSendingPanel.test.tsx b/client/src/modules/CrossCheck/components/SolutionReview/MessageSendingPanel/MessageSendingPanel.test.tsx index 8e89d622cf..c07af4213b 100644 --- a/client/src/modules/CrossCheck/components/SolutionReview/MessageSendingPanel/MessageSendingPanel.test.tsx +++ b/client/src/modules/CrossCheck/components/SolutionReview/MessageSendingPanel/MessageSendingPanel.test.tsx @@ -1,6 +1,6 @@ import { Form } from 'antd'; import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { CrossCheckMessageDtoRoleEnum } from '@client/api'; import { CrossCheckMessageAuthor } from '@client/services/course'; import MessageSendingPanel, { MessageSendingPanelProps } from './MessageSendingPanel'; @@ -34,47 +34,36 @@ function renderPanel(props: Partial = {}) { } describe('', () => { - it('renders a collapsed "Leave a message" input initially', () => { + it('renders collapsed controls, opens on click and cancels', async () => { + const user = setupUser(); renderPanel(); - expect(screen.getByPlaceholderText('Leave a message')).toBeInTheDocument(); + const collapsed = screen.getByPlaceholderText('Leave a message'); + expect(collapsed).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Send message/ })).not.toBeInTheDocument(); - }); - - it('opens the editing panel when the collapsed input is clicked', async () => { - const user = userEvent.setup(); - renderPanel(); - - await user.click(screen.getByPlaceholderText('Leave a message')); + await user.click(collapsed); expect(screen.getByRole('button', { name: /Send message/ })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Preview' })).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(screen.queryByRole('button', { name: /Send message/ })).not.toBeInTheDocument(); }); it('opens the editing panel when Enter is pressed on the collapsed input', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderPanel(); const collapsed = screen.getByPlaceholderText('Leave a message'); - collapsed.focus(); + await user.click(collapsed); await user.keyboard('{Enter}'); expect(screen.getByRole('button', { name: /Send message/ })).toBeInTheDocument(); }); - it('closes the panel again via Cancel', async () => { - const user = userEvent.setup(); - renderPanel(); - - await user.click(screen.getByPlaceholderText('Leave a message')); - await user.click(screen.getByRole('button', { name: 'Cancel' })); - - expect(screen.queryByRole('button', { name: /Send message/ })).not.toBeInTheDocument(); - }); - it('submits the typed message content through the form', async () => { - const user = userEvent.setup(); + const user = setupUser(); const { onFinish } = renderPanel(); await user.click(screen.getByPlaceholderText('Leave a message')); @@ -88,7 +77,7 @@ describe('', () => { }); it('blocks submitting an empty message and shows a validation error', async () => { - const user = userEvent.setup(); + const user = setupUser(); const { onFinish } = renderPanel(); await user.click(screen.getByPlaceholderText('Leave a message')); @@ -99,7 +88,7 @@ describe('', () => { }); it('toggles the markdown preview and shows the typed content', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderPanel(); await user.click(screen.getByPlaceholderText('Leave a message')); @@ -112,7 +101,7 @@ describe('', () => { }); it('shows "Nothing to preview" when previewing an empty message', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderPanel(); await user.click(screen.getByPlaceholderText('Leave a message')); diff --git a/client/src/modules/CrossCheck/components/SolutionReview/SolutionReview.test.tsx b/client/src/modules/CrossCheck/components/SolutionReview/SolutionReview.test.tsx index 320aecef23..7196540155 100644 --- a/client/src/modules/CrossCheck/components/SolutionReview/SolutionReview.test.tsx +++ b/client/src/modules/CrossCheck/components/SolutionReview/SolutionReview.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { CrossCheckCriteriaDataDtoTypeEnum, CrossCheckMessageDtoRoleEnum, @@ -64,6 +64,7 @@ describe('', () => { expect(screen.getByText('80')).toBeInTheDocument(); expect(screen.getByText('maximum score: 100')).toBeInTheDocument(); expect(screen.getByText('Nice solution')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Show detailed feedback' })).not.toBeInTheDocument(); }); it('shows "unknown" when no max score is provided', () => { @@ -73,7 +74,7 @@ describe('', () => { }); it('opens a detailed-feedback modal when criteria are present', async () => { - const user = userEvent.setup(); + const user = setupUser(); render( ', () => { expect(screen.getByText('Subtask in feedback')).toBeInTheDocument(); }); - it('does not render the detailed-feedback button when there are no criteria', () => { - render(); - - expect(screen.queryByRole('button', { name: 'Show detailed feedback' })).not.toBeInTheDocument(); - }); - it('sends a message through the course service with the markdown label', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByPlaceholderText('Leave a message')); diff --git a/client/src/modules/CrossCheck/components/SolutionReview/SolutionReview.tsx b/client/src/modules/CrossCheck/components/SolutionReview/SolutionReview.tsx index 8d420370de..58dabbef47 100644 --- a/client/src/modules/CrossCheck/components/SolutionReview/SolutionReview.tsx +++ b/client/src/modules/CrossCheck/components/SolutionReview/SolutionReview.tsx @@ -70,7 +70,7 @@ function SolutionReview(props: SolutionReviewProps) { if (!courseTaskId || !amountUnreadMessages) return; notification.info({ - message: howManyUnreadMessagesText, + title: howManyUnreadMessagesText, }); (async () => { diff --git a/client/src/modules/CrossCheck/components/SolutionReview/UserAvatar/UserAvatar.test.tsx b/client/src/modules/CrossCheck/components/SolutionReview/UserAvatar/UserAvatar.test.tsx index 67f8a26723..e323fe8ceb 100644 --- a/client/src/modules/CrossCheck/components/SolutionReview/UserAvatar/UserAvatar.test.tsx +++ b/client/src/modules/CrossCheck/components/SolutionReview/UserAvatar/UserAvatar.test.tsx @@ -10,40 +10,28 @@ function getAvatarImg(container: HTMLElement) { } describe('', () => { - it('uses the github avatar for a reviewer when contacts are visible', () => { - const { container } = render( + it('selects the avatar for each role and visibility state', () => { + const { container, rerender } = render( , ); expect(getAvatarImg(container)).toHaveAttribute('src', 'https://cdn.rs.school/avatars/octocat.png?size=64'); - }); - it('uses the expert icon for a reviewer when contacts are hidden', () => { - const { container } = render( + rerender( , ); expect(getAvatarImg(container)).toHaveAttribute('src', '/static/svg/sloths/Expert.svg'); - }); - it('uses the expert icon for a reviewer when author is null', () => { - const { container } = render( - , - ); + rerender(); expect(getAvatarImg(container)).toHaveAttribute('src', '/static/svg/sloths/Expert.svg'); - }); - it('uses the github avatar for a student when contacts are visible (size doubled)', () => { - const { container } = render( - , - ); + rerender(); expect(getAvatarImg(container)).toHaveAttribute('src', 'https://cdn.rs.school/avatars/octocat.png?size=48'); - }); - it('uses the thanks icon for a student when contacts are hidden', () => { - const { container } = render( + rerender( , ); diff --git a/client/src/modules/CrossCheck/components/SolutionReview/Username/Username.test.tsx b/client/src/modules/CrossCheck/components/SolutionReview/Username/Username.test.tsx index 1dddf808c4..ca2b75e745 100644 --- a/client/src/modules/CrossCheck/components/SolutionReview/Username/Username.test.tsx +++ b/client/src/modules/CrossCheck/components/SolutionReview/Username/Username.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import { CrossCheckMessageAuthor } from '@client/services/course'; import { Username } from '.'; import { CrossCheckMessageDtoRoleEnum } from '@client/api'; @@ -9,27 +9,30 @@ const mockAuthor: CrossCheckMessageAuthor = { }; describe('Username', () => { - test.each` - reviewNumber | author | role | areContactsVisible | expectedUsername - ${0} | ${null} | ${CrossCheckMessageDtoRoleEnum.Reviewer} | ${true} | ${'Reviewer 1'} - ${1} | ${null} | ${CrossCheckMessageDtoRoleEnum.Reviewer} | ${false} | ${'Reviewer 2'} - ${2} | ${mockAuthor} | ${CrossCheckMessageDtoRoleEnum.Reviewer} | ${true} | ${'test-github-1234'} - ${3} | ${mockAuthor} | ${CrossCheckMessageDtoRoleEnum.Reviewer} | ${false} | ${'Reviewer 4 (hidden)'} - ${4} | ${null} | ${CrossCheckMessageDtoRoleEnum.Student} | ${true} | ${'Student'} - ${5} | ${null} | ${CrossCheckMessageDtoRoleEnum.Student} | ${false} | ${'Student'} - ${6} | ${mockAuthor} | ${CrossCheckMessageDtoRoleEnum.Student} | ${true} | ${'test-github-1234'} - ${7} | ${mockAuthor} | ${CrossCheckMessageDtoRoleEnum.Student} | ${false} | ${'Student (hidden)'} - `( - `should display "$expectedUsername" if: - "reviewNumber" = "$reviewNumber", "author" = "$author", "role" = "$role", "areContactsVisible" = "$areContactsVisible"`, - ({ reviewNumber, author, role, areContactsVisible, expectedUsername }) => { - render( - , - ); + test('displays the expected username for every role, author, and visibility combination', () => { + const cases = [ + [0, null, CrossCheckMessageDtoRoleEnum.Reviewer, true, 'Reviewer 1'], + [1, null, CrossCheckMessageDtoRoleEnum.Reviewer, false, 'Reviewer 2'], + [2, mockAuthor, CrossCheckMessageDtoRoleEnum.Reviewer, true, 'test-github-1234'], + [3, mockAuthor, CrossCheckMessageDtoRoleEnum.Reviewer, false, 'Reviewer 4 (hidden)'], + [4, null, CrossCheckMessageDtoRoleEnum.Student, true, 'Student'], + [5, null, CrossCheckMessageDtoRoleEnum.Student, false, 'Student'], + [6, mockAuthor, CrossCheckMessageDtoRoleEnum.Student, true, 'test-github-1234'], + [7, mockAuthor, CrossCheckMessageDtoRoleEnum.Student, false, 'Student (hidden)'], + ] as const; - const username = screen.getByText(expectedUsername); + render( + <> + {cases.map(([reviewNumber, author, role, areContactsVisible], index) => ( +
    + +
    + ))} + , + ); - expect(username).toBeInTheDocument(); - }, - ); + cases.forEach(([, , , , expectedUsername], index) => { + expect(within(screen.getByTestId(`username-${index}`)).getByText(expectedUsername)).toBeInTheDocument(); + }); + }); }); diff --git a/client/src/modules/CrossCheck/components/SolutionReviewSettingsPanel/SolutionReviewSettingsPanel.test.tsx b/client/src/modules/CrossCheck/components/SolutionReviewSettingsPanel/SolutionReviewSettingsPanel.test.tsx index 1a21fd7d2f..cea517c220 100644 --- a/client/src/modules/CrossCheck/components/SolutionReviewSettingsPanel/SolutionReviewSettingsPanel.test.tsx +++ b/client/src/modules/CrossCheck/components/SolutionReviewSettingsPanel/SolutionReviewSettingsPanel.test.tsx @@ -1,37 +1,23 @@ import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import SolutionReviewSettingsPanel from './SolutionReviewSettingsPanel'; describe('', () => { - it('renders the contacts label and an unchecked switch by default', () => { - render(); + it('renders and toggles contact visibility with optional callbacks', async () => { + const user = setupUser(); + const setAreContactsVisible = vi.fn(); + const { rerender } = render( + , + ); expect(screen.getByText('Contacts')).toBeInTheDocument(); expect(screen.getByRole('switch')).not.toBeChecked(); - }); - - it('renders a checked switch when contacts are visible', () => { - render(); - - expect(screen.getByRole('switch')).toBeChecked(); - }); - - it('toggles contact visibility when the switch is clicked', async () => { - const user = userEvent.setup(); - const setAreContactsVisible = vi.fn(); - render(); - await user.click(screen.getByRole('switch')); - expect(setAreContactsVisible).toHaveBeenCalledWith(true); - }); - - it('does not throw when setAreContactsVisible is not provided', async () => { - const user = userEvent.setup(); - render(); + rerender(); + expect(screen.getByRole('switch')).toBeChecked(); await user.click(screen.getByRole('switch')); - expect(screen.getByRole('switch')).toBeInTheDocument(); }); }); diff --git a/client/src/modules/CrossCheck/components/SubmittedStatus.test.tsx b/client/src/modules/CrossCheck/components/SubmittedStatus.test.tsx index 2df925527c..4c41743a17 100644 --- a/client/src/modules/CrossCheck/components/SubmittedStatus.test.tsx +++ b/client/src/modules/CrossCheck/components/SubmittedStatus.test.tsx @@ -1,33 +1,34 @@ import { render, screen } from '@testing-library/react'; import { TaskSolution } from '@client/services/course'; +import type { ReactNode } from 'react'; import { SubmittedStatus } from './SubmittedStatus'; +vi.mock('antd', () => ({ + Alert: ({ title, message }: { title?: ReactNode; message?: ReactNode }) =>
    {title ?? message}
    , +})); + const solution = { url: 'https://github.com/student/solution', updatedDate: '2024-03-01T10:00:00.000Z', } as TaskSolution; describe('', () => { - it('renders nothing when the task does not exist', () => { - const { container } = render(); + it('renders each task and submission state', () => { + const { container, rerender } = render( + , + ); expect(container).toBeEmptyDOMElement(); - }); - it('encourages submission when no solution and deadline has not passed', () => { - render(); + rerender(); expect(screen.getByText(/Try to submit your solution as soon as possible/)).toBeInTheDocument(); - }); - it('warns about a passed deadline when no solution and deadline passed', () => { - render(); + rerender(); expect(screen.getByText(/Submission deadline has already passed/)).toBeInTheDocument(); - }); - it('renders the submitted solution link when a solution exists', () => { - render(); + rerender(); const link = screen.getByRole('link', { name: solution.url }); expect(link).toHaveAttribute('href', solution.url); diff --git a/client/src/modules/CrossCheck/components/criteria/CrossCheckCriteria.test.tsx b/client/src/modules/CrossCheck/components/criteria/CrossCheckCriteria.test.tsx index 20854d3540..352795577f 100644 --- a/client/src/modules/CrossCheck/components/criteria/CrossCheckCriteria.test.tsx +++ b/client/src/modules/CrossCheck/components/criteria/CrossCheckCriteria.test.tsx @@ -2,6 +2,22 @@ import { render, screen } from '@testing-library/react'; import { CrossCheckCriteriaDataDto, CrossCheckCriteriaDataDtoTypeEnum } from '@client/api'; import { CrossCheckCriteria } from './CrossCheckCriteria'; +vi.mock('antd', async () => { + const actual = await vi.importActual('antd'); + return { + ...actual, + theme: { + ...actual.theme, + useToken: () => ({ token: { colorBorder: '#ddd', colorBgLayout: '#fff', red3: '#f00', green3: '#0f0' } }), + }, + Typography: { + ...actual.Typography, + Text: ({ children }: { children: React.ReactNode }) => {children}, + Title: ({ children }: { children: React.ReactNode }) =>

    {children}

    , + }, + }; +}); + function criteria(overrides: Partial): CrossCheckCriteriaDataDto { return { key: 'k', @@ -13,82 +29,49 @@ function criteria(overrides: Partial): CrossCheckCrit } describe('', () => { - it('renders nothing when criteria is null', () => { - const { container } = render(); + it('renders empty, subtask, comment, points, penalty, and title states', () => { + const { container, rerender } = render(); expect(container).toBeEmptyDOMElement(); - }); - it('renders nothing when criteria is an empty array', () => { - const { container } = render(); + rerender(); expect(container).toBeEmptyDOMElement(); - }); - it('renders a subtask with its points and no comment', () => { - render( - , - ); - - expect(screen.getByText('Subtask one')).toBeInTheDocument(); - expect(screen.getByText('Points for criteria: 6/10')).toBeInTheDocument(); - expect(screen.queryByText('Comment:')).not.toBeInTheDocument(); - }); - - it('renders a multi-line comment for a subtask split into paragraphs', () => { - render( + rerender( , ); + expect(screen.getByText('Subtask one')).toBeInTheDocument(); + expect(screen.getByText('Points for criteria: 6/10')).toBeInTheDocument(); expect(screen.getByText('Comment:')).toBeInTheDocument(); expect(screen.getByText('line one')).toBeInTheDocument(); expect(screen.getByText('line two')).toBeInTheDocument(); - }); - - it('falls back to 0 points when a subtask has no point value', () => { - render(); - expect(screen.getByText('Points for criteria: 0/5')).toBeInTheDocument(); - }); - - it('renders the penalty section when a penalty with a point is present', () => { - render( - , - ); - expect(screen.getByRole('heading', { name: 'Penalty' })).toBeInTheDocument(); expect(screen.getByText(/Penalty for X/)).toBeInTheDocument(); - }); + expect(screen.queryByText('A title')).not.toBeInTheDocument(); - it('does not render the penalty section when penalty has no point', () => { - render( + rerender( , ); expect(screen.queryByRole('heading', { name: 'Penalty' })).not.toBeInTheDocument(); }); - - it('ignores title criteria entirely', () => { - render(); - - expect(screen.queryByText('A title')).not.toBeInTheDocument(); - }); }); diff --git a/client/src/modules/CrossCheck/components/criteria/CrossCheckCriteriaModal.test.tsx b/client/src/modules/CrossCheck/components/criteria/CrossCheckCriteriaModal.test.tsx index 41e7fd1293..8ab7ce9ed6 100644 --- a/client/src/modules/CrossCheck/components/criteria/CrossCheckCriteriaModal.test.tsx +++ b/client/src/modules/CrossCheck/components/criteria/CrossCheckCriteriaModal.test.tsx @@ -1,5 +1,4 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { fireEvent, render, screen } from '@testing-library/react'; import { CrossCheckCriteriaDataDto, CrossCheckCriteriaDataDtoTypeEnum } from '@client/api'; import { CrossCheckCriteriaModal } from './CrossCheckCriteriaModal'; @@ -14,37 +13,23 @@ const modalInfo: CrossCheckCriteriaDataDto[] = [ ]; describe('', () => { - it('does not render its content when closed', () => { - render(); + it('renders closed and open states and invokes both close actions', () => { + const showModal = vi.fn(); + const { rerender } = render( + , + ); expect(screen.queryByText('Subtask in modal')).not.toBeInTheDocument(); - }); - - it('renders the feedback title and criteria when open', () => { - render(); + rerender(); expect(screen.getByText('Feedback')).toBeInTheDocument(); expect(screen.getByText('Subtask in modal')).toBeInTheDocument(); expect(screen.getByText('Points for criteria: 5/10')).toBeInTheDocument(); - }); - - it('closes via the OK button', async () => { - const user = userEvent.setup(); - const showModal = vi.fn(); - render(); - - await user.click(screen.getByRole('button', { name: 'OK' })); + fireEvent.click(screen.getByRole('button', { name: 'OK' })); expect(showModal).toHaveBeenCalledWith(false); - }); - - it('closes via the Cancel button', async () => { - const user = userEvent.setup(); - const showModal = vi.fn(); - render(); - - await user.click(screen.getByRole('button', { name: 'Cancel' })); - + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); expect(showModal).toHaveBeenCalledWith(false); + expect(showModal).toHaveBeenCalledTimes(2); }); }); diff --git a/client/src/modules/CrossCheck/components/criteria/PenaltyCriteria.test.tsx b/client/src/modules/CrossCheck/components/criteria/PenaltyCriteria.test.tsx index 6596558fe4..cdfdd1df55 100644 --- a/client/src/modules/CrossCheck/components/criteria/PenaltyCriteria.test.tsx +++ b/client/src/modules/CrossCheck/components/criteria/PenaltyCriteria.test.tsx @@ -14,42 +14,26 @@ function makePenalty(overrides: Partial = {}): CrossC } describe('', () => { - it('renders the penalty text with the negative score', () => { - render(); + it('renders and updates unapplied and applied penalty states', async () => { + const user = userEvent.setup(); + const updateCriteriaData = vi.fn(); + const { rerender } = render( + , + ); expect(screen.getByText(/Late submission/)).toBeInTheDocument(); expect(screen.getByText(/\(-10 points\)/)).toBeInTheDocument(); - }); - - it('defaults to "No" when there is no penalty point', () => { - render(); const noRadio = screen.getByRole('radio', { name: 'No' }); const yesRadio = screen.getByRole('radio', { name: 'Yes' }); expect(noRadio).toBeChecked(); expect(yesRadio).not.toBeChecked(); - }); - - it('shows "Yes" selected when a penalty point is already applied', () => { - render(); - - expect(screen.getByRole('radio', { name: 'Yes' })).toBeChecked(); - }); - - it('applies the negative penalty score when the user selects "Yes"', async () => { - const user = userEvent.setup(); - const updateCriteriaData = vi.fn(); - render(); await user.click(screen.getByRole('radio', { name: 'Yes' })); - expect(updateCriteriaData).toHaveBeenCalledWith(expect.objectContaining({ key: 'penalty-1', point: -10 })); - }); - it('clears the penalty score when the user selects "No"', async () => { - const user = userEvent.setup(); - const updateCriteriaData = vi.fn(); - render(); + rerender(); + expect(screen.getByRole('radio', { name: 'Yes' })).toBeChecked(); await user.click(screen.getByRole('radio', { name: 'No' })); diff --git a/client/src/modules/CrossCheck/components/criteria/SubtaskCriteria.test.tsx b/client/src/modules/CrossCheck/components/criteria/SubtaskCriteria.test.tsx index 55bd91f645..14f0501a9b 100644 --- a/client/src/modules/CrossCheck/components/criteria/SubtaskCriteria.test.tsx +++ b/client/src/modules/CrossCheck/components/criteria/SubtaskCriteria.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { CrossCheckCriteriaDataDto, CrossCheckCriteriaDataDtoTypeEnum } from '@client/api'; import { SubtaskCriteria } from './SubtaskCriteria'; @@ -15,84 +15,47 @@ function makeSubtask(overrides: Partial = {}): CrossC } describe('', () => { - it('renders the criteria text and max points', () => { - render(); + it('renders criteria values and handles reviewer input', async () => { + const user = setupUser(); + const updateCriteriaData = vi.fn(); + render(); expect(screen.getByText('Implements feature X')).toBeInTheDocument(); expect(screen.getByText(/Max 10 points for criteria/)).toBeInTheDocument(); - }); - - it('reflects the current score in the number input', () => { - render(); - - expect(screen.getByRole('spinbutton')).toHaveValue('7'); - }); - - it('updates the score when the user types into the number input', async () => { - const user = userEvent.setup(); - const updateCriteriaData = vi.fn(); - render(); - const input = screen.getByRole('spinbutton'); + expect(input).toHaveValue('7'); await user.clear(input); await user.type(input, '5'); - expect(updateCriteriaData).toHaveBeenCalledWith(expect.objectContaining({ key: 'subtask-1', point: 5 })); - }); - - it('updates the comment when the user types into the textarea', async () => { - const user = userEvent.setup(); - const updateCriteriaData = vi.fn(); - render(); await user.type(screen.getByRole('textbox'), 'A'); - expect(updateCriteriaData).toHaveBeenCalledWith(expect.objectContaining({ textComment: 'A' })); + + fireEvent.keyDown(screen.getByRole('slider'), { key: 'ArrowRight', keyCode: 39 }); + expect(updateCriteriaData).toHaveBeenCalledWith(expect.objectContaining({ key: 'subtask-1', point: 8 })); }); - it('warns the user to leave a detailed comment when score is below max with a short comment', () => { - render( + it('shows the detailed-comment warning only when required', () => { + const { rerender } = render( , ); - expect(screen.getByText('Please leave a detailed comment')).toBeInTheDocument(); - }); - it('does not warn when a sufficiently long comment is provided for a partial score', () => { - render( + rerender( , ); - expect(screen.queryByText('Please leave a detailed comment')).not.toBeInTheDocument(); - }); - - it('does not warn when the full score is given', () => { - render(); + rerender(); expect(screen.queryByText('Please leave a detailed comment')).not.toBeInTheDocument(); - }); - - it('does not warn when the score is undefined (not yet scored)', () => { - render(); + rerender(); expect(screen.queryByText('Please leave a detailed comment')).not.toBeInTheDocument(); }); - - it('updates the score when the reviewer moves the slider', () => { - const updateCriteriaData = vi.fn(); - render(); - - // antd Slider does not respond to keyboard reliably in jsdom (no layout width); - // fire a keydown on the slider handle to drive its onChange. - const sliderHandle = screen.getByRole('slider'); - fireEvent.keyDown(sliderHandle, { key: 'ArrowRight', keyCode: 39 }); - - expect(updateCriteriaData).toHaveBeenCalledWith(expect.objectContaining({ key: 'subtask-1', point: 1 })); - }); }); diff --git a/client/src/modules/CrossCheck/components/criteria/TitleCriteria.test.tsx b/client/src/modules/CrossCheck/components/criteria/TitleCriteria.test.tsx index 306ef771f3..3f4cb61695 100644 --- a/client/src/modules/CrossCheck/components/criteria/TitleCriteria.test.tsx +++ b/client/src/modules/CrossCheck/components/criteria/TitleCriteria.test.tsx @@ -2,6 +2,11 @@ import { render, screen } from '@testing-library/react'; import { CrossCheckCriteriaDataDto, CrossCheckCriteriaDataDtoTypeEnum } from '@client/api'; import { TitleCriteria } from './TitleCriteria'; +vi.mock('antd', () => ({ + theme: { useToken: () => ({ token: { blue2: '#e6f4ff' } }) }, + Typography: { Text: ({ children }: React.PropsWithChildren) => children }, +})); + const titleData: CrossCheckCriteriaDataDto = { key: 'title-1', text: 'Section: Layout', diff --git a/client/src/modules/CrossCheckPairs/components/BadReview/BadReviewControllers.test.tsx b/client/src/modules/CrossCheckPairs/components/BadReview/BadReviewControllers.test.tsx index a587f9b977..c4e6bdcc1e 100644 --- a/client/src/modules/CrossCheckPairs/components/BadReview/BadReviewControllers.test.tsx +++ b/client/src/modules/CrossCheckPairs/components/BadReview/BadReviewControllers.test.tsx @@ -1,6 +1,6 @@ /* eslint-disable testing-library/no-node-access */ import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { CourseTaskDetailedDto } from '@client/api'; import { BadReviewControllers, IBadReview } from './BadReviewControllers'; @@ -28,7 +28,7 @@ const badReviews: IBadReview[] = [ }, ]; -async function selectTask(user: ReturnType, optionName: string) { +async function selectTask(user: ReturnType, optionName: string) { await user.click(screen.getByRole('combobox')); await user.click(await screen.findByText(optionName, { selector: '.ant-select-item-option-content' })); } @@ -39,42 +39,24 @@ describe('', () => { getData.mockResolvedValue(badReviews); }); - it('disables the action buttons until a task is selected', () => { + it('enables actions after task selection, shows bad comments and closes the modal', async () => { + const user = setupUser(); render(); - // antd Button with href renders an ; when disabled it has aria-disabled="true". const downloadLink = screen.getByText('Download solutions urls').closest('a') as HTMLElement; expect(downloadLink).toHaveAttribute('aria-disabled', 'true'); - expect(screen.getByRole('button', { name: 'Bad comment' })).toBeDisabled(); + const badCommentButton = screen.getByRole('button', { name: 'Bad comment' }); + expect(badCommentButton).toBeDisabled(); expect(screen.getByRole('button', { name: "Didn't check" })).toBeDisabled(); - }); - - it('lists the course tasks as select options', async () => { - const user = userEvent.setup(); - render(); await user.click(screen.getByRole('combobox')); - - expect(await screen.findByText('Task One', { selector: '.ant-select-item-option-content' })).toBeInTheDocument(); + const taskOne = await screen.findByText('Task One', { selector: '.ant-select-item-option-content' }); + expect(taskOne).toBeInTheDocument(); expect(screen.getByText('Task Two', { selector: '.ant-select-item-option-content' })).toBeInTheDocument(); - }); - - it('enables the actions and points the download link to the selected task', async () => { - const user = userEvent.setup(); - render(); + await user.click(taskOne); - await selectTask(user, 'Task One'); - - expect(screen.getByRole('button', { name: 'Bad comment' })).toBeEnabled(); - const downloadLink = screen.getByText('Download solutions urls').closest('a') as HTMLElement; + expect(badCommentButton).toBeEnabled(); expect(downloadLink).toHaveAttribute('href', '/api/v2/courses/42/cross-checks/1/csv'); - }); - - it('opens the "Bad comment" modal and shows the fetched data', async () => { - const user = userEvent.setup(); - render(); - - await selectTask(user, 'Task One'); await user.click(screen.getByRole('button', { name: 'Bad comment' })); const dialog = await screen.findByRole('dialog'); @@ -84,10 +66,14 @@ describe('', () => { expect(getData).toHaveBeenCalledWith(1, 'Bad comment', 42); }); expect(await within(dialog).findByText('too short')).toBeInTheDocument(); + + await waitFor(() => expect(dialog).toBeVisible()); + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.getByText('Bad checkers in Bad comment')).not.toBeVisible()); }); it('opens the "Didn\'t check" modal with the matching check type', async () => { - const user = userEvent.setup(); + const user = setupUser(); getData.mockResolvedValue([{ ...badReviews[0], studentAvgScore: 8 }]); render(); @@ -101,20 +87,4 @@ describe('', () => { expect(getData).toHaveBeenCalledWith(1, 'Did not check', 42); }); }); - - it('closes the modal via the Cancel button', async () => { - const user = userEvent.setup(); - render(); - - await selectTask(user, 'Task One'); - await user.click(screen.getByRole('button', { name: 'Bad comment' })); - - const dialog = await screen.findByRole('dialog'); - expect(dialog).toBeVisible(); - await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); - - await waitFor(() => { - expect(screen.getByText('Bad checkers in Bad comment')).not.toBeVisible(); - }); - }); }); diff --git a/client/src/modules/CrossCheckPairs/components/BadReview/BadReviewTable.test.tsx b/client/src/modules/CrossCheckPairs/components/BadReview/BadReviewTable.test.tsx index 5b0ce0a95a..34080681cb 100644 --- a/client/src/modules/CrossCheckPairs/components/BadReview/BadReviewTable.test.tsx +++ b/client/src/modules/CrossCheckPairs/components/BadReview/BadReviewTable.test.tsx @@ -14,32 +14,22 @@ const rows: IBadReview[] = [ ]; describe('', () => { - it('renders "No data" when there are no rows', () => { - render(); + it('renders empty, bad-comment, and did-not-check views', () => { + const { rerender } = render(); expect(screen.getByText('No data')).toBeInTheDocument(); - }); - it('renders the comment column but hides the average score for "Bad comment"', () => { - render(); + rerender(); - // antd Table duplicates header cells in a hidden measure row -> use getAllByText. expect(screen.getByRole('columnheader', { name: "Checker's comment" })).toBeInTheDocument(); expect(screen.getByText('too short')).toBeInTheDocument(); expect(screen.queryByRole('columnheader', { name: 'Average student score' })).not.toBeInTheDocument(); - }); + expect(screen.getByRole('link', { name: /checker-gh/ })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /student-gh/ })).toBeInTheDocument(); - it('renders the average score column but hides the comment for "Did not check"', () => { - render(); + rerender(); expect(screen.getByRole('columnheader', { name: 'Average student score' })).toBeInTheDocument(); expect(screen.queryByRole('columnheader', { name: "Checker's comment" })).not.toBeInTheDocument(); }); - - it('renders the checker and student as github links', () => { - render(); - - expect(screen.getByRole('link', { name: /checker-gh/ })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: /student-gh/ })).toBeInTheDocument(); - }); }); diff --git a/client/src/modules/CrossCheckPairs/components/BadReview/BadReviewTable.tsx b/client/src/modules/CrossCheckPairs/components/BadReview/BadReviewTable.tsx index af0307611c..ce6688704a 100644 --- a/client/src/modules/CrossCheckPairs/components/BadReview/BadReviewTable.tsx +++ b/client/src/modules/CrossCheckPairs/components/BadReview/BadReviewTable.tsx @@ -59,6 +59,17 @@ export const BadReviewTable = ({ data, type }: IBadReviewTableProps) => { } return ( - <>{data.length ? : No data} + <> + {data.length ? ( +
    JSON.stringify([record.taskName, record.checkerGithubId, record.studentGithubId])} + columns={columnsType} + dataSource={data} + scroll={{ x: true }} + /> + ) : ( + No data + )} + ); }; diff --git a/client/src/modules/CrossCheckPairs/components/CrossCheckPairsTable/CrossCheckPairsTable.test.tsx b/client/src/modules/CrossCheckPairs/components/CrossCheckPairsTable/CrossCheckPairsTable.test.tsx index 78467bfdee..44a7de7937 100644 --- a/client/src/modules/CrossCheckPairs/components/CrossCheckPairsTable/CrossCheckPairsTable.test.tsx +++ b/client/src/modules/CrossCheckPairs/components/CrossCheckPairsTable/CrossCheckPairsTable.test.tsx @@ -31,23 +31,12 @@ function makeProps(overrides: Partial', () => { - it('renders nothing until loaded is true', () => { - const { container } = render(); + it('renders its loading, populated, and empty states', () => { + const { container, rerender } = render(); expect(container).toBeEmptyDOMElement(); - }); - - it('renders the table header columns once loaded', () => { - render(); - - expect(screen.getByRole('columnheader', { name: /Task/ })).toBeInTheDocument(); - expect(screen.getByRole('columnheader', { name: /Checker/ })).toBeInTheDocument(); - expect(screen.getByRole('columnheader', { name: /Student/ })).toBeInTheDocument(); - expect(screen.getByRole('columnheader', { name: /Score/ })).toBeInTheDocument(); - }); - it('renders a row for each cross-check pair', () => { - render( + rerender( ', () => { })} />, ); - + expect(screen.getByRole('columnheader', { name: /Task/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Checker/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Student/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Score/ })).toBeInTheDocument(); expect(screen.getByText('Task A')).toBeInTheDocument(); expect(screen.getByText('Task B')).toBeInTheDocument(); expect(screen.getAllByRole('link', { name: 'student-gh' })).toHaveLength(2); - }); - it('renders an empty table when there are no pairs', () => { - render(); + rerender(); expect(screen.getByRole('columnheader', { name: /Task/ })).toBeInTheDocument(); expect(screen.queryByRole('link', { name: 'student-gh' })).not.toBeInTheDocument(); diff --git a/client/src/modules/CrossCheckPairs/components/CrossCheckPairsTable/CrossCheckPairsTable.tsx b/client/src/modules/CrossCheckPairs/components/CrossCheckPairsTable/CrossCheckPairsTable.tsx index 6a06fea25d..1591a4bd50 100644 --- a/client/src/modules/CrossCheckPairs/components/CrossCheckPairsTable/CrossCheckPairsTable.tsx +++ b/client/src/modules/CrossCheckPairs/components/CrossCheckPairsTable/CrossCheckPairsTable.tsx @@ -51,7 +51,7 @@ export const CrossCheckPairsTable = ({ size="small" rowClassName={styles.tableRow} onChange={onChange} - key="id" + rowKey="id" columns={getCrossCheckPairsColumns(viewComment)} /> diff --git a/client/src/modules/CrossCheckPairs/data/getCrossCheckPairsColumns.test.tsx b/client/src/modules/CrossCheckPairs/data/getCrossCheckPairsColumns.test.tsx index 2ce01aa955..eee6879e2c 100644 --- a/client/src/modules/CrossCheckPairs/data/getCrossCheckPairsColumns.test.tsx +++ b/client/src/modules/CrossCheckPairs/data/getCrossCheckPairsColumns.test.tsx @@ -1,6 +1,6 @@ import { Table } from 'antd'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { render, screen, within } from '@testing-library/react'; +import { setupUser } from '@client/__tests__/setupUser'; import { CrossCheckPairDto } from '@client/api'; import { getCrossCheckPairsColumns } from './getCrossCheckPairsColumns'; @@ -26,23 +26,15 @@ function renderTable(data: CrossCheckPairDto[], viewComment = vi.fn()) { return { viewComment }; } -describe('getCrossCheckPairsColumns', () => { - it('renders the github links for checker and student', () => { - renderTable([makePair()]); - - const checkerLink = screen.getByRole('link', { name: 'checker-gh' }); - const studentLink = screen.getByRole('link', { name: 'student-gh' }); - expect(checkerLink).toHaveAttribute('href', 'https://github.com/checker-gh'); - expect(studentLink).toHaveAttribute('href', 'https://github.com/student-gh'); - }); - - it('renders the task name and solution url', () => { - renderTable([makePair()]); - - expect(screen.getByText('Task 1')).toBeInTheDocument(); - expect(screen.getByText('https://github.com/student/solution')).toBeInTheDocument(); - }); +function getCommentButton() { + // Scope the action query to its data row to avoid scanning table filter controls. + // eslint-disable-next-line testing-library/no-node-access + const row = screen.getByText('Task 1').closest('tr') as HTMLTableRowElement; + expect(row).toHaveRole('row'); + return within(row).getByRole('button', { name: 'Show' }); +} +describe('getCrossCheckPairsColumns', () => { it('renders the score value', () => { renderTable([makePair({ score: 42 })]); @@ -56,11 +48,18 @@ describe('getCrossCheckPairsColumns', () => { }); it('enables the comment button when historical scores exist and calls viewComment', async () => { - const user = userEvent.setup(); + const user = setupUser(); const pair = makePair(); const { viewComment } = renderTable([pair]); - const showButton = screen.getByRole('button', { name: 'Show' }); + const checkerLink = screen.getByRole('link', { name: 'checker-gh' }); + const studentLink = screen.getByRole('link', { name: 'student-gh' }); + expect(checkerLink).toHaveAttribute('href', 'https://github.com/checker-gh'); + expect(studentLink).toHaveAttribute('href', 'https://github.com/student-gh'); + expect(screen.getByText('Task 1')).toBeInTheDocument(); + expect(screen.getByText('https://github.com/student/solution')).toBeInTheDocument(); + + const showButton = getCommentButton(); expect(showButton).toBeEnabled(); await user.click(showButton); @@ -74,12 +73,12 @@ describe('getCrossCheckPairsColumns', () => { // null/undefined value disables it. renderTable([makePair({ historicalScores: undefined as unknown as CrossCheckPairDto['historicalScores'] })]); - expect(screen.getByRole('button', { name: 'Show' })).toBeDisabled(); + expect(getCommentButton()).toBeDisabled(); }); it('keeps the comment button enabled even when historicalScores is an empty array', () => { renderTable([makePair({ historicalScores: [] })]); - expect(screen.getByRole('button', { name: 'Show' })).toBeEnabled(); + expect(getCommentButton()).toBeEnabled(); }); }); diff --git a/client/src/modules/CrossCheckPairs/pages/CrossCheckPairs/CrossCheckPairs.test.tsx b/client/src/modules/CrossCheckPairs/pages/CrossCheckPairs/CrossCheckPairs.test.tsx index 54a26ef892..b98e6a8fcf 100644 --- a/client/src/modules/CrossCheckPairs/pages/CrossCheckPairs/CrossCheckPairs.test.tsx +++ b/client/src/modules/CrossCheckPairs/pages/CrossCheckPairs/CrossCheckPairs.test.tsx @@ -1,5 +1,5 @@ -import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { act, render, screen, waitFor, within } from '@testing-library/react'; +import { setupUser } from '@client/__tests__/setupUser'; import { ReactNode } from 'react'; import { Modal } from 'antd'; import { CrossCheckPairDto } from '@client/api'; @@ -81,33 +81,22 @@ describe('', () => { ]); }); - afterEach(() => { - Modal.destroyAll(); - }); - - it('loads the cross-check pairs and renders them in the table', async () => { - render(); - - expect(await screen.findByText('https://github.com/student/solution')).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'checker-gh' })).toBeInTheDocument(); - - await waitFor(() => { - expect(getCrossCheckPairs).toHaveBeenCalledWith(42, 50, 1, 'task', 'ASC'); + afterEach(async () => { + await act(async () => { + Modal.destroyAll(); }); }); - it('passes only tasks that have pairs to the bad-review controllers', async () => { - render(); - - // Only the task with pairsCount > 0 is forwarded. - expect(await screen.findByText('tasks:1')).toBeInTheDocument(); - }); - it('opens the comment modal with the historical feedback for a pair', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); - const showButton = await screen.findByRole('button', { name: 'Show' }); + const solution = await screen.findByText('https://github.com/student/solution'); + // Scope the action query to the pair instead of table filter controls. + // eslint-disable-next-line testing-library/no-node-access + const row = solution.closest('tr') as HTMLTableRowElement; + expect(row).toHaveRole('row'); + const showButton = within(row).getByRole('button', { name: 'Show' }); await user.click(showButton); const dialog = await screen.findByRole('dialog'); @@ -116,11 +105,14 @@ describe('', () => { }); it('re-fetches with sort/pagination params when the table changes', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); // Wait for the initial load to finish. - await screen.findByText('https://github.com/student/solution'); + expect(await screen.findByText('https://github.com/student/solution')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'checker-gh' })).toBeInTheDocument(); + await waitFor(() => expect(getCrossCheckPairs).toHaveBeenCalledWith(42, 50, 1, 'task', 'ASC')); + expect(await screen.findByText('tasks:1')).toBeInTheDocument(); getCrossCheckPairs.mockClear(); // Click a sortable column header (e.g. Score) to trigger onChange. diff --git a/client/src/modules/CrossCheckPairs/pages/CrossCheckPairs/CrossCheckPairs.tsx b/client/src/modules/CrossCheckPairs/pages/CrossCheckPairs/CrossCheckPairs.tsx index f2a9e4e1ee..5f31777ef2 100644 --- a/client/src/modules/CrossCheckPairs/pages/CrossCheckPairs/CrossCheckPairs.tsx +++ b/client/src/modules/CrossCheckPairs/pages/CrossCheckPairs/CrossCheckPairs.tsx @@ -118,7 +118,7 @@ export default function Page() { const handleViewComment = ({ historicalScores, checker, messages }: CrossCheckPairDto) => { modal.info({ width: 1020, - maskClosable: true, + mask: { closable: true }, title: `Comment from ${checker.githubId}`, content: historicalScores.map((historicalScore, index) => ( diff --git a/client/src/modules/Discipline/components/DisciplineModal.test.tsx b/client/src/modules/Discipline/components/DisciplineModal.test.tsx index 6f013a033f..a260c784ee 100644 --- a/client/src/modules/Discipline/components/DisciplineModal.test.tsx +++ b/client/src/modules/Discipline/components/DisciplineModal.test.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { message } from 'antd'; +import { setupUser } from '@client/__tests__/setupUser'; import { DisciplineDto } from '@client/api'; import { DisciplineModal } from './DisciplineModal'; @@ -13,8 +13,7 @@ const { createDiscipline, updateDiscipline } = vi.hoisted(() => ({ updateDiscipline: vi.fn(), })); -vi.mock('@client/api', async () => ({ - ...(await vi.importActual('@client/api')), +vi.mock('@client/api', () => ({ DisciplinesApi: function DisciplinesApi() { return { createDiscipline, updateDiscipline }; }, @@ -39,22 +38,6 @@ describe('', () => { updateDiscipline.mockResolvedValue({}); }); - it('renders the "Add discipline" title and an empty input when creating', () => { - render(); - - expect(screen.getByText('Add discipline')).toBeInTheDocument(); - const input = screen.getByLabelText('Discipline'); - expect(input).toBeInTheDocument(); - expect(input).toHaveValue(''); - }); - - it('renders the "Edit discipline" title and prefills the input when editing', () => { - render(); - - expect(screen.getByText('Edit discipline')).toBeInTheDocument(); - expect(screen.getByLabelText('Discipline')).toHaveValue('Frontend'); - }); - it('does not render the modal body when not visible', () => { render(); @@ -63,7 +46,7 @@ describe('', () => { }); it('shows a validation error and does not submit when the name is empty', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); @@ -76,7 +59,7 @@ describe('', () => { }); it('creates a discipline with the typed name, reloads and closes', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); @@ -90,13 +73,15 @@ describe('', () => { }); it('updates the existing discipline by id when editing', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps({ discipline: editDiscipline }); render(); + expect(screen.getByText('Edit discipline')).toBeInTheDocument(); const input = screen.getByLabelText('Discipline'); + expect(input).toHaveValue('Frontend'); await user.clear(input); - await user.type(input, 'Fullstack'); + await user.type(input, 'Fullstack', { skipClick: true }); await user.click(screen.getByRole('button', { name: /ok/i })); await waitFor(() => expect(updateDiscipline).toHaveBeenCalledWith(7, { name: 'Fullstack' })); @@ -106,7 +91,7 @@ describe('', () => { }); it('shows an error message and stays open when the API rejects', async () => { - const user = userEvent.setup(); + const user = setupUser(); const errorSpy = vi.spyOn(message, 'error').mockImplementation(() => ({}) as never); createDiscipline.mockRejectedValueOnce(new Error('boom')); const props = makeProps(); @@ -121,10 +106,15 @@ describe('', () => { }); it('calls onCancel when the Cancel button is clicked', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); + expect(screen.getByText('Add discipline')).toBeInTheDocument(); + const input = screen.getByLabelText('Discipline'); + expect(input).toBeInTheDocument(); + expect(input).toHaveValue(''); + await user.click(screen.getByRole('button', { name: /cancel/i })); expect(props.onCancel).toHaveBeenCalled(); diff --git a/client/src/modules/Discipline/components/DisciplineModal.tsx b/client/src/modules/Discipline/components/DisciplineModal.tsx index c43667ce00..ea50de2a60 100644 --- a/client/src/modules/Discipline/components/DisciplineModal.tsx +++ b/client/src/modules/Discipline/components/DisciplineModal.tsx @@ -13,7 +13,11 @@ const disciplineService = new DisciplinesApi(); export function DisciplineModal({ isModalVisible, onCancel, loadDisciplines, discipline }: IDisciplineModal) { const [form] = Form.useForm(); - useEffect(() => form.resetFields, [isModalVisible]); + useEffect(() => { + if (isModalVisible) { + form.resetFields(); + } + }, [isModalVisible, form]); const initialValues = { name: discipline?.name, diff --git a/client/src/modules/Discipline/components/DisciplineTable.test.tsx b/client/src/modules/Discipline/components/DisciplineTable.test.tsx index a3a5d9186a..03592b57c1 100644 --- a/client/src/modules/Discipline/components/DisciplineTable.test.tsx +++ b/client/src/modules/Discipline/components/DisciplineTable.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { Modal } from 'antd'; import { DisciplineDto } from '@client/api'; import { DisciplineTable } from './DisciplineTable'; @@ -26,6 +26,13 @@ async function awaitNoOpenDialog() { await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), { timeout: 3000 }); } +function getDisciplineRow(name: string) { + // eslint-disable-next-line testing-library/no-node-access -- Avoid querying buttons across every table row. + const row = screen.getByText(name).closest('tr'); + expect(row).toHaveRole('row'); + return row!; +} + describe('', () => { beforeEach(() => vi.clearAllMocks()); afterEach(async () => { @@ -37,47 +44,32 @@ describe('', () => { } }); - it('renders the column headers', () => { - render(); + it('renders headers, rows and action buttons, then edits the selected record', async () => { + const user = setupUser(); + const props = makeProps(); + render(); expect(screen.getByText('Discipline')).toBeInTheDocument(); expect(screen.getByText('Actions')).toBeInTheDocument(); - }); - - it('renders a row per discipline', () => { - render(); - expect(screen.getByText('Frontend')).toBeInTheDocument(); expect(screen.getByText('Backend')).toBeInTheDocument(); - }); - - it('renders edit and delete buttons for every row', () => { - render(); - - // Each row has two action buttons (edit + delete) → 2 rows = 4 buttons. - const table = screen.getByRole('table'); - expect(within(table).getAllByRole('button')).toHaveLength(4); - }); - - it('calls handleUpdate with the clicked record when its edit button is pressed', async () => { - const user = userEvent.setup(); - const props = makeProps(); - render(); + const buttons = within(screen.getByRole('table')).getAllByRole('button'); + expect(buttons).toHaveLength(4); // The first action button of the first row is "edit". - const [firstEditBtn] = within(screen.getByRole('table')).getAllByRole('button'); + const [firstEditBtn] = buttons; await user.click(firstEditBtn!); expect(props.handleUpdate).toHaveBeenCalledWith(disciplines[0]); }); it('opens a confirm dialog and calls handleDelete on confirm', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); // Buttons render as [edit, delete] per row → index 1 is the first row's delete. - const buttons = within(screen.getByRole('table')).getAllByRole('button'); + const buttons = within(getDisciplineRow('Frontend')).getAllByRole('button'); await user.click(buttons[1]!); // antd Modal.confirm renders into the document body. It echoes the title text in @@ -91,11 +83,11 @@ describe('', () => { }); it('does not call handleDelete when the confirm dialog is cancelled', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); - const buttons = within(screen.getByRole('table')).getAllByRole('button'); + const buttons = within(getDisciplineRow('Frontend')).getAllByRole('button'); await user.click(buttons[1]!); const dialog = await screen.findByRole('dialog'); diff --git a/client/src/modules/Discipline/pages/DisciplinePage.test.tsx b/client/src/modules/Discipline/pages/DisciplinePage.test.tsx index 3b429415fc..14cccc82ea 100644 --- a/client/src/modules/Discipline/pages/DisciplinePage.test.tsx +++ b/client/src/modules/Discipline/pages/DisciplinePage.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { ReactNode } from 'react'; import { message, Modal } from 'antd'; import { DisciplineDto } from '@client/api'; @@ -31,8 +31,7 @@ const { getDisciplines, createDiscipline, updateDiscipline, deleteDiscipline } = deleteDiscipline: vi.fn(), })); -vi.mock('@client/api', async () => ({ - ...(await vi.importActual('@client/api')), +vi.mock('@client/api', () => ({ DisciplinesApi: function DisciplinesApi() { return { getDisciplines, createDiscipline, updateDiscipline, deleteDiscipline }; }, @@ -68,25 +67,14 @@ describe('', () => { expect(screen.getByRole('heading', { name: /manage disciplines/i })).toBeInTheDocument(); }); - it('opens the create modal when "Add Disciplines" is clicked', async () => { - const user = userEvent.setup(); + it('creates a discipline and reloads the list on submit', async () => { + const user = setupUser(); render(); await screen.findByText('Frontend'); await user.click(screen.getByRole('button', { name: /add disciplines/i })); - expect(await screen.findByText('Add discipline')).toBeInTheDocument(); - // Create mode → empty input. expect(screen.getByLabelText('Discipline')).toHaveValue(''); - }); - - it('creates a discipline and reloads the list on submit', async () => { - const user = userEvent.setup(); - render(); - await screen.findByText('Frontend'); - - await user.click(screen.getByRole('button', { name: /add disciplines/i })); - await screen.findByText('Add discipline'); await user.type(screen.getByLabelText('Discipline'), 'DevOps'); await user.click(screen.getByRole('button', { name: /ok/i })); @@ -96,29 +84,17 @@ describe('', () => { await waitFor(() => expect(getDisciplines).toHaveBeenCalledTimes(2)); }); - it('opens the edit modal prefilled when a row edit button is clicked', async () => { - const user = userEvent.setup(); - render(); - await screen.findByText('Frontend'); - - // First row's first action button is "edit". - const [firstEditBtn] = within(screen.getByRole('table')).getAllByRole('button'); - await user.click(firstEditBtn!); - - expect(await screen.findByText('Edit discipline')).toBeInTheDocument(); - expect(screen.getByLabelText('Discipline')).toHaveValue('Frontend'); - }); - it('updates the discipline by id when editing and saving', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('Frontend'); const [firstEditBtn] = within(screen.getByRole('table')).getAllByRole('button'); await user.click(firstEditBtn!); - await screen.findByText('Edit discipline'); + expect(await screen.findByText('Edit discipline')).toBeInTheDocument(); const input = screen.getByLabelText('Discipline'); + expect(input).toHaveValue('Frontend'); await user.clear(input); await user.type(input, 'Frontend X'); await user.click(screen.getByRole('button', { name: /ok/i })); @@ -127,7 +103,7 @@ describe('', () => { }); it('deletes a discipline after confirming and reloads the list', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('Frontend'); diff --git a/client/src/modules/DiscordAdmin/components/DiscordServersModal.test.tsx b/client/src/modules/DiscordAdmin/components/DiscordServersModal.test.tsx index 2bdd40310a..287835db8d 100644 --- a/client/src/modules/DiscordAdmin/components/DiscordServersModal.test.tsx +++ b/client/src/modules/DiscordAdmin/components/DiscordServersModal.test.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { DiscordServerDto, UpdateDiscordServerDto } from '@client/api'; +import { setupUser } from '@client/__tests__/setupUser'; import { DiscordServersModal } from './DiscordServersModal'; // --- Boundary --------------------------------------------------------------- @@ -33,25 +33,8 @@ describe('', () => { expect(container).toBeEmptyDOMElement(); }); - it('renders the title and three empty inputs when creating', () => { - render(); - - expect(screen.getByText('Discord/Telegram channel')).toBeInTheDocument(); - expect(screen.getByLabelText('Name')).toHaveValue(''); - expect(screen.getByLabelText('Gratitude URL')).toHaveValue(''); - expect(screen.getByLabelText('Mentors chat URL')).toHaveValue(''); - }); - - it('prefills the inputs from getInitialValues when editing', () => { - render(); - - expect(screen.getByLabelText('Name')).toHaveValue('RS Discord'); - expect(screen.getByLabelText('Gratitude URL')).toHaveValue('https://discord.gg/grat'); - expect(screen.getByLabelText('Mentors chat URL')).toHaveValue('https://discord.gg/mentors'); - }); - it('shows validation errors and does not submit when fields are empty', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); @@ -64,7 +47,7 @@ describe('', () => { }); it('submits the typed values when all fields are valid', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); @@ -83,23 +66,32 @@ describe('', () => { }); it('submits edited values keyed off the existing record', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps({ data: editServer }); render(); + expect(screen.getByLabelText('Name')).toHaveValue('RS Discord'); + expect(screen.getByLabelText('Gratitude URL')).toHaveValue('https://discord.gg/grat'); + expect(screen.getByLabelText('Mentors chat URL')).toHaveValue('https://discord.gg/mentors'); + const name = screen.getByLabelText('Name'); await user.clear(name); - await user.type(name, 'Renamed'); + await user.type(name, 'Renamed', { skipClick: true }); await user.click(screen.getByRole('button', { name: /save/i })); await waitFor(() => expect(props.submit).toHaveBeenCalledWith(expect.objectContaining({ name: 'Renamed' }))); }); - it('cancels immediately when the form is untouched', async () => { - const user = userEvent.setup(); + it('renders empty create fields and cancels when untouched', async () => { + const user = setupUser(); const props = makeProps(); render(); + expect(screen.getByText('Discord/Telegram channel')).toBeInTheDocument(); + expect(screen.getByLabelText('Name')).toHaveValue(''); + expect(screen.getByLabelText('Gratitude URL')).toHaveValue(''); + expect(screen.getByLabelText('Mentors chat URL')).toHaveValue(''); + await user.click(screen.getByRole('button', { name: /cancel/i })); expect(props.cancel).toHaveBeenCalled(); diff --git a/client/src/modules/DiscordAdmin/components/DiscordServersTable.test.tsx b/client/src/modules/DiscordAdmin/components/DiscordServersTable.test.tsx index 868c382c25..06794bdd4c 100644 --- a/client/src/modules/DiscordAdmin/components/DiscordServersTable.test.tsx +++ b/client/src/modules/DiscordAdmin/components/DiscordServersTable.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { DiscordServerDto } from '@client/api'; import { DiscordServersTable } from './DiscordServersTable'; @@ -8,33 +8,37 @@ const data: DiscordServerDto[] = [ { id: 2, name: 'Beta', gratitudeUrl: 'https://b/grat', mentorsChatUrl: 'https://b/mentors' }, ]; +function getServerRow(name: string) { + // Avoid computing accessible names for every table row. + // eslint-disable-next-line testing-library/no-node-access + const row = screen.getByText(name).closest('tr') as HTMLTableRowElement; + expect(row).toHaveRole('row'); + return row; +} + describe('', () => { - it('renders a row per server with its name and urls', () => { - render(); + it('calls onEdit with the row record when Edit is clicked', async () => { + const user = setupUser(); + const onEdit = vi.fn(); + render(); expect(screen.getByText('Alpha')).toBeInTheDocument(); expect(screen.getByText('Beta')).toBeInTheDocument(); expect(screen.getByText('https://a/grat')).toBeInTheDocument(); expect(screen.getByText('https://b/mentors')).toBeInTheDocument(); - }); - - it('calls onEdit with the row record when Edit is clicked', async () => { - const user = userEvent.setup(); - const onEdit = vi.fn(); - render(); - const alphaRow = screen.getByRole('row', { name: /Alpha/ }); + const alphaRow = getServerRow('Alpha'); await user.click(within(alphaRow).getByText('Edit')); expect(onEdit).toHaveBeenCalledWith(data[0]); }); it('calls onDelete with the id only after confirming the popconfirm', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onDelete = vi.fn(); render(); - const betaRow = screen.getByRole('row', { name: /Beta/ }); + const betaRow = getServerRow('Beta'); await user.click(within(betaRow).getByText('Delete')); // Popconfirm bubble appears; clicking its OK fires the confirm. @@ -45,7 +49,7 @@ describe('', () => { }); it('sorts by name when the Name column header is clicked', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByText('Name')); diff --git a/client/src/modules/DiscordAdmin/pages/DiscordAdminPage/DiscordAdminPage.test.tsx b/client/src/modules/DiscordAdmin/pages/DiscordAdminPage/DiscordAdminPage.test.tsx index 9ef6fec624..6a3b2b2065 100644 --- a/client/src/modules/DiscordAdmin/pages/DiscordAdminPage/DiscordAdminPage.test.tsx +++ b/client/src/modules/DiscordAdmin/pages/DiscordAdminPage/DiscordAdminPage.test.tsx @@ -1,7 +1,7 @@ import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { ReactNode } from 'react'; import { message } from 'antd'; +import { setupUser } from '@client/__tests__/setupUser'; import { DiscordServerDto } from '@client/api'; import { DiscordAdminPage } from './DiscordAdminPage'; @@ -31,8 +31,7 @@ const { getDiscordServers, createDiscordServer, updateDiscordServer, deleteDisco deleteDiscordServer: vi.fn(), })); -vi.mock('@client/api', async () => ({ - ...(await vi.importActual('@client/api')), +vi.mock('@client/api', () => ({ DiscordServersApi: function DiscordServersApi() { return { getDiscordServers, createDiscordServer, updateDiscordServer, deleteDiscordServer }; }, @@ -43,6 +42,13 @@ const servers: DiscordServerDto[] = [ { id: 2, name: 'Beta', gratitudeUrl: 'https://b/grat', mentorsChatUrl: 'https://b/mentors' }, ]; +function getServerRow(name: string) { + // eslint-disable-next-line testing-library/no-node-access -- Avoid computing accessible names for every table row. + const row = screen.getByText(name).closest('tr'); + expect(row).toHaveRole('row'); + return row!; +} + describe('', () => { beforeEach(() => { vi.clearAllMocks(); @@ -61,29 +67,17 @@ describe('', () => { expect(screen.getByRole('heading', { name: /manage discord\/telegram/i })).toBeInTheDocument(); }); - it('opens an empty create modal when the add button is clicked', async () => { - const user = userEvent.setup(); - render(); - await screen.findByText('Alpha'); - - await user.click(screen.getByRole('button', { name: /add discord\/telegram channel/i })); - - expect(await screen.findByText('Discord/Telegram channel')).toBeInTheDocument(); - // The Table renders sortable diff --git a/client/src/modules/Home/components/NoCourse/index.test.tsx b/client/src/modules/Home/components/NoCourse/index.test.tsx index c6f9e37c35..89e2886f01 100644 --- a/client/src/modules/Home/components/NoCourse/index.test.tsx +++ b/client/src/modules/Home/components/NoCourse/index.test.tsx @@ -15,35 +15,26 @@ function makeCourse(overrides: Partial = {}): Course { } describe('', () => { - it('always offers mentor registration', () => { - render(); + it('renders registration options for each course state', () => { + const { rerender } = render(); expect(screen.getByText(/not student or mentor in any active course/i)).toBeInTheDocument(); - // antd renders Button with href as an anchor (role="link"). const mentorLink = screen.getByRole('link', { name: /register as mentor/i }); expect(mentorLink).toHaveAttribute('href', '/registry/mentor'); - }); - it('hides student registration when there are no planned courses', () => { - render(); + rerender(); expect(screen.queryByRole('link', { name: /register as student/i })).not.toBeInTheDocument(); expect(screen.getByText(/there are no any planned courses/i)).toBeInTheDocument(); - }); - it('shows student registration and an upcoming-course hint when a planned course exists', () => { - render(); + rerender(); const studentLink = screen.getByRole('link', { name: /register as student/i }); expect(studentLink).toHaveAttribute('href', '/registry/student'); expect(screen.getByText(/register to the upcoming course/i)).toBeInTheDocument(); - }); - it('treats a planned-but-completed course as not planned', () => { - render(); + rerender(); expect(screen.queryByRole('link', { name: /register as student/i })).not.toBeInTheDocument(); - }); - it('renders a confirm button per preselected course', () => { const preselected = [makeCourse({ id: 5, name: 'Mentored', alias: 'mn' })]; - render(); + rerender(); const confirm = screen.getByRole('link', { name: /confirm mentored/i }); expect(confirm).toHaveAttribute('href', '/course/mentor/confirm?course=mn'); }); diff --git a/client/src/modules/Home/components/RegistryBanner/index.test.tsx b/client/src/modules/Home/components/RegistryBanner/index.test.tsx index 8a5b999085..c9397c89eb 100644 --- a/client/src/modules/Home/components/RegistryBanner/index.test.tsx +++ b/client/src/modules/Home/components/RegistryBanner/index.test.tsx @@ -2,16 +2,15 @@ import { render, screen } from '@testing-library/react'; import { RegistryBanner } from './'; describe('', () => { - it('invites the user to register as a mentor', () => { - render(); + it('invites the user to register and forwards alert props', () => { + const { rerender } = render(); expect(screen.getByText(/looking for mentors/i)).toBeInTheDocument(); // antd renders Button with href as an anchor (role="link"). const link = screen.getByRole('link', { name: /register as mentor/i }); expect(link).toHaveAttribute('href', '/registry/mentor'); - }); - it('forwards extra alert props such as a custom type', () => { - render(); + rerender(); + expect(screen.getByRole('alert')).toHaveClass('ant-alert-success'); }); }); diff --git a/client/src/modules/Home/components/SystemAlerts/index.test.tsx b/client/src/modules/Home/components/SystemAlerts/index.test.tsx index 162d599af9..47b3cc345d 100644 --- a/client/src/modules/Home/components/SystemAlerts/index.test.tsx +++ b/client/src/modules/Home/components/SystemAlerts/index.test.tsx @@ -2,26 +2,30 @@ import { render, screen } from '@testing-library/react'; import { SystemAlerts } from './'; import type { AlertDto } from '@client/api'; +vi.mock('antd', () => ({ + Alert: ({ title, type }: { title: React.ReactNode; type: string }) => ( +
    + {title} +
    + ), +})); + describe('', () => { - it('renders nothing when there are no alerts', () => { - const { container } = render(); + it('renders no empty alerts and maps supplied alert types', () => { + const { container, rerender } = render(); expect(container).toBeEmptyDOMElement(); - }); - it('renders one antd alert per item', () => { const alerts = [ { text: 'First alert', type: 'info' }, { text: 'Second alert', type: 'error' }, ] as AlertDto[]; - render(); + rerender(); expect(screen.getByText('First alert')).toBeInTheDocument(); expect(screen.getByText('Second alert')).toBeInTheDocument(); expect(screen.getAllByRole('alert')).toHaveLength(2); - }); - it('maps the "warn" type to antd "warning"', () => { - const alerts = [{ text: 'Careful', type: 'warn' }] as AlertDto[]; - render(); + const warningAlerts = [{ text: 'Careful', type: 'warn' }] as AlertDto[]; + rerender(); expect(screen.getByRole('alert')).toHaveClass('ant-alert-warning'); }); }); diff --git a/client/src/modules/Home/components/SystemAlerts/index.tsx b/client/src/modules/Home/components/SystemAlerts/index.tsx index 6a2db0274f..ede630dbe0 100644 --- a/client/src/modules/Home/components/SystemAlerts/index.tsx +++ b/client/src/modules/Home/components/SystemAlerts/index.tsx @@ -10,7 +10,7 @@ export function SystemAlerts({ alerts }: Props) { <> {alerts.map(({ text, type }) => { const alertType = type === 'warn' ? 'warning' : type; - return ; + return ; })} ); diff --git a/client/src/modules/Home/hooks/useActiveCourse.test.tsx b/client/src/modules/Home/hooks/useActiveCourse.test.tsx index 716ac01fd9..4b0d2271e4 100644 --- a/client/src/modules/Home/hooks/useActiveCourse.test.tsx +++ b/client/src/modules/Home/hooks/useActiveCourse.test.tsx @@ -10,9 +10,14 @@ describe('useActiveCourse', () => { { id: 3, name: 'Course 3' }, ] as ProfileCourseDto[]; - it('should return the first course as the active course by default', () => { + it('returns the first course by default and updates the active course', () => { const { result } = renderHook(() => useActiveCourse(courses)); expect(result.current[0]).toEqual(courses[0]); + + act(() => { + result.current[1](3); + }); + expect(result.current[0]).toEqual(courses[2]); }); it('should return the previously selected course when it is stored in local storage', () => { @@ -20,12 +25,4 @@ describe('useActiveCourse', () => { const { result } = renderHook(() => useActiveCourse(courses)); expect(result.current[0]).toEqual(courses[1]); }); - - it('should return the correct course when setActiveCourse is called', () => { - const { result } = renderHook(() => useActiveCourse(courses)); - act(() => { - result.current[1](3); - }); - expect(result.current[0]).toEqual(courses[2]); - }); }); diff --git a/client/src/modules/Home/hooks/useStudentSummary.test.tsx b/client/src/modules/Home/hooks/useStudentSummary.test.tsx index 4fd60bd46e..05d98d0588 100644 --- a/client/src/modules/Home/hooks/useStudentSummary.test.tsx +++ b/client/src/modules/Home/hooks/useStudentSummary.test.tsx @@ -1,4 +1,4 @@ -import { renderHook, waitFor } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react'; import { useStudentSummary } from './useStudentSummary'; import { Session } from '@client/components/withSession'; import { Course } from '@client/services/models'; @@ -21,6 +21,7 @@ describe('useStudentSummary', () => { it('returns empty defaults when there is no active course', async () => { const { result } = renderHook(() => useStudentSummary(session, null)); + await act(async () => undefined); expect(result.current.studentSummary).toBeNull(); expect(result.current.courseTasks).toEqual([]); expect(loadHomeData).not.toHaveBeenCalled(); @@ -29,7 +30,8 @@ describe('useStudentSummary', () => { it('does not load data when the user is not a student in the course', async () => { vi.mocked(isStudent).mockReturnValue(false); const { result } = renderHook(() => useStudentSummary(session, course)); - await waitFor(() => expect(isStudent).toHaveBeenCalledWith(session, 10)); + await act(async () => undefined); + expect(isStudent).toHaveBeenCalledWith(session, 10); expect(loadHomeData).not.toHaveBeenCalled(); expect(result.current.studentSummary).toBeNull(); }); @@ -44,7 +46,8 @@ describe('useStudentSummary', () => { const { result } = renderHook(() => useStudentSummary(session, course)); - await waitFor(() => expect(result.current.studentSummary).toEqual(summary)); + await act(async () => undefined); + expect(result.current.studentSummary).toEqual(summary); expect(loadHomeData).toHaveBeenCalledWith(10, 'octocat'); expect(result.current.courseTasks).toEqual([{ id: 1 }, { id: 2 }]); }); diff --git a/client/src/modules/Interview/Student/components/ExtraInfo.test.tsx b/client/src/modules/Interview/Student/components/ExtraInfo.test.tsx index 5360dbac88..0592595379 100644 --- a/client/src/modules/Interview/Student/components/ExtraInfo.test.tsx +++ b/client/src/modules/Interview/Student/components/ExtraInfo.test.tsx @@ -10,30 +10,28 @@ const PAST = '2000-01-01T00:00:00.000Z'; describe('', () => { beforeEach(() => vi.clearAllMocks()); - it('renders an enabled "Register" button when registration is open and the user is not registered', () => { + it('renders and handles open, registered, and not-started states', () => { const onRegister = vi.fn(); - render(); + const { rerender } = render( + , + ); const button = screen.getByRole('button', { name: /^register$/i }); expect(button).toBeEnabled(); fireEvent.click(button); expect(onRegister).toHaveBeenCalledWith('42'); - }); - it('renders a disabled "Registered" button (with check icon) and does not fire onRegister when already registered', () => { - const onRegister = vi.fn(); - render(); + onRegister.mockClear(); + rerender(); - const button = screen.getByRole('button', { name: /registered/i }); - expect(button).toBeDisabled(); + const registeredButton = screen.getByRole('button', { name: /registered/i }); + expect(registeredButton).toBeDisabled(); - fireEvent.click(button); + fireEvent.click(registeredButton); expect(onRegister).not.toHaveBeenCalled(); - }); - it('renders a "Registration starts on" tag (no button) when registration has not started', () => { - render(); + rerender(); expect(screen.queryByRole('button')).not.toBeInTheDocument(); expect(screen.getByText(/registration starts on/i)).toBeInTheDocument(); diff --git a/client/src/modules/Interview/Student/components/InterviewCard.test.tsx b/client/src/modules/Interview/Student/components/InterviewCard.test.tsx index 3a6820c06b..7146ca5bb6 100644 --- a/client/src/modules/Interview/Student/components/InterviewCard.test.tsx +++ b/client/src/modules/Interview/Student/components/InterviewCard.test.tsx @@ -46,30 +46,21 @@ function makeItem(overrides: Partial = {}): InterviewDetails { describe('', () => { beforeEach(() => vi.clearAllMocks()); - it('renders the interview name as an external link to its description', () => { - render(); - - const link = screen.getByRole('link', { name: 'JS Interview' }); - expect(link).toHaveAttribute('href', 'https://example.com/interview'); - expect(link).toHaveAttribute('target', '_blank'); - }); - describe('not registered, registration open (no pair)', () => { - it('shows an enabled Register button and prompts to register', () => { - render(); - - const register = screen.getByRole('button', { name: /^register$/i }); - expect(register).toBeEnabled(); - expect(screen.getByText(/register and get ready for your exciting interview/i)).toBeInTheDocument(); - }); - it('calls onRegister with the interview id (as string) on click', () => { const onRegister = vi.fn(); render( , ); - fireEvent.click(screen.getByRole('button', { name: /^register$/i })); + const link = screen.getByRole('link', { name: 'JS Interview' }); + expect(link).toHaveAttribute('href', 'https://example.com/interview'); + expect(link).toHaveAttribute('target', '_blank'); + const register = screen.getByRole('button', { name: /^register$/i }); + expect(register).toBeEnabled(); + expect(screen.getByText(/register and get ready for your exciting interview/i)).toBeInTheDocument(); + + fireEvent.click(register); expect(onRegister).toHaveBeenCalledTimes(1); expect(onRegister).toHaveBeenCalledWith('7'); @@ -129,18 +120,6 @@ describe('', () => { // Completed status label + accepted result. expect(screen.getByText('Completed')).toBeInTheDocument(); expect(screen.getByText('Mentor accepted')).toBeInTheDocument(); - }); - - it('shows the congratulations message when the interview passed with a "yes" result', () => { - render( - , - ); - expect(screen.getByText(/you have your interview result\. congratulations/i)).toBeInTheDocument(); }); diff --git a/client/src/modules/Interviews/pages/InterviewFeedback/index.test.tsx b/client/src/modules/Interviews/pages/InterviewFeedback/index.test.tsx index 40e588897e..7c8f50541e 100644 --- a/client/src/modules/Interviews/pages/InterviewFeedback/index.test.tsx +++ b/client/src/modules/Interviews/pages/InterviewFeedback/index.test.tsx @@ -1,10 +1,15 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { useRouter } from 'next/router'; import { InterviewFeedback } from './index'; import type { FeedbackProps } from '../../data/getInterviewData'; +const { showError } = vi.hoisted(() => ({ showError: vi.fn() })); +vi.mock('@client/hooks', () => ({ + useMessage: () => ({ message: { success: vi.fn(), error: showError } }), +})); + // Boundary: CourseService (the only network call this page makes). const { postStudentInterviewResult } = vi.hoisted(() => ({ postStudentInterviewResult: vi.fn(), @@ -88,33 +93,8 @@ function makeProps(overrides: Partial = {}): FeedbackProps { describe('', () => { beforeEach(() => vi.clearAllMocks()); - it('renders the template heading, sample-questions link and student github link', () => { - render(); - - expect(screen.getByRole('heading', { name: /Tiny Track: Interview Feedback/i })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: /Sample interview questions/i })).toHaveAttribute( - 'href', - 'https://example.com/questions', - ); - expect(screen.getByRole('link', { name: /candidate-gh/i })).toHaveAttribute( - 'href', - '/profile?githubId=candidate-gh', - ); - }); - - it('renders both the checkbox and textarea question inputs for the category', () => { - render(); - - // Category title (name is wrapped with its description, so match loosely). - expect(screen.getByText('Category One')).toBeInTheDocument(); - // Checkbox-type question. - expect(screen.getByRole('checkbox', { name: /Checkbox question/i })).toBeInTheDocument(); - // Input-type question renders a labelled textarea. - expect(screen.getByLabelText('Text question')).toBeInTheDocument(); - }); - it('does not submit when no score is selected (required validation blocks it)', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole('button', { name: /^Submit$/i })); @@ -126,7 +106,7 @@ describe('', () => { }); it('submits the feedback with score, answers and comment, then resets the form', async () => { - const user = userEvent.setup(); + const user = setupUser(); postStudentInterviewResult.mockResolvedValue({}); render(); @@ -138,10 +118,8 @@ describe('', () => { await user.click(screen.getByText('8')); // Fill the required comment (min length 30). - await user.type( - screen.getByLabelText('Comment'), - 'Solid candidate with good fundamentals and clear communication.', - ); + await user.click(screen.getByLabelText('Comment')); + await user.paste('Solid candidate with good fundamentals and clear communication.'); await user.click(screen.getByRole('button', { name: /^Submit$/i })); @@ -157,16 +135,18 @@ describe('', () => { { questionId: '102', questionText: 'Text question', answer: 'Answered well' }, ]), ); + await waitFor(() => expect(screen.getByLabelText('Comment')).toHaveValue('')); }); it('does not call the API when there is no githubId', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByText('8')); // Fill the required comment so form validation passes and handleSubmit actually runs; // it must then early-return because githubId is empty. - await user.type(screen.getByLabelText('Comment'), 'A sufficiently long comment to satisfy validation.'); + await user.click(screen.getByLabelText('Comment')); + await user.paste('A sufficiently long comment to satisfy validation.'); await user.click(screen.getByRole('button', { name: /^Submit$/i })); await waitFor(() => { @@ -176,7 +156,7 @@ describe('', () => { }); it('keeps the form when the submission request fails', async () => { - const user = userEvent.setup(); + const user = setupUser(); postStudentInterviewResult.mockRejectedValue({ response: { data: { data: { message: 'Server exploded' } } }, }); @@ -184,20 +164,39 @@ describe('', () => { await user.click(screen.getByText('8')); // Comment is required (min 30 chars) — fill it so validation passes and submit reaches the API. - await user.type(screen.getByLabelText('Comment'), 'A sufficiently long comment to satisfy validation.'); + await user.click(screen.getByLabelText('Comment')); + await user.paste('A sufficiently long comment to satisfy validation.'); await user.click(screen.getByRole('button', { name: /^Submit$/i })); await waitFor(() => { expect(postStudentInterviewResult).toHaveBeenCalled(); }); - // Comment field still present (form not reset on the error path). + await waitFor(() => expect(showError).toHaveBeenCalledWith('Server exploded')); expect(screen.getByLabelText('Comment')).toBeInTheDocument(); + expect(screen.getByLabelText('Comment')).toHaveValue('A sufficiently long comment to satisfy validation.'); }); - it('navigates back when the "Back" button is clicked', async () => { - const user = userEvent.setup(); + it('renders the template links and inputs, then navigates Back', async () => { + const user = setupUser(); render(); + expect(screen.getByRole('heading', { name: /Tiny Track: Interview Feedback/i })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Sample interview questions/i })).toHaveAttribute( + 'href', + 'https://example.com/questions', + ); + expect(screen.getByRole('link', { name: /candidate-gh/i })).toHaveAttribute( + 'href', + '/profile?githubId=candidate-gh', + ); + + // Category title (name is wrapped with its description, so match loosely). + expect(screen.getByText('Category One')).toBeInTheDocument(); + // Checkbox-type question. + expect(screen.getByRole('checkbox', { name: /Checkbox question/i })).toBeInTheDocument(); + // Input-type question renders a labelled textarea. + expect(screen.getByLabelText('Text question')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /^Back$/i })); expect(back).toHaveBeenCalledTimes(1); }); @@ -220,17 +219,19 @@ describe('', () => { }); it('shows a generic error message when the failure carries no server message', async () => { - const user = userEvent.setup(); + const user = setupUser(); // Reject with a bare error (no response.data.data.message) → `?? 'An error occurred…'` fallback. postStudentInterviewResult.mockRejectedValue(new Error('network down')); render(); await user.click(screen.getByText('8')); - await user.type(screen.getByLabelText('Comment'), 'A sufficiently long comment to satisfy validation.'); + await user.click(screen.getByLabelText('Comment')); + await user.paste('A sufficiently long comment to satisfy validation.'); await user.click(screen.getByRole('button', { name: /^Submit$/i })); await waitFor(() => expect(postStudentInterviewResult).toHaveBeenCalled()); - // Form is not reset on error (comment remains). + await waitFor(() => expect(showError).toHaveBeenCalledWith('An error occurred. Please try later.')); expect(screen.getByLabelText('Comment')).toBeInTheDocument(); + expect(screen.getByLabelText('Comment')).toHaveValue('A sufficiently long comment to satisfy validation.'); }); }); diff --git a/client/src/modules/Interviews/pages/InterviewFeedback/index.tsx b/client/src/modules/Interviews/pages/InterviewFeedback/index.tsx index 8d12e13cfb..5a4ac3d239 100644 --- a/client/src/modules/Interviews/pages/InterviewFeedback/index.tsx +++ b/client/src/modules/Interviews/pages/InterviewFeedback/index.tsx @@ -87,18 +87,14 @@ export function InterviewFeedback({ course, type, interviewTaskId, githubId }: F Student: {' '} - - {githubId} - + {githubId} {template.categories.map(category => ( - - {category.name} - {category.description ? {category.description} : null} - + {category.name} + {category.description ? {category.description} : null} {category.questions.map(question => { switch (question.type) { case InputType.Input: diff --git a/client/src/modules/Interviews/pages/StageInterviewFeedback/FormItem.test.tsx b/client/src/modules/Interviews/pages/StageInterviewFeedback/FormItem.test.tsx index 243a3a72e2..18bf155336 100644 --- a/client/src/modules/Interviews/pages/StageInterviewFeedback/FormItem.test.tsx +++ b/client/src/modules/Interviews/pages/StageInterviewFeedback/FormItem.test.tsx @@ -1,11 +1,11 @@ import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { Form } from 'antd'; import { ReactNode } from 'react'; import { FormItem } from './FormItem'; import { StepForm } from './StepForm'; import { FeedbackStep, FeedbackStepId, StepFormItem } from '@client/data/interviews/technical-screening'; import { InputType } from '@client/data/interviews'; +import { setupUser } from '@client/__tests__/setupUser'; // FormItem only renders a non-brittle widget per branch (Radio / RadioButton / Checkbox / // Input / TextArea). The Rating branch delegates to QuestionList, whose Form.List + @@ -36,9 +36,15 @@ function renderItem( // We pass `form={undefined}` for items that never touch `form` (every branch except Radio, // whose NestedRadio child needs a real form). For the Radio case we render with a real form. +function getRadio(label: string) { + const radio = screen.getByLabelText(label); + expect(radio).toHaveRole('radio'); + return radio; +} + describe('FormItem branches', () => { it('renders a TextArea and submits its typed value', async () => { - const user = userEvent.setup(); + const user = setupUser(); const item: StepFormItem = { id: 'comment', type: InputType.TextArea, @@ -56,7 +62,7 @@ describe('FormItem branches', () => { }); it('blocks submit and shows "Required" for an empty required TextArea', async () => { - const user = userEvent.setup(); + const user = setupUser(); const item: StepFormItem = { id: 'comment', type: InputType.TextArea, @@ -74,7 +80,7 @@ describe('FormItem branches', () => { }); it('renders a text Input and submits its value', async () => { - const user = userEvent.setup(); + const user = setupUser(); const item: StepFormItem = { id: 'name', type: InputType.Input, @@ -92,7 +98,7 @@ describe('FormItem branches', () => { }); it('renders a number Input (narrow style) and submits a numeric string', async () => { - const user = userEvent.setup(); + const user = setupUser(); const item: StepFormItem = { id: 'finalScore', type: InputType.Input, @@ -111,7 +117,7 @@ describe('FormItem branches', () => { }); it('renders RadioButton options (with description) and submits the chosen id', async () => { - const user = userEvent.setup(); + const user = setupUser(); const item: StepFormItem = { id: 'englishCertificate', type: InputType.RadioButton, @@ -137,7 +143,7 @@ describe('FormItem branches', () => { }); it('shows "Required" for a required RadioButton left unselected', async () => { - const user = userEvent.setup(); + const user = setupUser(); const item: StepFormItem = { id: 'englishCertificate', type: InputType.RadioButton, @@ -155,7 +161,7 @@ describe('FormItem branches', () => { }); it('renders a Checkbox.Group and submits the checked ids as an array', async () => { - const user = userEvent.setup(); + const user = setupUser(); const item: StepFormItem = { id: 'isGoodCandidate', type: InputType.Checkbox, @@ -233,37 +239,26 @@ describe('FormItem Radio + nested conditional (real form)', () => { }; it('shows nested sub-options only after the parent option with children is selected', async () => { - const user = userEvent.setup(); + const user = setupUser(); render( {form => } , ); + expect(screen.getAllByRole('radio')).toHaveLength(2); // Initially nested reasons are hidden. expect(screen.queryByRole('radio', { name: 'Has a reason.' })).not.toBeInTheDocument(); // Selecting "No, failed." (which has child options) reveals the nested radios. - await user.click(screen.getByRole('radio', { name: 'No, failed.' })); + await user.click(getRadio('No, failed.')); expect(await screen.findByRole('radio', { name: 'Has a reason.' })).toBeInTheDocument(); - expect(screen.getByRole('radio', { name: 'Ignores mentor.' })).toBeInTheDocument(); + expect(getRadio('Ignores mentor.')).toBeInTheDocument(); // Selecting the option WITHOUT children hides the nested group again. - await user.click(screen.getByRole('radio', { name: "Yes, it's ok." })); + await user.click(getRadio("Yes, it's ok.")); await waitFor(() => expect(screen.queryByRole('radio', { name: 'Has a reason.' })).not.toBeInTheDocument()); }); - - it('does not render a nested group for a childless option', () => { - const user = userEvent.setup(); - render( - - {form => } - , - ); - // "Yes, it's ok." has no `options`, so NestedRadio returns null → no extra radios. - void user; - expect(screen.getAllByRole('radio')).toHaveLength(2); // only the two top-level options - }); }); // StepForm wires the initial-values derivation (getInitialQuestions): an Input item with a @@ -317,7 +312,7 @@ describe(' initial values + navigation labels', () => { }); it('shows Back and "Submit" on a final, non-first step and calls back on click', async () => { - const user = userEvent.setup(); + const user = setupUser(); const back = vi.fn(); const step = makeStep([{ id: 'comment', type: InputType.TextArea, title: 'c', placeholder: 'c' }]); render(); @@ -328,7 +323,7 @@ describe(' initial values + navigation labels', () => { }); it('does not call next and runs onFinishFailed when a required field is empty on submit', async () => { - const user = userEvent.setup(); + const user = setupUser(); const next = vi.fn(); const step = makeStep([{ id: 'comment', type: InputType.TextArea, title: 'c', required: true, placeholder: 'c' }]); render(); @@ -341,7 +336,7 @@ describe(' initial values + navigation labels', () => { }); it('calls next with the collected values on a valid submit', async () => { - const user = userEvent.setup(); + const user = setupUser(); const next = vi.fn(); const step = makeStep([{ id: 'comment', type: InputType.TextArea, title: 'c', placeholder: 'type' }]); render(); @@ -353,7 +348,7 @@ describe(' initial values + navigation labels', () => { }); it('reports value changes via onValuesChange as the user types', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onValuesChange = vi.fn(); const step = makeStep([{ id: 'comment', type: InputType.TextArea, title: 'c', placeholder: 'type here' }]); render( @@ -370,7 +365,7 @@ describe(' initial values + navigation labels', () => { // A focused check that nested options live in the same group as their parent. describe('FormItem Radio nested group structure', () => { it('nests sub-options under the selected parent', async () => { - const user = userEvent.setup(); + const user = setupUser(); function Harness() { const [form] = Form.useForm(); return ( @@ -397,7 +392,7 @@ describe('FormItem Radio nested group structure', () => { } render(); - await user.click(screen.getByRole('radio', { name: 'No, failed.' })); + await user.click(getRadio('No, failed.')); const groups = screen.getAllByRole('radiogroup'); // Outer group + the revealed nested group. expect(groups.length).toBeGreaterThanOrEqual(2); diff --git a/client/src/modules/Interviews/pages/StageInterviewFeedback/QuestionList.test.tsx b/client/src/modules/Interviews/pages/StageInterviewFeedback/QuestionList.test.tsx index b15184683e..1ea0bea36e 100644 --- a/client/src/modules/Interviews/pages/StageInterviewFeedback/QuestionList.test.tsx +++ b/client/src/modules/Interviews/pages/StageInterviewFeedback/QuestionList.test.tsx @@ -2,8 +2,8 @@ // row-count and delete-icon assertions reach into the DOM by class — intentional here. /* eslint-disable testing-library/no-node-access */ import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { ReactNode } from 'react'; +import { setupUser } from '@client/__tests__/setupUser'; import { FeedbackStepId, QuestionItem } from '@client/data/interviews/technical-screening'; import { InputType } from '@client/data/interviews'; @@ -78,22 +78,6 @@ function Harness({ } describe(' question picker + custom + remove', () => { - it('renders the initial questions with topic + title and a Rate per row', () => { - render(Harness()); - - expect(screen.getByText('HTML/CSS question')).toBeInTheDocument(); - expect(screen.getByText('OOP question')).toBeInTheDocument(); - // Two rows → two Rate widgets, each exposing 5 radio stars. - const rates = document.querySelectorAll('.ant-rate'); - expect(rates).toHaveLength(2); - }); - - it('shows "Add from list" only while there are unused pool questions', () => { - // examples contains an extra pool question (algorithms) not in initial → button shown. - render(Harness()); - expect(screen.getByRole('button', { name: /Add from list/i })).toBeInTheDocument(); - }); - it('hides "Add from list" when every example is already added', () => { render(Harness({ question: makeQuestion({ examples: baseQuestions }) })); expect(screen.queryByRole('button', { name: /Add from list/i })).not.toBeInTheDocument(); @@ -101,19 +85,21 @@ describe(' question picker + custom + remove', () => { expect(screen.getByRole('button', { name: /Custom question/i })).toBeInTheDocument(); }); - it('labels the custom button "Custom task" on the Practice step and "Custom question" otherwise', () => { - const { unmount } = render(Harness({ stepId: FeedbackStepId.Practice })); + it('labels the custom button "Custom task" on the Practice step', () => { + render(Harness({ stepId: FeedbackStepId.Practice })); expect(screen.getByRole('button', { name: /Custom task/i })).toBeInTheDocument(); - unmount(); - - render(Harness({ stepId: FeedbackStepId.Theory })); - expect(screen.getByRole('button', { name: /Custom question/i })).toBeInTheDocument(); }); it('opens the picker modal, validates an empty selection, then adds a pooled question', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(Harness()); + expect(screen.getByText('HTML/CSS question')).toBeInTheDocument(); + expect(screen.getByText('OOP question')).toBeInTheDocument(); + expect(document.querySelectorAll('.ant-rate')).toHaveLength(2); + expect(screen.getByRole('button', { name: /Add from list/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Custom question/i })).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /Add from list/i })); const dialog = await screen.findByRole('dialog'); @@ -136,7 +122,7 @@ describe(' question picker + custom + remove', () => { }); it('cancels the picker modal without adding anything', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(Harness()); await user.click(screen.getByRole('button', { name: /Add from list/i })); @@ -148,7 +134,7 @@ describe(' question picker + custom + remove', () => { }); it('adds a custom question (typed) as a new row and ignores blank input', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(Harness()); await user.click(screen.getByRole('button', { name: /Custom question/i })); @@ -166,7 +152,7 @@ describe(' question picker + custom + remove', () => { }); it('adds a custom question via Enter key (onPressEnter)', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(Harness()); await user.click(screen.getByRole('button', { name: /Custom question/i })); @@ -178,7 +164,7 @@ describe(' question picker + custom + remove', () => { }); it('cancels the custom-question card without adding', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(Harness()); await user.click(screen.getByRole('button', { name: /Custom question/i })); @@ -190,7 +176,7 @@ describe(' question picker + custom + remove', () => { }); it('removes a question row (delete icon shown only when more than one row)', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(Harness()); // Two rows → delete icons present. @@ -205,7 +191,7 @@ describe(' question picker + custom + remove', () => { }); it('submits the rated question values through the form', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onFinish = vi.fn(); render(Harness({ onFinish })); diff --git a/client/src/modules/Interviews/pages/StageInterviewFeedback/StageInterviewFeedback.test.tsx b/client/src/modules/Interviews/pages/StageInterviewFeedback/StageInterviewFeedback.test.tsx index c24bab9b7e..4b811817e5 100644 --- a/client/src/modules/Interviews/pages/StageInterviewFeedback/StageInterviewFeedback.test.tsx +++ b/client/src/modules/Interviews/pages/StageInterviewFeedback/StageInterviewFeedback.test.tsx @@ -89,8 +89,8 @@ function makeProps(feedback: Partial = {}): StageFeedbackP describe(' (page)', () => { beforeEach(() => vi.clearAllMocks()); - it('renders the full feedback form (header, sub-header, first step, student sidebar)', () => { - render(); + it('renders the full feedback form, missing completion state and every step', async () => { + render(); expect(screen.getByRole('banner')).toHaveTextContent('Technical screening'); expect(screen.getByText('Feedback form')).toBeInTheDocument(); @@ -100,6 +100,11 @@ describe(' (page)', () => { expect(screen.getByRole('heading', { name: 'Ada Lovelace' })).toBeInTheDocument(); // Sub-header reflects the not-completed state. expect(screen.getByText('Uncompleted')).toBeInTheDocument(); + expect(screen.queryByText('Completed')).not.toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText('Interview confirmation')).toBeInTheDocument(); + expect(screen.getByText('Student admission to the mentoring program')).toBeInTheDocument(); + }); }); it('falls back to the legacy screening page when the feedback version is 0', async () => { @@ -132,20 +137,4 @@ describe(' (page)', () => { expect(tag).toBeInTheDocument(); expect(tag).toHaveClass('ant-tag-green'); }); - - it('treats a missing isCompleted flag as "Uncompleted" in the sub-header', () => { - render(); - - expect(screen.getByText('Uncompleted')).toBeInTheDocument(); - expect(screen.queryByText('Completed')).not.toBeInTheDocument(); - }); - - it('shows the vertical stepper with every template step', async () => { - render(); - - await waitFor(() => { - expect(screen.getByText('Interview confirmation')).toBeInTheDocument(); - expect(screen.getByText('Student admission to the mentoring program')).toBeInTheDocument(); - }); - }); }); diff --git a/client/src/modules/Interviews/pages/StageInterviewFeedback/StepContext.test.tsx b/client/src/modules/Interviews/pages/StageInterviewFeedback/StepContext.test.tsx index 6016195bc4..f8e0ba4816 100644 --- a/client/src/modules/Interviews/pages/StageInterviewFeedback/StepContext.test.tsx +++ b/client/src/modules/Interviews/pages/StageInterviewFeedback/StepContext.test.tsx @@ -2,8 +2,8 @@ // reach into the DOM by class — direct node access is intentional and unavoidable here. /* eslint-disable testing-library/no-node-access */ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { useContext } from 'react'; +import { ReactNode, useContext } from 'react'; +import { setupUser } from '@client/__tests__/setupUser'; import { StepContextProvider, StepContext } from './StepContext'; import { StepsContent } from './StepsContent'; import { Steps } from './Steps'; @@ -88,7 +88,7 @@ function makeFeedback(overrides: Partial = {}): InterviewF }; } -function renderProvider(feedback: InterviewFeedbackDto = makeFeedback()) { +function renderProvider(feedback: InterviewFeedbackDto = makeFeedback(), children?: ReactNode) { return render( + {children} , ); } @@ -116,7 +117,7 @@ function ContextProbe() { } // Helper: select the "Yes, it's ok." radio on the Introduction step. -async function answerIntroductionAsConducted(user: ReturnType) { +async function answerIntroductionAsConducted(user: ReturnType) { await user.click(screen.getByRole('radio', { name: /Yes, it's ok\./i })); } @@ -124,18 +125,7 @@ describe(' (multi-step feedback container)', () => { beforeEach(() => vi.clearAllMocks()); it('starts on the Introduction step (step 0) with 5 steps total', () => { - renderProvider(); - render( - - - , - ); + renderProvider(makeFeedback(), ); const [probe] = screen.getAllByTestId('active-index'); expect(probe).toHaveTextContent('0'); @@ -144,10 +134,15 @@ describe(' (multi-step feedback container)', () => { // The Introduction title and Next button (not Submit, since not final). expect(screen.getByRole('heading', { level: 3, name: 'Introduction' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Next' })).toBeInTheDocument(); + expect(screen.getByText('Interview confirmation')).toBeInTheDocument(); + expect(screen.getByText('Talk about theory, how things work')).toBeInTheDocument(); + expect(screen.getByText('Propose technical tasks to solve')).toBeInTheDocument(); + expect(screen.getByText('Check English level')).toBeInTheDocument(); + expect(screen.getByText('Student admission to the mentoring program')).toBeInTheDocument(); }); it('blocks Next while a required field is empty (no API call, stays on step 0)', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderProvider(); await user.click(screen.getByRole('button', { name: 'Next' })); @@ -158,8 +153,8 @@ describe(' (multi-step feedback container)', () => { expect(screen.getByRole('heading', { level: 3, name: 'Introduction' })).toBeInTheDocument(); }); - it('saves the Introduction step and advances to Theory, calling the API with the exact payload', async () => { - const user = userEvent.setup(); + it('saves Introduction, updates the Theory stepper, and navigates Back without saving', async () => { + const user = setupUser(); renderProvider(); await answerIntroductionAsConducted(user); @@ -187,15 +182,15 @@ describe(' (multi-step feedback container)', () => { // Advanced to Theory. expect(await screen.findByRole('heading', { level: 3, name: 'Theory' })).toBeInTheDocument(); - }); - it('navigates Back from Theory to Introduction without calling the API', async () => { - const user = userEvent.setup(); - renderProvider(); + // The antd stepper exposes status via aria/class; assert the Introduction item is now finished. + const introductionConfirmation = screen.getByText('Interview confirmation'); + const introItem = introductionConfirmation.closest('.ant-steps-item'); + expect(introItem).toHaveClass('ant-steps-item-finish'); - await answerIntroductionAsConducted(user); - await user.click(screen.getByRole('button', { name: 'Next' })); - await screen.findByRole('heading', { level: 3, name: 'Theory' }); + const theoryDesc = screen.getByText('Talk about theory, how things work'); + const theoryItem = theoryDesc.closest('.ant-steps-item'); + expect(theoryItem).toHaveClass('ant-steps-item-process'); createInterviewFeedback.mockClear(); await user.click(screen.getByRole('button', { name: 'Back' })); @@ -207,7 +202,7 @@ describe(' (multi-step feedback container)', () => { }); it('marks the interview as missed → becomes final, shows Submit, and completes on submit', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderProvider(); // Choosing "No, interview is failed." reveals a nested required reason radio, @@ -233,7 +228,7 @@ describe(' (multi-step feedback container)', () => { }); it('shows an error and stays on the step when the save API rejects', async () => { - const user = userEvent.setup(); + const user = setupUser(); createInterviewFeedback.mockRejectedValueOnce(new Error('boom')); renderProvider(); @@ -269,17 +264,6 @@ describe(' (multi-step feedback container)', () => { expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument(); }); - it('renders the vertical stepper with one entry per template step', () => { - renderProvider(); - // antd Steps render each title; the sidebar stepper duplicates titles already in the form, - // so assert on the stepper-only descriptions. - expect(screen.getByText('Interview confirmation')).toBeInTheDocument(); - expect(screen.getByText('Talk about theory, how things work')).toBeInTheDocument(); - expect(screen.getByText('Propose technical tasks to solve')).toBeInTheDocument(); - expect(screen.getByText('Check English level')).toBeInTheDocument(); - expect(screen.getByText('Student admission to the mentoring program')).toBeInTheDocument(); - }); - it('falls back to "Step not found" when the active step is missing', () => { // Render StepsContent with a hand-built context whose steps array is empty. render( @@ -302,7 +286,7 @@ describe(' (multi-step feedback container)', () => { }); it('keeps the index at 0 when prev() is invoked on the first step (clamp guard)', async () => { - const user = userEvent.setup(); + const user = setupUser(); // A consumer that exposes the context `prev` action via a button and reports the index. function PrevProbe() { const { prev, activeStepIndex } = useContext(StepContext); @@ -333,24 +317,6 @@ describe(' (multi-step feedback container)', () => { await user.click(screen.getByRole('button', { name: 'go-prev' })); expect(screen.getByTestId('idx')).toHaveTextContent('0'); }); - - it('marks completed prior steps as "finish" and the active one as "process" in the stepper', async () => { - const user = userEvent.setup(); - renderProvider(); - - await answerIntroductionAsConducted(user); - await user.click(screen.getByRole('button', { name: 'Next' })); - await screen.findByRole('heading', { level: 3, name: 'Theory' }); - - // The antd stepper exposes status via aria/class; assert the Introduction item is now finished. - const introductionConfirmation = screen.getByText('Interview confirmation'); - const introItem = introductionConfirmation.closest('.ant-steps-item'); - expect(introItem).toHaveClass('ant-steps-item-finish'); - - const theoryDesc = screen.getByText('Talk about theory, how things work'); - const theoryItem = theoryDesc.closest('.ant-steps-item'); - expect(theoryItem).toHaveClass('ant-steps-item-process'); - }); }); describe('StepContextProvider with no template steps (defensive guards)', () => { @@ -363,7 +329,7 @@ describe('StepContextProvider with no template steps (defensive guards)', () => }); it('treats an empty-steps feedback as not-finished and no-ops onValuesChange', async () => { - const user = userEvent.setup(); + const user = setupUser(); // Consumer that surfaces isFinalStep and lets us fire onValuesChange when activeStep is undefined. function EmptyProbe() { const { isFinalStep, onValuesChange, steps } = useContext(StepContext); @@ -405,7 +371,7 @@ describe('StepContext loading spinner', () => { beforeEach(() => vi.clearAllMocks()); it('disables the form via Spin while the save request is in flight', async () => { - const user = userEvent.setup(); + const user = setupUser(); let resolveSave: (v?: unknown) => void = () => {}; createInterviewFeedback.mockReturnValueOnce( new Promise(resolve => { diff --git a/client/src/modules/Interviews/pages/StageInterviewFeedback/Steps.tsx b/client/src/modules/Interviews/pages/StageInterviewFeedback/Steps.tsx index 9aeff3c609..610eaa56f3 100644 --- a/client/src/modules/Interviews/pages/StageInterviewFeedback/Steps.tsx +++ b/client/src/modules/Interviews/pages/StageInterviewFeedback/Steps.tsx @@ -7,13 +7,13 @@ export function Steps() { return ( ({ title: step.title, - description: step.stepperDescription, + content: step.stepperDescription, status: getStatus(index), }))} /> diff --git a/client/src/modules/Interviews/pages/StageInterviewFeedback/StudentInfo.test.tsx b/client/src/modules/Interviews/pages/StageInterviewFeedback/StudentInfo.test.tsx index 4765371442..a335f769c9 100644 --- a/client/src/modules/Interviews/pages/StageInterviewFeedback/StudentInfo.test.tsx +++ b/client/src/modules/Interviews/pages/StageInterviewFeedback/StudentInfo.test.tsx @@ -17,8 +17,8 @@ function makeStudent(overrides: Partial = {}): StudentDto { const courseSummary = { totalScore: 1000, studentsCount: 50 }; describe('', () => { - it('renders the student name, github link, rank/position, score and location', () => { - render(); + it('renders student details and handles name and location variants', () => { + const { rerender } = render(); expect(screen.getByRole('heading', { name: 'Ada Lovelace' })).toBeInTheDocument(); @@ -29,22 +29,15 @@ describe('', () => { expect(screen.getByText('3/50')).toBeInTheDocument(); expect(screen.getByText('850/1000')).toBeInTheDocument(); expect(screen.getByText('London, UK')).toBeInTheDocument(); - }); - it('omits the name heading when the name is empty or the placeholder "(Empty)"', () => { - const { rerender } = render( - , - ); + rerender(); expect(screen.queryByRole('heading')).not.toBeInTheDocument(); - // Github link is still present. expect(screen.getByRole('link', { name: /ada-lovelace/ })).toBeInTheDocument(); rerender(); expect(screen.queryByRole('heading')).not.toBeInTheDocument(); - }); - it('joins only the populated location parts (city missing → country only)', () => { - render( + rerender( ', () => { ); expect(screen.getByText('Poland')).toBeInTheDocument(); expect(screen.queryByText(/,/)).not.toBeInTheDocument(); - }); - it('renders an empty location string when neither city nor country is set', () => { - render( + rerender( , ); - // Position + Total Score labels still render. expect(screen.getByText('Position')).toBeInTheDocument(); expect(screen.getByText('Total Score')).toBeInTheDocument(); expect(screen.getByText('Location')).toBeInTheDocument(); diff --git a/client/src/modules/Interviews/pages/StageInterviewFeedback/SubHeader.test.tsx b/client/src/modules/Interviews/pages/StageInterviewFeedback/SubHeader.test.tsx index c10f2b9783..c680df09d9 100644 --- a/client/src/modules/Interviews/pages/StageInterviewFeedback/SubHeader.test.tsx +++ b/client/src/modules/Interviews/pages/StageInterviewFeedback/SubHeader.test.tsx @@ -12,26 +12,19 @@ const back = (useRouter() as unknown as { back: ReturnType }).back describe('', () => { beforeEach(() => vi.clearAllMocks()); - it('shows the "Completed" green tag when isCompleted is true', () => { - render(); + it('renders both completion states and navigates back', async () => { + const user = userEvent.setup(); + const { container, rerender } = render(); expect(screen.getByText('Feedback form')).toBeInTheDocument(); const tag = screen.getByText('Completed'); expect(tag).toBeInTheDocument(); expect(tag).toHaveClass('ant-tag-green'); - }); - it('shows the "Uncompleted" tag (no green) when isCompleted is false', () => { - render(); - - const tag = screen.getByText('Uncompleted'); - expect(tag).toBeInTheDocument(); - expect(tag).not.toHaveClass('ant-tag-green'); - }); - - it('navigates back when the back arrow is clicked', async () => { - const user = userEvent.setup(); - const { container } = render(); + rerender(); + const uncompletedTag = screen.getByText('Uncompleted'); + expect(uncompletedTag).toBeInTheDocument(); + expect(uncompletedTag).not.toHaveClass('ant-tag-green'); const arrow = container.querySelector('.anticon-arrow-left'); expect(arrow).toBeTruthy(); diff --git a/client/src/modules/Mentor/components/Instructions/Instructions.test.tsx b/client/src/modules/Mentor/components/Instructions/Instructions.test.tsx index 67b96b4d64..be98732bef 100644 --- a/client/src/modules/Mentor/components/Instructions/Instructions.test.tsx +++ b/client/src/modules/Mentor/components/Instructions/Instructions.test.tsx @@ -19,36 +19,20 @@ vi.mock('@client/api', async importOriginal => { describe('Instructions', () => { beforeEach(() => getInviteLinkByDiscordServerId.mockReset().mockResolvedValue({ data: 'https://t.me/rsschool' })); - it('should render the title and description', () => { - render(); + it('renders the instructions and applies the fetched telegram invite link', async () => { + render(); expect(screen.getByText(INSTRUCTIONS_TEXT.title)).toBeInTheDocument(); expect(screen.getByText(INSTRUCTIONS_TEXT.description)).toBeInTheDocument(); - }); - - it('should render each instruction step title', () => { - render(); - for (const step of INSTRUCTIONS_TEXT.steps) { expect(screen.getByText(step.title)).toBeInTheDocument(); } - }); - - it('should render the social links for the first step (github/discord/linkedin)', () => { - render(); - expect(screen.getByRole('link', { name: /github/i })).toHaveAttribute( 'href', 'https://github.com/rolling-scopes/rsschool-app', ); - }); - - it('should fetch and apply the telegram invite link when a discord server id is provided', async () => { - render(); await waitFor(() => expect(getInviteLinkByDiscordServerId).toHaveBeenCalledWith(400, 42)); - - // once the telegram url resolves, the telegram link becomes clickable with that href await waitFor(() => { const links = screen.getAllByRole('link'); expect(links.some(link => link.getAttribute('href') === 'https://t.me/rsschool')).toBe(true); @@ -62,8 +46,6 @@ describe('Instructions', () => { }); it('renders a social link with no icon for an unknown platform title', () => { - // renderSocialLinks needs theme context, so render it through a host component. - // An unknown title falls through the icon switch `default` branch (no icon). function Host() { return <>{renderSocialLinks([{ title: 'myspace', url: 'https://myspace.com/rs' }])}; } diff --git a/client/src/modules/Mentor/components/MentorDashboard/MentorDashboard.test.tsx b/client/src/modules/Mentor/components/MentorDashboard/MentorDashboard.test.tsx index d5422bede0..6a214607fe 100644 --- a/client/src/modules/Mentor/components/MentorDashboard/MentorDashboard.test.tsx +++ b/client/src/modules/Mentor/components/MentorDashboard/MentorDashboard.test.tsx @@ -12,10 +12,10 @@ vi.mock('next/router', () => ({ })); describe('MentorDashboard', () => { - it('should render instructions when mentor has no students for this course', async () => { + it('renders instructions without students and the table with students', async () => { vi.mocked(useMentorDashboard).mockReturnValue([[], false, vi.fn()]); - render( + const dashboard = () => ( { } > - , + ); + const { rerender } = render(dashboard()); const instructionsTitle = await screen.findByText(INSTRUCTIONS_TEXT.title); expect(instructionsTitle).toBeInTheDocument(); - }); - - it('should render table when mentor has students for this course', async () => { const mockData = [ { courseTaskId: 1, @@ -60,27 +58,7 @@ describe('MentorDashboard', () => { vi.mocked(useMentorDashboard).mockReturnValue([mockData, false, vi.fn()]); - render( - - - , - ); + rerender(dashboard()); const emptyTable = await screen.findByText(/John Doe/i); diff --git a/client/src/modules/Mentor/components/ReviewRandomTask/ReviewRandomTask.test.tsx b/client/src/modules/Mentor/components/ReviewRandomTask/ReviewRandomTask.test.tsx index ef4b56bd82..d5f2c18191 100644 --- a/client/src/modules/Mentor/components/ReviewRandomTask/ReviewRandomTask.test.tsx +++ b/client/src/modules/Mentor/components/ReviewRandomTask/ReviewRandomTask.test.tsx @@ -1,5 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import ReviewRandomTask from './ReviewRandomTask'; const { getRandomTask, messageInfo } = vi.hoisted(() => ({ @@ -20,6 +19,17 @@ vi.mock('antd', async () => { const PROPS = { mentorId: 1, courseId: 400, onClick: vi.fn() }; +function createDeferredRequest() { + let resolve!: (value: { data: object }) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise<{ data: object }>((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + + return { promise, resolve, reject }; +} + describe('ReviewRandomTask', () => { beforeEach(() => { getRandomTask.mockReset(); @@ -27,42 +37,43 @@ describe('ReviewRandomTask', () => { PROPS.onClick = vi.fn(); }); - it('should render the "Review random task" button', () => { + it('should request a random task for the mentor/course and notify the parent on success', async () => { + const request = createDeferredRequest(); + getRandomTask.mockReturnValueOnce(request.promise); render(); - expect(screen.getByRole('button', { name: /review random task/i })).toBeInTheDocument(); - }); + const button = screen.getByRole('button', { name: /review random task/i }); + fireEvent.click(button); - it('should request a random task for the mentor/course and notify the parent on success', async () => { - const user = userEvent.setup(); - getRandomTask.mockResolvedValueOnce({ data: {} }); - render(); + expect(getRandomTask).toHaveBeenCalledWith(1, 400); + expect(button).toBeDisabled(); - await user.click(screen.getByRole('button', { name: /review random task/i })); + await act(async () => request.resolve({ data: {} })); - await waitFor(() => expect(getRandomTask).toHaveBeenCalledWith(1, 400)); expect(PROPS.onClick).toHaveBeenCalled(); }); it('should show an info message and not notify the parent when no task is found (404)', async () => { - const user = userEvent.setup(); - getRandomTask.mockRejectedValueOnce({ response: { status: 404 } }); + const request = createDeferredRequest(); + getRandomTask.mockReturnValueOnce(request.promise); render(); - await user.click(screen.getByRole('button', { name: /review random task/i })); + fireEvent.click(screen.getByRole('button', { name: /review random task/i })); + await act(async () => request.reject({ response: { status: 404 } })); - await waitFor(() => expect(messageInfo).toHaveBeenCalledWith('Task for review was not found. Please try later.')); + expect(messageInfo).toHaveBeenCalledWith('Task for review was not found. Please try later.'); expect(PROPS.onClick).not.toHaveBeenCalled(); }); it('should swallow non-404 errors without an info message', async () => { - const user = userEvent.setup(); - getRandomTask.mockRejectedValueOnce({ response: { status: 500 } }); + const request = createDeferredRequest(); + getRandomTask.mockReturnValueOnce(request.promise); render(); - await user.click(screen.getByRole('button', { name: /review random task/i })); + fireEvent.click(screen.getByRole('button', { name: /review random task/i })); + await act(async () => request.reject({ response: { status: 500 } })); - await waitFor(() => expect(getRandomTask).toHaveBeenCalled()); + expect(getRandomTask).toHaveBeenCalled(); expect(messageInfo).not.toHaveBeenCalled(); expect(PROPS.onClick).not.toHaveBeenCalled(); }); diff --git a/client/src/modules/Mentor/components/SubmitReviewModal/SubmitReviewModal.test.tsx b/client/src/modules/Mentor/components/SubmitReviewModal/SubmitReviewModal.test.tsx index e1343aae5c..8e753bb3ce 100644 --- a/client/src/modules/Mentor/components/SubmitReviewModal/SubmitReviewModal.test.tsx +++ b/client/src/modules/Mentor/components/SubmitReviewModal/SubmitReviewModal.test.tsx @@ -56,32 +56,6 @@ describe('SubmitReviewModal', () => { mockAxios.reset(); }); - it.each` - text | role - ${MODAL_DATA_MOCK.taskName} | ${'link'} - ${MODAL_DATA_MOCK.solutionUrl} | ${'link'} - ${'Submit'} | ${'button'} - ${'Cancel'} | ${'button'} - `('should render $role "$text"', ({ text, role }: { text: string; role: string }) => { - render(); - - const element = screen.getByRole(role, { name: new RegExp(text) }); - - expect(element).toBeInTheDocument(); - }); - - it.each` - text - ${MODAL_DATA_MOCK.maxScore} - ${MODAL_DATA_MOCK.studentName} - `('should render field "$text"', ({ text }: { text: string }) => { - render(); - - const element = screen.getByText(new RegExp(text)); - - expect(element).toBeInTheDocument(); - }); - it('should not render fields when data was not provided', () => { render(); @@ -132,9 +106,17 @@ describe('SubmitReviewModal', () => { expect(screen.queryByText(SUCCESS_MESSAGE)).not.toBeInTheDocument(); }); - it('should call onClose when "Cancel" button was clicked', async () => { + it('should render review details and call onClose when Cancel is clicked', async () => { render(); + for (const text of [MODAL_DATA_MOCK.taskName, MODAL_DATA_MOCK.solutionUrl]) { + expect(screen.getByRole('link', { name: new RegExp(text) })).toBeInTheDocument(); + } + expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument(); + for (const text of [MODAL_DATA_MOCK.maxScore, MODAL_DATA_MOCK.studentName]) { + expect(screen.getByText(new RegExp(String(text)))).toBeInTheDocument(); + } const cancelBtn = screen.getByRole('button', { name: 'Cancel' }); + expect(cancelBtn).toBeInTheDocument(); fireEvent.click(cancelBtn); diff --git a/client/src/modules/Mentor/components/TaskSolutionsTable/TaskSolutionsTable.test.tsx b/client/src/modules/Mentor/components/TaskSolutionsTable/TaskSolutionsTable.test.tsx index 89c0598df6..5c6068120e 100644 --- a/client/src/modules/Mentor/components/TaskSolutionsTable/TaskSolutionsTable.test.tsx +++ b/client/src/modules/Mentor/components/TaskSolutionsTable/TaskSolutionsTable.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; import { TaskSolutionsTable, TaskSolutionsTableProps } from '.'; import { MentorDashboardDto } from '@client/api'; import { SolutionItemStatus, TaskSolutionsTableColumnName } from '../../constants'; @@ -30,37 +30,27 @@ const mockProps: TaskSolutionsTableProps = { describe('TaskSolutionsTable', () => { describe('when full data was provided', () => { - it.each` - label - ${TaskSolutionsTableColumnName.Student} - ${TaskSolutionsTableColumnName.Task} - ${TaskSolutionsTableColumnName.SolutionUrl} - ${TaskSolutionsTableColumnName.Score} - ${TaskSolutionsTableColumnName.SubmitScores} - ${TaskSolutionsTableColumnName.DesiredDeadline} - `('should render column name "$label"', ({ label }: { label: string }) => { + it('should render column names and data', () => { render(); - const name = screen.getByText(label); - - expect(name).toBeInTheDocument(); - }); - - it.each` - value - ${'Student 0'} - ${'Task 0'} - ${'solution-url-0'} - ${'20 / 100'} - ${'1970-02-01 00:00'} - `('should render column data "$value"', ({ value }: { value: string }) => { - render(); - - expect(screen.getByText(value)).toBeInTheDocument(); + for (const label of [ + TaskSolutionsTableColumnName.Student, + TaskSolutionsTableColumnName.Task, + TaskSolutionsTableColumnName.SolutionUrl, + TaskSolutionsTableColumnName.Score, + TaskSolutionsTableColumnName.SubmitScores, + TaskSolutionsTableColumnName.DesiredDeadline, + ]) { + expect(screen.getByText(label)).toBeInTheDocument(); + } + for (const value of ['Student 0', 'Task 0', 'solution-url-0', '20 / 100', '1970-02-01 00:00']) { + expect(screen.getByText(value)).toBeInTheDocument(); + } }); - it('should render "Review random task" button when "Random task" tab is selected', async () => { + it('should show "Review random task" only after selecting the "Random task" tab', async () => { render(); + expect(screen.queryByText(/review random task/i)).not.toBeInTheDocument(); const randomTaskTab = screen.getByRole('tab', { name: /random task/i }); fireEvent.click(randomTaskTab); @@ -69,20 +59,16 @@ describe('TaskSolutionsTable', () => { expect(reviewBtn).toBeInTheDocument(); }); - it('should not render "Review random task" button when "Random task" tab is not selected', () => { - render(); - - const reviewBtn = screen.queryByText(/review random task/i); - expect(reviewBtn).not.toBeInTheDocument(); - }); - it('should open the submit review modal when a row Submit button is clicked', () => { // Clicking the per-row Submit button runs handleSubmitButtonClick -> setModalData, // which opens the SubmitReviewModal titled with the student name. render(); - const submitButtons = screen.getAllByRole('button', { name: 'Submit' }); - fireEvent.click(submitButtons[0]); + // Scope the action to its student without traversing every row's buttons. + // eslint-disable-next-line testing-library/no-node-access + const row = screen.getByText('Student 0').closest('tr'); + expect(row).toHaveRole('row'); + fireEvent.click(within(row!).getByRole('button', { name: 'Submit' })); expect(screen.getByText(/Submit Score for Student 0/)).toBeInTheDocument(); }); @@ -121,23 +107,7 @@ describe('TaskSolutionsTable', () => { }); describe('when result score was not provided', () => { - describe('and when deadline passed', () => { - it('should render date as warning', () => { - const data = [ - { - ...(generateData()[0] as MentorDashboardDto), - resultScore: null, - endDate: new Date('1970-05-05T00:00:00').toISOString(), - }, - ]; - render(); - - const date = screen.getByText('1970-05-05 00:00'); - expect(date).toHaveClass('ant-typography-warning'); - }); - }); - - it('should render "-" instead of result score', () => { + it('should render a missing score and warn when the deadline passed', () => { const data = [ { ...(generateData()[0] as MentorDashboardDto), @@ -147,8 +117,8 @@ describe('TaskSolutionsTable', () => { ]; render(); - const score = screen.getByText('- / 100'); - expect(score).toBeInTheDocument(); + expect(screen.getByText('1970-05-05 00:00')).toHaveClass('ant-typography-warning'); + expect(screen.getByText('- / 100')).toBeInTheDocument(); }); }); }); diff --git a/client/src/modules/Mentor/components/TaskStatusTabs/TaskStatusTabs.test.tsx b/client/src/modules/Mentor/components/TaskStatusTabs/TaskStatusTabs.test.tsx index ba98c9946c..fe35111d82 100644 --- a/client/src/modules/Mentor/components/TaskStatusTabs/TaskStatusTabs.test.tsx +++ b/client/src/modules/Mentor/components/TaskStatusTabs/TaskStatusTabs.test.tsx @@ -9,59 +9,29 @@ const PROPS_MOCK = { }; describe('TaskStatusTabs', () => { - it('should render status tabs', () => { - const statuses = generateStatuses(); - - render(); + it('renders status counts, handles missing statuses, and reports tab changes', () => { + const statuses = [ + ...generateStatuses(2, SolutionItemStatus.Done), + ...generateStatuses(3, SolutionItemStatus.InReview), + ...generateStatuses(4, SolutionItemStatus.RandomTask), + ]; + const { rerender } = render(); expect(screen.getAllByRole('tab')).toHaveLength(TASKS_STATUSES.length); - }); - - it('should render status tabs when statuses were not provided', () => { - render(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.getByText('4')).toBeInTheDocument(); + fireEvent.click(screen.getByText(new RegExp(SolutionItemStatus.Done, 'i'))); + expect(PROPS_MOCK.onTabChange).toHaveBeenCalledWith(SolutionItemStatus.Done); + rerender(); expect(screen.getAllByRole('tab')).toHaveLength(TASKS_STATUSES.length); - }); - - it('should render zero-count badges when statuses is undefined', () => { - // statuses={undefined} drives the `statuses?.filter(...).length ?? 0` nullish - // fallback in tabsRenderer so every tab shows a 0 count. - render(); + rerender(); const tabs = screen.getAllByRole('tab'); expect(tabs).toHaveLength(TASKS_STATUSES.length); - // every tab badge renders a "0" count (showZero) expect(screen.getAllByText('0').length).toBe(TASKS_STATUSES.length); }); - - it.each` - status | count - ${SolutionItemStatus.Done} | ${2} - ${SolutionItemStatus.InReview} | ${3} - ${SolutionItemStatus.RandomTask} | ${4} - `( - 'should render badge with count of $count for "$status" tab', - ({ status, count }: { status: SolutionItemStatus; count: number }) => { - const statuses = generateStatuses(count, status); - - render(); - - expect(screen.getByText(count)).toBeInTheDocument(); - }, - ); - - describe('when active tab was changed', () => { - it('should call onTabChange with tab name "Done"', () => { - const tabName = SolutionItemStatus.Done; - const statuses = generateStatuses(); - render(); - - const selectedTab = screen.getByText(new RegExp(tabName, 'i')); - fireEvent.click(selectedTab); - - expect(PROPS_MOCK.onTabChange).toHaveBeenCalledWith(tabName); - }); - }); }); function generateStatuses(count = 3, status = SolutionItemStatus.InReview): Status[] { diff --git a/client/src/modules/Mentor/pages/Interviews/components/InterviewCard.test.tsx b/client/src/modules/Mentor/pages/Interviews/components/InterviewCard.test.tsx index 17b19db6ad..ecf4d2ba63 100644 --- a/client/src/modules/Mentor/pages/Interviews/components/InterviewCard.test.tsx +++ b/client/src/modules/Mentor/pages/Interviews/components/InterviewCard.test.tsx @@ -26,44 +26,29 @@ function makeTask(overrides: Partial = {}): InterviewDto { } function renderCard(task = makeTask()) { - render(); + return render( + , + ); } describe('InterviewCard', () => { - it('should render the interview name as the card title', () => { - renderCard(); + it('renders interview details and handles an empty description', () => { + const { rerender } = renderCard(); expect(screen.getByText('CoreJS Interview')).toBeInTheDocument(); - }); - - it('should render the description when provided', () => { - renderCard(); - expect(screen.getByText('Interview description')).toBeInTheDocument(); - }); - - it('should not render a description paragraph when description is empty', () => { - renderCard(makeTask({ description: '' })); - - expect(screen.queryByText('Interview description')).not.toBeInTheDocument(); - }); - - it('should render a "Read more" link pointing to the description url', () => { - renderCard(); - expect(screen.getByRole('link', { name: 'Read more' })).toHaveAttribute('href', 'https://docs.rs.school/interview'); - }); - - it('should render the interview details child', () => { - renderCard(); - expect(screen.getByText('interview-details')).toBeInTheDocument(); - }); - - it('should render the interview period (start - end dates)', () => { - renderCard(); - - // InterviewPeriod renders the formatted start/end dates with a calendar icon expect(screen.getByText(/2025/)).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.queryByText('Interview description')).not.toBeInTheDocument(); }); }); diff --git a/client/src/modules/Mentor/pages/Interviews/components/InterviewDetails.test.tsx b/client/src/modules/Mentor/pages/Interviews/components/InterviewDetails.test.tsx index e5bec3a351..a92027cc42 100644 --- a/client/src/modules/Mentor/pages/Interviews/components/InterviewDetails.test.tsx +++ b/client/src/modules/Mentor/pages/Interviews/components/InterviewDetails.test.tsx @@ -27,14 +27,14 @@ function makeTask(startDate: string): InterviewDto { } as unknown as InterviewDto; } -function renderDetails(startDate: string) { - render( +function details(startDate: string) { + return ( , + /> ); } @@ -42,27 +42,21 @@ describe('InterviewDetails', () => { beforeAll(() => vi.useFakeTimers().setSystemTime(new Date('2025-06-15'))); afterAll(() => vi.useRealTimers()); - it('should render the wait-list alert and interviews list once the interview has started', () => { + it('renders details appropriate to the interview start date', () => { // start date in the past => interviewStarted = true - renderDetails('2025-06-01'); + const { rerender } = render(details('2025-06-01')); expect(screen.getByText(/waitlist-alert/)).toBeInTheDocument(); expect(screen.getByText('interviews-list')).toBeInTheDocument(); expect(screen.queryByText(/registration-notice/)).not.toBeInTheDocument(); - }); - - it('should render the registration notice while registration is in progress', () => { // start date within the next 2 weeks => registration in progress, not started - renderDetails('2025-06-20'); + rerender(details('2025-06-20')); expect(screen.getByText(/registration-notice/)).toBeInTheDocument(); expect(screen.queryByText(/waitlist-alert/)).not.toBeInTheDocument(); expect(screen.queryByText('interviews-list')).not.toBeInTheDocument(); - }); - - it('should render nothing when the interview is far in the future', () => { // start date well beyond the 2-week registration window - renderDetails('2025-09-01'); + rerender(details('2025-09-01')); expect(screen.queryByText(/waitlist-alert/)).not.toBeInTheDocument(); expect(screen.queryByText(/registration-notice/)).not.toBeInTheDocument(); diff --git a/client/src/modules/Mentor/pages/Interviews/components/InterviewsList.test.tsx b/client/src/modules/Mentor/pages/Interviews/components/InterviewsList.test.tsx index f363ae987f..840e5f6dea 100644 --- a/client/src/modules/Mentor/pages/Interviews/components/InterviewsList.test.tsx +++ b/client/src/modules/Mentor/pages/Interviews/components/InterviewsList.test.tsx @@ -1,5 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import type { InterviewDto } from '@client/api'; import { TaskDtoTypeEnum } from '@client/api'; import { InterviewStatus } from '@client/domain/interview'; @@ -55,7 +54,7 @@ function makeInterview(githubId: string): MentorInterview { } function renderList(interviews?: MentorInterview[]) { - render( + return render( { beforeEach(() => fetchStudentInterviews.mockReset().mockResolvedValue(undefined)); - it('should render the empty-state alert when there are no interviews', () => { - renderList([]); + it('should render the empty state for empty and undefined interviews', () => { + const { rerender } = renderList([]); expect(screen.getByText("You don't have any assigned interviews yet.")).toBeInTheDocument(); - }); - it('should render the empty-state alert when interviews is undefined', () => { - renderList(undefined); + rerender( + , + ); expect(screen.getByText("You don't have any assigned interviews yet.")).toBeInTheDocument(); }); - it('should render the summary but not the student list until expanded', () => { + it('should render the summary, toggle student details, and reload interviews', async () => { + let resolveReload!: () => void; + fetchStudentInterviews.mockReturnValueOnce( + new Promise(resolve => { + resolveReload = resolve; + }), + ); renderList([makeInterview('alice'), makeInterview('bob')]); expect(screen.getByText('summary for 2')).toBeInTheDocument(); expect(screen.queryByText('student-alice')).not.toBeInTheDocument(); - }); - it('should reveal the per-student list after toggling details', async () => { - const user = userEvent.setup(); - renderList([makeInterview('alice'), makeInterview('bob')]); - - await user.click(screen.getByRole('button', { name: 'toggle-details' })); + fireEvent.click(screen.getByRole('button', { name: 'toggle-details' })); expect(screen.getByText('student-alice')).toBeInTheDocument(); expect(screen.getByText('student-bob')).toBeInTheDocument(); - }); - it('should collapse the list again on a second toggle', async () => { - const user = userEvent.setup(); - renderList([makeInterview('alice')]); - - await user.click(screen.getByRole('button', { name: 'toggle-details' })); - expect(screen.getByText('student-alice')).toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: 'toggle-details' })); + fireEvent.click(screen.getByRole('button', { name: 'toggle-details' })); expect(screen.queryByText('student-alice')).not.toBeInTheDocument(); - }); - - it('should call fetchStudentInterviews when the summary triggers a reload', async () => { - const user = userEvent.setup(); - renderList([makeInterview('alice')]); - await user.click(screen.getByRole('button', { name: 'reload' })); + fireEvent.click(screen.getByRole('button', { name: 'reload' })); + expect(fetchStudentInterviews).toHaveBeenCalled(); - await waitFor(() => expect(fetchStudentInterviews).toHaveBeenCalled()); + await act(async () => resolveReload()); }); }); diff --git a/client/src/modules/Mentor/pages/Interviews/components/InterviewsSummary.test.tsx b/client/src/modules/Mentor/pages/Interviews/components/InterviewsSummary.test.tsx index 8366101007..2b84ef8694 100644 --- a/client/src/modules/Mentor/pages/Interviews/components/InterviewsSummary.test.tsx +++ b/client/src/modules/Mentor/pages/Interviews/components/InterviewsSummary.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import type { InterviewDto } from '@client/api'; import { InterviewStatus } from '@client/domain/interview'; import type { MentorInterview } from '@client/services/course'; @@ -75,34 +75,25 @@ function renderSummary(props: Partial[0]> = describe('InterviewsSummary', () => { beforeEach(() => updateStageInterview.mockReset().mockResolvedValue({})); - it('should render the completed/total count', () => { - renderSummary(); - - expect(screen.getByText(/Interviewed students 1\(2\)/)).toBeInTheDocument(); - }); - - it('should toggle details when "Show details" is clicked', async () => { - const user = userEvent.setup(); + it('should render summary actions, toggle details and cancel a transfer', async () => { + const user = setupUser(); const { toggleDetails } = renderSummary(); - await user.click(screen.getByRole('button', { name: 'Show details' })); - - expect(toggleDetails).toHaveBeenCalled(); - }); - - it('should link "Add student" to the wait list', () => { - renderSummary(); - + expect(screen.getByText(/Interviewed students 1\(2\)/)).toBeInTheDocument(); expect(screen.getByRole('link', { name: /Add student/ })).toHaveAttribute( 'href', '/course/mentor/interview-wait-list?course=rs-2025&interviewId=99', ); - }); - it('should show "Transfer student" only for a technical screening with uncompleted interviews', () => { - renderSummary(); + await user.click(screen.getByRole('button', { name: 'Show details' })); + expect(toggleDetails).toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: /Transfer student/ })); + expect(screen.getByRole('dialog', { name: 'select-mentor' })).toBeInTheDocument(); + expect(screen.getByText('transfer candidates: 1')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Transfer student/ })).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'close-transfer' })); + expect(screen.queryByRole('dialog', { name: 'select-mentor' })).not.toBeInTheDocument(); }); it('should not show "Transfer student" for a non-screening interview', () => { @@ -119,29 +110,8 @@ describe('InterviewsSummary', () => { expect(screen.queryByRole('button', { name: /Transfer student/ })).not.toBeInTheDocument(); }); - it('should open the transfer modal with only the uncompleted interviews', async () => { - const user = userEvent.setup(); - renderSummary(); - - await user.click(screen.getByRole('button', { name: /Transfer student/ })); - - expect(screen.getByRole('dialog', { name: 'select-mentor' })).toBeInTheDocument(); - // only id:2 is uncompleted - expect(screen.getByText('transfer candidates: 1')).toBeInTheDocument(); - }); - - it('should close the transfer modal on cancel', async () => { - const user = userEvent.setup(); - renderSummary(); - - await user.click(screen.getByRole('button', { name: /Transfer student/ })); - await user.click(screen.getByRole('button', { name: 'close-transfer' })); - - expect(screen.queryByRole('dialog', { name: 'select-mentor' })).not.toBeInTheDocument(); - }); - it('should transfer the interview, reload the list and close the modal on confirm', async () => { - const user = userEvent.setup(); + const user = setupUser(); const { reloadList } = renderSummary(); await user.click(screen.getByRole('button', { name: /Transfer student/ })); diff --git a/client/src/modules/Mentor/pages/Interviews/components/MentorPreferencesModal.test.tsx b/client/src/modules/Mentor/pages/Interviews/components/MentorPreferencesModal.test.tsx index df4410eedf..12f0b2c09c 100644 --- a/client/src/modules/Mentor/pages/Interviews/components/MentorPreferencesModal.test.tsx +++ b/client/src/modules/Mentor/pages/Interviews/components/MentorPreferencesModal.test.tsx @@ -1,6 +1,6 @@ import { useContext } from 'react'; import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import type { FormInstance } from 'antd'; import { Form } from 'antd'; import type { Session, CourseInfo } from '@client/components/withSession'; @@ -99,29 +99,16 @@ describe('MentorPreferencesModal', () => { createMentor.mockReset().mockResolvedValue({}); }); - it('should not render the modal until showMentorOptions is invoked', () => { + it('should start closed, load mentor options when opened and close on cancel', async () => { + const user = setupUser(); renderProvider(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - - it('should open the modal and load mentor options when triggered', async () => { - const user = userEvent.setup(); - renderProvider(); - await user.click(screen.getByRole('button', { name: 'open-options' })); expect(await screen.findByRole('dialog')).toBeInTheDocument(); expect(screen.getByText('RS 2025')).toBeInTheDocument(); await waitFor(() => expect(getMentorOptions).toHaveBeenCalledWith(17, 400)); - }); - - it('should close the modal on cancel', async () => { - const user = userEvent.setup(); - renderProvider(); - - await user.click(screen.getByRole('button', { name: 'open-options' })); - await screen.findByRole('dialog'); await user.click(screen.getByRole('button', { name: /Cancel/ })); @@ -129,7 +116,7 @@ describe('MentorPreferencesModal', () => { }); it('should submit the preferences via createMentor and close on confirm', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderProvider(); await user.click(screen.getByRole('button', { name: 'open-options' })); @@ -151,7 +138,7 @@ describe('MentorPreferencesModal', () => { // No `students` field registered -> values.students is undefined -> // `values.students?.map(...) ?? []` falls back to []. omitStudents.value = true; - const user = userEvent.setup(); + const user = setupUser(); renderProvider(); await user.click(screen.getByRole('button', { name: 'open-options' })); @@ -169,7 +156,7 @@ describe('MentorPreferencesModal', () => { }); it('should not call getMentorOptions when the session has no mentorId for the course', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderProvider({ ...SESSION, courses: { 400: { roles: ['mentor'] } as CourseInfo }, diff --git a/client/src/modules/Mentor/pages/Interviews/components/MentorPreferencesModal.tsx b/client/src/modules/Mentor/pages/Interviews/components/MentorPreferencesModal.tsx index f72f5744e2..72ee3d3d4c 100644 --- a/client/src/modules/Mentor/pages/Interviews/components/MentorPreferencesModal.tsx +++ b/client/src/modules/Mentor/pages/Interviews/components/MentorPreferencesModal.tsx @@ -57,7 +57,7 @@ function MentorOptionsModal({ course, close, session }: Props & { close: () => v { const values = await form.validateFields(); if (values) { diff --git a/client/src/modules/Mentor/pages/Interviews/components/RegistrationNotice.test.tsx b/client/src/modules/Mentor/pages/Interviews/components/RegistrationNotice.test.tsx index 2ae0596662..3aef925e69 100644 --- a/client/src/modules/Mentor/pages/Interviews/components/RegistrationNotice.test.tsx +++ b/client/src/modules/Mentor/pages/Interviews/components/RegistrationNotice.test.tsx @@ -1,5 +1,4 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { InterviewDto } from '@client/api'; import { RegistrationNoticeAlert } from './RegistrationNoticeAlert'; import { MentorOptionsContext } from './MentorPreferencesModal'; @@ -21,56 +20,44 @@ describe('RegistrationNoticeAlert', () => { studentRegistrationStartDate: new Date('2023-01-01').toISOString(), }; - it('should not render component if registration not yet started', () => { - render(); + it('handles eligibility, mentoring options, dismissal, and stored dismissal', () => { + window.sessionStorage.clear(); + const showMentorOptions = vi.fn(); + const { rerender, unmount } = render( + + + , + ); expect(screen.queryByText('test course')).not.toBeInTheDocument(); - }); - it('should not render component if interview is not of stage type', () => { - render(); + rerender( + + + , + ); expect(screen.queryByText('test course')).not.toBeInTheDocument(); - }); - - it('should render component if registration in progress', () => { - render(); - expect(screen.getByText('test course', { exact: false })).toBeInTheDocument(); - }); - - it('opens mentoring options when the inline link is clicked', async () => { - vi.useRealTimers(); - const showMentorOptions = vi.fn(); - const user = userEvent.setup(); - - render( + rerender( , ); - await user.click(screen.getByText('mentoring options')); + expect(screen.getByText('test course', { exact: false })).toBeInTheDocument(); + fireEvent.click(screen.getByText('mentoring options')); expect(showMentorOptions).toHaveBeenCalled(); - vi.useFakeTimers().setSystemTime(new Date('2023-01-01')); - }); - - it('dismisses the alert when the close button is clicked', async () => { - vi.useRealTimers(); - const user = userEvent.setup(); - - render(); // antd Alert renders a close button when `closable`. - await user.click(screen.getByRole('button', { name: /close/i })); + fireEvent.click(screen.getByRole('button', { name: /close/i })); + act(() => vi.runOnlyPendingTimers()); // After dismissal the alert text is gone (useAlert persisted via sessionStorage). expect(screen.queryByText('test course', { exact: false })).not.toBeInTheDocument(); - vi.useFakeTimers().setSystemTime(new Date('2023-01-01')); - }); + unmount(); - it('does not render once it has been dismissed (persisted in sessionStorage)', () => { // Pre-set the sessionStorage flag useAlert reads so the `isDismissed` early-return runs. window.sessionStorage.setItem(`registration-notice-alert-${interview.id}`, 'true'); diff --git a/client/src/modules/Mentor/pages/Interviews/components/SelectMentorModal.test.tsx b/client/src/modules/Mentor/pages/Interviews/components/SelectMentorModal.test.tsx index 4c9f411dc5..260b9bfcf7 100644 --- a/client/src/modules/Mentor/pages/Interviews/components/SelectMentorModal.test.tsx +++ b/client/src/modules/Mentor/pages/Interviews/components/SelectMentorModal.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import type { MentorInterview } from '@client/services/course'; import { SelectMentorModal } from './SelectMentorModal'; @@ -35,17 +35,13 @@ function renderModal(props: Partial[0]> = { } describe('SelectMentorModal', () => { - it('should render the modal with Student and Mentor fields', () => { - renderModal(); + it('should call onCancel when the modal is cancelled', async () => { + const user = setupUser(); + const { onCancel } = renderModal(); expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByLabelText('Student')).toBeInTheDocument(); expect(screen.getByLabelText('Mentor')).toBeInTheDocument(); - }); - - it('should call onCancel when the modal is cancelled', async () => { - const user = userEvent.setup(); - const { onCancel } = renderModal(); await user.click(screen.getByRole('button', { name: /Cancel/ })); @@ -53,7 +49,7 @@ describe('SelectMentorModal', () => { }); it('should not submit and should show validation errors when no student/mentor is chosen', async () => { - const user = userEvent.setup(); + const user = setupUser(); const { onOk } = renderModal(); await user.click(screen.getByRole('button', { name: /Save/ })); @@ -65,11 +61,13 @@ describe('SelectMentorModal', () => { }); it('should submit the selected student and mentor through onOk', async () => { - const user = userEvent.setup(); + const user = setupUser(); const { onOk } = renderModal(); // open the Student combobox (antd opens dropdowns on mouseDown) and pick Alice fireEvent.mouseDown(screen.getByLabelText('Student')); + await screen.findByRole('listbox'); + expect(await screen.findByText('bob')).toBeInTheDocument(); fireEvent.click(await screen.findByText('Alice A')); // set the mentor github id on the stubbed MentorSearch input @@ -79,15 +77,4 @@ describe('SelectMentorModal', () => { await waitFor(() => expect(onOk).toHaveBeenCalledWith('mentor-gh', 11)); }); - - it('should fall back to the githubId option label when the student has no name', async () => { - renderModal(); - - fireEvent.mouseDown(screen.getByLabelText('Student')); - - // bob has no name, so the option text falls back to the githubId - await screen.findByRole('listbox'); - const bobOption = await screen.findByText('bob'); - expect(bobOption).toBeInTheDocument(); - }); }); diff --git a/client/src/modules/Mentor/pages/Interviews/components/StudentInterview.test.tsx b/client/src/modules/Mentor/pages/Interviews/components/StudentInterview.test.tsx index 0074b7f7e1..0eb0586c25 100644 --- a/client/src/modules/Mentor/pages/Interviews/components/StudentInterview.test.tsx +++ b/client/src/modules/Mentor/pages/Interviews/components/StudentInterview.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { TaskDtoTypeEnum } from '@client/api'; import { InterviewStatus } from '@client/domain/interview'; import { Decision } from '@client/data/interviews/technical-screening'; @@ -78,49 +78,25 @@ describe('StudentInterview', () => { Object.defineProperty(window, 'location', { configurable: true, writable: true, value: originalLocation }); }); - it('should render the student name as a profile link', () => { - renderInterview(); - - const profileLink = screen.getByRole('link', { name: 'Student Name' }); - expect(profileLink).toHaveAttribute('href', '/profile?githubId=student-gh'); - }); - it('should fall back to githubId when the student has no name', () => { renderInterview({ student: { id: 7, githubId: 'student-gh', name: '' } as MentorInterview['student'] }); expect(screen.getByRole('link', { name: 'student-gh' })).toBeInTheDocument(); }); - it('should show "Provide feedback" when the interview is not completed', () => { + it('should submit a zero result and show success when the interview is rejected', async () => { + const user = setupUser(); renderInterview(); + expect(screen.getByRole('link', { name: 'Student Name' })).toHaveAttribute('href', '/profile?githubId=student-gh'); expect(screen.getByRole('button', { name: 'Provide feedback' })).toBeInTheDocument(); - }); - - it('should show "Edit feedback" when the interview is already completed', () => { - renderInterview({ completed: true }); - - expect(screen.getByRole('button', { name: 'Edit feedback' })).toBeInTheDocument(); - }); - - it('should open the reject popconfirm for an uncompleted CoreJS interview', async () => { - const user = userEvent.setup(); - renderInterview(); await user.click(screen.getByRole('button', { name: 'Provide feedback' })); - expect(await screen.findByText(/You can reject the interview with a result/)).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Reject/ })).toBeInTheDocument(); - // service not called just by opening + const rejectButton = screen.getByRole('button', { name: /Reject/ }); + expect(rejectButton).toBeInTheDocument(); expect(postStudentInterviewResult).not.toHaveBeenCalled(); - }); - - it('should submit a zero result and show success when the interview is rejected', async () => { - const user = userEvent.setup(); - renderInterview(); - - await user.click(screen.getByRole('button', { name: 'Provide feedback' })); - await user.click(await screen.findByRole('button', { name: /Reject/ })); + await user.click(rejectButton); await waitFor(() => expect(postStudentInterviewResult).toHaveBeenCalledWith('student-gh', 42, { @@ -134,7 +110,7 @@ describe('StudentInterview', () => { }); it('should navigate to the feedback url when "Provide feedback" is chosen in the popconfirm', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderInterview(); // open the popconfirm via the trigger button @@ -152,10 +128,12 @@ describe('StudentInterview', () => { }); it('should navigate directly to the feedback url for a completed interview without a popconfirm', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderInterview({ completed: true }); - await user.click(screen.getByRole('button', { name: 'Edit feedback' })); + const editButton = screen.getByRole('button', { name: 'Edit feedback' }); + expect(editButton).toBeInTheDocument(); + await user.click(editButton); expect(window.location.href).toContain( '/course/interview/core-js/feedback?course=rs-2025&githubId=student-gh&studentId=7&interviewId=42', @@ -164,7 +142,7 @@ describe('StudentInterview', () => { }); it('should navigate directly to the feedback url for a non-CoreJS interview', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderInterview({}, { interviewTaskType: TaskDtoTypeEnum.StageInterview }); await user.click(screen.getByRole('button', { name: 'Provide feedback' })); diff --git a/client/src/modules/Mentor/pages/Interviews/components/WaitListAlert.test.tsx b/client/src/modules/Mentor/pages/Interviews/components/WaitListAlert.test.tsx index e702d4c564..1f5dcc2ac4 100644 --- a/client/src/modules/Mentor/pages/Interviews/components/WaitListAlert.test.tsx +++ b/client/src/modules/Mentor/pages/Interviews/components/WaitListAlert.test.tsx @@ -1,47 +1,52 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { fireEvent, render, screen } from '@testing-library/react'; import { WaitListAlert } from './WaitListAlert'; // next/link is globally aliased to a mock; it renders an anchor with href. +vi.mock('antd', () => ({ + Alert: ({ + title, + description, + onClose, + }: { + title: React.ReactNode; + description: React.ReactNode; + onClose: () => void; + }) => ( +
    + {title} + {description} +
    + ), + theme: { useToken: () => ({ token: { blue7: '#00f' } }) }, + Typography: { + Text: ({ children, onClick }: React.ComponentProps<'span'>) => {children}, + }, +})); describe('WaitListAlert', () => { beforeEach(() => window.sessionStorage.clear()); - it('should render the waitlist invitation with a link to the wait list', () => { - render(); + it('should render, preserve description clicks, dismiss, and honor stored dismissal', () => { + const { unmount } = render(); expect(screen.getByText('Do you want to interview more students?')).toBeInTheDocument(); expect(screen.getByRole('link', { name: /students' waitlist/ })).toHaveAttribute( 'href', '/course/mentor/interview-wait-list?course=rs-2025&interviewId=7', ); - }); - it('should hide the alert after it is dismissed', async () => { - const user = userEvent.setup(); - render(); + fireEvent.click(screen.getByText(/Excellent candidates are waiting/)); + expect(screen.getByText('Do you want to interview more students?')).toBeInTheDocument(); - await user.click(screen.getByRole('img', { name: 'close' })); + fireEvent.click(screen.getByRole('button', { name: 'close' })); expect(screen.queryByText('Do you want to interview more students?')).not.toBeInTheDocument(); - }); - - it('should not render when previously dismissed in session storage', () => { + unmount(); window.sessionStorage.setItem('waitlist-alert-7', 'true'); render(); expect(screen.queryByText('Do you want to interview more students?')).not.toBeInTheDocument(); }); - - it('should keep the alert open when its description text is clicked (stopPropagation)', async () => { - const user = userEvent.setup(); - render(); - - // clicking the description text fires the onClick stopPropagation handler and - // must not dismiss the alert - await user.click(screen.getByText(/Excellent candidates are waiting/)); - - expect(screen.getByText('Do you want to interview more students?')).toBeInTheDocument(); - }); }); diff --git a/client/src/modules/Mentor/pages/StudentFeedback/StudentFeedback.test.tsx b/client/src/modules/Mentor/pages/StudentFeedback/StudentFeedback.test.tsx index 40fa69632b..b55a55b9c0 100644 --- a/client/src/modules/Mentor/pages/StudentFeedback/StudentFeedback.test.tsx +++ b/client/src/modules/Mentor/pages/StudentFeedback/StudentFeedback.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { CreateStudentFeedbackDto } from '@client/api'; import { Session, CourseInfo } from '@client/components/withSession'; import { SessionContext } from '@client/modules/Course/contexts'; @@ -96,18 +96,6 @@ describe('StudentFeedback page', () => { } as never); }); - it('should render the page title', () => { - renderPage(); - - expect(screen.getByText('Recommendation Letter')).toBeInTheDocument(); - }); - - it('should render the feedback form for the student id from the query', () => { - renderPage(); - - expect(screen.getByText('form for 7')).toBeInTheDocument(); - }); - it('should not render the form when there is no studentId in the query', () => { vi.mocked(useRouter).mockReturnValue({ push: vi.fn(), @@ -123,8 +111,10 @@ describe('StudentFeedback page', () => { }); it('should create feedback and reload on submit without an existing feedback id', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderPage(); + expect(screen.getByText('Recommendation Letter')).toBeInTheDocument(); + expect(screen.getByText('form for 7')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'create-feedback' })); @@ -135,7 +125,7 @@ describe('StudentFeedback page', () => { }); it('should update feedback and reload on submit with an existing feedback id', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderPage(); await user.click(screen.getByRole('button', { name: 'update-feedback' })); @@ -147,7 +137,7 @@ describe('StudentFeedback page', () => { }); it('should show an error message when creating feedback fails', async () => { - const user = userEvent.setup(); + const user = setupUser(); createStudentFeedback.mockRejectedValueOnce(new Error('boom')); renderPage(); @@ -158,7 +148,7 @@ describe('StudentFeedback page', () => { }); it('should show an error message when updating feedback fails', async () => { - const user = userEvent.setup(); + const user = setupUser(); updateStudentFeedback.mockRejectedValueOnce(new Error('boom')); renderPage(); diff --git a/client/src/modules/Mentor/pages/Students/Students.test.tsx b/client/src/modules/Mentor/pages/Students/Students.test.tsx index b170bd5582..61f8ddce89 100644 --- a/client/src/modules/Mentor/pages/Students/Students.test.tsx +++ b/client/src/modules/Mentor/pages/Students/Students.test.tsx @@ -1,5 +1,5 @@ import { render, screen, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { MentorStudentDto } from '@client/api'; import { Session, CourseInfo } from '@client/components/withSession'; import { SessionContext } from '@client/modules/Course/contexts'; @@ -68,39 +68,31 @@ describe('Students page', () => { } as never); }); - it('should render the page title and a card per student with score and rank', () => { - renderStudents([buildStudent()]); - - expect(screen.getByText('Your students')).toBeInTheDocument(); - expect(screen.getByText('John Doe')).toBeInTheDocument(); - expect(screen.getByText('250')).toBeInTheDocument(); - expect(screen.getByText('5')).toBeInTheDocument(); - expect(screen.getByText('Minsk, Belarus')).toBeInTheDocument(); - }); - it('should show the empty state when the mentor has no students', () => { renderStudents([]); expect(screen.getByText('You do not have students')).toBeInTheDocument(); }); - it('should label the feedback action "Give Feedback" when there is no feedback yet', () => { - renderStudents([buildStudent({ feedbacks: [] })]); - - expect(screen.getByRole('button', { name: /give feedback/i })).toBeInTheDocument(); - }); - it('should label the feedback action "Edit Feedback" when feedback exists', () => { renderStudents([buildStudent({ feedbacks: [{ id: 1 } as never] })]); expect(screen.getByRole('button', { name: /edit feedback/i })).toBeInTheDocument(); }); - it('should navigate to the feedback route for the student on click', async () => { - const user = userEvent.setup(); + it('should render student details and navigate to Give Feedback on click', async () => { + const user = setupUser(); renderStudents([buildStudent({ id: 11 })]); - await user.click(screen.getByRole('button', { name: /give feedback/i })); + expect(screen.getByText('Your students')).toBeInTheDocument(); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('250')).toBeInTheDocument(); + expect(screen.getByText('5')).toBeInTheDocument(); + expect(screen.getByText('Minsk, Belarus')).toBeInTheDocument(); + const feedbackButton = screen.getByRole('button', { name: /give feedback/i }); + expect(feedbackButton).toBeInTheDocument(); + + await user.click(feedbackButton); expect(push).toHaveBeenCalledWith( expect.objectContaining({ pathname: '/course/mentor/feedback', query: { course: 'rs-2025', studentId: 11 } }), @@ -108,7 +100,7 @@ describe('Students page', () => { }); it('should render the "Change Status" action for active students and navigate to expel', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderStudents([buildStudent({ active: true })]); const changeStatusBtn = screen.getByRole('button', { name: /change status/i }); diff --git a/client/src/modules/MentorRegistry/components/InviteMentorsModal.test.tsx b/client/src/modules/MentorRegistry/components/InviteMentorsModal.test.tsx index fe74bcdd1a..8dba4ec9d0 100644 --- a/client/src/modules/MentorRegistry/components/InviteMentorsModal.test.tsx +++ b/client/src/modules/MentorRegistry/components/InviteMentorsModal.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import InviteMentorsModal from './InviteMentorsModal'; // --- Boundary mocks -------------------------------------------------------- @@ -28,8 +28,7 @@ const { getDisciplines, inviteMentors } = vi.hoisted(() => ({ inviteMentors: vi.fn(), })); -vi.mock('@client/api', async () => ({ - ...(await vi.importActual('@client/api')), +vi.mock('@client/api', () => ({ DisciplinesApi: function DisciplinesApi() { return { getDisciplines }; }, @@ -54,35 +53,8 @@ describe('', () => { inviteMentors.mockResolvedValue(undefined); }); - it('renders the modal with the title and all form fields', async () => { - render(); - - expect(screen.getByRole('dialog')).toBeInTheDocument(); - expect(screen.getByText('Invite as a Mentor')).toBeInTheDocument(); - expect(screen.getByLabelText('Disciplines')).toBeInTheDocument(); - expect(screen.getByText('Mentor in the Past')).toBeInTheDocument(); - expect(screen.getByLabelText('Invitation Text')).toBeInTheDocument(); - - // Disciplines load asynchronously and populate the multi-select options. - await waitFor(() => expect(getDisciplines).toHaveBeenCalled()); - }); - - it('loads discipline options from the API and shows them in the select', async () => { - render(); - - await waitFor(() => expect(getDisciplines).toHaveBeenCalled()); - - const select = screen.getByLabelText('Disciplines'); - fireEvent.mouseDown(select); - - await waitFor(() => { - expect(within(document.body).getByText('JavaScript')).toBeInTheDocument(); - expect(within(document.body).getByText('Java')).toBeInTheDocument(); - }); - }); - it('blocks submit and shows validation errors when required fields are empty', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole('button', { name: /save/i })); @@ -94,7 +66,7 @@ describe('', () => { it('submits the filled form, calls inviteMentors with the payload and closes', async () => { const onCancel = vi.fn(); - const user = userEvent.setup(); + const user = setupUser(); render(); await waitFor(() => expect(getDisciplines).toHaveBeenCalled()); @@ -102,7 +74,10 @@ describe('', () => { // Pick a discipline from the multi-select. const select = screen.getByLabelText('Disciplines'); fireEvent.mouseDown(select); - fireEvent.click(await within(document.body).findByText('JavaScript')); + const javascriptOption = await within(document.body).findByText('JavaScript'); + expect(javascriptOption).toBeInTheDocument(); + expect(within(document.body).getByText('Java')).toBeInTheDocument(); + fireEvent.click(javascriptOption); // Toggle "Mentor in the Past". await user.click(screen.getByRole('checkbox')); @@ -123,9 +98,16 @@ describe('', () => { it('calls onCancel when the modal is dismissed without changes', async () => { const onCancel = vi.fn(); - const user = userEvent.setup(); + const user = setupUser(); render(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Invite as a Mentor')).toBeInTheDocument(); + expect(screen.getByLabelText('Disciplines')).toBeInTheDocument(); + expect(screen.getByText('Mentor in the Past')).toBeInTheDocument(); + expect(screen.getByLabelText('Invitation Text')).toBeInTheDocument(); + await waitFor(() => expect(getDisciplines).toHaveBeenCalled()); + await user.click(screen.getByRole('button', { name: /cancel/i })); expect(onCancel).toHaveBeenCalled(); diff --git a/client/src/modules/MentorRegistry/components/MentorRegistryDeleteModal.test.tsx b/client/src/modules/MentorRegistry/components/MentorRegistryDeleteModal.test.tsx index 7a7e45cabe..1d85226935 100644 --- a/client/src/modules/MentorRegistry/components/MentorRegistryDeleteModal.test.tsx +++ b/client/src/modules/MentorRegistry/components/MentorRegistryDeleteModal.test.tsx @@ -1,44 +1,33 @@ /* eslint-disable testing-library/no-node-access */ import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { MentorRegistryDeleteModal } from './MentorRegistryDeleteModal'; const modalData = { record: { githubId: 'octocat' } }; describe('', () => { - it('renders the confirmation dialog with warning copy and a Delete button', () => { - render(); + it('renders the loading confirmation dialog and calls onCancel', async () => { + const user = setupUser(); + const onCancel = vi.fn(); + render(); expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByText('Are you sure to delete this Mentor apply?')).toBeInTheDocument(); expect(screen.getByText("If you delete mentor's apply you can't restore it.")).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument(); + expect(document.querySelector('.ant-spin-spinning')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /cancel/i })); + expect(onCancel).toHaveBeenCalled(); }); it('calls cancelMentor with the record githubId when Delete is confirmed', async () => { const cancelMentor = vi.fn().mockResolvedValue(undefined); - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole('button', { name: 'Delete' })); expect(cancelMentor).toHaveBeenCalledWith('octocat'); }); - - it('calls onCancel when the Cancel button is clicked', async () => { - const onCancel = vi.fn(); - const user = userEvent.setup(); - render(); - - await user.click(screen.getByRole('button', { name: /cancel/i })); - - expect(onCancel).toHaveBeenCalled(); - }); - - it('shows a spinner while modalLoading is true', () => { - // The Modal renders into a portal on document.body, so query the document. - render(); - - expect(document.querySelector('.ant-spin-spinning')).toBeInTheDocument(); - }); }); diff --git a/client/src/modules/MentorRegistry/components/MentorRegistryResendModal.test.tsx b/client/src/modules/MentorRegistry/components/MentorRegistryResendModal.test.tsx index 4dca545942..bc92c453e7 100644 --- a/client/src/modules/MentorRegistry/components/MentorRegistryResendModal.test.tsx +++ b/client/src/modules/MentorRegistry/components/MentorRegistryResendModal.test.tsx @@ -1,49 +1,42 @@ /* eslint-disable testing-library/no-node-access */ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '@testing-library/react'; +import { setupUser } from '@client/__tests__/setupUser'; import { MentorRegistryResendModal } from './MentorRegistryResendModal'; const record = { githubId: 'octocat' } as never; const modalData = { record }; describe('', () => { - it('renders the dialog with resend copy and a Re-send button', () => { - render(); + it('renders and handles resend, cancel, and loading states', async () => { + const user = setupUser(); + const resendConfirmation = vi.fn(); + const onCancel = vi.fn(); + const { rerender } = render( + , + ); expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByText('Re-send Invitation for a Courses')).toBeInTheDocument(); expect(screen.getByText('Do you want resend invitation for a not accepted courses?')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Re-send' })).toBeInTheDocument(); - }); - it('calls resendConfirmation with the record when Re-send is clicked', async () => { - const resendConfirmation = vi.fn(); - const user = userEvent.setup(); - render( - , + rerender( + , + ); + await waitFor(() => expect(document.querySelector('.ant-spin-spinning')).toBeInTheDocument()); + rerender( + , ); await user.click(screen.getByRole('button', { name: 'Re-send' })); - expect(resendConfirmation).toHaveBeenCalledWith(record); - }); - - it('calls onCancel when the Cancel button is clicked', async () => { - const onCancel = vi.fn(); - const user = userEvent.setup(); - render(); await user.click(screen.getByRole('button', { name: /cancel/i })); - expect(onCancel).toHaveBeenCalled(); }); - - it('shows a spinner while modalLoading is true', () => { - // The Modal renders into a portal on document.body, so query the document. - render( - , - ); - - expect(document.querySelector('.ant-spin-spinning')).toBeInTheDocument(); - }); }); diff --git a/client/src/modules/MentorRegistry/components/MentorRegistryTableContainer.test.tsx b/client/src/modules/MentorRegistry/components/MentorRegistryTableContainer.test.tsx index 064a5bc6f4..007100f848 100644 --- a/client/src/modules/MentorRegistry/components/MentorRegistryTableContainer.test.tsx +++ b/client/src/modules/MentorRegistry/components/MentorRegistryTableContainer.test.tsx @@ -1,6 +1,7 @@ /* eslint-disable testing-library/no-container, testing-library/no-node-access */ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { message } from 'antd'; +import { setupUser } from '@client/__tests__/setupUser'; import { MentorRegistryDto, DisciplineDto } from '@client/api'; import { Course } from '@client/services/models'; import { ModalDataMode } from '@client/pages/admin/mentor-registry'; @@ -8,6 +9,11 @@ import { MentorRegistryTableContainer, CombinedFilter } from './MentorRegistryTa import { MentorRegistryTable } from './MentorRegistryTable'; import { MentorRegistryTabsMode } from '../constants'; +vi.mock('@client/shared/components/Icons', async importOriginal => ({ + ...(await importOriginal()), + PublicSvgIcon: () => , +})); + // Render the real table through the container's render-prop so both collaborate // like in production. Only props/services are supplied by the test. @@ -107,12 +113,6 @@ async function openRowDropdown() { describe(' + ', () => { beforeEach(() => vi.clearAllMocks()); - it('renders a populated table row with the mentor github id', () => { - renderContainer(); - expect(screen.getByText('octocat')).toBeInTheDocument(); - expect(screen.getByText('Octo Cat')).toBeInTheDocument(); - }); - it('renders an empty-state table when there are no mentors', () => { renderContainer({ mentors: [] }); // Fixed-column tables duplicate the empty placeholder, so allow multiple matches. @@ -120,9 +120,12 @@ describe(' + ', () => { expect(screen.queryByText('octocat')).not.toBeInTheDocument(); }); - it('opens the Invite modal when the row "Invite" action is clicked', async () => { - const user = userEvent.setup(); - const { handleModalDataChange } = renderContainer(); + it('renders the mentor row with its copy link and opens Invite', async () => { + const user = setupUser(); + const { container, handleModalDataChange } = renderContainer(); + expect(screen.getByText('octocat')).toBeInTheDocument(); + expect(screen.getByText('Octo Cat')).toBeInTheDocument(); + expect(container.querySelector('.anticon-copy')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Invite' })); @@ -132,40 +135,19 @@ describe(' + ', () => { ); }); - it('triggers the Re-send action from the row dropdown (New tab)', async () => { - const { handleModalDataChange } = renderContainer(); - - const menu = await openRowDropdown(); - fireEvent.click(within(menu).getByText('Re-send')); - - expect(handleModalDataChange).toHaveBeenCalledWith( - ModalDataMode.Resend, - expect.objectContaining({ githubId: 'octocat' }), - ); - }); - - it('triggers the Delete action from the dropdown', async () => { + it('triggers Re-send, Delete and Add comment from the New tab row dropdown', async () => { const { handleModalDataChange } = renderContainer(); - const menu = await openRowDropdown(); - fireEvent.click(within(menu).getByText('Delete')); - - expect(handleModalDataChange).toHaveBeenCalledWith( - ModalDataMode.Delete, - expect.objectContaining({ githubId: 'octocat' }), - ); - }); - - it('triggers the "Add comment" action from the dropdown', async () => { - const { handleModalDataChange } = renderContainer(); - - const menu = await openRowDropdown(); - fireEvent.click(within(menu).getByText('Add comment')); - - expect(handleModalDataChange).toHaveBeenCalledWith( - ModalDataMode.Comment, - expect.objectContaining({ githubId: 'octocat' }), - ); + for (const [label, mode] of [ + ['Re-send', ModalDataMode.Resend], + ['Delete', ModalDataMode.Delete], + ['Add comment', ModalDataMode.Comment], + ] as const) { + handleModalDataChange.mockClear(); + const menu = await openRowDropdown(); + fireEvent.click(within(menu).getByText(label)); + expect(handleModalDataChange).toHaveBeenCalledWith(mode, expect.objectContaining({ githubId: 'octocat' })); + } }); it('shows "Edit comment" label when the mentor already has a comment', async () => { @@ -218,7 +200,7 @@ describe(' + ', () => { it('clears all tag filters when "Clear all" is clicked', async () => { const setCombinedFilter = vi.fn(); const setTagFilters = vi.fn(); - const user = userEvent.setup(); + const user = setupUser(); renderContainer({ tagFilters: ['Technologies: React'], setCombinedFilter, @@ -232,6 +214,7 @@ describe(' + ', () => { }); it('shows an error and leaves filters untouched for an unrecognized tag prefix', () => { + const error = vi.spyOn(message, 'error').mockImplementation(() => (() => {}) as never); const setCombinedFilter = vi.fn(); const setTagFilters = vi.fn(); renderContainer({ @@ -244,9 +227,10 @@ describe(' + ', () => { const closeIcon = tag.closest('.ant-tag')?.querySelector('.ant-tag-close-icon') as HTMLElement; fireEvent.click(closeIcon); - // Default branch hits message.error and does not call setCombinedFilter. + expect(error).toHaveBeenCalledWith('An error occurred. Please try again later.'); expect(setCombinedFilter).not.toHaveBeenCalled(); expect(setTagFilters).toHaveBeenCalled(); + error.mockRestore(); }); it('renders extra "Additional" info icons for a mentor with a certificate and comment', () => { @@ -303,16 +287,6 @@ describe(' + ', () => { expect(screen.getAllByText('Course One').length).toBeGreaterThan(0); }); - it('renders a copy-link button for a not-yet-confirmed Pre-Selected course', () => { - // record.courses does NOT include the preselected id -> renderTagWithCopyButton branch. - const { container } = renderContainer({ - mentors: [makeMentor({ preselectedCourses: [1], courses: [] })], - }); - - // CopyToClipboardButton renders a copy icon link in the Pre-Selected cell. - expect(container.querySelector('.anticon-copy')).toBeInTheDocument(); - }); - it('falls back to the raw course id when a preferred course is not found', () => { // preferedCourses includes an id (999) absent from the courses list -> the // preferred/preselected renderers fall back to the numeric id. diff --git a/client/src/modules/MentorRegistry/components/MentorRegistryTableContainer.tsx b/client/src/modules/MentorRegistry/components/MentorRegistryTableContainer.tsx index 6d33327900..22794f1bf5 100644 --- a/client/src/modules/MentorRegistry/components/MentorRegistryTableContainer.tsx +++ b/client/src/modules/MentorRegistry/components/MentorRegistryTableContainer.tsx @@ -1,4 +1,4 @@ -import { Dispatch, SetStateAction } from 'react'; +import { Dispatch, Fragment, SetStateAction } from 'react'; import { GithubUserLink } from '@client/shared/components/GithubUserLink'; import { SafetyCertificateTwoTone } from '@ant-design/icons'; import { @@ -92,9 +92,9 @@ export const MentorRegistryTableContainer = ({ const renderTagWithCopyButton = (value: string, alias: string) => { const link = `${window.location.origin}/course/mentor/confirm?course=${alias}`; return ( - <> + {colorTagRenderer(value)} - + ); }; diff --git a/client/src/modules/MentorTasksReview/components/AssignReviewerModal/AssignReviewerModal.test.tsx b/client/src/modules/MentorTasksReview/components/AssignReviewerModal/AssignReviewerModal.test.tsx index 209a5515db..3a83dd3964 100644 --- a/client/src/modules/MentorTasksReview/components/AssignReviewerModal/AssignReviewerModal.test.tsx +++ b/client/src/modules/MentorTasksReview/components/AssignReviewerModal/AssignReviewerModal.test.tsx @@ -1,5 +1,6 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { message } from 'antd'; +import { setupUser } from '@client/__tests__/setupUser'; import { MentorReviewDto } from '@client/api'; import AssignReviewerModal from './AssignReviewerModal'; @@ -66,22 +67,8 @@ describe('AssignReviewerModal', () => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); - it('should render the modal title with the student name and the review details', () => { - renderModal(); - - expect(screen.getByText(/Assign Reviewer for student-github/i)).toBeInTheDocument(); - expect(screen.getByRole('link', { name: REVIEW_MOCK.taskName })).toHaveAttribute( - 'href', - REVIEW_MOCK.taskDescriptionUrl, - ); - expect(screen.getByRole('link', { name: REVIEW_MOCK.solutionUrl })).toHaveAttribute( - 'href', - REVIEW_MOCK.solutionUrl, - ); - }); - it('should assign the reviewer and show the success result on submit', async () => { - const user = userEvent.setup(); + const user = setupUser(); const { onSubmit } = renderModal(); await user.type(screen.getByLabelText('mentor-search'), '99'); @@ -93,7 +80,7 @@ describe('AssignReviewerModal', () => { }); it('should submit with an undefined mentorId when none is selected', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderModal(); await user.click(screen.getByRole('button', { name: 'Submit' })); @@ -104,7 +91,7 @@ describe('AssignReviewerModal', () => { }); it('should render the server error message when the request rejects', async () => { - const user = userEvent.setup(); + const user = setupUser(); runAsync.mockRejectedValueOnce({ response: { data: { message: 'Reviewer is busy' } } }); renderModal(); @@ -114,7 +101,7 @@ describe('AssignReviewerModal', () => { }); it('should fall back to the error message when the response has no body', async () => { - const user = userEvent.setup(); + const user = setupUser(); runAsync.mockRejectedValueOnce(new Error('Network down')); renderModal(); @@ -124,19 +111,31 @@ describe('AssignReviewerModal', () => { }); it('should reset state and call onClose when cancelled', async () => { - const user = userEvent.setup(); + const user = setupUser(); const { onClose } = renderModal(); + expect(screen.getByText(/Assign Reviewer for student-github/i)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: REVIEW_MOCK.taskName })).toHaveAttribute( + 'href', + REVIEW_MOCK.taskDescriptionUrl, + ); + expect(screen.getByRole('link', { name: REVIEW_MOCK.solutionUrl })).toHaveAttribute( + 'href', + REVIEW_MOCK.solutionUrl, + ); + await user.click(screen.getByRole('button', { name: 'Cancel' })); expect(onClose).toHaveBeenCalled(); }); it('should show an antd error message when the request hook errors', () => { + const error = vi.spyOn(message, 'error').mockImplementation(() => (() => {}) as never); renderModal(); const [, options] = useRequestMock.mock.calls[0] as [unknown, { onError: () => void }]; - // exercising the onError callback wired into useRequest does not throw - expect(() => options.onError()).not.toThrow(); + options.onError(); + expect(error).toHaveBeenCalledWith('An unexpected error occurred. Please try later.'); + error.mockRestore(); }); }); diff --git a/client/src/modules/MentorTasksReview/components/ReviewsTable/ReviewsTable.test.tsx b/client/src/modules/MentorTasksReview/components/ReviewsTable/ReviewsTable.test.tsx index 42c4069560..9fa6dd2139 100644 --- a/client/src/modules/MentorTasksReview/components/ReviewsTable/ReviewsTable.test.tsx +++ b/client/src/modules/MentorTasksReview/components/ReviewsTable/ReviewsTable.test.tsx @@ -1,5 +1,5 @@ import { render, screen, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { CourseTaskDto, MentorReviewDto } from '@client/api'; import MentorReviewsTable from '.'; @@ -62,24 +62,21 @@ function renderTable(props: Partial { - it.each` - columnName - ${'Task Name'} - ${'Student'} - ${'Submitted Date'} - ${'Submitted Link'} - ${'Checker'} - ${'Reviewed Date'} - ${'Score'} - ${'Actions'} - `('should render the "$columnName" column header for a manager', ({ columnName }: { columnName: string }) => { + it('should render manager headers and row data: task link, github links, dates and score', () => { renderTable(); - expect(screen.getByText(columnName)).toBeInTheDocument(); - }); - - it('should render the row data: task link, github links, dates and score', () => { - renderTable(); + for (const columnName of [ + 'Task Name', + 'Student', + 'Submitted Date', + 'Submitted Link', + 'Checker', + 'Reviewed Date', + 'Score', + 'Actions', + ]) { + expect(screen.getByText(columnName)).toBeInTheDocument(); + } const table = screen.getByRole('table'); expect(within(table).getByRole('link', { name: 'Cross-check task' })).toHaveAttribute( @@ -113,8 +110,8 @@ describe('MentorReviewsTable', () => { expect(within(table).queryByText('checker-github')).not.toBeInTheDocument(); }); - it('should open the assign-reviewer modal with the clicked review', async () => { - const user = userEvent.setup(); + it('should open the clicked review and close the modal from inside it', async () => { + const user = setupUser(); renderTable(); expect(screen.queryByRole('dialog', { name: 'assign-reviewer' })).not.toBeInTheDocument(); @@ -122,20 +119,13 @@ describe('MentorReviewsTable', () => { const dialog = screen.getByRole('dialog', { name: 'assign-reviewer' }); expect(within(dialog).getByText('assigning: student-github')).toBeInTheDocument(); - }); - - it('should close the modal again from inside it', async () => { - const user = userEvent.setup(); - renderTable(); - - await user.click(screen.getByRole('button', { name: 'Assign Reviewer' })); await user.click(screen.getByRole('button', { name: 'close-modal' })); expect(screen.queryByRole('dialog', { name: 'assign-reviewer' })).not.toBeInTheDocument(); }); it('should propagate the modal submit to handleReviewerAssigned', async () => { - const user = userEvent.setup(); + const user = setupUser(); const { handleReviewerAssigned } = renderTable(); await user.click(screen.getByRole('button', { name: 'Assign Reviewer' })); diff --git a/client/src/modules/MentorTasksReview/pages/MentorTasksReview.test.tsx b/client/src/modules/MentorTasksReview/pages/MentorTasksReview.test.tsx index 65113288db..3b17870178 100644 --- a/client/src/modules/MentorTasksReview/pages/MentorTasksReview.test.tsx +++ b/client/src/modules/MentorTasksReview/pages/MentorTasksReview.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { CourseTaskDtoCheckerEnum, MentorReviewDto } from '@client/api'; import { MentorTasksReview } from './MentorTasksReview'; @@ -34,16 +34,19 @@ vi.mock('ahooks/lib/useRequest', () => ({ default: () => ({ runAsync: vi.fn().mockResolvedValue(undefined), loading: false }), })); -vi.mock('@client/modules/Course/contexts', () => ({ - SessionContext: { - Provider: ({ children }: { children: React.ReactNode }) => <>{children}, - displayName: 'Session', - }, - useActiveCourseContext: () => ({ +vi.mock('@client/modules/Course/contexts', () => { + const activeCourse = { course: { id: 1, name: 'RS 2025' }, courses: [{ id: 1, name: 'RS 2025' }], - }), -})); + }; + return { + SessionContext: { + Provider: ({ children }: { children: React.ReactNode }) => <>{children}, + displayName: 'Session', + }, + useActiveCourseContext: () => activeCourse, + }; +}); vi.mock('@client/domain/user', () => ({ isCourseManager: (...args: unknown[]) => isCourseManagerMock(...args), @@ -102,17 +105,12 @@ describe('MentorTasksReview page', () => { isCourseManagerMock.mockReset().mockReturnValue(true); }); - it('should render the title and the active course name', async () => { + it('should render the initial page, manager hint and loaded reviews and request mentor tasks', async () => { render(); expect(screen.getByRole('heading', { name: 'Mentor tasks review' })).toBeInTheDocument(); expect(screen.getByText('Submitted tasks')).toBeInTheDocument(); expect(await screen.findByText('RS 2025')).toBeInTheDocument(); - }); - - it('should show the manager hint and fetch reviews for the active course on mount', async () => { - render(); - expect(screen.getByText(/You can assign a checker/i)).toBeInTheDocument(); await waitFor(() => expect(getMentorReviews).toHaveBeenCalledWith( @@ -126,23 +124,14 @@ describe('MentorTasksReview page', () => { undefined, ), ); - }); - - it('should render the loaded review rows inside the table', async () => { - render(); const table = await screen.findByRole('table'); expect(within(table).getByRole('link', { name: 'Review task' })).toBeInTheDocument(); expect(within(table).getAllByText('student-github').length).toBeGreaterThan(0); - }); - - it('should request mentor course tasks for the checker dropdown', () => { - render(); const [requestFn] = useRequestMock.mock.calls[0] as [() => Promise]; - return requestFn().then(() => { - expect(getCourseTasks).toHaveBeenCalledWith(1, undefined, CourseTaskDtoCheckerEnum.Mentor); - }); + await requestFn(); + expect(getCourseTasks).toHaveBeenCalledWith(1, undefined, CourseTaskDtoCheckerEnum.Mentor); }); it('should hide the manager hint and the action column for non-managers', async () => { @@ -162,7 +151,7 @@ describe('MentorTasksReview page', () => { }); it('should re-fetch reviews after a reviewer is assigned from the modal', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByRole('table'); @@ -177,7 +166,7 @@ describe('MentorTasksReview page', () => { }); it('should re-fetch reviews with sort params when the table sorting changes', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByRole('table'); diff --git a/client/src/modules/MentorsHallOfFame/components/MentorCard/MentorCard.test.tsx b/client/src/modules/MentorsHallOfFame/components/MentorCard/MentorCard.test.tsx index 65411ac2e1..8a9fa5fa30 100644 --- a/client/src/modules/MentorsHallOfFame/components/MentorCard/MentorCard.test.tsx +++ b/client/src/modules/MentorsHallOfFame/components/MentorCard/MentorCard.test.tsx @@ -17,131 +17,51 @@ const mockMentor: TopMentorDto = { }; describe('MentorCard', () => { - it('renders mentor name, githubId, and avatar', () => { - render(); + it('renders mentor details and handles data variants', () => { + const { rerender } = render(); expect(screen.getByText('Test Mentor')).toBeInTheDocument(); expect(screen.getByText('@testmentor')).toBeInTheDocument(); - // Avatar is rendered expect(screen.getAllByRole('img').length).toBeGreaterThan(0); - }); - - it('does not display rank badge', () => { - render(); - expect(screen.queryByTitle('1')).not.toBeInTheDocument(); - }); - - it('displays total students count', () => { - render(); - expect(screen.getByText('25')).toBeInTheDocument(); expect(screen.getByText(/certified students/i)).toBeInTheDocument(); - }); - - it('displays total gratitudes count with heart emoji', () => { - render(); - expect(screen.getByText('12')).toBeInTheDocument(); expect(screen.getByText('❤️')).toBeInTheDocument(); - }); - - it('renders course stats list', () => { - render(); - expect(screen.getByText('JS Course')).toBeInTheDocument(); expect(screen.getByText('15')).toBeInTheDocument(); expect(screen.getByText('React Course')).toBeInTheDocument(); expect(screen.getByText('10')).toBeInTheDocument(); - }); - - it('renders "Say Thank you!" button that navigates to /gratitude', () => { - render(); - - const button = screen.getByRole('button', { name: /say thank you/i }); - expect(button).toBeInTheDocument(); - }); - - it('handles empty course stats gracefully', () => { - const mentorWithoutCourseStats: TopMentorDto = { - ...mockMentor, - courseStats: [], - }; - - render(); - - expect(screen.getByText('Test Mentor')).toBeInTheDocument(); - expect(screen.queryByText('JS Course')).not.toBeInTheDocument(); - }); - - it('renders GitHub profile link', () => { - render(); - + expect(screen.getByRole('button', { name: /say thank you/i })).toBeInTheDocument(); const githubLink = screen.getByText('@testmentor'); expect(githubLink).toHaveAttribute('href', 'https://github.com/testmentor'); expect(githubLink).toHaveAttribute('target', '_blank'); - }); - it('renders zero students and gratitudes', () => { - const mentorWithZeroValues: TopMentorDto = { - ...mockMentor, - totalStudents: 0, - totalGratitudes: 0, - }; - - render(); + rerender(); + expect(screen.queryByText('JS Course')).not.toBeInTheDocument(); + rerender(); expect(screen.getAllByText('0')).toHaveLength(2); expect(screen.getByText(/certified students/i)).toBeInTheDocument(); - }); - - it('renders mentor when only firstName exists in name', () => { - const mentorWithFirstNameOnly: TopMentorDto = { - ...mockMentor, - name: 'John', - }; - - render(); + rerender(); expect(screen.getByText('John')).toBeInTheDocument(); expect(screen.queryByText('Test Mentor')).not.toBeInTheDocument(); - }); - - it('renders mentor when only lastName exists in name', () => { - const mentorWithLastNameOnly: TopMentorDto = { - ...mockMentor, - name: 'Doe', - }; - - render(); + rerender(); expect(screen.getByText('Doe')).toBeInTheDocument(); expect(screen.queryByText('Test Mentor')).not.toBeInTheDocument(); - }); - it('renders very long course names', () => { const longCourseName = 'Very Long Course Name With Many Words For Overflow Testing Very Long Course Name With Many Words For Overflow Testing'; - const mentorWithLongCourseName: TopMentorDto = { - ...mockMentor, - courseStats: [{ courseName: longCourseName, studentsCount: 7 }], - }; - - render(); - + rerender( + , + ); expect(screen.getByText(longCourseName)).toBeInTheDocument(); - }); - it('renders very long mentor names', () => { const longMentorName = 'Very Long Mentor Name With Many Words For Overflow Testing Very Long Mentor Name With Many Words For Overflow Testing'; - const mentorWithLongName: TopMentorDto = { - ...mockMentor, - name: longMentorName, - }; - - render(); - + rerender(); expect(screen.getByText(longMentorName)).toBeInTheDocument(); }); }); diff --git a/client/src/modules/MentorsHallOfFame/pages/MentorsHallOfFamePage.test.tsx b/client/src/modules/MentorsHallOfFame/pages/MentorsHallOfFamePage.test.tsx index faaab78596..115dbfd6d0 100644 --- a/client/src/modules/MentorsHallOfFame/pages/MentorsHallOfFamePage.test.tsx +++ b/client/src/modules/MentorsHallOfFame/pages/MentorsHallOfFamePage.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { TopMentorDto } from '@client/api'; vi.mock('next/config', () => () => ({})); @@ -43,22 +43,15 @@ describe('MentorsHallOfFamePage', () => { mockedGetTopMentors.mockResolvedValue([]); }); - it('renders page title', async () => { + it('loads and renders the page title and mentors list', async () => { mockedGetTopMentors.mockResolvedValueOnce(lastYearMentors); render(); expect(await screen.findByText('Mentors Hall of Fame')).toBeInTheDocument(); - }); - - it('loads mentors data on mount', async () => { - mockedGetTopMentors.mockResolvedValueOnce(lastYearMentors); - - render(); - - await waitFor(() => { - expect(mockedGetTopMentors).toHaveBeenCalledWith(false); - }); + expect(await screen.findByText('Last Year Mentor')).toBeInTheDocument(); + expect(screen.getByText('@mentor-last-year')).toBeInTheDocument(); + expect(mockedGetTopMentors).toHaveBeenCalledWith(false); }); it('shows loading state during request', async () => { @@ -76,22 +69,18 @@ describe('MentorsHallOfFamePage', () => { expect(await screen.findByText('Last Year Mentor')).toBeInTheDocument(); }); - it('renders mentors list after successful load', async () => { - mockedGetTopMentors.mockResolvedValueOnce(lastYearMentors); - - render(); - - expect(await screen.findByText('Last Year Mentor')).toBeInTheDocument(); - expect(screen.getByText('@mentor-last-year')).toBeInTheDocument(); - }); - - it('switches period from lastYear to allTime', async () => { - const user = userEvent.setup(); + it('switches period, updates the description and refetches all-time mentors', async () => { + const user = setupUser(); mockedGetTopMentors.mockResolvedValueOnce(lastYearMentors).mockResolvedValueOnce(allTimeMentors); render(); await screen.findByText('Last Year Mentor'); + expect( + screen.getByText( + 'Celebrating our top mentors who guided the most students to receive certificates in the last year', + ), + ).toBeInTheDocument(); await user.click(screen.getByText('All Time')); @@ -99,25 +88,12 @@ describe('MentorsHallOfFamePage', () => { expect(mockedGetTopMentors).toHaveBeenNthCalledWith(2, true); }); expect(await screen.findByText('All Time Mentor')).toBeInTheDocument(); - }); - - it('updates description when period changes', async () => { - const user = userEvent.setup(); - mockedGetTopMentors.mockResolvedValueOnce(lastYearMentors).mockResolvedValueOnce(allTimeMentors); - - render(); - expect( - await screen.findByText( - 'Celebrating our top mentors who guided the most students to receive certificates in the last year', - ), + screen.getByText('Celebrating our top mentors who guided the most students to receive certificates'), ).toBeInTheDocument(); - await user.click(screen.getByText('All Time')); - - expect( - await screen.findByText('Celebrating our top mentors who guided the most students to receive certificates'), - ).toBeInTheDocument(); + expect(mockedGetTopMentors).toHaveBeenCalledTimes(2); + expect(mockedGetTopMentors).toHaveBeenNthCalledWith(1, false); }); it('renders empty state when there are no mentors', async () => { @@ -139,21 +115,4 @@ describe('MentorsHallOfFamePage', () => { consoleErrorSpy.mockRestore(); }); - - it('refetches mentors when period changes', async () => { - const user = userEvent.setup(); - mockedGetTopMentors.mockResolvedValueOnce(lastYearMentors).mockResolvedValueOnce(allTimeMentors); - - render(); - - await screen.findByText('Last Year Mentor'); - - await user.click(screen.getByText('All Time')); - - await waitFor(() => { - expect(mockedGetTopMentors).toHaveBeenCalledTimes(2); - }); - expect(mockedGetTopMentors).toHaveBeenNthCalledWith(1, false); - expect(mockedGetTopMentors).toHaveBeenNthCalledWith(2, true); - }); }); diff --git a/client/src/modules/Notifications/components/Consents.test.tsx b/client/src/modules/Notifications/components/Consents.test.tsx index 072539cafc..fe53827029 100644 --- a/client/src/modules/Notifications/components/Consents.test.tsx +++ b/client/src/modules/Notifications/components/Consents.test.tsx @@ -1,5 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { message } from 'antd'; import { Consents, Connection } from './Consents'; @@ -23,31 +22,23 @@ describe('Consents', () => { sendEmailConfirmationLink.mockResolvedValue(undefined); }); - it('renders nothing when email and telegram are both connected', () => { - const { container } = render( + it('renders connected and incomplete contact states', () => { + const { container, rerender } = render( , ); - // hasContacts is true → component returns null. expect(container).toBeEmptyDOMElement(); - }); - - it('always shows the telegram bot info alert when contacts are incomplete', () => { - render(); + rerender(); expect(screen.getByText(/@rsschool_bot/i)).toBeInTheDocument(); - }); - - it('prompts to add an email on the Profile page when no email is set', () => { - render(); + expect(screen.queryByText(/email is not verified/i)).not.toBeInTheDocument(); + rerender(); expect(screen.getByText(/enter your email on/i)).toBeInTheDocument(); const profileLink = screen.getByRole('link', { name: /profile/i }); expect(profileLink).toHaveAttribute('href', '/profile'); - }); - it('renders the email confirmation prompt when an email is added but not verified', () => { - render( + rerender( , ); @@ -55,18 +46,16 @@ describe('Consents', () => { }); it('sends a confirmation email when the resend link is clicked', async () => { - const user = userEvent.setup(); render( , ); - await user.click(screen.getByText(/send confirmation email/i)); + fireEvent.click(screen.getByText(/send confirmation email/i)); await waitFor(() => expect(sendEmailConfirmationLink).toHaveBeenCalledTimes(1)); }); it('shows an error message when sending the confirmation email fails', async () => { - const user = userEvent.setup(); const errorSpy = vi.spyOn(message, 'error').mockImplementation(() => ({}) as ReturnType); sendEmailConfirmationLink.mockRejectedValue(new Error('boom')); @@ -74,15 +63,9 @@ describe('Consents', () => { , ); - await user.click(screen.getByText(/send confirmation email/i)); + fireEvent.click(screen.getByText(/send confirmation email/i)); await waitFor(() => expect(errorSpy).toHaveBeenCalledWith('Error has occured. Please try again later')); errorSpy.mockRestore(); }); - - it('does not show the email confirmation prompt once the email is verified', () => { - render(); - - expect(screen.queryByText(/email is not verified/i)).not.toBeInTheDocument(); - }); }); diff --git a/client/src/modules/Notifications/components/NotificationSettingsModal.test.tsx b/client/src/modules/Notifications/components/NotificationSettingsModal.test.tsx index 241458b992..4a038549a2 100644 --- a/client/src/modules/Notifications/components/NotificationSettingsModal.test.tsx +++ b/client/src/modules/Notifications/components/NotificationSettingsModal.test.tsx @@ -1,7 +1,7 @@ /* eslint-disable testing-library/no-node-access */ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { NotificationDto, NotificationType } from '@client/api'; +import { setupUser } from '@client/__tests__/setupUser'; import { NotificationSettingsModal } from './NotificationSettingsModal'; function makeNotification(overrides: Partial = {}): NotificationDto { @@ -29,66 +29,6 @@ function getChannelPanel(channelId: string): HTMLElement { } describe('NotificationSettingsModal', () => { - it('renders the modal with its title and Settings-tab fields', () => { - render(); - - expect(screen.getByRole('dialog')).toBeInTheDocument(); - expect(screen.getByText('Notification Settings')).toBeInTheDocument(); - expect(screen.getByLabelText('Id')).toBeInTheDocument(); - expect(screen.getByLabelText('Name')).toBeInTheDocument(); - expect(screen.getByText('Active')).toBeInTheDocument(); - expect(screen.getByLabelText('Type')).toBeInTheDocument(); - }); - - it('renders a Settings tab plus one tab per channel (email, telegram, discord)', () => { - render(); - - expect(screen.getByRole('tab', { name: 'Settings' })).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: 'email' })).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: 'telegram' })).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: 'discord' })).toBeInTheDocument(); - }); - - it('leaves the Id field editable when creating a new notification', () => { - render(); - - expect(screen.getByLabelText('Id')).not.toBeDisabled(); - }); - - it('disables the Id field when editing an existing notification', () => { - render( - , - ); - - expect(screen.getByLabelText('Id')).toBeDisabled(); - }); - - it('pre-fills the form from the existing notification', () => { - render( - , - ); - - expect(screen.getByLabelText('Id')).toHaveValue('task-deadline'); - expect(screen.getByLabelText('Name')).toHaveValue('Task Deadline'); - expect(screen.getByRole('checkbox', { name: 'Active' })).toBeChecked(); - }); - - it('does not render the Active checkbox as checked for a brand-new notification', () => { - render(); - - expect(screen.getByRole('checkbox', { name: 'Active' })).not.toBeChecked(); - }); - it('hides the Parent select when there is one or fewer notifications', () => { render(); @@ -110,27 +50,8 @@ describe('NotificationSettingsModal', () => { expect(screen.getByLabelText('Parent')).toBeInTheDocument(); }); - it('renders subject + body on the email tab and only body on the telegram tab', () => { - render( - , - ); - - const emailPanel = getChannelPanel('email'); - expect(within(emailPanel).getByLabelText('subject')).toHaveValue('Hi'); - expect(within(emailPanel).getByLabelText('body')).toBeInTheDocument(); - - const telegramPanel = getChannelPanel('telegram'); - expect(within(telegramPanel).queryByLabelText('subject')).toBeNull(); - expect(within(telegramPanel).getByLabelText('body')).toBeInTheDocument(); - }); - it('switches between Settings and channel template tabs', async () => { - const user = userEvent.setup(); + const user = setupUser(); render( { expect(settingsTab).toHaveAttribute('aria-selected', 'false'); }); - it('toggles the Active checkbox', async () => { - const user = userEvent.setup(); - render(); - - const active = screen.getByRole('checkbox', { name: 'Active' }); - expect(active).not.toBeChecked(); - - await user.click(active); - expect(active).toBeChecked(); - }); - it('shows validation errors and does not call onOk when required fields are empty', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onOk = vi.fn(); render(); @@ -176,13 +86,16 @@ describe('NotificationSettingsModal', () => { }); it('submits the filled form and calls onOk with the entered values', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onOk = vi.fn(); render(); await user.type(screen.getByLabelText('Id'), 'new-notification'); await user.type(screen.getByLabelText('Name'), 'New Notification'); - await user.click(screen.getByRole('checkbox', { name: 'Active' })); + const active = screen.getByRole('checkbox', { name: 'Active' }); + expect(active).not.toBeChecked(); + await user.click(active); + expect(active).toBeChecked(); // Type select (antd Select → role combobox; open via mouseDown, options in body). const typeSelect = screen.getByLabelText('Type'); @@ -200,13 +113,24 @@ describe('NotificationSettingsModal', () => { expect(submitted.type).toBe(NotificationType.Message); }); - it('includes a channel entry with the template body when submitting', async () => { - const user = userEvent.setup(); + it('prefills existing settings and channel fields, then submits their values', async () => { + const user = setupUser(); const onOk = vi.fn(); render( , ); + expect(screen.getByLabelText('Id')).toBeDisabled(); + expect(screen.getByLabelText('Id')).toHaveValue('task-deadline'); + expect(screen.getByLabelText('Name')).toHaveValue('Task Deadline'); + expect(screen.getByRole('checkbox', { name: 'Active' })).toBeChecked(); + const emailPanel = getChannelPanel('email'); + expect(within(emailPanel).getByLabelText('subject')).toHaveValue('Hi'); + expect(within(emailPanel).getByLabelText('body')).toBeInTheDocument(); + const telegramPanel = getChannelPanel('telegram'); + expect(within(telegramPanel).queryByLabelText('subject')).toBeNull(); + expect(within(telegramPanel).getByLabelText('body')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /save/i })); await waitFor(() => expect(onOk).toHaveBeenCalledTimes(1)); @@ -218,7 +142,7 @@ describe('NotificationSettingsModal', () => { }); it('updates a channel template body and submits the new value', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onOk = vi.fn(); render( , @@ -237,11 +161,23 @@ describe('NotificationSettingsModal', () => { expect(telegramChannel.template.body).toBe('Telegram message'); }); - it('calls onCancel when the modal is dismissed without changes', async () => { - const user = userEvent.setup(); + it('renders the new notification fields and tabs, then cancels without changes', async () => { + const user = setupUser(); const onCancel = vi.fn(); render(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Notification Settings')).toBeInTheDocument(); + for (const label of ['Id', 'Name', 'Type']) { + expect(screen.getByLabelText(label)).toBeInTheDocument(); + } + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.getByLabelText('Id')).not.toBeDisabled(); + expect(screen.getByRole('checkbox', { name: 'Active' })).not.toBeChecked(); + for (const name of ['Settings', 'email', 'telegram', 'discord']) { + expect(screen.getByRole('tab', { name })).toBeInTheDocument(); + } + await user.click(screen.getByRole('button', { name: /cancel/i })); expect(onCancel).toHaveBeenCalledTimes(1); diff --git a/client/src/modules/Notifications/components/NotificationSettingsModal.tsx b/client/src/modules/Notifications/components/NotificationSettingsModal.tsx index 7be13f7feb..e5e29eb513 100644 --- a/client/src/modules/Notifications/components/NotificationSettingsModal.tsx +++ b/client/src/modules/Notifications/components/NotificationSettingsModal.tsx @@ -44,7 +44,7 @@ export function NotificationSettingsModal(props: Props) { key: 'sd', label: 'Settings', forceRender: true, - destroyInactiveTabPane: false, + destroyOnHidden: false, children: ( <> @@ -69,7 +69,7 @@ export function NotificationSettingsModal(props: Props) {
    , so scope the field lookup - // to the modal dialog to avoid colliding with the column header. - const dialog = screen.getByRole('dialog'); - expect(within(dialog).getByLabelText('Name')).toHaveValue(''); - }); - it('creates a server and reloads the list on submit', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('Alpha'); await user.click(screen.getByRole('button', { name: /add discord\/telegram channel/i })); await screen.findByText('Discord/Telegram channel'); + expect(screen.getByText('Discord/Telegram channel')).toBeInTheDocument(); const dialog = screen.getByRole('dialog'); + expect(within(dialog).getByLabelText('Name')).toHaveValue(''); await user.type(within(dialog).getByLabelText('Name'), 'Gamma'); await user.type(within(dialog).getByLabelText('Gratitude URL'), 'https://g/grat'); await user.type(within(dialog).getByLabelText('Mentors chat URL'), 'https://g/mentors'); @@ -100,11 +94,11 @@ describe('', () => { }); it('opens the edit modal prefilled and updates by id', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('Alpha'); - const alphaRow = screen.getByRole('row', { name: /Alpha/ }); + const alphaRow = getServerRow('Alpha'); await user.click(within(alphaRow).getByText('Edit')); await screen.findByText('Discord/Telegram channel'); @@ -121,11 +115,11 @@ describe('', () => { }); it('deletes a server after confirming and reloads', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('Alpha'); - const betaRow = screen.getByRole('row', { name: /Beta/ }); + const betaRow = getServerRow('Beta'); await user.click(within(betaRow).getByText('Delete')); await user.click(await screen.findByRole('button', { name: /^ok$/i })); @@ -134,13 +128,13 @@ describe('', () => { }); it('shows an error message when delete fails', async () => { - const user = userEvent.setup(); + const user = setupUser(); const errorSpy = vi.spyOn(message, 'error').mockImplementation(() => ({}) as never); deleteDiscordServer.mockRejectedValueOnce(new Error('boom')); render(); await screen.findByText('Alpha'); - const betaRow = screen.getByRole('row', { name: /Beta/ }); + const betaRow = getServerRow('Beta'); await user.click(within(betaRow).getByText('Delete')); await user.click(await screen.findByRole('button', { name: /^ok$/i })); @@ -151,7 +145,7 @@ describe('', () => { }); it('shows an error message when create fails and keeps the modal open', async () => { - const user = userEvent.setup(); + const user = setupUser(); const errorSpy = vi.spyOn(message, 'error').mockImplementation(() => ({}) as never); createDiscordServer.mockRejectedValueOnce(new Error('boom')); render(); diff --git a/client/src/modules/EventsAdmin/components/EventsModal.test.tsx b/client/src/modules/EventsAdmin/components/EventsModal.test.tsx index 8cd5dc0826..c6816ead9f 100644 --- a/client/src/modules/EventsAdmin/components/EventsModal.test.tsx +++ b/client/src/modules/EventsAdmin/components/EventsModal.test.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { DisciplineDto, EventDto } from '@client/api'; +import { setupUser } from '@client/__tests__/setupUser'; import { EventsModal } from './EventsModal'; // Pure presentational wrapper around the shared ModalForm; no API of its own. @@ -33,7 +33,7 @@ function makeProps(overrides: Partial[0]> = {}) { } // Open an antd Select by label and pick an option by its visible text. -async function selectOption(user: ReturnType, label: string, optionText: string) { +async function selectOption(user: ReturnType, label: string, optionText: string) { const combobox = screen.getByLabelText(label); await user.click(combobox); const option = await screen.findByText(optionText, { selector: '.ant-select-item-option-content' }); @@ -46,25 +46,8 @@ describe('', () => { expect(container).toBeEmptyDOMElement(); }); - it('renders the title and an empty name input when creating', () => { - render(); - - expect(screen.getByText('Event')).toBeInTheDocument(); - expect(screen.getByLabelText('Name')).toHaveValue(''); - }); - - it('lists the supplied disciplines as options', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByLabelText('Discipline')); - - expect(await screen.findByText('Frontend', { selector: '.ant-select-item-option-content' })).toBeInTheDocument(); - expect(screen.getByText('Backend', { selector: '.ant-select-item-option-content' })).toBeInTheDocument(); - }); - it('shows validation errors and does not submit when required fields are empty', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); @@ -77,13 +60,17 @@ describe('', () => { }); it('submits name, selected type, discipline and optional fields', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); await user.type(screen.getByLabelText('Name'), 'Kickoff'); await selectOption(user, 'Event Type', 'Online Lecture'); - await selectOption(user, 'Discipline', 'Backend'); + await user.click(screen.getByLabelText('Discipline')); + expect(await screen.findByText('Frontend', { selector: '.ant-select-item-option-content' })).toBeInTheDocument(); + const backendOption = screen.getByText('Backend', { selector: '.ant-select-item-option-content' }); + expect(backendOption).toBeInTheDocument(); + await user.click(backendOption); await user.type(screen.getByLabelText('Description URL'), 'https://u'); await user.type(screen.getByLabelText('Description'), 'desc body'); await user.click(screen.getByRole('button', { name: /save/i })); @@ -109,11 +96,14 @@ describe('', () => { expect(screen.getByText('Frontend')).toBeInTheDocument(); }); - it('cancels when the form is untouched', async () => { - const user = userEvent.setup(); + it('renders empty create fields and cancels when untouched', async () => { + const user = setupUser(); const props = makeProps(); render(); + expect(screen.getByText('Event')).toBeInTheDocument(); + expect(screen.getByLabelText('Name')).toHaveValue(''); + await user.click(screen.getByRole('button', { name: /cancel/i })); expect(props.cancel).toHaveBeenCalled(); diff --git a/client/src/modules/EventsAdmin/components/EventsTable.test.tsx b/client/src/modules/EventsAdmin/components/EventsTable.test.tsx index b5674ee20a..cc12165b14 100644 --- a/client/src/modules/EventsAdmin/components/EventsTable.test.tsx +++ b/client/src/modules/EventsAdmin/components/EventsTable.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { EventDto } from '@client/api'; import { EventsTable } from './EventsTable'; @@ -22,33 +22,37 @@ const data = [ }, ] as unknown as EventDto[]; +function eventRow(name: string) { + // Resolve by event text to avoid computing accessible names for every row. + // eslint-disable-next-line testing-library/no-node-access + const row = screen.getByText(name).closest('tr'); + expect(row).toHaveRole('row'); + return row!; +} + describe('', () => { - it('renders a row per event with name, discipline and type', () => { - render(); + it('calls onEdit with the row record when Edit is clicked', async () => { + const user = setupUser(); + const onEdit = vi.fn(); + render(); expect(screen.getByText('Alpha')).toBeInTheDocument(); expect(screen.getByText('Beta')).toBeInTheDocument(); expect(screen.getByText('Frontend')).toBeInTheDocument(); expect(screen.getByText('webinar')).toBeInTheDocument(); - }); - - it('calls onEdit with the row record when Edit is clicked', async () => { - const user = userEvent.setup(); - const onEdit = vi.fn(); - render(); - const alphaRow = screen.getByRole('row', { name: /Alpha/ }); + const alphaRow = eventRow('Alpha'); await user.click(within(alphaRow).getByText('Edit')); expect(onEdit).toHaveBeenCalledWith(data[0]); }); it('calls onDelete with the id after confirming', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onDelete = vi.fn(); render(); - const betaRow = screen.getByRole('row', { name: /Beta/ }); + const betaRow = eventRow('Beta'); await user.click(within(betaRow).getByText('Delete')); await user.click(await screen.findByRole('button', { name: /^ok$/i })); @@ -56,7 +60,7 @@ describe('', () => { }); it('filters rows via the Name column search', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); // The search icon in the Name column header opens the filter dropdown. diff --git a/client/src/modules/EventsAdmin/pages/EventsAdminPage/EventsAdminPage.test.tsx b/client/src/modules/EventsAdmin/pages/EventsAdminPage/EventsAdminPage.test.tsx index bc26475e2f..e378847010 100644 --- a/client/src/modules/EventsAdmin/pages/EventsAdminPage/EventsAdminPage.test.tsx +++ b/client/src/modules/EventsAdmin/pages/EventsAdminPage/EventsAdminPage.test.tsx @@ -1,7 +1,7 @@ import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { ReactNode } from 'react'; import { message } from 'antd'; +import { setupUser } from '@client/__tests__/setupUser'; import { DisciplineDto, EventDto } from '@client/api'; import { EventsAdminPage } from './EventsAdminPage'; @@ -55,12 +55,14 @@ const events = [ }, ] as unknown as EventDto[]; -async function selectOption( - user: ReturnType, - dialog: HTMLElement, - label: string, - text: string, -) { +function getEventRow(name: string) { + // eslint-disable-next-line testing-library/no-node-access -- Avoid computing accessible names for every table row. + const row = screen.getByText(name).closest('tr'); + expect(row).toHaveRole('row'); + return row!; +} + +async function selectOption(user: ReturnType, dialog: HTMLElement, label: string, text: string) { await user.click(within(dialog).getByLabelText(label)); const option = await screen.findByText(text, { selector: '.ant-select-item-option-content' }); await user.click(option); @@ -86,7 +88,7 @@ describe('', () => { }); it('creates an event with the mapped CreateEventDto payload', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('Alpha'); @@ -112,11 +114,11 @@ describe('', () => { }); it('opens the edit modal prefilled and updates by id', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('Alpha'); - const row = screen.getByRole('row', { name: /Alpha/ }); + const row = getEventRow('Alpha'); await user.click(within(row).getByText('Edit')); await screen.findByText('Event'); const dialog = screen.getByRole('dialog'); @@ -131,11 +133,11 @@ describe('', () => { }); it('deletes an event after confirming and reloads', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('Alpha'); - const row = screen.getByRole('row', { name: /Alpha/ }); + const row = getEventRow('Alpha'); await user.click(within(row).getByText('Delete')); await user.click(await screen.findByRole('button', { name: /^ok$/i })); @@ -144,13 +146,13 @@ describe('', () => { }); it('shows an error message when delete fails', async () => { - const user = userEvent.setup(); + const user = setupUser(); const errorSpy = vi.spyOn(message, 'error').mockImplementation(() => ({}) as never); deleteEvent.mockRejectedValueOnce(new Error('boom')); render(); await screen.findByText('Alpha'); - const row = screen.getByRole('row', { name: /Alpha/ }); + const row = getEventRow('Alpha'); await user.click(within(row).getByText('Delete')); await user.click(await screen.findByRole('button', { name: /^ok$/i })); @@ -159,14 +161,14 @@ describe('', () => { }); it('shows an error message when save fails', async () => { - const user = userEvent.setup(); + const user = setupUser(); const errorSpy = vi.spyOn(message, 'error').mockImplementation(() => ({}) as never); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); updateEvent.mockRejectedValueOnce(new Error('boom')); render(); await screen.findByText('Alpha'); - const row = screen.getByRole('row', { name: /Alpha/ }); + const row = getEventRow('Alpha'); await user.click(within(row).getByText('Edit')); await screen.findByText('Event'); await user.click(screen.getByRole('button', { name: /save/i })); diff --git a/client/src/modules/Feedback/components/FeedbackForm.test.tsx b/client/src/modules/Feedback/components/FeedbackForm.test.tsx index 6e2db49744..00e465e80f 100644 --- a/client/src/modules/Feedback/components/FeedbackForm.test.tsx +++ b/client/src/modules/Feedback/components/FeedbackForm.test.tsx @@ -1,6 +1,6 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { setupUser } from '@client/__tests__/setupUser'; import { CreateStudentFeedbackDtoEnglishLevelEnum as EnglishLevelEnum, CreateStudentFeedbackDtoRecommendationEnum as RecommendationEnum, @@ -75,15 +75,23 @@ const studentWithFeedback = makeStudent({ ] as MentorStudentDto['feedbacks'], }); +function getRadio(label: string | RegExp) { + const radio = screen.getByLabelText(label); + expect(radio).toHaveRole('radio'); + return radio; +} + describe('', () => { beforeEach(() => vi.clearAllMocks()); - it('renders the recommendation radios, comment field, english levels and soft-skill rates', () => { - render(); + it('renders the form fields and blocks submission when required fields are empty', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + const user = setupUser(); + render(); // Recommendation radios. - expect(screen.getByRole('radio', { name: /^hire$/i })).toBeInTheDocument(); - expect(screen.getByRole('radio', { name: /not hire/i })).toBeInTheDocument(); + expect(getRadio(/^hire$/i)).toBeInTheDocument(); + expect(getRadio(/not hire/i)).toBeInTheDocument(); // Conditional comment field is always present (label "What was good"). expect(screen.getByLabelText(/what was good/i)).toBeInTheDocument(); @@ -91,7 +99,7 @@ describe('', () => { // English levels rendered uppercased. ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'].forEach(level => { - expect(screen.getByRole('radio', { name: level })).toBeInTheDocument(); + expect(getRadio(level)).toBeInTheDocument(); }); // Three soft-skill Rate widgets, labelled by their skill names. @@ -100,12 +108,6 @@ describe('', () => { expect(screen.getByText('Communicable')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /^submit$/i })).toBeInTheDocument(); - }); - - it('blocks submit and shows required errors when recommendation and comment are empty', async () => { - const onSubmit = vi.fn().mockResolvedValue(undefined); - const user = userEvent.setup(); - render(); await user.click(screen.getByRole('button', { name: /^submit$/i })); @@ -118,15 +120,15 @@ describe('', () => { it('submits the full payload to onSubmit when required fields are filled (Hire, english level, soft skills)', async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); - const user = userEvent.setup(); + const user = setupUser(); render(); // antd Radio.Button inner input has `pointer-events: none` in jsdom, which // userEvent.click rejects; fireEvent.click on the radio is the supported path. - fireEvent.click(screen.getByRole('radio', { name: /^hire$/i })); + fireEvent.click(getRadio(/^hire$/i)); await user.type(screen.getByLabelText(/what was good/i), 'Excellent communication'); await user.type(screen.getByLabelText(/what could be improved/i), 'More tests'); - fireEvent.click(screen.getByRole('radio', { name: 'B1' })); + fireEvent.click(getRadio('B1')); // Rate the first soft-skill star = 1 (-> Poor). In antd v6 a Rate exposes its // stars as elements with role="radio" and accessible name "star star"; the @@ -156,10 +158,10 @@ describe('', () => { it('defaults englishLevel to Unknown and suggestions to empty string when left blank', async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); - const user = userEvent.setup(); + const user = setupUser(); render(); - fireEvent.click(screen.getByRole('radio', { name: /not hire/i })); + fireEvent.click(getRadio(/not hire/i)); await user.type(screen.getByLabelText(/what was good/i), 'ok'); await user.click(screen.getByRole('button', { name: /^submit$/i })); @@ -173,7 +175,7 @@ describe('', () => { it('prefills values from an existing feedback and submits with the existing feedback id', async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); - const user = userEvent.setup(); + const user = setupUser(); render(); // Prefilled comment and suggestions. @@ -182,9 +184,9 @@ describe('', () => { }); expect(screen.getByLabelText(/what could be improved/i)).toHaveValue('Keep practicing'); // Prefilled Hire radio is checked. - expect(screen.getByRole('radio', { name: /^hire$/i })).toBeChecked(); + expect(getRadio(/^hire$/i)).toBeChecked(); // Prefilled english level B2 is checked. - expect(screen.getByRole('radio', { name: 'B2' })).toBeChecked(); + expect(getRadio('B2')).toBeChecked(); await user.click(screen.getByRole('button', { name: /^submit$/i })); @@ -242,10 +244,10 @@ describe('', () => { it('shows an error message when onSubmit rejects', async () => { const onSubmit = vi.fn().mockRejectedValue(new Error('boom')); - const user = userEvent.setup(); + const user = setupUser(); render(); - fireEvent.click(screen.getByRole('radio', { name: /^hire$/i })); + fireEvent.click(getRadio(/^hire$/i)); await user.type(screen.getByLabelText(/what was good/i), 'ok'); await user.click(screen.getByRole('button', { name: /^submit$/i })); diff --git a/client/src/modules/Home/components/CourseLinks/CourseLinks.test.tsx b/client/src/modules/Home/components/CourseLinks/CourseLinks.test.tsx index e7781dc132..b851ab525c 100644 --- a/client/src/modules/Home/components/CourseLinks/CourseLinks.test.tsx +++ b/client/src/modules/Home/components/CourseLinks/CourseLinks.test.tsx @@ -3,17 +3,15 @@ import CourseLinks from './CourseLinks'; import { LinkRenderData } from '@client/modules/Home/data/links'; describe('', () => { - it('renders nothing when there are no links', () => { - const { container } = render(); + it('renders nothing when empty and maps supplied links', () => { + const { container, rerender } = render(); expect(container).toBeEmptyDOMElement(); - }); - it('renders one anchor per link pointing at its url', () => { const courseLinks: LinkRenderData[] = [ { name: 'Score', url: '/course/score?course=c1', icon: }, { name: 'Schedule', url: '/course/schedule?course=c1', icon: }, ]; - render(); + rerender(); const score = screen.getByRole('link', { name: /score/i }); expect(score).toHaveAttribute('href', '/course/score?course=c1'); diff --git a/client/src/modules/Home/components/CourseSelector/index.test.tsx b/client/src/modules/Home/components/CourseSelector/index.test.tsx index f783d7d48d..ee5e57c972 100644 --- a/client/src/modules/Home/components/CourseSelector/index.test.tsx +++ b/client/src/modules/Home/components/CourseSelector/index.test.tsx @@ -32,25 +32,15 @@ describe('', () => { expect(container).toBeEmptyDOMElement(); }); - it('renders a combobox defaulted to the active course', () => { - render(); + it('renders courses and reports the selected course id', async () => { + const onChangeCourse = vi.fn(); + render(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); expect(screen.getByText('Active Course')).toBeInTheDocument(); - }); - - it('marks completed courses as archived in the options', () => { - render(); fireEvent.mouseDown(screen.getByRole('combobox')); expect(screen.getByText(/\(Archived\)/)).toBeInTheDocument(); - }); - it('calls onChangeCourse with the selected course id', async () => { - const onChangeCourse = vi.fn(); - render(); - fireEvent.mouseDown(screen.getByRole('combobox')); - // antd wires its select handler on the `.ant-select-item-option` wrapper (the - // role="option" nodes are empty aria mirrors), and option labels are JSX so they - // have no computed accessible name — locate the wrapper by its content text. const option = await waitFor(() => { const match = Array.from(document.querySelectorAll('.ant-select-item-option')).find(el => el.textContent?.includes('Old Course'), @@ -59,7 +49,6 @@ describe('', () => { return match as HTMLElement; }); fireEvent.click(option); - // antd passes (value, option) to onChange. expect(onChangeCourse).toHaveBeenCalledWith(2, expect.anything()); }); }); diff --git a/client/src/modules/Home/components/HomeSummary/HomeSummary.test.tsx b/client/src/modules/Home/components/HomeSummary/HomeSummary.test.tsx index 0a45b3061d..378bff0d03 100644 --- a/client/src/modules/Home/components/HomeSummary/HomeSummary.test.tsx +++ b/client/src/modules/Home/components/HomeSummary/HomeSummary.test.tsx @@ -19,35 +19,17 @@ function makeSummary(overrides: Partial = {}): StudentSummary } describe('', () => { - it('renders nothing when there is no summary', () => { - const { container } = render(); + it('renders summary status, score, and optional mentor details', () => { + const { container, rerender } = render(); expect(container).toBeEmptyDOMElement(); - }); - it('shows score points and completed-task ratio (only positive scores count)', () => { - render(); + rerender(); expect(screen.getByText('Score Points')).toBeInTheDocument(); expect(screen.getByText('120')).toBeInTheDocument(); - // 2 of 3 results have score > 0, total tasks = 4. expect(screen.getByText('2/4')).toBeInTheDocument(); - }); - - it('shows an Active status when the student is active', () => { - render(); expect(screen.getByText('Active')).toBeInTheDocument(); - }); - - it('shows an Inactive status when the student is inactive', () => { - render(); - expect(screen.getByText('Inactive')).toBeInTheDocument(); - }); - - it('hides the mentor card when there is no mentor', () => { - render(); expect(screen.queryByText('Your mentor')).not.toBeInTheDocument(); - }); - it('renders mentor name, github link and only the populated contacts', () => { const mentor = { name: 'Jane Mentor', githubId: 'jane', @@ -58,12 +40,12 @@ describe('', () => { contactsNotes: null, contactsWhatsApp: null, }; - render(); + rerender(); + expect(screen.getByText('Inactive')).toBeInTheDocument(); expect(screen.getByText('Your mentor')).toBeInTheDocument(); expect(screen.getByText('Jane Mentor')).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'jane' })).toBeInTheDocument(); - // Populated contacts render; empty ones are skipped. expect(screen.getByText('jane@example.com')).toBeInTheDocument(); expect(screen.getByText('jane_tg')).toBeInTheDocument(); expect(screen.getByText('Email:')).toBeInTheDocument(); diff --git a/client/src/modules/Home/components/HomeSummary/HomeSummary.tsx b/client/src/modules/Home/components/HomeSummary/HomeSummary.tsx index f25b145281..4cf11352fe 100644 --- a/client/src/modules/Home/components/HomeSummary/HomeSummary.tsx +++ b/client/src/modules/Home/components/HomeSummary/HomeSummary.tsx @@ -50,7 +50,7 @@ export default function HomeSummary({ summary, courseTasks }: HomeSummaryProps)
    +
    ({ - ...(await vi.importActual('@client/api')), +vi.mock('@client/api', () => ({ PromptsApi: function PromptsApi() { return { getPrompts, createPrompt, updatePrompt, deletePrompt }; }, @@ -54,6 +53,13 @@ const prompts = [ { id: 2, type: 'gratitude', temperature: 0.7, text: 'B body' }, ]; +function getPromptRow(type: string) { + // eslint-disable-next-line testing-library/no-node-access -- Avoid computing accessible names for every table row. + const row = screen.getByText(type).closest('tr'); + expect(row).toHaveRole('row'); + return row!; +} + describe('', () => { beforeEach(() => { vi.clearAllMocks(); @@ -72,23 +78,13 @@ describe('', () => { expect(screen.getByRole('heading', { name: /manage prompts/i })).toBeInTheDocument(); }); - it('opens the create modal when "Add Prompt" is clicked', async () => { - const user = userEvent.setup(); - render(); - await screen.findByText('summary'); - - await user.click(screen.getByRole('button', { name: /add prompt/i })); - - expect(await screen.findByText('Add prompt')).toBeInTheDocument(); - }); - it('creates a prompt and reloads the list on submit', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('summary'); await user.click(screen.getByRole('button', { name: /add prompt/i })); - await screen.findByText('Add prompt'); + expect(await screen.findByText('Add prompt')).toBeInTheDocument(); const dialog = screen.getByRole('dialog'); await user.type(within(dialog).getByLabelText('Type'), 'newtype'); @@ -100,11 +96,11 @@ describe('', () => { }); it('opens the edit modal prefilled and updates by id', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('summary'); - const row = screen.getByRole('row', { name: /summary/ }); + const row = getPromptRow('summary'); const [editBtn] = within(row).getAllByRole('button'); await user.click(editBtn); @@ -121,11 +117,11 @@ describe('', () => { }); it('deletes a prompt and reloads the list', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('gratitude'); - const row = screen.getByRole('row', { name: /gratitude/ }); + const row = getPromptRow('gratitude'); const buttons = within(row).getAllByRole('button'); await user.click(buttons[1]); diff --git a/client/src/modules/Registry/components/Cards/AdditionalInfo/AdditionalInfo.test.tsx b/client/src/modules/Registry/components/Cards/AdditionalInfo/AdditionalInfo.test.tsx index c741988c24..04bda77c93 100644 --- a/client/src/modules/Registry/components/Cards/AdditionalInfo/AdditionalInfo.test.tsx +++ b/client/src/modules/Registry/components/Cards/AdditionalInfo/AdditionalInfo.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { Form } from 'antd'; import { UpdateUserDtoLanguagesEnum } from '@client/api'; import { LABELS } from '@client/modules/Registry/constants'; @@ -40,45 +40,18 @@ describe('AdditionalInfo', () => { vi.clearAllMocks(); }); - const user = userEvent.setup(); - - test.each` - label - ${LABELS.courses} - ${LABELS.aboutYourself} - `('should render field with $label label', async ({ label }) => { - renderAdditionalInfo(mockValues); - - const field = await screen.findByText(label); - expect(field).toBeInTheDocument(); - }); - - test('should render data processing checkbox', async () => { - renderAdditionalInfo(mockValues); - - const checkbox = await screen.findByRole('checkbox'); - expect(checkbox).toBeInTheDocument(); - }); - - test('should render Previous button', async () => { - renderAdditionalInfo(mockValues); - - const button = await screen.findByRole('button', { name: /previous/i }); - expect(button).toBeInTheDocument(); - }); - - test('should render Submit button', async () => { + test('should render populated fields and navigation, then call only submitHandler', async () => { + const user = setupUser(); renderAdditionalInfo(mockValues); - const button = await screen.findByRole('button', { name: /submit/i }); - expect(button).toBeInTheDocument(); - }); - - test('should call only submitHandler', async () => { - renderAdditionalInfo(mockValues); + expect(screen.getByText(LABELS.courses)).toBeInTheDocument(); + expect(screen.getByText(LABELS.aboutYourself)).toBeInTheDocument(); + expect(screen.getByRole('checkbox')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument(); const button = await screen.findByRole('button', { name: /submit/i }); + expect(button).toBeInTheDocument(); await user.click(button); expect(submitHandler).toHaveBeenCalled(); @@ -86,6 +59,7 @@ describe('AdditionalInfo', () => { }); test('should call only submitFailedHandler', async () => { + const user = setupUser(); renderAdditionalInfo({ ...mockValues, dataProcessing: 0 }); const button = await screen.findByRole('button', { name: /submit/i }); diff --git a/client/src/modules/Registry/components/Cards/ContactInfo/ContactInfo.test.tsx b/client/src/modules/Registry/components/Cards/ContactInfo/ContactInfo.test.tsx index fcd7bba44a..461bfcac31 100644 --- a/client/src/modules/Registry/components/Cards/ContactInfo/ContactInfo.test.tsx +++ b/client/src/modules/Registry/components/Cards/ContactInfo/ContactInfo.test.tsx @@ -22,74 +22,31 @@ const renderContactInfo = (values: Values = mockValues) => ); describe('ContactInfo', () => { - test.each(Object.values(mockValues).map(value => ({ value })))( - 'should render form item with $value value', - async ({ value }) => { - renderContactInfo(); - - const item = await screen.findByDisplayValue(value); - expect(item).toBeInTheDocument(); - }, - ); - - test('should render Continue button', async () => { - renderContactInfo(); - - const button = await screen.findByRole('button', { name: /continue/i }); - expect(button).toBeInTheDocument(); - }); - - test('should render Telegram bot link', async () => { + test('should render initial values, labels, placeholders and navigation', () => { renderContactInfo(); - const link = await screen.findByRole('link'); + for (const value of Object.values(mockValues)) { + expect(screen.getByDisplayValue(value)).toBeInTheDocument(); + } + for (const label of [LABELS.telegram, LABELS.skype, LABELS.whatsApp, LABELS.email, LABELS.phone, LABELS.notes]) { + expect(screen.getByLabelText(label)).toBeInTheDocument(); + } + for (const placeholder of [ + PLACEHOLDERS.telegram, + PLACEHOLDERS.skype, + PLACEHOLDERS.whatsApp, + PLACEHOLDERS.email, + PLACEHOLDERS.phone, + PLACEHOLDERS.notes, + ]) { + expect(screen.getByPlaceholderText(placeholder)).toBeInTheDocument(); + } + expect(screen.getByRole('button', { name: /continue/i })).toBeInTheDocument(); + const link = screen.getByRole('link'); expect(link).toBeInTheDocument(); expect(link).toHaveAttribute('href', RSSCHOOL_BOT_LINK); }); - test.each` - label - ${LABELS.telegram} - ${LABELS.skype} - ${LABELS.whatsApp} - ${LABELS.email} - ${LABELS.phone} - ${LABELS.notes} - `('should render field with $label label', async ({ label }) => { - renderContactInfo(); - - const fieldLabel = await screen.findByLabelText(label); - expect(fieldLabel).toBeInTheDocument(); - }); - - test.each` - placeholder - ${PLACEHOLDERS.telegram} - ${PLACEHOLDERS.skype} - ${PLACEHOLDERS.whatsApp} - ${PLACEHOLDERS.email} - ${PLACEHOLDERS.phone} - ${PLACEHOLDERS.notes} - `('should render field with $placeholder placeholder', async ({ placeholder }) => { - renderContactInfo(); - - const fieldPlaceholder = await screen.findByPlaceholderText(placeholder); - expect(fieldPlaceholder).toBeInTheDocument(); - }); - - test.each` - placeholder | message - ${PLACEHOLDERS.email} | ${ERROR_MESSAGES.email} - ${PLACEHOLDERS.phone} | ${ERROR_MESSAGES.phone} - `('should not render $message error message on valid input', async ({ placeholder, message }) => { - renderContactInfo(); - - const input = await screen.findByPlaceholderText(placeholder); - const errorMessage = screen.queryByText(message); - expect(input).toBeInTheDocument(); - expect(errorMessage).not.toBeInTheDocument(); - }); - test.each` placeholder | value | message ${PLACEHOLDERS.email} | ${'test'} | ${ERROR_MESSAGES.email} @@ -98,6 +55,8 @@ describe('ContactInfo', () => { renderContactInfo(); const input = await screen.findByPlaceholderText(placeholder); + expect(input).toBeInTheDocument(); + expect(screen.queryByText(message)).not.toBeInTheDocument(); fireEvent.change(input, { target: { diff --git a/client/src/modules/Registry/components/Cards/CourseDetails/CourseDetails.test.tsx b/client/src/modules/Registry/components/Cards/CourseDetails/CourseDetails.test.tsx index fb2edbd079..aa7b6fcc53 100644 --- a/client/src/modules/Registry/components/Cards/CourseDetails/CourseDetails.test.tsx +++ b/client/src/modules/Registry/components/Cards/CourseDetails/CourseDetails.test.tsx @@ -12,25 +12,12 @@ const renderCourseDetails = (courses: CourseDto[] = []) => ); describe('CourseDetail', () => { - test.each` - label - ${LABELS.course} - ${LABELS.languagesStudent} - `('should render field with $label label', async ({ label }) => { + test('should render field labels and placeholders', async () => { renderCourseDetails(); - const fieldLabel = await screen.findByLabelText(label); - expect(fieldLabel).toBeInTheDocument(); - }); - - test.each` - placeholder - ${PLACEHOLDERS.courses} - ${PLACEHOLDERS.languages} - `('should render field with $placeholder placeholder', async ({ placeholder }) => { - renderCourseDetails(); - - const fieldPlaceholder = await screen.findByText(placeholder); - expect(fieldPlaceholder).toBeInTheDocument(); + expect(await screen.findByLabelText(LABELS.course)).toBeInTheDocument(); + expect(screen.getByLabelText(LABELS.languagesStudent)).toBeInTheDocument(); + expect(screen.getByText(PLACEHOLDERS.courses)).toBeInTheDocument(); + expect(screen.getByText(PLACEHOLDERS.languages)).toBeInTheDocument(); }); }); diff --git a/client/src/modules/Registry/components/Cards/Disciplines/Disciplines.test.tsx b/client/src/modules/Registry/components/Cards/Disciplines/Disciplines.test.tsx index 20c585d3c9..947ba9e096 100644 --- a/client/src/modules/Registry/components/Cards/Disciplines/Disciplines.test.tsx +++ b/client/src/modules/Registry/components/Cards/Disciplines/Disciplines.test.tsx @@ -14,30 +14,18 @@ const mockDisciplines = [ ] as DisciplineDto[]; describe('Disciplines', () => { - test.each(mockDisciplines)('should render form item with $name value', async ({ name }) => { - render( + test('renders discipline values, label, and empty state', async () => { + const { rerender } = render(
    , ); - const item = await screen.findByDisplayValue(name); - expect(item).toBeInTheDocument(); - }); - - test(`should render field with "${LABELS.disciplines}" label`, async () => { - render( -
    - - , - ); - - const fieldLabel = await screen.findByTitle(LABELS.disciplines); - expect(fieldLabel).toBeInTheDocument(); - }); + expect(await screen.findByDisplayValue('JS')).toBeInTheDocument(); + expect(screen.getByDisplayValue('TS')).toBeInTheDocument(); + expect(screen.getByTitle(LABELS.disciplines)).toBeInTheDocument(); - test("should render when there's no disciplines", async () => { - render( + rerender(
    , diff --git a/client/src/modules/Registry/components/Cards/PersonalInfo/PersonalInfo.test.tsx b/client/src/modules/Registry/components/Cards/PersonalInfo/PersonalInfo.test.tsx index 2865001486..2535dcba86 100644 --- a/client/src/modules/Registry/components/Cards/PersonalInfo/PersonalInfo.test.tsx +++ b/client/src/modules/Registry/components/Cards/PersonalInfo/PersonalInfo.test.tsx @@ -35,62 +35,26 @@ const renderPersonalInfo = (values: Values = mockValues, isStudentForm?: boolean ); describe('PersonalInfo', () => { - test.each( - Object.values(mockValues) - .filter(Boolean) - .map(value => ({ value })), - )('should render form item with $value value', async ({ value }) => { + test('should render initial mentor values, labels and placeholders', () => { renderPersonalInfo(); - const item = await screen.findByDisplayValue(value as string); - expect(item).toBeInTheDocument(); - }); - - test.each` - label - ${LABELS.firstName} - ${LABELS.lastName} - ${LABELS.primaryEmail} - ${LABELS.epamEmail} - `('should render field with $label label', async ({ label }) => { - renderPersonalInfo(); - - const fieldLabel = await screen.findByLabelText(label); - expect(fieldLabel).toBeInTheDocument(); - }); - - test('should render field with location label', async () => { - renderPersonalInfo(); - const fieldLabel = await screen.findByText(LABELS.location); - expect(fieldLabel).toBeInTheDocument(); - }); - - test.each` - placeholder - ${PLACEHOLDERS.firstName} - ${PLACEHOLDERS.lastName} - ${PLACEHOLDERS.email} - ${PLACEHOLDERS.epamEmail} - `('should render field with $placeholder placeholder', async ({ placeholder }) => { - renderPersonalInfo(); - - const fieldPlaceholder = await screen.findByPlaceholderText(placeholder); - expect(fieldPlaceholder).toBeInTheDocument(); - }); - - test.each` - placeholder | message - ${PLACEHOLDERS.email} | ${ERROR_MESSAGES.email} - ${PLACEHOLDERS.epamEmail} | ${ERROR_MESSAGES.epamEmail} - ${PLACEHOLDERS.firstName} | ${ERROR_MESSAGES.inEnglish('First name')} - ${PLACEHOLDERS.lastName} | ${ERROR_MESSAGES.inEnglish('Last name')} - `('should not render $message error message on valid input', async ({ placeholder, message }) => { - renderPersonalInfo(); - - const input = await screen.findByPlaceholderText(placeholder); - const errorMessage = screen.queryByText(message); - expect(input).toBeInTheDocument(); - expect(errorMessage).not.toBeInTheDocument(); + for (const value of Object.values(mockValues).filter(Boolean)) { + expect(screen.getByDisplayValue(value as string)).toBeInTheDocument(); + } + for (const label of [LABELS.firstName, LABELS.lastName, LABELS.primaryEmail, LABELS.epamEmail]) { + expect(screen.getByLabelText(label)).toBeInTheDocument(); + } + expect(screen.getByText(LABELS.location)).toBeInTheDocument(); + for (const placeholder of [ + PLACEHOLDERS.firstName, + PLACEHOLDERS.lastName, + PLACEHOLDERS.email, + PLACEHOLDERS.epamEmail, + ]) { + expect(screen.getByPlaceholderText(placeholder)).toBeInTheDocument(); + } + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /submit/i })).not.toBeInTheDocument(); }); test.each` @@ -99,22 +63,27 @@ describe('PersonalInfo', () => { ${PLACEHOLDERS.epamEmail} | ${'test@epam.com'} | ${ERROR_MESSAGES.epamEmail} ${PLACEHOLDERS.firstName} | ${'Róża'} | ${ERROR_MESSAGES.inEnglish('First name')} ${PLACEHOLDERS.lastName} | ${'Wójcik'} | ${ERROR_MESSAGES.inEnglish('Last name')} - `('should render $message error message on invalid input', async ({ placeholder, value, message }) => { - renderPersonalInfo(); + `( + 'should show $message only after changing valid input to invalid input', + async ({ placeholder, value, message }) => { + renderPersonalInfo(); - const input = await screen.findByPlaceholderText(placeholder); + const input = await screen.findByPlaceholderText(placeholder); + expect(input).toBeInTheDocument(); + expect(screen.queryByText(message)).not.toBeInTheDocument(); - fireEvent.change(input, { - target: { - value, - }, - }); + fireEvent.change(input, { + target: { + value, + }, + }); - expect(input).toHaveValue(value); + expect(input).toHaveValue(value); - const errorMessage = await screen.findByText(message); - expect(errorMessage).toBeInTheDocument(); - }); + const errorMessage = await screen.findByText(message); + expect(errorMessage).toBeInTheDocument(); + }, + ); test('should render error messages only on required fields', async () => { renderPersonalInfo({}); @@ -135,31 +104,10 @@ describe('PersonalInfo', () => { expect(errorEpamEmail).not.toBeInTheDocument(); }); - test('should render data processing checkbox on student form', async () => { - renderPersonalInfo(mockValues, true); - - const checkbox = await screen.findByRole('checkbox'); - expect(checkbox).toBeInTheDocument(); - }); - - test('should render Submit button on student form', async () => { + test('should render data processing checkbox and Submit button on student form', async () => { renderPersonalInfo(mockValues, true); - const button = await screen.findByRole('button', { name: /submit/i }); - expect(button).toBeInTheDocument(); - }); - - test('should not render data processing checkbox on mentor form', () => { - renderPersonalInfo(); - - const checkbox = screen.queryByRole('checkbox'); - expect(checkbox).not.toBeInTheDocument(); - }); - - test('should not render Submit button on mentor form', () => { - renderPersonalInfo(); - - const button = screen.queryByRole('button', { name: /submit/i }); - expect(button).not.toBeInTheDocument(); + expect(await screen.findByRole('checkbox')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument(); }); }); diff --git a/client/src/modules/Registry/components/Cards/Preferences/Preferences.test.tsx b/client/src/modules/Registry/components/Cards/Preferences/Preferences.test.tsx index 446b83bfd7..bdca9d9fff 100644 --- a/client/src/modules/Registry/components/Cards/Preferences/Preferences.test.tsx +++ b/client/src/modules/Registry/components/Cards/Preferences/Preferences.test.tsx @@ -11,25 +11,12 @@ const renderPreferences = () => ); describe('Preferences', () => { - test.each` - value - ${2} - ${'any'} - `('should render form item with $value value', async ({ value }) => { + test('should render preference values and labels', async () => { renderPreferences(); - const item = await screen.findByDisplayValue(value); - expect(item).toBeInTheDocument(); - }); - - test.each` - label - ${LABELS.studentsCount} - ${LABELS.studentsLocation} - `('should render field with $label label', async ({ label }) => { - renderPreferences(); - - const fieldLabel = await screen.findByTitle(label); - expect(fieldLabel).toBeInTheDocument(); + expect(await screen.findByDisplayValue(2)).toBeInTheDocument(); + expect(screen.getByDisplayValue('any')).toBeInTheDocument(); + expect(screen.getByTitle(LABELS.studentsCount)).toBeInTheDocument(); + expect(screen.getByTitle(LABELS.studentsLocation)).toBeInTheDocument(); }); }); diff --git a/client/src/modules/Registry/components/CourseCertificateAlert/CourseCertificateAlert.test.tsx b/client/src/modules/Registry/components/CourseCertificateAlert/CourseCertificateAlert.test.tsx index 79339a8928..73a5bff82a 100644 --- a/client/src/modules/Registry/components/CourseCertificateAlert/CourseCertificateAlert.test.tsx +++ b/client/src/modules/Registry/components/CourseCertificateAlert/CourseCertificateAlert.test.tsx @@ -1,29 +1,35 @@ import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; import { CourseCertificateAlert } from './CourseCertificateAlert'; +vi.mock('antd', () => ({ + Button: ({ children, href }: { children: ReactNode; href: string }) => {children}, + Result: ({ icon, title, subTitle, extra }: Record<'icon' | 'title' | 'subTitle' | 'extra', ReactNode>) => ( +
    + {icon} +

    {title}

    +

    {subTitle}

    + {extra} +
    + ), +})); + describe('CourseCertificateAlert', () => { - test('falls back to "any" discipline when none provided', () => { - render(); + test('renders the default content and a specified discipline', () => { + const { rerender } = render(); expect( screen.getByText('To register for this course, you need to already have any RS School certificate.'), ).toBeInTheDocument(); expect(screen.getByText('Complete any course to unlock access.')).toBeInTheDocument(); - }); + expect(screen.getByRole('link', { name: 'Back to Home' })).toHaveAttribute('href', '/'); + expect(screen.getByRole('img', { name: 'train icon' })).toBeInTheDocument(); - test('renders the specific discipline name passed in', () => { - render(); + rerender(); expect( screen.getByText('To register for this course, you need to already have JavaScript RS School certificate.'), ).toBeInTheDocument(); expect(screen.getByText('Complete JavaScript course to unlock access.')).toBeInTheDocument(); }); - - test('renders a Back to Home link and the train icon', () => { - render(); - - expect(screen.getByRole('link', { name: 'Back to Home' })).toHaveAttribute('href', '/'); - expect(screen.getByRole('img', { name: 'train icon' })).toBeInTheDocument(); - }); }); diff --git a/client/src/modules/Registry/components/CourseLabel/CourseLabel.test.tsx b/client/src/modules/Registry/components/CourseLabel/CourseLabel.test.tsx index 97777f6dbe..798f85e245 100644 --- a/client/src/modules/Registry/components/CourseLabel/CourseLabel.test.tsx +++ b/client/src/modules/Registry/components/CourseLabel/CourseLabel.test.tsx @@ -17,35 +17,29 @@ const baseCourse = { } as unknown as CourseDto; describe('CourseLabel', () => { - test('student form: shows discipline, name and the friendly start month', () => { - render(); + test('renders student and mentor labels with optional data', () => { + const { rerender } = render(); // ` JS Course (JavaScript, Mar 2024) ` expect(screen.getByText(/JS Course \(JavaScript, Mar 2024\)/)).toBeInTheDocument(); - }); - test('student form: omits the discipline prefix when discipline has no name', () => { const course = { ...baseCourse, discipline: undefined } as CourseDto; - render(); + rerender(); expect(screen.getByText(/JS Course \(Mar 2024\)/)).toBeInTheDocument(); expect(screen.queryByText(/JavaScript,/)).not.toBeInTheDocument(); - }); - test('mentor form: shows the personal mentoring date range', () => { - render(); + rerender(); // ` JS Course (Mentoring: Apr 2024-Jun 2024) ` expect(screen.getByText(/JS Course \(Mentoring: Apr 2024-Jun 2024\)/)).toBeInTheDocument(); - }); - test('mentor form: tolerates missing mentoring dates', () => { - const course = { + const courseWithoutDates = { ...baseCourse, personalMentoringStartDate: undefined, personalMentoringEndDate: undefined, } as CourseDto; - render(); + rerender(); expect(screen.getByText(/JS Course \(Mentoring: -\)/)).toBeInTheDocument(); }); diff --git a/client/src/modules/Registry/components/DataProcessingCheckbox/DataProcessingCheckbox.test.tsx b/client/src/modules/Registry/components/DataProcessingCheckbox/DataProcessingCheckbox.test.tsx index 8c86b70d8d..426121ec06 100644 --- a/client/src/modules/Registry/components/DataProcessingCheckbox/DataProcessingCheckbox.test.tsx +++ b/client/src/modules/Registry/components/DataProcessingCheckbox/DataProcessingCheckbox.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { Form } from 'antd'; import { ERROR_MESSAGES } from '@client/modules/Registry/constants'; import { DataProcessingCheckbox } from './DataProcessingCheckbox'; @@ -17,33 +17,19 @@ const renderCheckbox = (checked = Checkbox.notChecked) => ); describe('DataProcessingCheckbox', () => { - const user = userEvent.setup(); + const user = setupUser(); - test('should render checkbox', async () => { - renderCheckbox(); - - const checkbox = await screen.findByRole('checkbox'); - expect(checkbox).toBeInTheDocument(); - }); - - test('should not render error message when checkbox is selected', async () => { + test('renders checked state and validates when unchecked', async () => { renderCheckbox(Checkbox.checked); const checkbox = await screen.findByRole('checkbox'); - const errorMessage = screen.queryByText(ERROR_MESSAGES.shouldAgree); + expect(checkbox).toBeInTheDocument(); expect(checkbox).toBeChecked(); - expect(errorMessage).not.toBeInTheDocument(); - }); - - test('should render error message when checkbox is not selected', async () => { - renderCheckbox(Checkbox.checked); - - const checkbox = await screen.findByRole('checkbox'); + expect(screen.queryByText(ERROR_MESSAGES.shouldAgree)).not.toBeInTheDocument(); await user.click(checkbox); - const errorMessage = await screen.findByText(ERROR_MESSAGES.shouldAgree); expect(checkbox).not.toBeChecked(); - expect(errorMessage).toBeInTheDocument(); + expect(await screen.findByText(ERROR_MESSAGES.shouldAgree)).toBeInTheDocument(); }); }); diff --git a/client/src/modules/Registry/components/Footer/Footer.test.tsx b/client/src/modules/Registry/components/Footer/Footer.test.tsx index a51d901808..6d75c64fb6 100644 --- a/client/src/modules/Registry/components/Footer/Footer.test.tsx +++ b/client/src/modules/Registry/components/Footer/Footer.test.tsx @@ -2,16 +2,11 @@ import { render, screen } from '@testing-library/react'; import { Footer } from './Footer'; describe('Footer', () => { - test('renders the copyright line with the current year', () => { + test('renders the current copyright inside a contentinfo landmark', () => { render(
    ); const year = new Date().getFullYear(); expect(screen.getByText(`Copyright © The Rolling Scopes ${year}`)).toBeInTheDocument(); - }); - - test('renders inside a contentinfo landmark', () => { - render(
    ); - expect(screen.getByRole('contentinfo')).toBeInTheDocument(); }); }); diff --git a/client/src/modules/Registry/components/FormButtons/FormButtons.test.tsx b/client/src/modules/Registry/components/FormButtons/FormButtons.test.tsx index 4b711915c0..99a077503b 100644 --- a/client/src/modules/Registry/components/FormButtons/FormButtons.test.tsx +++ b/client/src/modules/Registry/components/FormButtons/FormButtons.test.tsx @@ -23,17 +23,6 @@ describe('FormButtons', () => { vi.clearAllMocks(); }); - const user = userEvent.setup(); - - test('should render only Submit button', () => { - renderFormButtons(); - - const submitButton = screen.queryByRole('button', { name: /submit/i }); - const previousButton = screen.queryByRole('button', { name: /previous/i }); - expect(submitButton).toBeInTheDocument(); - expect(previousButton).not.toBeInTheDocument(); - }); - test('should render submit button with custom title', () => { const submitTitle = 'Continue'; renderFormButtons({ submitTitle }); @@ -42,19 +31,12 @@ describe('FormButtons', () => { expect(submitButton).toBeInTheDocument(); }); - test('should render both buttons (submit & previous)', () => { - renderFormButtons({ onPrevious: previousHandler }); - - const submitButton = screen.queryByRole('button', { name: /submit/i }); - const previousButton = screen.queryByRole('button', { name: /previous/i }); - expect(submitButton).toBeInTheDocument(); - expect(previousButton).toBeInTheDocument(); - }); - - test('should call previousHandler', async () => { + test('should render both buttons and call previousHandler', async () => { + const user = userEvent.setup(); renderFormButtons({ onPrevious: previousHandler }); - const button = await screen.findByRole('button', { name: /previous/i }); + expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument(); + const button = screen.getByRole('button', { name: /previous/i }); expect(button).toBeInTheDocument(); await user.click(button); @@ -62,10 +44,12 @@ describe('FormButtons', () => { expect(previousHandler).toHaveBeenCalled(); }); - test('should call submitHandler', async () => { + test('should render only Submit and call submitHandler', async () => { + const user = userEvent.setup(); renderFormButtons(); - const button = await screen.findByRole('button', { name: /submit/i }); + expect(screen.queryByRole('button', { name: /previous/i })).not.toBeInTheDocument(); + const button = screen.getByRole('button', { name: /submit/i }); expect(button).toBeInTheDocument(); await user.click(button); diff --git a/client/src/modules/Registry/components/FormCard/FormCard.test.tsx b/client/src/modules/Registry/components/FormCard/FormCard.test.tsx index ac670e2c5a..4e53cc70db 100644 --- a/client/src/modules/Registry/components/FormCard/FormCard.test.tsx +++ b/client/src/modules/Registry/components/FormCard/FormCard.test.tsx @@ -4,32 +4,30 @@ import { FormCard } from './FormCard'; const { Title } = Typography; -describe('FormCard', () => { - test('renders a plain string title in the card head', () => { - render(); - - expect(screen.getByText('Personal information')).toBeInTheDocument(); - }); - - test('renders a Typography Title node as an accessible heading (real usage)', () => { - render(Contact information} />); - - expect(screen.getByRole('heading', { name: 'Contact information' })).toBeInTheDocument(); - }); +vi.mock('antd', () => ({ + Card: ({ title, children }: { title: React.ReactNode; children?: React.ReactNode }) => ( +
    +
    {title}
    + {children} +
    + ), + Typography: { Title: ({ children }: { children: React.ReactNode }) =>
    {children}
    }, +})); - test('renders its children inside the card body', () => { - render( - +describe('FormCard', () => { + test('renders string and heading titles with optional body content', () => { + const { rerender } = render( +

    child content

    , ); + expect(screen.getByText('Personal information')).toBeInTheDocument(); expect(screen.getByText('child content')).toBeInTheDocument(); - }); - test('renders without children', () => { - render(); + rerender(Contact information} />); - expect(screen.getByText('Empty card')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Contact information' })).toBeInTheDocument(); + expect(screen.queryByText('child content')).not.toBeInTheDocument(); }); }); diff --git a/client/src/modules/Registry/components/FormSections/DoneSection/DoneSection.test.tsx b/client/src/modules/Registry/components/FormSections/DoneSection/DoneSection.test.tsx index 85ad6c0db0..937bda15b1 100644 --- a/client/src/modules/Registry/components/FormSections/DoneSection/DoneSection.test.tsx +++ b/client/src/modules/Registry/components/FormSections/DoneSection/DoneSection.test.tsx @@ -1,25 +1,17 @@ import { render, screen } from '@testing-library/react'; import { DoneSection } from './DoneSection'; -const renderDoneSection = (courseName?: string) => { - render(); -}; - const courseName = 'test-course'; describe('DoneSection', () => { - test('should render Continue link on student form', async () => { - renderDoneSection(courseName); + test('should render Continue only on the student form', async () => { + const { rerender } = render(); const link = await screen.findByRole('link', { name: /continue/i }); expect(link).toBeInTheDocument(); expect(link).toHaveAttribute('href', '/'); - }); - - test('should not render Continue link on mentor form', async () => { - renderDoneSection(); + rerender(); - const link = screen.queryByRole('link', { name: /continue/i }); - expect(link).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /continue/i })).not.toBeInTheDocument(); }); }); diff --git a/client/src/modules/Registry/components/FormSections/GeneralSection/GeneralSection.test.tsx b/client/src/modules/Registry/components/FormSections/GeneralSection/GeneralSection.test.tsx index c4f41abf03..f11a2ed411 100644 --- a/client/src/modules/Registry/components/FormSections/GeneralSection/GeneralSection.test.tsx +++ b/client/src/modules/Registry/components/FormSections/GeneralSection/GeneralSection.test.tsx @@ -27,39 +27,19 @@ const renderGeneralSection = (courses?: CourseDto[]) => { }; describe('GeneralSection', () => { - test.each` - title - ${CARD_TITLES.personalInfo} - ${CARD_TITLES.contactInfo} - `('should render mentor form card with $title title', async ({ title }) => { + test('renders personal and contact cards without course details on the mentor form', () => { renderGeneralSection(); - const card = await screen.findByRole('heading', { name: title }); - expect(card).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: CARD_TITLES.personalInfo })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: CARD_TITLES.contactInfo })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: CARD_TITLES.courseDetails })).not.toBeInTheDocument(); }); - test('should not render CourseDetails card on mentor form', async () => { - renderGeneralSection(); - - const card = screen.queryByRole('heading', { name: CARD_TITLES.courseDetails }); - expect(card).not.toBeInTheDocument(); - }); - - test.each` - title - ${CARD_TITLES.courseDetails} - ${CARD_TITLES.personalInfo} - `('should render student form card with $title title', async ({ title }) => { - renderGeneralSection([]); - - const card = await screen.findByRole('heading', { name: title }); - expect(card).toBeInTheDocument(); - }); - - test('should not render ContactInfo card on student form', async () => { + test('renders course and personal cards without contact information on the student form', () => { renderGeneralSection([]); - const card = screen.queryByRole('heading', { name: CARD_TITLES.contactInfo }); - expect(card).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { name: CARD_TITLES.courseDetails })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: CARD_TITLES.personalInfo })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: CARD_TITLES.contactInfo })).not.toBeInTheDocument(); }); }); diff --git a/client/src/modules/Registry/components/FormSections/MentorshipSection/MentorshipSection.test.tsx b/client/src/modules/Registry/components/FormSections/MentorshipSection/MentorshipSection.test.tsx index 7ce816ecb9..8e63bd55a5 100644 --- a/client/src/modules/Registry/components/FormSections/MentorshipSection/MentorshipSection.test.tsx +++ b/client/src/modules/Registry/components/FormSections/MentorshipSection/MentorshipSection.test.tsx @@ -12,15 +12,10 @@ const renderMentorshipSection = () => { }; describe('MentorshipSection', () => { - test.each` - title - ${CARD_TITLES.disciplines} - ${CARD_TITLES.preferences} - ${CARD_TITLES.additionalInfo} - `('should render card with $title title', async ({ title }) => { + test('should render discipline, preference and additional information cards', () => { renderMentorshipSection(); - const card = await screen.findByRole('heading', { name: title }); - expect(card).toBeInTheDocument(); + const titles = [CARD_TITLES.disciplines, CARD_TITLES.preferences, CARD_TITLES.additionalInfo]; + titles.forEach(title => expect(screen.getByRole('heading', { name: title })).toBeInTheDocument()); }); }); diff --git a/client/src/modules/Registry/components/Header/Header.test.tsx b/client/src/modules/Registry/components/Header/Header.test.tsx index 9d77449cb5..6cab30afb5 100644 --- a/client/src/modules/Registry/components/Header/Header.test.tsx +++ b/client/src/modules/Registry/components/Header/Header.test.tsx @@ -2,21 +2,14 @@ import { render, screen } from '@testing-library/react'; import { Header } from './Header'; describe('Header', () => { - test('renders the provided title as a heading', () => { - render(
    ); + test('renders string and ReactNode titles with the static subtitle', () => { + const { rerender } = render(
    ); expect(screen.getByRole('heading', { name: 'Mentors registration' })).toBeInTheDocument(); - }); + expect(screen.getByText('Free courses from the developer community')).toBeInTheDocument(); - test('renders a ReactNode title', () => { - render(
    Welcome to RS School} />); + rerender(
    Welcome to RS School} />); expect(screen.getByText('Welcome to RS School')).toBeInTheDocument(); }); - - test('renders the static subtitle', () => { - render(
    ); - - expect(screen.getByText('Free courses from the developer community')).toBeInTheDocument(); - }); }); diff --git a/client/src/modules/Registry/components/LanguagesMentoring/LanguagesMentoring.test.tsx b/client/src/modules/Registry/components/LanguagesMentoring/LanguagesMentoring.test.tsx index 54c3d2516c..b18c2fd9dc 100644 --- a/client/src/modules/Registry/components/LanguagesMentoring/LanguagesMentoring.test.tsx +++ b/client/src/modules/Registry/components/LanguagesMentoring/LanguagesMentoring.test.tsx @@ -1,7 +1,6 @@ import { render, screen } from '@testing-library/react'; import { Form } from 'antd'; import { UpdateUserDtoLanguagesEnum } from '@client/api'; -import { getLanguageName } from '@client/components/SelectLanguages'; import { LABELS } from '@client/modules/Registry/constants'; import { LanguagesMentoring } from './LanguagesMentoring'; @@ -15,11 +14,12 @@ const renderLanguages = (isStudentForm = false) => ); describe('LanguagesMentoring', () => { - test(`should render field with "${LABELS.languagesMentor}" label on mentor form`, async () => { + test(`should render mentor languages and the "${LABELS.languagesMentor}" label`, async () => { renderLanguages(); - const field = await screen.findByLabelText(LABELS.languagesMentor); - expect(field).toBeInTheDocument(); + expect(await screen.findByLabelText(LABELS.languagesMentor)).toBeInTheDocument(); + expect(screen.getByText('English')).toBeInTheDocument(); + expect(screen.getByText('Russian')).toBeInTheDocument(); }); test(`should render field with "${LABELS.languagesStudent}" label on student form`, async () => { @@ -28,15 +28,4 @@ describe('LanguagesMentoring', () => { const field = await screen.findByLabelText(LABELS.languagesStudent); expect(field).toBeInTheDocument(); }); - - test.each` - value - ${getLanguageName(UpdateUserDtoLanguagesEnum.En)} - ${getLanguageName(UpdateUserDtoLanguagesEnum.Ru)} - `('should render pre-selected option with $value value', async ({ value }) => { - renderLanguages(); - - const option = await screen.findByText(value); - expect(option).toBeInTheDocument(); - }); }); diff --git a/client/src/modules/Registry/components/NoCourses/NoCourses.test.tsx b/client/src/modules/Registry/components/NoCourses/NoCourses.test.tsx index b8ce203521..53c8f48c6a 100644 --- a/client/src/modules/Registry/components/NoCourses/NoCourses.test.tsx +++ b/client/src/modules/Registry/components/NoCourses/NoCourses.test.tsx @@ -1,16 +1,25 @@ import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; import { NoCourses } from './NoCourses'; +vi.mock('@ant-design/icons', () => ({ MehTwoTone: () => null })); +vi.mock('antd', () => ({ + Button: ({ children, href }: { children: ReactNode; href: string }) => {children}, + Result: ({ title, subTitle, extra }: Record<'title' | 'subTitle' | 'extra', ReactNode>) => ( +
    +

    {title}

    +

    {subTitle}

    + {extra} +
    + ), +})); + describe('NoCourses', () => { - test('renders the empty-state title and subtitle', () => { + test('renders the empty state and home link', () => { render(); expect(screen.getByText('There are no available courses.')).toBeInTheDocument(); expect(screen.getByText('Please come back later.')).toBeInTheDocument(); - }); - - test('renders a Back to Home link pointing at the root', () => { - render(); const link = screen.getByRole('link', { name: 'Back to Home' }); expect(link).toHaveAttribute('href', '/'); diff --git a/client/src/modules/Registry/components/RegistrationForm/RegistrationForm.test.tsx b/client/src/modules/Registry/components/RegistrationForm/RegistrationForm.test.tsx index c27135b729..f4b4f1371b 100644 --- a/client/src/modules/Registry/components/RegistrationForm/RegistrationForm.test.tsx +++ b/client/src/modules/Registry/components/RegistrationForm/RegistrationForm.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { Form, Input } from 'antd'; import { FORM_TITLES } from '@client/modules/Registry/constants'; import { RegistrationForm } from './RegistrationForm'; @@ -41,18 +41,14 @@ const renderForm = (type?: 'mentor' | 'student') => { }; describe('RegistrationForm', () => { - test('should render form', async () => { + test('should render the mentor form, steps and current content', async () => { renderForm(); const form = await screen.findByRole('form'); expect(form).toBeInTheDocument(); - }); - - test('should render mentor form title', async () => { - renderForm(); - - const title = await screen.findByText(FORM_TITLES.mentorForm); - expect(title).toBeInTheDocument(); + expect(screen.getByText(FORM_TITLES.mentorForm)).toBeInTheDocument(); + steps.forEach(({ title }) => expect(screen.getByText(title)).toBeInTheDocument()); + expect(screen.getByText(`${steps[0]?.title}-content`)).toBeInTheDocument(); }); test('should render student form title', async () => { @@ -62,20 +58,6 @@ describe('RegistrationForm', () => { expect(title).toBeInTheDocument(); }); - test.each(steps)('should render step title', async ({ title }) => { - renderForm(); - - const stepTitle = await screen.findByText(title); - expect(stepTitle).toBeInTheDocument(); - }); - - test('should render current step content', async () => { - renderForm(); - - const content = await screen.findByText(`${steps[0]?.title}-content`); - expect(content).toBeInTheDocument(); - }); - test('hides step titles on small screens', async () => { // Force the small-screen layout so `isSmallScreen ? null : title` takes the null branch. layoutState.isSmallScreen = true; @@ -92,7 +74,7 @@ describe('RegistrationForm', () => { }); test('scrolls to the first invalid field when submit fails validation', async () => { - const user = userEvent.setup(); + const user = setupUser(); const scrollToField = vi.fn(); const stepsWithRequired = [ diff --git a/client/src/modules/Registry/hooks/useMentorData/useMentorData.test.tsx b/client/src/modules/Registry/hooks/useMentorData/useMentorData.test.tsx index b23b977b76..c5b9ea7b04 100644 --- a/client/src/modules/Registry/hooks/useMentorData/useMentorData.test.tsx +++ b/client/src/modules/Registry/hooks/useMentorData/useMentorData.test.tsx @@ -116,7 +116,7 @@ async function renderLoaded(courseAlias?: string | string[]) { } describe('useMentorData', () => { - test('loads profile-derived initial values into resume', async () => { + test('loads profile values, steps, and initial step', async () => { const { result } = await renderLoaded(); expect(result.current.resume).toMatchObject({ @@ -131,17 +131,7 @@ describe('useMentorData', () => { technicalMentoring: [], preferedCourses: [], // no alias => none preselected }); - }); - - test('builds the General/Mentorship/Done steps', async () => { - const { result } = await renderLoaded(); - expect(result.current.steps.map(s => s.title)).toEqual(['General', 'Mentorship', 'Done']); - }); - - test('starts on the first step', async () => { - const { result } = await renderLoaded(); - expect(result.current.currentStep).toBe(0); }); @@ -157,25 +147,15 @@ describe('useMentorData', () => { expect(result.current.resume?.preferedCourses).toEqual([1, 3]); }); - test('first submit only advances the step without calling the API', async () => { + test('advances first, then submits user and mentor payloads and reaches Done', async () => { const { result } = await renderLoaded(); await act(async () => { await result.current.handleSubmit({ firstName: 'New' } as never); }); - expect(result.current.currentStep).toBe(1); expect(updateUser).not.toHaveBeenCalled(); expect(registerMentor).not.toHaveBeenCalled(); - }); - - test('second submit posts the user and mentor registry payloads and advances to Done', async () => { - const { result } = await renderLoaded(); - - // advance to mentorship step - await act(async () => { - await result.current.handleSubmit({} as never); - }); await act(async () => { await result.current.handleSubmit({ diff --git a/client/src/modules/Registry/hooks/useStudentData/useStudentData.test.tsx b/client/src/modules/Registry/hooks/useStudentData/useStudentData.test.tsx index 1e1ba5ad8b..755fbc3195 100644 --- a/client/src/modules/Registry/hooks/useStudentData/useStudentData.test.tsx +++ b/client/src/modules/Registry/hooks/useStudentData/useStudentData.test.tsx @@ -1,6 +1,6 @@ -import { ReactNode } from 'react'; +import { Form } from 'antd'; import { render, screen, waitFor, act } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { useRouter } from 'next/router'; import { useStudentData } from './useStudentData'; @@ -112,7 +112,7 @@ type Api = ReturnType; function Harness({ courseAlias, onReady }: { courseAlias?: string; onReady: (api: Api) => void }) { const api = useStudentData('octocat', 42, courseAlias); onReady(api); - return (<>{api.modalContext}) as ReactNode; + return
    {api.modalContext}; } function renderHookView(courseAlias?: string) { @@ -138,22 +138,6 @@ beforeEach(() => { }); describe('useStudentData', () => { - test('loads eligible courses and clears the loading flag', async () => { - const view = renderHookView(); - - await waitFor(() => expect(view.current.loading).toBe(false)); - expect(view.current.courses).toHaveLength(1); - expect(view.current.courses[0]?.id).toBe(1); - expect(view.current.registered).toBe(false); - }); - - test('builds the General/Done steps', async () => { - const view = renderHookView(); - - await waitFor(() => expect(view.current.loading).toBe(false)); - expect(view.current.steps.map(s => s.title)).toEqual(['General', 'Done']); - }); - test('filters out invite-only courses', async () => { getCourses.mockResolvedValue([openCourse, { ...openCourse, id: 3, alias: 'x', inviteOnly: true }]); const view = renderHookView(); @@ -185,6 +169,11 @@ describe('useStudentData', () => { const view = renderHookView(); await waitFor(() => expect(view.current.loading).toBe(false)); + expect(view.current.courses).toHaveLength(1); + expect(view.current.courses[0]?.id).toBe(1); + expect(view.current.registered).toBe(false); + expect(view.current.steps.map(s => s.title)).toEqual(['General', 'Done']); + await act(async () => { await view.current.handleSubmit({ courseId: 1, @@ -207,26 +196,8 @@ describe('useStudentData', () => { languages: ['English'], }); expect(registerStudent).toHaveBeenCalledWith({ type: 'student', courseId: 1 }); - await waitFor(() => expect(view.current.currentStep).toBe(1)); - }); - - test('resets the cached auth session so the new course is visible right away', async () => { - const view = renderHookView(); - await waitFor(() => expect(view.current.loading).toBe(false)); - - await act(async () => { - await view.current.handleSubmit({ - courseId: 1, - location: { countryName: 'Poland', cityName: 'Warsaw' }, - primaryEmail: 'a@b.c', - contactsEpamEmail: 'a@epam.com', - firstName: 'Ada', - lastName: 'L', - languagesMentoring: ['English'], - } as never); - }); - expect(clearAuthUserSessionCache).toHaveBeenCalledWith(42); + await waitFor(() => expect(view.current.currentStep).toBe(1)); }); test('still completes registration when the session cache reset fails', async () => { @@ -252,7 +223,7 @@ describe('useStudentData', () => { }); test('warns about existing enrollments and registers only after confirming', async () => { - const user = userEvent.setup(); + const user = setupUser(); getCourses.mockResolvedValue([openCourse, enrolledCourse]); getProfileInfo.mockResolvedValue({ studentStats: [{ courseId: 2, isExpelled: false, isCourseCompleted: false, certificateId: null }], @@ -312,9 +283,13 @@ describe('useStudentData', () => { getProfileInfo.mockResolvedValue({ studentStats: [{ courseId: 2, isExpelled: false, isCourseCompleted: false, certificateId: null }], }); - const view = renderHookView('react-2024'); + let view: ReturnType; + // eslint-disable-next-line testing-library/no-unnecessary-act -- Settle registration before advancing fake timers + await act(async () => { + view = renderHookView('react-2024'); + }); - await vi.waitFor(() => expect(view.current.registered).toBe(true)); + expect(view!.current.registered).toBe(true); expect(push).not.toHaveBeenCalled(); await act(async () => { diff --git a/client/src/modules/Registry/hooks/useStudentData/useStudentData.tsx b/client/src/modules/Registry/hooks/useStudentData/useStudentData.tsx index 589a52e377..c0f48ab776 100644 --- a/client/src/modules/Registry/hooks/useStudentData/useStudentData.tsx +++ b/client/src/modules/Registry/hooks/useStudentData/useStudentData.tsx @@ -136,8 +136,8 @@ export function useStudentData(githubId: string, userId: number, courseAlias?: s await confirmRegistration(); }, okText: 'Register', - maskClosable: true, - autoFocusButton: 'cancel', + mask: { closable: true }, + focusable: { autoFocusButton: 'cancel' }, }); } else { await confirmRegistration(); diff --git a/client/src/modules/Registry/pages/Mentor/Mentor.test.tsx b/client/src/modules/Registry/pages/Mentor/Mentor.test.tsx index 8d01a3473a..499895d39e 100644 --- a/client/src/modules/Registry/pages/Mentor/Mentor.test.tsx +++ b/client/src/modules/Registry/pages/Mentor/Mentor.test.tsx @@ -46,37 +46,28 @@ beforeEach(() => { }); describe('MentorRegistry', () => { - test('forwards the course query param to useMentorData', () => { + test('renders the registry from router and mentor data state', () => { vi.mocked(useRouter).mockReturnValue({ query: { course: 'react-2024' }, push: vi.fn() } as never); - render(); + const { rerender } = render(); expect(mockedUseMentorData).toHaveBeenCalledWith('react-2024'); - }); - test('renders the registration form with resume as initial values', () => { setData({ resume: { firstName: 'Ada' } }); - - render(); + rerender(); const form = screen.getByTestId('registration-form'); expect(form).toBeInTheDocument(); expect(form).toHaveTextContent('has-initial-values'); - }); - test('renders no form until the resume (initial values) is loaded', () => { setData({ resume: undefined }); - - render(); + rerender(); expect(screen.queryByTestId('registration-form')).not.toBeInTheDocument(); expect(screen.getByTestId('page-layout')).toBeInTheDocument(); - }); - test('passes the loading flag down to the page layout', () => { setData({ loading: true, resume: undefined }); - - render(); + rerender(); expect(screen.getByTestId('page-layout')).toHaveAttribute('data-loading', 'true'); }); diff --git a/client/src/modules/Registry/pages/Student/Student.test.tsx b/client/src/modules/Registry/pages/Student/Student.test.tsx index 692c6b8e8b..402c2ad417 100644 --- a/client/src/modules/Registry/pages/Student/Student.test.tsx +++ b/client/src/modules/Registry/pages/Student/Student.test.tsx @@ -1,6 +1,5 @@ import { render, screen } from '@testing-library/react'; import { useRouter } from 'next/router'; -// eslint-disable-next-line boundaries/element-types -- the page itself consumes SessionContext from this module; the test must provide it. import { SessionContext } from '@client/modules/Course/contexts'; import type { Session } from '@client/components/withSession'; import { StudentRegistry } from './Student'; @@ -49,14 +48,18 @@ function setData(overrides: Partial = {}) { mockedUseStudentData.mockReturnValue({ ...baseData, ...overrides }); } -function renderPage(session: Partial = { githubId: 'octocat', id: 1 }) { - return render( +function Page({ session = { githubId: 'octocat', id: 1 } }: { session?: Partial }) { + return ( - , + ); } +function renderPage(session?: Partial) { + return render(); +} + beforeEach(() => { vi.clearAllMocks(); vi.mocked(useRouter).mockReturnValue({ query: {}, push: vi.fn() } as never); @@ -64,70 +67,50 @@ beforeEach(() => { }); describe('StudentRegistry', () => { - test('passes session data and course query param to useStudentData', () => { + test('passes inputs and renders every loading and registration branch', () => { vi.mocked(useRouter).mockReturnValue({ query: { course: 'js-2024' }, push: vi.fn() } as never); setData({ courses: [{ id: 1 } as never] }); - renderPage({ githubId: 'octocat', id: 1 }); + const { rerender } = renderPage({ githubId: 'octocat', id: 1 }); expect(mockedUseStudentData).toHaveBeenCalledWith('octocat', 1, 'js-2024'); - }); + expect(screen.getByTestId('registration-form')).toHaveTextContent('type:student'); + expect(screen.getByTestId('modal-context')).toBeInTheDocument(); - test('renders nothing but the modal context while loading', () => { setData({ loading: true }); - - renderPage(); + rerender(); expect(screen.getByTestId('modal-context')).toBeInTheDocument(); expect(screen.queryByTestId('registration-form')).not.toBeInTheDocument(); expect(screen.queryByText('There are no available courses.')).not.toBeInTheDocument(); expect(screen.getByTestId('page-layout')).toHaveAttribute('data-loading', 'true'); - }); - test('renders no content once registered (redirecting)', () => { setData({ registered: true }); - - renderPage(); + rerender(); expect(screen.queryByTestId('registration-form')).not.toBeInTheDocument(); expect(screen.queryByText('There are no available courses.')).not.toBeInTheDocument(); - }); - test('shows the certificate alert when disciplines are missing and courses exist', () => { setData({ missingDisciplines: 'JavaScript', courses: [{ id: 1 } as never] }); - - renderPage(); + rerender(); expect( screen.getByText('To register for this course, you need to already have JavaScript RS School certificate.'), ).toBeInTheDocument(); expect(screen.queryByTestId('registration-form')).not.toBeInTheDocument(); - }); - test('shows the empty state when there are no courses', () => { setData({ courses: [] }); - - renderPage(); + rerender(); expect(screen.getByText('There are no available courses.')).toBeInTheDocument(); expect(screen.queryByTestId('registration-form')).not.toBeInTheDocument(); - }); - test('renders the student registration form when courses are available', () => { setData({ courses: [{ id: 1 } as never] }); - - renderPage(); + rerender(); const form = screen.getByTestId('registration-form'); expect(form).toBeInTheDocument(); expect(form).toHaveTextContent('type:student'); - }); - - test('always renders the modal context regardless of branch', () => { - setData({ courses: [{ id: 1 } as never] }); - - renderPage(); - expect(screen.getByTestId('modal-context')).toBeInTheDocument(); }); }); diff --git a/client/src/modules/Schedule/components/AdditionalActions/AdditionalActions.test.tsx b/client/src/modules/Schedule/components/AdditionalActions/AdditionalActions.test.tsx index 97479ca6d4..aa82529df8 100644 --- a/client/src/modules/Schedule/components/AdditionalActions/AdditionalActions.test.tsx +++ b/client/src/modules/Schedule/components/AdditionalActions/AdditionalActions.test.tsx @@ -22,7 +22,7 @@ const PROPS_MOCK: AdditionalActionsProps = { }; describe('AdditionalActions', () => { - it('should render menu items', async () => { + it('renders menu items and dispatches copy, calendar-link and export actions', async () => { render(); const moreBtn = screen.getByRole('button', { name: /more/i }); @@ -30,28 +30,14 @@ describe('AdditionalActions', () => { const menuItems = await screen.findAllByRole('menuitem'); expect(menuItems).toHaveLength(4); - }); - - it('should call onCopyFromCourse when "Copy from" action was clicked', async () => { - render(); - const moreBtn = screen.getByRole('button', { name: /more/i }); - fireEvent.click(moreBtn); - - const copyBtn = await screen.findByRole('menuitem', { name: new RegExp(SettingsButtons.Copy, 'i') }); - fireEvent.click(copyBtn); + fireEvent.click(await screen.findByRole('menuitem', { name: new RegExp(SettingsButtons.Copy, 'i') })); await waitFor(() => { expect(PROPS_MOCK.onCopyFromCourse).toHaveBeenCalled(); }); - }); - it('should call onCalendarCopyLink when "Copy iCal Link" action was clicked', async () => { - render(); - const moreBtn = screen.getByRole('button', { name: /more/i }); fireEvent.click(moreBtn); - - const calendarBtn = await screen.findByRole('menuitem', { name: new RegExp(SettingsButtons.CopyLink, 'i') }); - fireEvent.click(calendarBtn); + fireEvent.click(await screen.findByRole('menuitem', { name: new RegExp(SettingsButtons.CopyLink, 'i') })); await waitFor(() => { expect(buildICalendarLink).toHaveBeenCalledWith( @@ -60,15 +46,9 @@ describe('AdditionalActions', () => { PROPS_MOCK.timezone, ); }); - }); - it('should call onExport when "Export" action was clicked', async () => { - render(); - const moreBtn = screen.getByRole('button', { name: /more/i }); fireEvent.click(moreBtn); - - const exportBtn = await screen.findByRole('menuitem', { name: new RegExp(SettingsButtons.Export, 'i') }); - fireEvent.click(exportBtn); + fireEvent.click(await screen.findByRole('menuitem', { name: new RegExp(SettingsButtons.Export, 'i') })); expect(buildExportLink).toHaveBeenCalledWith(PROPS_MOCK.courseId, PROPS_MOCK.timezone); expect(setExportLink).toHaveBeenCalled(); diff --git a/client/src/modules/Schedule/components/FilteredTags/FilteredTags.test.tsx b/client/src/modules/Schedule/components/FilteredTags/FilteredTags.test.tsx index a7477989ed..5ecf329cb9 100644 --- a/client/src/modules/Schedule/components/FilteredTags/FilteredTags.test.tsx +++ b/client/src/modules/Schedule/components/FilteredTags/FilteredTags.test.tsx @@ -4,87 +4,32 @@ import { CourseScheduleItemDto, CourseScheduleItemDtoTagEnum as TagsEnum } from import { TAG_NAME_MAP } from '@client/modules/Schedule/constants'; describe('FilteredTags', () => { - const onTagCloseMock = vi.fn(); - const onClearAllButtonClick = vi.fn(); - - it('should not render when tags were not provided', () => { - render(); - - expect(screen.queryByText(/Type /)).not.toBeInTheDocument(); - }); - - it.each` - tag - ${TagsEnum.Coding} - ${TagsEnum.CrossCheckReview} - ${TagsEnum.CrossCheckSubmit} - ${TagsEnum.Interview} - ${TagsEnum.Lecture} - ${TagsEnum.SelfStudy} - ${TagsEnum.Test} - `('should render tag "$tag"', ({ tag }: { tag: CourseScheduleItemDto['tag'] }) => { - render( - , - ); - - expect(screen.getByText(getTagLabel(tag))).toBeInTheDocument(); - }); - - it('should render several tags', () => { - render( - , - ); - - expect(screen.getByText(getTagLabel(TagsEnum.Coding))).toBeInTheDocument(); - expect(screen.getByText(getTagLabel(TagsEnum.CrossCheckReview))).toBeInTheDocument(); - expect(screen.getByText(getTagLabel(TagsEnum.Interview))).toBeInTheDocument(); - }); - - it('should render "Clear all" button', () => { - render( - , + it('renders all tags and handles close, clear, and empty states', () => { + const onTagClose = vi.fn(); + const onClearAllButtonClick = vi.fn(); + const tags = [ + TagsEnum.Coding, + TagsEnum.CrossCheckReview, + TagsEnum.CrossCheckSubmit, + TagsEnum.Interview, + TagsEnum.Lecture, + TagsEnum.SelfStudy, + TagsEnum.Test, + ]; + const { rerender } = render( + , ); - expect(screen.getByText(/Clear all/)).toBeInTheDocument(); - }); - - it('should remove selected tag when onTagClose was called', () => { - render( - , - ); + for (const tag of tags) expect(screen.getByText(getTagLabel(tag))).toBeInTheDocument(); const interviewTag = screen.getByText(getTagLabel(TagsEnum.Interview)); - const interviewCrossIcon = within(interviewTag).getByRole('img', { name: 'Close' }); - - fireEvent.click(interviewCrossIcon); - - expect(onTagCloseMock).toHaveBeenCalledWith(TagsEnum.Interview); - }); - - it('should clear all tags when onClearAllButtonClick was called', () => { - render( - , - ); - - const clearAllBtn = screen.getByText(/Clear all/); - fireEvent.click(clearAllBtn); + fireEvent.click(within(interviewTag).getByRole('img', { name: 'Close' })); + expect(onTagClose).toHaveBeenCalledWith(TagsEnum.Interview); + fireEvent.click(screen.getByText(/Clear all/)); expect(onClearAllButtonClick).toHaveBeenCalled(); + + rerender(); + expect(screen.queryByText(/Type /)).not.toBeInTheDocument(); }); }); diff --git a/client/src/modules/Schedule/components/MobileItemCard/MobileItemCard.test.tsx b/client/src/modules/Schedule/components/MobileItemCard/MobileItemCard.test.tsx index 9790bcdd94..21775d0ef2 100644 --- a/client/src/modules/Schedule/components/MobileItemCard/MobileItemCard.test.tsx +++ b/client/src/modules/Schedule/components/MobileItemCard/MobileItemCard.test.tsx @@ -28,46 +28,21 @@ function makeItem(overrides: Partial = {}): CourseSchedul } describe('', () => { - it('renders the item name linking to its description URL', () => { - render(); + it('renders item details and their optional variants', () => { + const { rerender } = render(); const link = screen.getByRole('link', { name: 'Intro to JS' }); expect(link).toHaveAttribute('href', 'https://example.com/task'); expect(link).toHaveAttribute('target', '_blank'); - }); - - it('falls back to an empty href when the item has no description URL', () => { - render(); - - const link = screen.getByRole('link', { name: 'Intro to JS' }); - expect(link).toHaveAttribute('href', ''); - }); - - it('renders the tag label and the capitalized status', () => { - render(); - expect(screen.getByText(TAG_NAME_MAP[TagEnum.Coding])).toBeInTheDocument(); - // statusRenderer capitalizes the enum value. expect(screen.getByText('Done')).toBeInTheDocument(); - }); - - it('renders the timezone offset for the supplied timezone', () => { - render(); - - // UTC offset is +00:00. - expect(screen.getByText('(UTC +00:00)')).toBeInTheDocument(); - }); - - it('renders both start and end dates with a separator when an end date exists', () => { - render(); - - // The SwapRightOutlined icon renders as an accessible image labelled "swap-right". expect(screen.getByRole('img', { name: 'swap-right' })).toBeInTheDocument(); - }); - - it('omits the end date and separator when the item has no end date', () => { - render(); + rerender(); + const heading = screen.getByRole('heading', { name: 'Intro to JS' }); + // eslint-disable-next-line testing-library/no-node-access -- Empty-href anchors have no link role in DOM queries. + expect(heading.closest('a')).toHaveAttribute('href', ''); + expect(screen.getByText('(UTC +00:00)')).toBeInTheDocument(); expect(screen.queryByRole('img', { name: 'swap-right' })).not.toBeInTheDocument(); }); }); diff --git a/client/src/modules/Schedule/components/SettingsDrawer/ChangeTagColors.test.tsx b/client/src/modules/Schedule/components/SettingsDrawer/ChangeTagColors.test.tsx index 048ae36a5a..960a92f1e4 100644 --- a/client/src/modules/Schedule/components/SettingsDrawer/ChangeTagColors.test.tsx +++ b/client/src/modules/Schedule/components/SettingsDrawer/ChangeTagColors.test.tsx @@ -1,6 +1,6 @@ /* eslint-disable testing-library/no-node-access */ import { fireEvent, render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import ChangeTagColors from './ChangeTagColors'; import { CourseScheduleItemDtoTagEnum as TagEnum } from '@client/api'; import { TAG_NAME_MAP } from '../../constants'; @@ -26,60 +26,39 @@ const tags = [TagEnum.Coding, TagEnum.Test]; // ChangeTagColors content lives inside a collapsed SettingsItem (antd Collapse) — // expand its header before reaching the tag chips / color pickers. async function renderExpanded(props: Parameters[0]) { - const user = userEvent.setup(); - render(); + const user = setupUser(); + const utils = render(); await user.click(document.querySelector('.ant-collapse-header') as HTMLElement); - return user; + return { ...utils, user }; } describe('', () => { - it('renders a labelled tag chip and a color picker for each tag', async () => { - await renderExpanded({ tags, tagColors: {}, setTagColors: vi.fn() }); - - expect(screen.getByText(TAG_NAME_MAP[TagEnum.Coding])).toBeInTheDocument(); - expect(screen.getByText(TAG_NAME_MAP[TagEnum.Test])).toBeInTheDocument(); - expect(screen.getAllByTestId('color-picker')).toHaveLength(tags.length); - }); - - it('seeds each picker with its current color from tagColors', async () => { - await renderExpanded({ - tags, - tagColors: { [TagEnum.Coding]: '#111111', [TagEnum.Test]: '#222222' }, - setTagColors: vi.fn(), - }); - - const [coding, test] = screen.getAllByTestId('color-picker'); - expect(coding).toHaveValue('#111111'); - expect(test).toHaveValue('#222222'); - }); - - it('merges the new hex value for the changed tag and preserves the others', async () => { + it('renders, seeds, changes, and handles tag variants', async () => { const setTagColors = vi.fn(); - await renderExpanded({ + const { rerender } = await renderExpanded({ tags, tagColors: { [TagEnum.Coding]: '#111111', [TagEnum.Test]: '#222222' }, setTagColors, }); - const [coding] = screen.getAllByTestId('color-picker'); - fireEvent.change(coding, { target: { value: '#abcdef' } }); + expect(screen.getByText(TAG_NAME_MAP[TagEnum.Coding])).toBeInTheDocument(); + expect(screen.getByText(TAG_NAME_MAP[TagEnum.Test])).toBeInTheDocument(); + expect(screen.getAllByTestId('color-picker')).toHaveLength(tags.length); + const [coding, test] = screen.getAllByTestId('color-picker') as HTMLInputElement[]; + expect(coding).toHaveValue('#111111'); + expect(test).toHaveValue('#222222'); + fireEvent.change(coding!, { target: { value: '#abcdef' } }); expect(setTagColors).toHaveBeenCalledWith({ [TagEnum.Coding]: '#abcdef', [TagEnum.Test]: '#222222', }); - }); - it('falls back to the raw tag name when no friendly label exists', async () => { const unknownTag = 'mystery-tag' as TagEnum; - await renderExpanded({ tags: [unknownTag], tagColors: {}, setTagColors: vi.fn() }); - + rerender(); expect(screen.getByText('mystery-tag')).toBeInTheDocument(); - }); - - it('renders nothing in the list when there are no tags', async () => { - await renderExpanded({ tags: [], tagColors: {}, setTagColors: vi.fn() }); + rerender(); expect(screen.queryByTestId('color-picker')).not.toBeInTheDocument(); }); }); diff --git a/client/src/modules/Schedule/components/SettingsDrawer/SettingsDrawer.test.tsx b/client/src/modules/Schedule/components/SettingsDrawer/SettingsDrawer.test.tsx index e4e9cf036c..33b73eaaca 100644 --- a/client/src/modules/Schedule/components/SettingsDrawer/SettingsDrawer.test.tsx +++ b/client/src/modules/Schedule/components/SettingsDrawer/SettingsDrawer.test.tsx @@ -1,6 +1,6 @@ /* eslint-disable testing-library/no-node-access */ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import SettingsDrawer from './SettingsDrawer'; import { CourseScheduleItemDtoTagEnum as TagEnum } from '@client/api'; import { ScheduleSettings } from '@client/modules/Schedule/hooks/useScheduleSettings'; @@ -27,39 +27,25 @@ const settings: ScheduleSettings = { }; describe('', () => { - it('renders the trigger button but keeps the drawer closed initially', () => { + it('renders, opens, and closes the settings drawer', async () => { + const user = setupUser(); render(); expect(screen.getByTestId('Settings')).toBeInTheDocument(); expect(screen.queryByText('Schedule settings')).not.toBeInTheDocument(); - }); - - it('opens the drawer with all three settings sections when the trigger is clicked', async () => { - const user = userEvent.setup(); - render(); await user.click(screen.getByTestId('Settings')); expect(await screen.findByText('Schedule settings')).toBeInTheDocument(); - // Collapsible section headers from the three child panels. expect(screen.getByText('Time zone')).toBeInTheDocument(); expect(screen.getByText('Table columns')).toBeInTheDocument(); expect(screen.getByText('Change Tag Colors')).toBeInTheDocument(); - }); - it('closes the drawer when the close button is clicked', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByTestId('Settings')); - expect(await screen.findByText('Schedule settings')).toBeInTheDocument(); - // While open, the drawer content wrapper is not hidden. const hiddenWrapper = () => document.querySelector('.ant-drawer-content-wrapper-hidden'); expect(hiddenWrapper()).toBeNull(); await user.click(screen.getByRole('button', { name: /close/i })); - // antd keeps the Drawer mounted but applies the `-hidden` class on close. await waitFor(() => expect(hiddenWrapper()).not.toBeNull()); }); }); diff --git a/client/src/modules/Schedule/components/SettingsDrawer/ShowTableColumns.test.tsx b/client/src/modules/Schedule/components/SettingsDrawer/ShowTableColumns.test.tsx index e8e3601b75..3eef4f2f23 100644 --- a/client/src/modules/Schedule/components/SettingsDrawer/ShowTableColumns.test.tsx +++ b/client/src/modules/Schedule/components/SettingsDrawer/ShowTableColumns.test.tsx @@ -1,6 +1,6 @@ /* eslint-disable testing-library/no-node-access */ import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import ShowTableColumns from './ShowTableColumns'; import { COLUMNS, CONFIGURABLE_COLUMNS, ColumnKey, ColumnName } from '../../constants'; @@ -8,34 +8,33 @@ const AVAILABLE = COLUMNS.filter(c => CONFIGURABLE_COLUMNS.includes(c.key)); // ShowTableColumns is wrapped in a SettingsItem (antd Collapse) that starts collapsed, // so the checkboxes only render once the panel header is expanded. -async function expandPanel(user: ReturnType) { +async function expandPanel(user: ReturnType) { await user.click(document.querySelector('.ant-collapse-header') as HTMLElement); } describe('', () => { - it('renders a checkbox for every configurable column', async () => { - const user = userEvent.setup(); - render(); + it('renders every column and shows a hidden column when checked', async () => { + const user = setupUser(); + const setColumnsHidden = vi.fn(); + render( + , + ); await expandPanel(user); expect(screen.getByText('Visible Columns')).toBeInTheDocument(); AVAILABLE.forEach(({ name }) => { expect(screen.getByRole('checkbox', { name })).toBeInTheDocument(); }); - }); - - it('marks a column as unchecked when it is in columnsHidden', async () => { - const user = userEvent.setup(); - render(); - await expandPanel(user); - expect(screen.getByRole('checkbox', { name: ColumnName.Type })).not.toBeChecked(); - expect(screen.getByRole('checkbox', { name: ColumnName.Organizer })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: ColumnName.Organizer })).not.toBeChecked(); + + await user.click(screen.getByRole('checkbox', { name: ColumnName.Type })); + expect(setColumnsHidden).toHaveBeenCalledWith([ColumnKey.Organizer]); }); it('hides a visible column (adds its key) when its checkbox is unchecked', async () => { const setColumnsHidden = vi.fn(); - const user = userEvent.setup(); + const user = setupUser(); render(); await expandPanel(user); @@ -43,17 +42,4 @@ describe('', () => { expect(setColumnsHidden).toHaveBeenCalledWith([ColumnKey.Type]); }); - - it('shows a hidden column (removes its key) when its checkbox is re-checked', async () => { - const setColumnsHidden = vi.fn(); - const user = userEvent.setup(); - render( - , - ); - await expandPanel(user); - - await user.click(screen.getByRole('checkbox', { name: ColumnName.Type })); - - expect(setColumnsHidden).toHaveBeenCalledWith([ColumnKey.Organizer]); - }); }); diff --git a/client/src/modules/Schedule/components/SettingsDrawer/TimeZone.test.tsx b/client/src/modules/Schedule/components/SettingsDrawer/TimeZone.test.tsx index 0078bc55fb..dc6ec7d6c8 100644 --- a/client/src/modules/Schedule/components/SettingsDrawer/TimeZone.test.tsx +++ b/client/src/modules/Schedule/components/SettingsDrawer/TimeZone.test.tsx @@ -1,12 +1,12 @@ /* eslint-disable testing-library/no-node-access */ import { fireEvent, render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import TimeZone from './TimeZone'; // TimeZone is wrapped in a SettingsItem (antd Collapse) that starts collapsed, // so its Select is not rendered until the panel header is expanded. async function expandPanel() { - const user = userEvent.setup(); + const user = setupUser(); const header = document.querySelector('.ant-collapse-header') as HTMLElement; await user.click(header); } @@ -28,8 +28,9 @@ async function pickFromDropdown(query: string, optionLabel: string) { } describe('', () => { - it('renders the heading and description inside the collapsible panel', async () => { - render(); + it('renders the panel and calls setTimezone with the selected value', async () => { + const setTimezone = vi.fn(); + render(); await expandPanel(); @@ -37,37 +38,18 @@ describe('', () => { expect(screen.getByText('Manage region-specific options for the schedule.')).toBeInTheDocument(); // antd Select renders the chosen value in the selection item. expect(screen.getByText('Europe/Moscow')).toBeInTheDocument(); - }); - - it('relabels the legacy "Europe/Kiev" zone to "Europe/Kyiv" in the dropdown', async () => { - render(); - await expandPanel(); - // The legacy zone's value is "Europe/Kiev" (so filter by that) but its label is "Europe/Kyiv". - const option = await pickFromDropdown('kiev', 'Europe/Kyiv'); - - expect(option).toBeInTheDocument(); - }); - - it('calls setTimezone with the chosen value when an option is selected', async () => { - const setTimezone = vi.fn(); - render(); - - await expandPanel(); - const option = await pickFromDropdown('moscow', 'Europe/Moscow'); + const option = await pickFromDropdown('utc', 'UTC'); fireEvent.click(option); - - // antd Select calls onChange with (value, option); assert the chosen value. - expect(setTimezone).toHaveBeenCalled(); - expect(setTimezone.mock.calls[0]?.[0]).toBe('Europe/Moscow'); + expect(setTimezone.mock.calls[0]?.[0]).toBe('UTC'); }); - it('filters options case-insensitively when typing in the search box', async () => { + it('filters case-insensitively and relabels the legacy Kiev zone', async () => { render(); await expandPanel(); - // Uppercase query still matches the lowercased value via the custom filterOption. - const option = await pickFromDropdown('MOSCOW', 'Europe/Moscow'); + // The legacy zone's value is "Europe/Kiev" (so filter by that) but its label is "Europe/Kyiv". + const option = await pickFromDropdown('KIEV', 'Europe/Kyiv'); expect(option).toBeInTheDocument(); }); diff --git a/client/src/modules/Schedule/components/SettingsPanel/SettingsPanel.test.tsx b/client/src/modules/Schedule/components/SettingsPanel/SettingsPanel.test.tsx index 3f5e331794..51a8b377a3 100644 --- a/client/src/modules/Schedule/components/SettingsPanel/SettingsPanel.test.tsx +++ b/client/src/modules/Schedule/components/SettingsPanel/SettingsPanel.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { SettingsButtons, SettingsPanel, SettingsPanelProps } from '.'; const PROPS_MOCK: SettingsPanelProps = { @@ -17,30 +17,19 @@ const PROPS_MOCK: SettingsPanelProps = { }; describe('SettingsPanel', () => { - it.each` - button - ${'Event'} - ${'Task'} - ${'Settings'} - ${'More'} - `('should render "$button" button', ({ button }: { button: string }) => { + it('renders the manager action buttons', () => { render(); - const settingsBtn = screen.getByTestId(button); - - expect(settingsBtn).toBeInTheDocument(); + for (const button of ['Event', 'Task', 'Settings', 'More']) { + expect(screen.getByTestId(button)).toBeInTheDocument(); + } }); - it.each` - button - ${'Event'} - ${'Task'} - `('should not render "$button" button when user is not a course manager', ({ button }: { button: string }) => { + it('does not render Event or Task buttons for a non-manager', () => { render(); - const moreBtn = screen.queryByText(button); - - expect(moreBtn).not.toBeInTheDocument(); + expect(screen.queryByText('Event')).not.toBeInTheDocument(); + expect(screen.queryByText('Task')).not.toBeInTheDocument(); }); it('should not render "More" button when user is not a course manager and calendar token was not provided', () => { @@ -52,24 +41,22 @@ describe('SettingsPanel', () => { }); it.each` - item | prop | condition - ${SettingsButtons.CopyLink} | ${'calendarToken'} | ${'calendar token was not provided'} - ${SettingsButtons.Download} | ${'calendarToken'} | ${'calendar token was not provided'} - ${SettingsButtons.Export} | ${'isCourseManager'} | ${'user is not a course manager'} - ${SettingsButtons.Copy} | ${'isCourseManager'} | ${'user is not a course manager'} + item | prop | condition + ${[SettingsButtons.CopyLink, SettingsButtons.Download]} | ${'calendarToken'} | ${'calendar token was not provided'} + ${[SettingsButtons.Export, SettingsButtons.Copy]} | ${'isCourseManager'} | ${'user is not a course manager'} `( 'should not render additional action "$item" when $condition', - async ({ item, prop }: { item: string; prop: keyof SettingsPanelProps }) => { + async ({ item, prop }: { item: string[]; prop: keyof SettingsPanelProps }) => { const props = { ...PROPS_MOCK, [prop]: null }; render(); const moreBtn = screen.getByRole('button', { name: /more/i }); fireEvent.click(moreBtn); - await waitFor(() => { - const menuItem = screen.queryByRole('menuitem', { name: new RegExp(item, 'i') }); - expect(menuItem).not.toBeInTheDocument(); - }); + await screen.findByRole('menu'); + for (const action of item) { + expect(screen.queryByRole('menuitem', { name: new RegExp(action, 'i') })).not.toBeInTheDocument(); + } }, ); }); diff --git a/client/src/modules/Schedule/components/StatusTabs/StatusTabs.test.tsx b/client/src/modules/Schedule/components/StatusTabs/StatusTabs.test.tsx index 5a7f37c6b0..dd97bbf0e0 100644 --- a/client/src/modules/Schedule/components/StatusTabs/StatusTabs.test.tsx +++ b/client/src/modules/Schedule/components/StatusTabs/StatusTabs.test.tsx @@ -8,15 +8,6 @@ const StatusEnum = CourseScheduleItemDtoStatusEnum; describe('StatusTabs', () => { const onTabChangeMock = vi.fn(); - it('should render status tabs', () => { - const statuses = generateStatuses(); - - render(); - - const expectedStatusCount = SCHEDULE_STATUSES.length + 1; // +1 is for 'All' tab - expect(screen.getAllByRole('tab')).toHaveLength(expectedStatusCount); - }); - it('should render status tabs when statuses were not provided', () => { render(); @@ -41,13 +32,14 @@ describe('StatusTabs', () => { }, ); - it('should order tabs', () => { + it('should render all status tabs in order', () => { const statuses = generateStatuses(); render(); - const [all, available, review, future, missed, done, registered, unAvailable, archived] = - screen.getAllByRole('tab'); + const tabs = screen.getAllByRole('tab'); + expect(tabs).toHaveLength(SCHEDULE_STATUSES.length + 1); + const [all, available, review, future, missed, done, registered, unAvailable, archived] = tabs; expect(all).toHaveTextContent(new RegExp(ALL_TAB_KEY, 'i')); expect(available).toHaveTextContent(new RegExp(StatusEnum.Available, 'i')); expect(review).toHaveTextContent(new RegExp(StatusEnum.Review, 'i')); diff --git a/client/src/modules/Schedule/components/TableView/TableView.test.tsx b/client/src/modules/Schedule/components/TableView/TableView.test.tsx index 82b24f5d7e..47ed4fd6bd 100644 --- a/client/src/modules/Schedule/components/TableView/TableView.test.tsx +++ b/client/src/modules/Schedule/components/TableView/TableView.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen, within, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import TableView from './TableView'; import * as ReactUse from 'react-use'; import { ALL_TAB_KEY, ColumnKey, ColumnName } from '@client/modules/Schedule/constants'; @@ -26,36 +26,34 @@ const PROPS_SETTINGS_MOCK: ScheduleSettings = { }; describe('TableView', () => { - it.each` - label - ${ColumnName.Status} - ${ColumnName.Name} - ${ColumnName.Type} - ${ColumnName.Organizer} - ${ColumnName.Weight} - ${ColumnName.Score} - ${'End Date (UTC +03:00)'} - ${'Start Date (UTC +03:00)'} - `('should render column "$label"', ({ label }: { label: string }) => { + it('should render the column headers and data fields', () => { render(); - expect(screen.getByText(label)).toBeInTheDocument(); - }); - - it.each` - value - ${'Course Item 0'} - ${'2020-02-02 00:00'} - ${'2020-03-15 23:59'} - ${'×0.2'} - ${'20 / 100'} - ${'Missed'} - ${'Test'} - `('should render data field "$value"', ({ value }: { value: string }) => { - render(); + for (const label of [ + ColumnName.Status, + ColumnName.Name, + ColumnName.Type, + ColumnName.Organizer, + ColumnName.Weight, + ColumnName.Score, + 'End Date (UTC +03:00)', + 'Start Date (UTC +03:00)', + ]) { + expect(screen.getByText(label)).toBeInTheDocument(); + } - const [dataField] = screen.getAllByText(value); - expect(dataField).toBeInTheDocument(); + for (const value of [ + 'Course Item 0', + '2020-02-02 00:00', + '2020-03-15 23:59', + '×0.2', + '20 / 100', + 'Missed', + 'Test', + ]) { + const [dataField] = screen.getAllByText(value); + expect(dataField).toBeInTheDocument(); + } }); it('should not render hidden columns', () => { @@ -114,7 +112,7 @@ describe('TableView', () => { ${ColumnName.Name} | ${'Course Item 0'} ${ColumnName.Organizer} | ${'organizer 0'} `('by "$field" column search', async ({ field, searchQuery }: { field: string; searchQuery: string }) => { - const user = userEvent.setup(); + const user = setupUser(); const data = generateCourseData(); render(); // Check that all items rendered @@ -156,12 +154,7 @@ describe('TableView', () => { ); }); - it.each` - tag - ${TagsEnum.Coding} - ${TagsEnum.Test} - ${TagsEnum.Interview} - `('should check filters in dropdown when tag "$tag" was selected', async ({ tag }: { tag: string }) => { + it('should check the selected type filters in the dropdown', async () => { vi.spyOn(ReactUse, 'useLocalStorage') // Mock useLocalStorage for combinedFilter .mockReturnValueOnce([ @@ -177,9 +170,11 @@ describe('TableView', () => { } const filtersDropdown = await screen.findByRole('menu'); - const menuItem = within(filtersDropdown).getByRole('menuitem', { name: new RegExp(tag, 'i') }); - const checkbox = within(menuItem).getByRole('checkbox'); - expect(checkbox).toBeChecked(); + for (const tag of [TagsEnum.Coding, TagsEnum.Test, TagsEnum.Interview]) { + const menuItem = within(filtersDropdown).getByRole('menuitem', { name: new RegExp(tag, 'i') }); + const checkbox = within(menuItem).getByRole('checkbox'); + expect(checkbox).toBeChecked(); + } }); it('should not render filtered tags when tags is empty', () => { diff --git a/client/src/modules/Schedule/components/TableView/renderers.test.tsx b/client/src/modules/Schedule/components/TableView/renderers.test.tsx index cb863175d4..acc318d0e4 100644 --- a/client/src/modules/Schedule/components/TableView/renderers.test.tsx +++ b/client/src/modules/Schedule/components/TableView/renderers.test.tsx @@ -3,25 +3,25 @@ import { describe, it, expect } from 'vitest'; import { CourseScheduleItemDtoStatusEnum as StatusEnum } from '@client/api'; import { statusRenderer, renderStatusWithStyle, renderTagWithStyle } from './renderers'; +vi.mock('antd', () => ({ + Badge: ({ text }: { text: React.ReactNode }) => {text}, + Tag: ({ children }: React.PropsWithChildren) => {children}, +})); + describe('TableView renderers', () => { - it('statusRenderer capitalizes the status into a badge label', () => { - render(
    {statusRenderer(StatusEnum.Missed)}
    ); - expect(screen.getByText('Missed')).toBeInTheDocument(); - }); + it('renders status and known and unknown tag labels', () => { + render( +
    + {statusRenderer(StatusEnum.Missed)} + {renderStatusWithStyle(StatusEnum.Available)} + {renderTagWithStyle('coding')} + {renderTagWithStyle('mystery-tag' as never)} +
    , + ); - it('renderStatusWithStyle renders a capitalized status tag', () => { - render(
    {renderStatusWithStyle(StatusEnum.Available)}
    ); + expect(screen.getByText('Missed')).toBeInTheDocument(); expect(screen.getByText('Available')).toBeInTheDocument(); - }); - - it('renderTagWithStyle uses the friendly TAG_NAME_MAP label for a known tag', () => { - render(
    {renderTagWithStyle('coding')}
    ); expect(screen.getByText('Coding')).toBeInTheDocument(); - }); - - it('renderTagWithStyle falls back to the raw tag value for an unknown tag', () => { - // `TAG_NAME_MAP[tagName] || tagName` → the raw value when the tag is not in the map. - render(
    {renderTagWithStyle('mystery-tag' as never)}
    ); expect(screen.getByText('mystery-tag')).toBeInTheDocument(); }); }); diff --git a/client/src/modules/Schedule/pages/SchedulePage/index.test.tsx b/client/src/modules/Schedule/pages/SchedulePage/index.test.tsx index 86c90ed8d6..501d941226 100644 --- a/client/src/modules/Schedule/pages/SchedulePage/index.test.tsx +++ b/client/src/modules/Schedule/pages/SchedulePage/index.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { SchedulePage } from './index'; import { CourseScheduleItemDtoStatusEnum as StatusEnum, @@ -54,12 +54,15 @@ vi.mock('@client/modules/Course/contexts', async () => { const reactUseState = vi.hoisted(() => ({ mobile: false, retry: vi.fn() })); vi.mock('react-use', async () => { const actual = await vi.importActual('react-use'); + const React = await vi.importActual('react'); return { ...actual, useMedia: () => reactUseState.mobile, - useAsyncRetry: (fn: () => Promise) => { - // Invoke once so the API boundary mocks are exercised, mirroring real behaviour. - fn(); + useAsyncRetry: (fn: () => Promise, deps: readonly unknown[]) => { + // Fetch after commit; fetching during render can loop when the callback sets state. + React.useEffect(() => { + void fn(); + }, deps); return { retry: reactUseState.retry, value: scheduleData, loading: false, error: undefined }; }, }; @@ -134,7 +137,7 @@ describe('', () => { isCourseManager.mockReturnValue(true); }); - it('renders the page title, status tabs and the schedule table rows', async () => { + it('fetches the active course schedule and renders its rows, tabs and manager actions', async () => { render(); // PageLayout's Header renders the title (+ course name) as plain text, not a heading role. @@ -142,18 +145,11 @@ describe('', () => { expect(screen.getByRole('tab', { name: /all/i })).toBeInTheDocument(); expect(screen.getByText('Course Item 0')).toBeInTheDocument(); expect(screen.getByText('Course Item 1')).toBeInTheDocument(); - }); - - it('fetches the schedule and the ical token for the active course on mount', async () => { - render(); await waitFor(() => expect(getSchedule).toHaveBeenCalledWith(42)); expect(getScheduleICalendarToken).toHaveBeenCalledWith(42); - }); - - it('shows the SettingsPanel with manager actions when the user is a course manager', async () => { - render(); - + expect(getSchedule).toHaveBeenCalledTimes(1); + expect(getScheduleICalendarToken).toHaveBeenCalledTimes(1); expect(await screen.findByTestId('Task')).toBeInTheDocument(); expect(screen.getByTestId('Event')).toBeInTheDocument(); }); @@ -176,7 +172,7 @@ describe('', () => { }); it('opens the task modal, submits it, creates the task and refreshes', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(await screen.findByTestId('Task')); @@ -189,7 +185,7 @@ describe('', () => { }); it('closes the task modal without creating a task when cancelled', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(await screen.findByTestId('Task')); @@ -201,7 +197,7 @@ describe('', () => { }); it('opens the event modal, submits it and refreshes', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(await screen.findByTestId('Event')); @@ -213,7 +209,7 @@ describe('', () => { }); it('closes the event modal without refreshing when cancelled', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(await screen.findByTestId('Event')); @@ -224,7 +220,7 @@ describe('', () => { }); it('closes the copy modal without copying when cancelled', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(await screen.findByTestId('More')); @@ -238,7 +234,7 @@ describe('', () => { }); it('copies the schedule from another course and refreshes', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); // The "Copy from another course" action lives in the SettingsPanel "More" menu. diff --git a/client/src/modules/Score/components/ExportCsvButton/index.test.tsx b/client/src/modules/Score/components/ExportCsvButton/index.test.tsx index 885d6ccbff..401249eeb2 100644 --- a/client/src/modules/Score/components/ExportCsvButton/index.test.tsx +++ b/client/src/modules/Score/components/ExportCsvButton/index.test.tsx @@ -1,31 +1,26 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { fireEvent, render, screen } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; import { ExportCsvButton } from './index'; +vi.mock('antd', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + Button: ({ children, onClick }: React.ComponentProps<'button'>) => , +})); + describe('', () => { - it('renders nothing when not enabled', () => { - const { container } = render(); + it('renders only when enabled and forwards clicks', () => { + const onClick = vi.fn(); + const { container, rerender } = render(); expect(container).toBeEmptyDOMElement(); expect(screen.queryByRole('button')).not.toBeInTheDocument(); - }); - it('renders nothing when "enabled" is omitted (undefined)', () => { - const { container } = render(); + rerender(); expect(container).toBeEmptyDOMElement(); - }); - it('renders a button when enabled', () => { - render(); + rerender(); expect(screen.getByRole('button')).toBeInTheDocument(); - }); - - it('calls onClick when the enabled button is clicked', async () => { - const user = userEvent.setup(); - const onClick = vi.fn(); - render(); - await user.click(screen.getByRole('button')); + fireEvent.click(screen.getByRole('button')); expect(onClick).toHaveBeenCalledTimes(1); }); diff --git a/client/src/modules/Score/components/ScoreTable/ScoreTableTabs.test.tsx b/client/src/modules/Score/components/ScoreTable/ScoreTableTabs.test.tsx index 97326b56f1..7c29c5a7a1 100644 --- a/client/src/modules/Score/components/ScoreTable/ScoreTableTabs.test.tsx +++ b/client/src/modules/Score/components/ScoreTable/ScoreTableTabs.test.tsx @@ -1,6 +1,6 @@ /* eslint-disable testing-library/no-node-access */ import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { useRouter } from 'next/router'; import { ScoreTableTabs } from './ScoreTableTabs'; @@ -54,7 +54,8 @@ describe('', () => { } as never); }); - it('renders "All students" and "Active students" tabs, defaulting to Active', () => { + it('renders the default tab, opens settings and switches to all students', async () => { + const user = setupUser(); render(); expect(screen.getByRole('tab', { name: /all students/i })).toBeInTheDocument(); @@ -63,10 +64,10 @@ describe('', () => { // Default active tab = "active" → its ScoreTable has activeOnly=true. const activeTable = screen.getByTestId('score-table'); expect(activeTable).toHaveAttribute('data-active-only', 'true'); - }); + expect(activeTable).toHaveAttribute('data-settings-open', 'false'); - it('switches to the "All students" tab and renders the activeOnly=false table', async () => { - render(); + await user.click(getSettingsButton()); + await waitFor(() => expect(screen.getByTestId('score-table')).toHaveAttribute('data-settings-open', 'true')); fireEvent.click(screen.getByRole('tab', { name: /all students/i })); @@ -80,21 +81,8 @@ describe('', () => { expect(within(visiblePanel).getByTestId('score-table')).toHaveAttribute('data-active-only', 'false'); }); - it('opens the settings (passes isVisibleSetting=true to the table) when the settings button is clicked', async () => { - const user = userEvent.setup(); - render(); - - expect(screen.getByTestId('score-table')).toHaveAttribute('data-settings-open', 'false'); - - await user.click(getSettingsButton()); - - await waitFor(() => { - expect(screen.getByTestId('score-table')).toHaveAttribute('data-settings-open', 'true'); - }); - }); - it('navigates to the CSV export URL (built from course id + query filters) on export click', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); // The export click sets window.location.href; stub the assignment to capture it. diff --git a/client/src/modules/Score/components/ScoreTable/index.test.tsx b/client/src/modules/Score/components/ScoreTable/index.test.tsx index 522a6171ba..39a506d6dc 100644 --- a/client/src/modules/Score/components/ScoreTable/index.test.tsx +++ b/client/src/modules/Score/components/ScoreTable/index.test.tsx @@ -1,6 +1,6 @@ /* eslint-disable testing-library/no-node-access */ import { render, screen, fireEvent, within, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { useRouter } from 'next/router'; import { ScoreTable, getTableWidth } from './index'; @@ -131,43 +131,24 @@ beforeEach(() => { }); describe('', () => { - it('toggles the loading flag on and off around the initial data load', async () => { + it('loads tasks and scores, toggles loading, and renders student and summary rows', async () => { const onLoading = vi.fn(); render(); await waitFor(() => expect(onLoading).toHaveBeenCalledWith(false)); expect(onLoading).toHaveBeenCalledWith(true); - }); - - it('fetches course tasks, course score and the student score on mount', async () => { - render(); - - await waitFor(() => expect(getCourseTasks).toHaveBeenCalledWith(42)); + expect(getCourseTasks).toHaveBeenCalledWith(42); expect(getStudentCourseScore).toHaveBeenCalledWith('me'); - // Initial course-score request carries activeOnly from the prop. expect(getCourseScore).toHaveBeenCalled(); expect(getCourseScore.mock.calls[0][1]).toMatchObject({ activeOnly: true }); - }); - - it('renders a row per student with the basic columns once loaded', async () => { - render(); await screen.findAllByText('alice'); const table = mainTable(); expect(within(table).getByText('alice')).toBeInTheDocument(); expect(within(table).getByText('bob')).toBeInTheDocument(); - // Task columns are derived from the fetched tasks. expect(within(table).getByText('Task Alpha')).toBeInTheDocument(); expect(within(table).getByText('Task Beta')).toBeInTheDocument(); - // Pagination total footer. expect(screen.getByText(/total 2 students/i)).toBeInTheDocument(); - }); - - it('renders the summary row (your score) when more than one student is present', async () => { - render(); - - // studentScore.totalScore (55) is rendered in the summary row. - await screen.findAllByText('alice'); expect(screen.getAllByText('55').length).toBeGreaterThan(0); }); @@ -189,8 +170,8 @@ describe('', () => { expect(screen.queryByText('alice')).not.toBeInTheDocument(); }); - it('requests the last page when the saved current page exceeds the available pages', async () => { - // First response says current(1) > totalPages(0) → component refetches the last page. + it('keeps the page positive when the API reports no available pages', async () => { + // An empty result must not cause a request for page zero. getCourseScore.mockResolvedValueOnce({ content: twoStudents, pagination: { current: 1, pageSize: 100, total: 2, totalPages: 0 }, @@ -200,8 +181,8 @@ describe('', () => { render(); await waitFor(() => expect(getCourseScore).toHaveBeenCalledTimes(2)); - // The refetch pins current to totalPages (0 here). - expect(getCourseScore.mock.calls[1][0]).toMatchObject({ current: 0 }); + // Pagination stays one-based even when totalPages is zero. + expect(getCourseScore.mock.calls[1][0]).toMatchObject({ current: 1 }); }); it('calls the paging hook with the requested page on pagination change', async () => { @@ -224,7 +205,7 @@ describe('', () => { }); it('applies a column search filter through the paging hook with the typed value', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findAllByText('alice'); @@ -262,7 +243,7 @@ describe('', () => { }); it('saves hidden columns to localStorage and closes the drawer when settings are saved', async () => { - const user = userEvent.setup(); + const user = setupUser(); const setIsVisibleSettings = vi.fn(); render(); @@ -281,7 +262,7 @@ describe('', () => { }); it('closes the settings drawer without saving when cancelled', async () => { - const user = userEvent.setup(); + const user = setupUser(); const setIsVisibleSettings = vi.fn(); render(); diff --git a/client/src/modules/Score/components/ScoreTable/index.tsx b/client/src/modules/Score/components/ScoreTable/index.tsx index bf0fb7b5b7..1ee024f9d3 100644 --- a/client/src/modules/Score/components/ScoreTable/index.tsx +++ b/client/src/modules/Score/components/ScoreTable/index.tsx @@ -109,13 +109,14 @@ export function ScoreTable(props: Props) { pagination: { current: currentPage }, } = students; + const lastPage = Math.max(1, totalPages); if (currentPage > totalPages) { const { content, pagination } = await courseService.getCourseScore( - { ...students.pagination, current: totalPages }, + { ...students.pagination, current: lastPage }, filters, students.order, ); - setStudents({ ...students, content, pagination: { ...pagination, current: totalPages } }); + setStudents({ ...students, content, pagination: { ...pagination, current: lastPage } }); } else { setStudents({ ...students, content, pagination: courseScore.pagination }); } diff --git a/client/src/modules/Score/components/SettingsDrawer/index.test.tsx b/client/src/modules/Score/components/SettingsDrawer/index.test.tsx index 69db5050a4..944b691b07 100644 --- a/client/src/modules/Score/components/SettingsDrawer/index.test.tsx +++ b/client/src/modules/Score/components/SettingsDrawer/index.test.tsx @@ -1,5 +1,5 @@ import { render, screen, within, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { Form } from 'antd'; import { SettingsDrawer } from './index'; @@ -24,7 +24,7 @@ function makeProps(overrides: Partial[0]> = {} // The drawer body wraps the form + action buttons in a collapsed antd Collapse panel // ("Columns visibility"). Expand it so the checkboxes and action buttons mount/become // interactive, then return the dialog body for scoped queries. -async function openPanel(user: ReturnType) { +async function openPanel(user: ReturnType) { await user.click(screen.getByText('Columns visibility')); // Action buttons live below the checkboxes once expanded. await screen.findByText('Save'); @@ -38,33 +38,8 @@ describe('', () => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); - it('renders the drawer title and the collapsible "Columns visibility" section', () => { - render(); - - expect(screen.getByText('Score settings')).toBeInTheDocument(); - expect(screen.getByText('Columns visibility')).toBeInTheDocument(); - }); - - it('renders a checkbox per course task seeded from isVisible once expanded', async () => { - const user = userEvent.setup(); - render(); - - await openPanel(user); - - expect(screen.getByText('Task A')).toBeInTheDocument(); - expect(screen.getByText('Task B')).toBeInTheDocument(); - expect(screen.getByText('Task C')).toBeInTheDocument(); - - const checkboxes = screen.getAllByRole('checkbox'); - expect(checkboxes).toHaveLength(3); - // Initial values mirror `isVisible`. - expect(checkboxes[0]).toBeChecked(); - expect(checkboxes[1]).not.toBeChecked(); - expect(checkboxes[2]).toBeChecked(); - }); - it('calls onCancel when the Cancel action is clicked', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); @@ -76,12 +51,25 @@ describe('', () => { }); it('toggles a checkbox and saves the current field map via onOk', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); + expect(screen.getByText('Score settings')).toBeInTheDocument(); + expect(screen.getByText('Columns visibility')).toBeInTheDocument(); + await openPanel(user); + expect(screen.getByText('Task A')).toBeInTheDocument(); + expect(screen.getByText('Task B')).toBeInTheDocument(); + expect(screen.getByText('Task C')).toBeInTheDocument(); + + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes).toHaveLength(3); + expect(checkboxes[0]).toBeChecked(); + expect(checkboxes[1]).not.toBeChecked(); + expect(checkboxes[2]).toBeChecked(); + // Uncheck Task A (was visible) → now hidden. await user.click(screen.getByRole('checkbox', { name: 'Task A' })); await user.click(screen.getByText('Save')); @@ -92,7 +80,7 @@ describe('', () => { }); it('"All" checks every checkbox', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await openPanel(user); @@ -104,7 +92,7 @@ describe('', () => { }); it('"None" unchecks every checkbox and saves them all as hidden', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); @@ -121,7 +109,7 @@ describe('', () => { }); it('closes via the drawer close (X) button', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); @@ -137,7 +125,7 @@ describe('', () => { afterEach(() => spy?.mockRestore()); it('does not call onOk if validateFields rejects', async () => { - const user = userEvent.setup(); + const user = setupUser(); // Keep the real form (so
    still works) but force validateFields to reject → // `await ….catch(() => null)` yields null → the `if (!values) return` guard short-circuits. const realUseForm = Form.useForm; diff --git a/client/src/modules/Score/hooks/useScorePaging.test.tsx b/client/src/modules/Score/hooks/useScorePaging.test.tsx index 28b39aec12..475df85ada 100644 --- a/client/src/modules/Score/hooks/useScorePaging.test.tsx +++ b/client/src/modules/Score/hooks/useScorePaging.test.tsx @@ -1,5 +1,5 @@ import { renderHook } from '@testing-library/react'; -import { act } from 'react-dom/test-utils'; +import { act } from 'react'; import type { NextRouter } from 'next/router'; import type { CourseService } from '@client/services/course'; import { useScorePaging } from './useScorePaging'; diff --git a/client/src/modules/Score/pages/ScorePage/UpdateAlert.test.tsx b/client/src/modules/Score/pages/ScorePage/UpdateAlert.test.tsx index acebe50b1a..f27b9f87d6 100644 --- a/client/src/modules/Score/pages/ScorePage/UpdateAlert.test.tsx +++ b/client/src/modules/Score/pages/ScorePage/UpdateAlert.test.tsx @@ -2,16 +2,12 @@ import { render, screen } from '@testing-library/react'; import { UpdateAlert } from './UpdateAlert'; describe('', () => { - it('renders the daily-update notice text', () => { - render(); + it('renders the daily-update notice and tooltip trigger', () => { + const { container } = render(); expect( screen.getAllByText(/Total score and position is updated every day at 04:00 GMT\+3/i).length, ).toBeGreaterThan(0); - }); - - it('exposes the notice as a tooltip trigger (question icon)', () => { - const { container } = render(); // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access expect(container.querySelector('.anticon-question-circle')).toBeInTheDocument(); }); diff --git a/client/src/modules/Score/pages/ScorePage/index.test.tsx b/client/src/modules/Score/pages/ScorePage/index.test.tsx index e5f688da12..a7014ed7fa 100644 --- a/client/src/modules/Score/pages/ScorePage/index.test.tsx +++ b/client/src/modules/Score/pages/ScorePage/index.test.tsx @@ -32,20 +32,39 @@ vi.mock('@client/modules/Score/components/ScoreTable/ScoreTableTabs', () => ({ ScoreTableTabs: () =>
    , })); +vi.mock('antd', () => ({ + Row: ({ children }: { children: React.ReactNode }) =>
    {children}
    , + Col: ({ children }: { children: React.ReactNode }) =>
    {children}
    , + Button: ({ children, href }: React.ComponentProps<'a'>) => {children}, + Result: ({ + title, + subTitle, + extra, + }: { + title: React.ReactNode; + subTitle: React.ReactNode; + extra: React.ReactNode; + }) => ( +
    +

    {title}

    +

    {subTitle}

    + {extra} +
    + ), +})); + describe('', () => { - it('renders the score table layout when a course is available', () => { + it('renders course and no-access branches', () => { ctx.course = { id: 42, name: 'RS Course' }; - render(); + const { rerender } = render(); const layout = screen.getByTestId('course-page-layout'); expect(layout).toHaveAttribute('data-title', 'Score'); expect(layout).toHaveAttribute('data-loading', 'false'); expect(screen.getByTestId('score-table-tabs')).toBeInTheDocument(); - }); - it('renders the no-access view when there is no active course', () => { ctx.course = null; - render(); + rerender(); expect(screen.getByText(/You Have No Access to Course Page/i)).toBeInTheDocument(); expect(screen.queryByTestId('score-table-tabs')).not.toBeInTheDocument(); diff --git a/client/src/modules/StudentDashboard/components/AvailableReviewCard/AvailableReviewCard.test.tsx b/client/src/modules/StudentDashboard/components/AvailableReviewCard/AvailableReviewCard.test.tsx index b20797e38d..04bfc2daec 100644 --- a/client/src/modules/StudentDashboard/components/AvailableReviewCard/AvailableReviewCard.test.tsx +++ b/client/src/modules/StudentDashboard/components/AvailableReviewCard/AvailableReviewCard.test.tsx @@ -19,29 +19,19 @@ const availableReviews: AvailableReviewStatsDto[] = [ const courseAlias = 'course1'; describe('AvailableReviewCard', () => { - it.each(availableReviews)('should render with available reviews', review => { - render(); - const link = screen.getByText(review.name); - expect(link).toBeInTheDocument(); - expect(link).toHaveAttribute('href', `./cross-check-review?course=${courseAlias}&taskId=${review.id}`); - expect(screen.getByText(`${review.completedChecksCount}/${review.checksCount}`)).toBeInTheDocument(); - }); - - it('should render 1 divider when 2 review', () => { - render(); - const dividers = screen.getAllByRole('separator'); - expect(dividers.length).toBe(1); - }); + it('renders multiple, single, and empty review states', () => { + const { rerender } = render(); + availableReviews.forEach(review => { + const link = screen.getByText(review.name); + expect(link).toHaveAttribute('href', `./cross-check-review?course=${courseAlias}&taskId=${review.id}`); + expect(screen.getByText(`${review.completedChecksCount}/${review.checksCount}`)).toBeInTheDocument(); + }); + expect(screen.getAllByRole('separator')).toHaveLength(1); - it('should not render divider when 1 review', () => { - render(); - const divider = screen.queryByRole('separator'); - expect(divider).not.toBeInTheDocument(); - }); + rerender(); + expect(screen.queryByRole('separator')).not.toBeInTheDocument(); - it('should render "At the moment, there are no tasks available for review." when no available reviews', () => { - const availableReviews: AvailableReviewStatsDto[] = []; - render(); + rerender(); expect(screen.getByText('Cross-check [Review]')).toBeInTheDocument(); expect(screen.getByText('At the moment, there are no tasks available for review')).toBeInTheDocument(); }); diff --git a/client/src/modules/StudentDashboard/components/CommonDashboardCard.test.tsx b/client/src/modules/StudentDashboard/components/CommonDashboardCard.test.tsx index c6c2885d69..a715705919 100644 --- a/client/src/modules/StudentDashboard/components/CommonDashboardCard.test.tsx +++ b/client/src/modules/StudentDashboard/components/CommonDashboardCard.test.tsx @@ -6,29 +6,23 @@ import { describe, it, expect } from 'vitest'; import CommonCard from './CommonDashboardCard'; describe('', () => { - it('renders the title and provided content', () => { - render(Hello content

    } />); + it('renders content, empty state, and optional more action', () => { + const { container, rerender } = render(Hello content

    } />); expect(screen.getByRole('heading', { name: 'My Card' })).toBeInTheDocument(); expect(screen.getByText('Hello content')).toBeInTheDocument(); - }); - it('renders an Empty placeholder with the noDataDescription when no content is provided', () => { - render(); + rerender(); expect(screen.getByText('Nothing here yet')).toBeInTheDocument(); - }); - it('renders a "more" action when isMoreContent is true', () => { - const { container } = render(body

    } isMoreContent />); + rerender(body

    } isMoreContent />); // The fullscreen icon is rendered inside the card actions list. expect(container.querySelector('.anticon-fullscreen')).toBeTruthy(); expect(container.querySelector('.ant-card-actions')).toBeTruthy(); - }); - it('does not render an actions list when isMoreContent is falsy', () => { - const { container } = render(body

    } />); + rerender(body

    } />); expect(container.querySelector('.ant-card-actions')).toBeFalsy(); }); diff --git a/client/src/modules/StudentDashboard/components/MainStatsCard.test.tsx b/client/src/modules/StudentDashboard/components/MainStatsCard.test.tsx index 40ac60aa1d..a0463a7eb9 100644 --- a/client/src/modules/StudentDashboard/components/MainStatsCard.test.tsx +++ b/client/src/modules/StudentDashboard/components/MainStatsCard.test.tsx @@ -14,32 +14,23 @@ function makeProps(overrides: Partial[0]> = {}) } describe('', () => { - it('renders the "Your stats" card with Position and Total Score labels', () => { - render(); + it('renders labels and formats regular, empty-total, and new-student stats', () => { + const { rerender } = render(); expect(screen.getByText('Your stats')).toBeInTheDocument(); expect(screen.getByText('Position')).toBeInTheDocument(); expect(screen.getByText('Total Score')).toBeInTheDocument(); - }); - - it('renders position as "rank / total" and score as "score / max"', () => { - render( - , - ); - expect(screen.getByText('5 / 200')).toBeInTheDocument(); expect(screen.getByText('120 / 1000')).toBeInTheDocument(); - }); - it('renders position without total when there are no students, and score without max when maxCourseScore is 0', () => { - render(); + rerender( + , + ); expect(screen.getByText('7')).toBeInTheDocument(); expect(screen.getByText('50')).toBeInTheDocument(); - }); - it('renders "New" when the position is at or above the default sentinel position', () => { - render(); + rerender(); expect(screen.getByText('New')).toBeInTheDocument(); }); diff --git a/client/src/modules/StudentDashboard/components/MentorCard/MentorCard.test.tsx b/client/src/modules/StudentDashboard/components/MentorCard/MentorCard.test.tsx index c6e71a9f49..2a1352f17c 100644 --- a/client/src/modules/StudentDashboard/components/MentorCard/MentorCard.test.tsx +++ b/client/src/modules/StudentDashboard/components/MentorCard/MentorCard.test.tsx @@ -22,25 +22,6 @@ const PROPS_MOCK: MentorCardProps = { }; describe('MentorCard', () => { - describe('when student has a mentor', () => { - it.each` - info - ${MENTOR_MOCK.githubId} - ${MENTOR_MOCK.name} - ${MENTOR_MOCK.cityName} - ${MENTOR_MOCK.countryName} - ${MENTOR_MOCK.contactsEmail} - ${MENTOR_MOCK.contactsNotes} - ${MENTOR_MOCK.contactsPhone} - ${MENTOR_MOCK.contactsSkype} - ${MENTOR_MOCK.contactsTelegram} - `('should render mentor info "$info"', ({ info }: { info: string }) => { - render(); - - expect(screen.getByText(new RegExp(info))).toBeInTheDocument(); - }); - }); - describe('when student does not have a mentor', () => { const propsWithoutMentor = { ...PROPS_MOCK, mentor: undefined }; @@ -48,26 +29,30 @@ describe('MentorCard', () => { render(); expect(screen.queryByText(MENTOR_MOCK.githubId)).not.toBeInTheDocument(); - }); - - it('should render note', () => { - render(); expect(screen.getByText(ASSERTION)).toBeInTheDocument(); }); }); - it('should render "Submit task" button', () => { - render(); - - const submitButton = screen.getByRole('button', { name: /submit task/i }); - expect(submitButton).toBeInTheDocument(); - }); - it('should open modal window when "Submit task" was clicked', async () => { render(); + for (const info of [ + MENTOR_MOCK.githubId, + MENTOR_MOCK.name, + MENTOR_MOCK.cityName, + MENTOR_MOCK.countryName, + MENTOR_MOCK.contactsEmail, + MENTOR_MOCK.contactsNotes, + MENTOR_MOCK.contactsPhone, + MENTOR_MOCK.contactsSkype, + MENTOR_MOCK.contactsTelegram, + ]) { + expect(screen.getByText(new RegExp(info))).toBeInTheDocument(); + } + const submitButton = screen.getByRole('button', { name: /submit task/i }); + expect(submitButton).toBeInTheDocument(); fireEvent.click(submitButton); const modal = await screen.findByRole('dialog', { name: /submit task for mentor review/i }); diff --git a/client/src/modules/StudentDashboard/components/MentorInfo/MentorInfo.test.tsx b/client/src/modules/StudentDashboard/components/MentorInfo/MentorInfo.test.tsx index efbecb79aa..e8bcff10c4 100644 --- a/client/src/modules/StudentDashboard/components/MentorInfo/MentorInfo.test.tsx +++ b/client/src/modules/StudentDashboard/components/MentorInfo/MentorInfo.test.tsx @@ -25,8 +25,8 @@ function makeMentor(overrides: Partial = {}): MentorStu } describe('', () => { - it('renders the mentor name, github link to the profile, and location', () => { - render(); + it('renders populated details and omits missing contact values', () => { + const { rerender } = render(); expect(screen.getByText('Mentor Name')).toBeInTheDocument(); @@ -35,10 +35,6 @@ describe('', () => { expect(link).toHaveAttribute('target', '_blank'); expect(screen.getByText('Minsk, Belarus')).toBeInTheDocument(); - }); - - it('renders every populated contact row', () => { - render(); expect(screen.getByText('E-mail:')).toBeInTheDocument(); expect(screen.getByText('mentor@example.com')).toBeInTheDocument(); @@ -48,10 +44,8 @@ describe('', () => { expect(screen.getByText('Skype:')).toBeInTheDocument(); expect(screen.getByText('Notes:')).toBeInTheDocument(); expect(screen.getByText('some notes')).toBeInTheDocument(); - }); - it('omits contact rows and the name when those values are missing', () => { - render( + rerender( ', () => { expect(screen.queryByText('Mentor Name')).not.toBeInTheDocument(); expect(screen.queryByText('E-mail:')).not.toBeInTheDocument(); expect(screen.queryByText('Notes:')).not.toBeInTheDocument(); - // github link still rendered expect(screen.getByRole('link', { name: /mentor-gh/ })).toBeInTheDocument(); }); }); diff --git a/client/src/modules/StudentDashboard/components/NextEventCard/NextEventCard.test.tsx b/client/src/modules/StudentDashboard/components/NextEventCard/NextEventCard.test.tsx index d1beb4cb90..eb1b6d7eae 100644 --- a/client/src/modules/StudentDashboard/components/NextEventCard/NextEventCard.test.tsx +++ b/client/src/modules/StudentDashboard/components/NextEventCard/NextEventCard.test.tsx @@ -10,18 +10,12 @@ const PROPS_MOCK = { }; describe('NextEventCard', () => { - it.each` - text - ${'Available tasks'} - ${'View all'} - ${NEXT_EVENTS[0]?.name} - ${NEXT_EVENTS[0]?.tag} - ${'Feb 01'} - `('should render $text', ({ text }: { text: string }) => { + it('renders the available task summary', () => { render(); - const match = new RegExp(text, 'i'); - expect(screen.getByText(match)).toBeInTheDocument(); + for (const text of ['Available tasks', 'View all', NEXT_EVENTS[0]?.name, NEXT_EVENTS[0]?.tag, 'Feb 01']) { + expect(screen.getByText(new RegExp(text ?? '', 'i'))).toBeInTheDocument(); + } }); }); diff --git a/client/src/modules/StudentDashboard/components/SubmitTaskSolution/SubmitTaskSolution.test.tsx b/client/src/modules/StudentDashboard/components/SubmitTaskSolution/SubmitTaskSolution.test.tsx index f50865c93d..8bc3d74f5e 100644 --- a/client/src/modules/StudentDashboard/components/SubmitTaskSolution/SubmitTaskSolution.test.tsx +++ b/client/src/modules/StudentDashboard/components/SubmitTaskSolution/SubmitTaskSolution.test.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor, within, fireEvent } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { setupUser } from '@client/__tests__/setupUser'; import { CheckerEnum, CourseTaskDto } from '@client/api'; import SubmitTaskSolution from './SubmitTaskSolution'; @@ -64,28 +64,17 @@ describe('', () => { expect(screen.getByRole('button', { name: /submit task/i })).toBeInTheDocument(); }); - it('opens the modal and loads mentor-checked course tasks when the trigger is clicked', async () => { - const user = userEvent.setup(); + it('submits the selected task and solution url, then shows the success result', async () => { + const user = setupUser(); getCourseTasksWithStudentSolution.mockResolvedValue({ data: tasks }); + createTaskSolution.mockResolvedValue({}); render(); await user.click(screen.getByRole('button', { name: /submit task/i })); - const dialog = await screen.findByRole('dialog', { name: /submit task for mentor review/i }); expect(dialog).toBeInTheDocument(); expect(getCourseTasksWithStudentSolution).toHaveBeenCalledWith(10); - // The solution link input is present. expect(within(dialog).getByLabelText(/add a solution link/i)).toBeInTheDocument(); - }); - - it('submits the selected task and solution url, then shows the success result', async () => { - const user = userEvent.setup(); - getCourseTasksWithStudentSolution.mockResolvedValue({ data: tasks }); - createTaskSolution.mockResolvedValue({}); - render(); - - await user.click(screen.getByRole('button', { name: /submit task/i })); - const dialog = await screen.findByRole('dialog', { name: /submit task for mentor review/i }); // Select a task via the antd Select. antd opens its dropdown on mouseDown (matching the // ManualSubmitTab reference test). CourseTaskSelect renders the task name inside a @@ -108,7 +97,7 @@ describe('', () => { }); it('shows an error alert when loading the tasks fails', async () => { - const user = userEvent.setup(); + const user = setupUser(); getCourseTasksWithStudentSolution.mockRejectedValue({ message: 'Network down' }); render(); diff --git a/client/src/modules/StudentDashboard/components/TasksChart.test.tsx b/client/src/modules/StudentDashboard/components/TasksChart.test.tsx index 24ff636170..205cc97c81 100644 --- a/client/src/modules/StudentDashboard/components/TasksChart.test.tsx +++ b/client/src/modules/StudentDashboard/components/TasksChart.test.tsx @@ -1,5 +1,4 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { fireEvent, render, screen } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { TasksChart } from './TasksChart'; @@ -35,8 +34,9 @@ describe('', () => { vi.clearAllMocks(); }); - it('passes the data, theme and angle/color fields to the Pie config', () => { - render(); + it('configures the chart, formats labels, and handles chart events', () => { + const onItemSelected = vi.fn(); + render(); expect(screen.getByTestId('pie')).toBeInTheDocument(); expect(lastConfig.current?.data).toEqual(data); @@ -44,29 +44,13 @@ describe('', () => { expect(lastConfig.current?.colorField).toBe('status'); // theme comes from the (mocked) useTheme hook → 'light' expect(lastConfig.current?.theme).toBe('light'); - }); - - it('invokes onItemSelected with the clicked datum on element:click', async () => { - const user = userEvent.setup(); - const onItemSelected = vi.fn(); - render(); - await user.click(screen.getByText('fire-click')); + fireEvent.click(screen.getByText('fire-click')); expect(onItemSelected).toHaveBeenCalledWith({ status: 'done', value: 3 }); - }); - - it('does not invoke onItemSelected for non-click events or when click data is missing', async () => { - const user = userEvent.setup(); - const onItemSelected = vi.fn(); - render(); - - await user.click(screen.getByText('fire-noop')); - await user.click(screen.getByText('fire-empty')); - expect(onItemSelected).not.toHaveBeenCalled(); - }); - it('builds the legend item label and tooltip from the status', () => { - render(); + fireEvent.click(screen.getByText('fire-noop')); + fireEvent.click(screen.getByText('fire-empty')); + expect(onItemSelected).toHaveBeenCalledTimes(1); const legend = lastConfig.current?.legend as { color: { itemLabelText: (d: unknown) => string } }; // string datum diff --git a/client/src/modules/StudentDashboard/components/TasksStatsCard.test.tsx b/client/src/modules/StudentDashboard/components/TasksStatsCard.test.tsx index e5f34d475e..b2e53cf71c 100644 --- a/client/src/modules/StudentDashboard/components/TasksStatsCard.test.tsx +++ b/client/src/modules/StudentDashboard/components/TasksStatsCard.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { useRouter } from 'next/router'; import { CourseScheduleItemDtoStatusEnum } from '@client/api'; @@ -74,25 +74,21 @@ describe('', () => { }); }); - it('renders the "Tasks Statistics" card with a chart entry per status', async () => { + it('renders chart entries and updates the URL when one is selected', async () => { + const user = setupUser(); render(); expect(screen.getByText('Tasks Statistics')).toBeInTheDocument(); expect(await screen.findByTestId('tasks-chart')).toBeInTheDocument(); expect(screen.getByText(/chart-done-1/)).toBeInTheDocument(); expect(screen.getByText(/chart-available-1/)).toBeInTheDocument(); - }); - - it('updates the URL with the chosen status when a chart segment is clicked', async () => { - const user = userEvent.setup(); - render(); - await user.click(await screen.findByText(/chart-done-1/)); expect(replace).toHaveBeenCalledWith(expect.stringContaining('statType=done')); }); - it('opens the stats modal when the router query has a valid statType', async () => { + it('opens the requested stats modal and clears the URL when dismissed', async () => { + const user = setupUser(); (useRouter as unknown as ReturnType).mockReturnValue({ query: { statType: CourseScheduleItemDtoStatusEnum.Done }, route: '/course/student/dashboard', @@ -103,9 +99,17 @@ describe('', () => { expect(await screen.findByText('Course Y statistics')).toBeInTheDocument(); expect(screen.getByText(/DONE TASKS/i)).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /close/i })); + + await waitFor(() => { + // updateUrl() with no statType -> replace called with a URL without statType. + const lastCall = replace.mock.calls.at(-1)?.[0] as string; + expect(lastCall).not.toContain('statType'); + }); }); - it('ignores an unknown statType in the query (no modal opens)', async () => { + it('ignores an unknown statType in the query', async () => { (useRouter as unknown as ReturnType).mockReturnValue({ query: { statType: 'not-a-real-status' }, route: '/course/student/dashboard', @@ -117,24 +121,4 @@ describe('', () => { expect(await screen.findByTestId('tasks-chart')).toBeInTheDocument(); expect(screen.queryByText('Course Y statistics')).not.toBeInTheDocument(); }); - - it('closes the modal and clears statType from the URL when dismissed', async () => { - const user = userEvent.setup(); - (useRouter as unknown as ReturnType).mockReturnValue({ - query: { statType: CourseScheduleItemDtoStatusEnum.Done }, - route: '/course/student/dashboard', - replace, - }); - - render(); - expect(await screen.findByText('Course Y statistics')).toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: /close/i })); - - await waitFor(() => { - // updateUrl() with no statType -> replace called with a URL without statType. - const lastCall = replace.mock.calls.at(-1)?.[0] as string; - expect(lastCall).not.toContain('statType'); - }); - }); }); diff --git a/client/src/modules/StudentDashboard/components/TasksStatsModal.test.tsx b/client/src/modules/StudentDashboard/components/TasksStatsModal.test.tsx index b34975fcb0..469d7b23c1 100644 --- a/client/src/modules/StudentDashboard/components/TasksStatsModal.test.tsx +++ b/client/src/modules/StudentDashboard/components/TasksStatsModal.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { describe, it, expect, vi } from 'vitest'; import { TasksStatsModal } from './TasksStatsModal'; import type { TaskStat } from './TasksStatsCard'; @@ -21,50 +21,43 @@ function makeTask(overrides: Partial = {}): TaskStat { } describe('', () => { - it('renders nothing visible when isVisible is false', () => { - render( + it('renders hidden, populated, fallback, and cancel states', async () => { + const user = setupUser(); + const onHide = vi.fn(); + const { rerender } = render( , ); expect(screen.queryByText('Course X statistics')).not.toBeInTheDocument(); - }); - it('renders the modal title, uppercased table name, and task rows with all column renderers', () => { - render( + rerender( , ); expect(screen.getByText('Course X statistics')).toBeInTheDocument(); expect(screen.getByText('COMPLETED TASKS')).toBeInTheDocument(); - // Task name rendered as a link to the descriptionUrl. const taskLink = screen.getByRole('link', { name: 'Task One' }); expect(taskLink).toHaveAttribute('href', 'https://example.com/task1'); - // Score / max. expect(screen.getByText('50')).toBeInTheDocument(); - // Weight: score * scoreWeight = 25.00 (text is split across nested nodes). expect(screen.getByText('25.00')).toBeInTheDocument(); - // Comment column. expect(screen.getByText('good job')).toBeInTheDocument(); - // GitHub PR link. expect(screen.getByRole('link', { name: 'PR' })).toHaveAttribute('href', 'https://github.com/pr/1'); - }); - it('renders fallbacks when score, descriptionUrl and PR uri are missing', () => { - render( + rerender( ', () => { }), ]} isVisible - onHide={vi.fn()} + onHide={onHide} />, ); - // Task name without link. expect(screen.queryByRole('link', { name: 'Plain Task' })).not.toBeInTheDocument(); expect(screen.getByText('Plain Task')).toBeInTheDocument(); - // No PR link. expect(screen.queryByRole('link', { name: 'PR' })).not.toBeInTheDocument(); - }); - - it('calls onHide when the modal is cancelled', async () => { - const user = userEvent.setup(); - const onHide = vi.fn(); - render( - , - ); await user.click(screen.getByRole('button', { name: /close/i })); expect(onHide).toHaveBeenCalled(); diff --git a/client/src/modules/StudentDashboard/hooks/useDashboardData.test.ts b/client/src/modules/StudentDashboard/hooks/useDashboardData.test.ts index 2509c54655..166ad97b14 100644 --- a/client/src/modules/StudentDashboard/hooks/useDashboardData.test.ts +++ b/client/src/modules/StudentDashboard/hooks/useDashboardData.test.ts @@ -1,4 +1,4 @@ -import { renderHook, waitFor } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { CoursesScheduleApi, @@ -61,7 +61,8 @@ describe('useDashboardData', () => { const { result } = renderHook(() => useDashboardData(COURSE_ID, GITHUB_ID)); - await waitFor(() => expect(result.current.data).toBeDefined()); + await act(async () => undefined); + expect(result.current.data).toBeDefined(); const data = result.current.data!; // maxCourseScore = round(100*1 + 40*0.5) = 120. @@ -104,7 +105,8 @@ describe('useDashboardData', () => { const { result } = renderHook(() => useDashboardData(COURSE_ID, GITHUB_ID)); - await waitFor(() => expect(result.current.data).toBeDefined()); + await act(async () => undefined); + expect(result.current.data).toBeDefined(); const data = result.current.data!; expect(data.maxCourseScore).toBe(0); // null maxScore -> 0 contribution. @@ -127,7 +129,8 @@ describe('useDashboardData', () => { const { result } = renderHook(() => useDashboardData(COURSE_ID, GITHUB_ID)); - await waitFor(() => expect(result.current.data).toBeDefined()); + await act(async () => undefined); + expect(result.current.data).toBeDefined(); expect(result.current.data!.tasksDetailCurrentCourse).toEqual([]); expect(result.current.data!.maxCourseScore).toBe(0); diff --git a/client/src/modules/Students/Pages/Students.test.tsx b/client/src/modules/Students/Pages/Students.test.tsx index af0960c8f4..487f14f301 100644 --- a/client/src/modules/Students/Pages/Students.test.tsx +++ b/client/src/modules/Students/Pages/Students.test.tsx @@ -1,6 +1,6 @@ /* eslint-disable testing-library/no-node-access -- header cells are resolved via .closest('th') */ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { ReactNode } from 'react'; import { message } from 'antd'; import { UserStudentDto } from '@client/api'; @@ -29,8 +29,7 @@ vi.mock('@client/modules/Course/contexts', () => ({ const { getUserStudents } = vi.hoisted(() => ({ getUserStudents: vi.fn() })); -vi.mock('@client/api', async () => ({ - ...(await vi.importActual('@client/api')), +vi.mock('@client/api', () => ({ StudentsApi: function StudentsApi() { return { getUserStudents }; }, @@ -71,42 +70,17 @@ describe('', () => { getUserStudents.mockResolvedValue({ data: responseData }); }); - it('fetches students on mount with the initial pagination and no filters', async () => { + it('loads students, opens the selected details and closes the drawer', async () => { + const user = setupUser(); render(); - - await waitFor(() => expect(getUserStudents).toHaveBeenCalled()); - // (current, pageSize, student, country, city, ongoing, previous) - expect(getUserStudents).toHaveBeenCalledWith('1', '20', undefined, undefined, undefined, undefined, undefined); - }); - - it('renders the fetched students in the table', async () => { - render(); - expect(await screen.findByText('Alice Smith')).toBeInTheDocument(); expect(screen.getByText('Bob Jones')).toBeInTheDocument(); expect(screen.getByRole('heading', { name: /students list/i })).toBeInTheDocument(); - }); - - it('opens the details drawer with the student info when a row is clicked', async () => { - render(); - await screen.findByText('Alice Smith'); + expect(getUserStudents).toHaveBeenCalledWith('1', '20', undefined, undefined, undefined, undefined, undefined); fireEvent.click(screen.getByText('Alice Smith')); - - // The Drawer renders into the body with the "Student Details" title. - const drawerTitle = await screen.findByText('Student Details'); - expect(drawerTitle).toBeInTheDocument(); - // StudentInfo renders the selected student's location in the drawer. + expect(await screen.findByText('Student Details')).toBeInTheDocument(); expect(await screen.findByText('Warsaw, Poland')).toBeInTheDocument(); - }); - - it('closes the drawer when its close button is clicked', async () => { - const user = userEvent.setup(); - render(); - await screen.findByText('Alice Smith'); - - fireEvent.click(screen.getByText('Alice Smith')); - await screen.findByText('Student Details'); await user.click(screen.getByRole('button', { name: /close/i })); @@ -114,7 +88,7 @@ describe('', () => { }); it('refetches with the country filter when a Country search is applied', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('Alice Smith'); getUserStudents.mockClear(); @@ -133,7 +107,7 @@ describe('', () => { }); it('refetches with the ongoing course filter when applied', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('Alice Smith'); getUserStudents.mockClear(); diff --git a/client/src/modules/Students/components/CourseItem/index.test.tsx b/client/src/modules/Students/components/CourseItem/index.test.tsx index 7d6ff05d71..077d5f4275 100644 --- a/client/src/modules/Students/components/CourseItem/index.test.tsx +++ b/client/src/modules/Students/components/CourseItem/index.test.tsx @@ -19,42 +19,25 @@ function makeCourse(overrides: Partial = {}): UserStudentC } describe('', () => { - it('renders the course name, score and position', () => { - render(); + it('renders course details and hides optional details when their values are absent', () => { + const { rerender } = render(); expect(screen.getByText('JS Course')).toBeInTheDocument(); expect(screen.getByText('Score: 150')).toBeInTheDocument(); expect(screen.getByText('Position: 3')).toBeInTheDocument(); - }); - - it('renders the certificate link when a certificateId exists', () => { - render(); - - const link = screen.getByRole('link', { name: /certificate/i }); - expect(link).toHaveAttribute('href', '/certificate/cert-1'); - }); - it('renders the mentor link when a mentor exists', () => { - render(); + expect(screen.getByRole('link', { name: /certificate/i })).toHaveAttribute('href', '/certificate/cert-1'); + expect(screen.getByRole('link', { name: 'Mentor One' })).toHaveAttribute('href', '/profile?githubId=mentor1'); - const link = screen.getByRole('link', { name: 'Mentor One' }); - expect(link).toHaveAttribute('href', '/profile?githubId=mentor1'); - }); - - it('hides the certificate link when there is no certificateId', () => { - render(); + rerender(); expect(screen.queryByRole('link', { name: /certificate/i })).not.toBeInTheDocument(); - }); - it('hides the mentor link when there is no mentor', () => { - render(); + rerender(); expect(screen.queryByRole('link', { name: 'Mentor One' })).not.toBeInTheDocument(); - }); - it('hides the position when rank is falsy but still shows the score', () => { - render(); + rerender(); expect(screen.queryByText(/position:/i)).not.toBeInTheDocument(); expect(screen.getByText('Score: 150')).toBeInTheDocument(); diff --git a/client/src/modules/Students/components/StudentInfo/index.test.tsx b/client/src/modules/Students/components/StudentInfo/index.test.tsx index 785f0151e7..8020dde551 100644 --- a/client/src/modules/Students/components/StudentInfo/index.test.tsx +++ b/client/src/modules/Students/components/StudentInfo/index.test.tsx @@ -1,6 +1,6 @@ /* eslint-disable testing-library/no-node-access -- the github link is resolved via .closest('a') */ -import { render, screen, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { render, screen } from '@testing-library/react'; +import { setupUser } from '@client/__tests__/setupUser'; import { UserStudentDto } from '@client/api'; import { StudentInfo } from './index'; @@ -36,7 +36,7 @@ function makeStudent(overrides: Partial = {}): UserStudentDto { } describe('', () => { - it('renders the student name as a profile link and the github handle', () => { + it('renders the student details, contacts and courses panels', () => { render(); const nameLink = screen.getByRole('link', { name: 'Alice Smith' }); @@ -45,24 +45,30 @@ describe('', () => { // The github handle renders inside a link to github.com. const ghLink = screen.getByText('alice').closest('a')!; expect(ghLink).toHaveAttribute('href', 'https://github.com/alice'); - }); - - it('renders the location as "city, country"', () => { - render(); - expect(screen.getByText('Warsaw, Poland')).toBeInTheDocument(); + expect(screen.getByText('JS Course')).toBeInTheDocument(); + expect(screen.getByText('RS Course')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /certificate/i })).toHaveAttribute('href', '/certificate/cert-123'); + expect(screen.getByRole('link', { name: 'Mentor One' })).toHaveAttribute('href', '/profile?githubId=mentor1'); + expect(screen.getByText('Score: 100')).toBeInTheDocument(); + expect(screen.getByText('Score: 200')).toBeInTheDocument(); + expect(screen.getByText('Position: 1')).toBeInTheDocument(); + expect(screen.getByText('Contacts')).toBeInTheDocument(); + expect(screen.getByText('Courses')).toBeInTheDocument(); + expect(screen.getByText('Location')).toBeInTheDocument(); }); it('omits an empty/placeholder full name', () => { - render(); + render(); expect(screen.queryByRole('link', { name: '(Empty)' })).not.toBeInTheDocument(); // Github handle link still renders. expect(screen.getByText('alice')).toBeInTheDocument(); + expect(screen.getByText('Location')).toBeInTheDocument(); }); it('renders only the filled contacts in the Contacts panel', async () => { - const user = userEvent.setup(); + const user = setupUser(); render( ', () => { expect(screen.queryByText('Discord')).not.toBeInTheDocument(); }); - it('renders courses (ongoing + previous) with certificate and mentor links', () => { - render(); - - expect(screen.getByText('JS Course')).toBeInTheDocument(); - expect(screen.getByText('RS Course')).toBeInTheDocument(); - - // Certificate link for the certified course. - const certLink = screen.getByRole('link', { name: /certificate/i }); - expect(certLink).toHaveAttribute('href', '/certificate/cert-123'); - - // Mentor link. - const mentorLink = screen.getByRole('link', { name: 'Mentor One' }); - expect(mentorLink).toHaveAttribute('href', '/profile?githubId=mentor1'); - }); - - it('renders score and position for courses', () => { - render(); - - expect(screen.getByText('Score: 100')).toBeInTheDocument(); - expect(screen.getByText('Score: 200')).toBeInTheDocument(); - expect(screen.getByText('Position: 1')).toBeInTheDocument(); - }); - - it('renders the Contacts and Courses collapse panels', () => { - render(); - - expect(screen.getByText('Contacts')).toBeInTheDocument(); - expect(screen.getByText('Courses')).toBeInTheDocument(); - expect(screen.getByText('Location')).toBeInTheDocument(); - }); - it('sorts certified courses ahead of non-certified ones in the Courses list', () => { // Mixed certificate flags across both lists exercise both sides of the sort // comparator (course.hasCertificate ? -1 : 1). @@ -140,13 +115,4 @@ describe('', () => { expect(screen.getByText('Prev Plain')).toBeInTheDocument(); expect(screen.getByText('Cur Plain')).toBeInTheDocument(); }); - - it('still renders the github link when there is no location', () => { - render(); - - // Location row is present but empty — no "city, country" text. - const locationLabel = screen.getByText('Location'); - expect(locationLabel).toBeInTheDocument(); - expect(within(document.body).getByText('alice')).toBeInTheDocument(); - }); }); diff --git a/client/src/modules/Students/components/StudentInfo/index.tsx b/client/src/modules/Students/components/StudentInfo/index.tsx index bdd01d63f8..8f18f4a1c7 100644 --- a/client/src/modules/Students/components/StudentInfo/index.tsx +++ b/client/src/modules/Students/components/StudentInfo/index.tsx @@ -15,8 +15,6 @@ type Props = { student: UserStudentDto; }; -const { Panel } = Collapse; - const { Text } = Typography; export function StudentInfo(props: Props) { @@ -68,26 +66,37 @@ export function StudentInfo(props: Props) { - - - ( - - } title={item.type} description={item.value} /> - - )} - /> - - - - course.hasCertificate ? -1 : 1, - )} - renderItem={course => } - /> - - + ( + + } title={item.type} description={item.value} /> + + )} + /> + ), + }, + { + key: 'courses', + label: 'Courses', + children: ( + + course.hasCertificate ? -1 : 1, + )} + renderItem={course => } + /> + ), + }, + ]} + /> ); } diff --git a/client/src/modules/Students/components/StudentsTable/index.test.tsx b/client/src/modules/Students/components/StudentsTable/index.test.tsx index a6cca2fbd6..649fadb2e5 100644 --- a/client/src/modules/Students/components/StudentsTable/index.test.tsx +++ b/client/src/modules/Students/components/StudentsTable/index.test.tsx @@ -1,6 +1,6 @@ /* eslint-disable testing-library/no-node-access -- header cells are resolved via .closest('th') */ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { CourseDto, UserStudentDto } from '@client/api'; import StudentsTable from './index'; @@ -57,33 +57,6 @@ describe('', () => { return screen.getAllByText(title)[0]!.closest('th')!; } - it('renders the column headers', () => { - render(); - - ['Student', 'Ongoing Courses', 'Previous Courses', 'Country', 'City', 'Languages'].forEach(title => { - expect(screen.getAllByText(title).length).toBeGreaterThan(0); - }); - }); - - it('renders a row per student with names, locations and language tags', () => { - render(); - - expect(screen.getByText('Alice Smith')).toBeInTheDocument(); - expect(screen.getByText('Bob Jones')).toBeInTheDocument(); - expect(screen.getByText('Warsaw')).toBeInTheDocument(); - expect(screen.getByText('Berlin')).toBeInTheDocument(); - expect(screen.getByText('Poland')).toBeInTheDocument(); - expect(screen.getByText('pl')).toBeInTheDocument(); - expect(screen.getByText('de')).toBeInTheDocument(); - }); - - it('renders the course aliases as tags', () => { - render(); - - expect(screen.getByText('ongoing-a')).toBeInTheDocument(); - expect(screen.getByText('previous-a')).toBeInTheDocument(); - }); - it('collapses more than three courses into a "+N more" overflow tag', () => { const manyCourses = [ { alias: 'c1', hasCertificate: false }, @@ -126,10 +99,24 @@ describe('', () => { expect(screen.getByText('+1 more')).toBeInTheDocument(); }); - it('calls setActiveStudent with the record when a row is clicked', async () => { + it('renders headers, student details and course tags, then selects the clicked record', async () => { const props = makeProps(); render(); + ['Student', 'Ongoing Courses', 'Previous Courses', 'Country', 'City', 'Languages'].forEach(title => { + expect(screen.getAllByText(title).length).toBeGreaterThan(0); + }); + + expect(screen.getByText('Alice Smith')).toBeInTheDocument(); + expect(screen.getByText('Bob Jones')).toBeInTheDocument(); + expect(screen.getByText('Warsaw')).toBeInTheDocument(); + expect(screen.getByText('Berlin')).toBeInTheDocument(); + expect(screen.getByText('Poland')).toBeInTheDocument(); + expect(screen.getByText('pl')).toBeInTheDocument(); + expect(screen.getByText('de')).toBeInTheDocument(); + expect(screen.getByText('ongoing-a')).toBeInTheDocument(); + expect(screen.getByText('previous-a')).toBeInTheDocument(); + // Click the cell text of the first data row. fireEvent.click(screen.getByText('Alice Smith')); @@ -143,24 +130,8 @@ describe('', () => { expect(screen.queryByText('Alice Smith')).not.toBeInTheDocument(); }); - it('shows only ongoing (not completed) courses in the Ongoing Courses filter list', async () => { - const user = userEvent.setup(); - render(); - - // The Ongoing Courses column has a server-side `filters` dropdown. - const ongoingHeader = headerCell('Ongoing Courses'); - const filterBtn = within(ongoingHeader).getByRole('button', { name: /filter/i }); - await user.click(filterBtn); - - const dropdown = await screen.findByRole('menu'); - expect(within(dropdown).getByText('ongoing-a')).toBeInTheDocument(); - expect(within(dropdown).getByText('ongoing-b')).toBeInTheDocument(); - // Completed courses must NOT appear in the ongoing filter. - expect(within(dropdown).queryByText('previous-a')).not.toBeInTheDocument(); - }); - it('passes the selected ongoing course id to handleChange when its filter is applied', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); @@ -168,6 +139,10 @@ describe('', () => { await user.click(within(ongoingHeader).getByRole('button', { name: /filter/i })); const dropdown = await screen.findByRole('menu'); + expect(within(dropdown).getByText('ongoing-a')).toBeInTheDocument(); + expect(within(dropdown).getByText('ongoing-b')).toBeInTheDocument(); + expect(within(dropdown).queryByText('previous-a')).not.toBeInTheDocument(); + await user.click(within(dropdown).getByText('ongoing-a')); await user.click(screen.getByRole('button', { name: /ok/i })); @@ -178,7 +153,7 @@ describe('', () => { }); it('passes the typed Country search value to handleChange', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); @@ -196,7 +171,7 @@ describe('', () => { }); it('passes the typed City search value to handleChange', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); @@ -213,7 +188,7 @@ describe('', () => { }); it('passes the typed Student search value to handleChange', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); diff --git a/client/src/modules/SubmitScores/ManualSubmitTab.test.tsx b/client/src/modules/SubmitScores/ManualSubmitTab.test.tsx index 0dd2209468..fc7a7887c8 100644 --- a/client/src/modules/SubmitScores/ManualSubmitTab.test.tsx +++ b/client/src/modules/SubmitScores/ManualSubmitTab.test.tsx @@ -10,6 +10,12 @@ vi.mock('@client/shared/components/StudentSearch', () => ({ ), })); +const { showError } = vi.hoisted(() => ({ showError: vi.fn() })); + +vi.mock('@client/hooks', () => ({ + useMessage: () => ({ message: { error: showError, success: vi.fn() } }), +})); + const courseTasks = [ { id: 1, name: 'Task A', studentStartDate: '2024-01-01', studentEndDate: '2024-12-31', maxScore: 100 }, { id: 2, name: 'Task B', studentStartDate: '2024-01-01', studentEndDate: '2024-12-31', maxScore: 50 }, @@ -29,7 +35,7 @@ function makeProps(overrides: Partial[0]> = { describe('', () => { beforeEach(() => vi.clearAllMocks()); - it('renders one initial row with student input, task select, score input and remove button', () => { + it('renders the initial row and appends rows with "Add row"', () => { render(); expect(screen.getAllByTestId('student-input')).toHaveLength(1); @@ -39,10 +45,6 @@ describe('', () => { expect(screen.getAllByRole('spinbutton')).toHaveLength(1); expect(screen.getByRole('button', { name: /add row/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /^submit$/i })).toBeInTheDocument(); - }); - - it('"Add row" button appends a new row', () => { - render(); fireEvent.click(screen.getByRole('button', { name: /add row/i })); fireEvent.click(screen.getByRole('button', { name: /add row/i })); @@ -52,20 +54,17 @@ describe('', () => { expect(screen.getAllByRole('spinbutton')).toHaveLength(3); }); - it('disables the remove button when only one row exists', () => { - render(); - - const removeBtn = screen.getByRole('button', { name: /remove row/i }); - expect(removeBtn).toBeDisabled(); - }); - it('removes a row when the remove button is clicked (with >1 rows)', () => { render(); + expect(screen.getByLabelText('Remove row')).toHaveRole('button'); + expect(screen.getByLabelText('Remove row')).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: /add row/i })); expect(screen.getAllByTestId('student-input')).toHaveLength(2); - const [firstRemove] = screen.getAllByRole('button', { name: /remove row/i }); + const [firstRemove] = screen.getAllByLabelText('Remove row'); + expect(firstRemove).toHaveRole('button'); fireEvent.click(firstRemove); expect(screen.getAllByTestId('student-input')).toHaveLength(1); @@ -77,17 +76,13 @@ describe('', () => { fireEvent.click(screen.getByRole('button', { name: /^submit$/i })); - // antd validation fires asynchronously; postMultipleScores should never be called. - await waitFor(() => { - expect(props.courseService.postMultipleScores).not.toHaveBeenCalled(); - }); + expect(await screen.findByText('Select a student')).toBeInTheDocument(); + expect(screen.getByText('Select a task')).toBeInTheDocument(); + expect(screen.getByText('Enter score')).toBeInTheDocument(); + expect(props.courseService.postMultipleScores).not.toHaveBeenCalled(); expect(props.onResults).not.toHaveBeenCalled(); }); - // Note: the (student, task) duplicate-detection logic itself is covered by unit tests - // for `findDuplicateRow` in utils.test.ts. Driving antd Select/InputNumber through - // jsdom is too brittle to be worth a parallel integration test here. - it('renders the configured task options in each row select', () => { render(); @@ -156,9 +151,10 @@ describe('', () => { fireEvent.click(screen.getByRole('button', { name: /^submit$/i })); - await waitFor(() => { - expect(props.onResults).not.toHaveBeenCalled(); - }); + await waitFor(() => + expect(showError).toHaveBeenCalledWith('Duplicate row: Alice for the same task. Remove one of them.'), + ); + expect(props.onResults).not.toHaveBeenCalled(); expect(postMultipleScores).not.toHaveBeenCalled(); expect(props.onLoadingChange).not.toHaveBeenCalled(); }); diff --git a/client/src/modules/SubmitScores/SubmitScorePage.csv.test.tsx b/client/src/modules/SubmitScores/SubmitScorePage.csv.test.tsx index 5958c41f35..4bf6534129 100644 --- a/client/src/modules/SubmitScores/SubmitScorePage.csv.test.tsx +++ b/client/src/modules/SubmitScores/SubmitScorePage.csv.test.tsx @@ -1,8 +1,8 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ReactNode, createContext } from 'react'; import type { UploadProps } from 'antd'; +import { setupUser } from '@client/__tests__/setupUser'; import { SubmitScorePage } from '@client/pages/course/submit-scores'; // --- Boundary & brittle-widget mocks -------------------------------------- @@ -32,13 +32,17 @@ const { getCourseTasks } = vi.hoisted(() => ({ }), })); -vi.mock('@client/api', async () => ({ - ...(await vi.importActual('@client/api')), +vi.mock('@client/api', () => ({ CoursesTasksApi: function CoursesTasksApi() { return { getCourseTasks }; }, })); +const { showError } = vi.hoisted(() => ({ showError: vi.fn() })); +vi.mock('@client/hooks', () => ({ + useMessage: () => ({ message: { error: showError, success: vi.fn() } }), +})); + const { postMultipleScores } = vi.hoisted(() => ({ postMultipleScores: vi.fn() })); vi.mock('@client/services/course', () => ({ @@ -135,7 +139,7 @@ describe(' CSV upload flow', () => { }); it('registers chosen files in the Upload fileList', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await waitFor(() => expect(getCourseTasks).toHaveBeenCalled()); @@ -147,7 +151,7 @@ describe(' CSV upload flow', () => { }); it('parses a valid CSV, uploads the deduped best scores, and shows the summary table', async () => { - const user = userEvent.setup(); + const user = setupUser(); postMultipleScores.mockResolvedValue([ { status: 'updated', value: undefined }, { status: 'updated', value: undefined }, @@ -187,7 +191,7 @@ describe(' CSV upload flow', () => { }); it('renders skipped students under a "Skipped students" section', async () => { - const user = userEvent.setup(); + const user = setupUser(); postMultipleScores.mockResolvedValue([ { status: 'created', value: undefined }, { status: 'skipped', value: 'ghost-student not found' }, @@ -208,7 +212,7 @@ describe(' CSV upload flow', () => { }); it('shows a specific "Incorrect data" error when required headers are missing', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await waitFor(() => expect(getCourseTasks).toHaveBeenCalled()); @@ -221,14 +225,16 @@ describe(' CSV upload flow', () => { await user.click(screen.getByRole('button', { name: 'mock-select' })); await user.click(screen.getByRole('button', { name: /^Submit$/i })); - await waitFor(() => { - // The component routes "Incorrect data" errors to message.error; the network call never happens. - expect(postMultipleScores).not.toHaveBeenCalled(); - }); + await waitFor(() => + expect(showError).toHaveBeenCalledWith( + 'Incorrect data: CSV file should contain the headers named "GitHub" and "Score"!', + ), + ); + expect(postMultipleScores).not.toHaveBeenCalled(); }); it('handles a generic upload failure without rendering a results summary', async () => { - const user = userEvent.setup(); + const user = setupUser(); // Reject with a non-"Incorrect data" error → falls into the generic message.error branch. postMultipleScores.mockRejectedValue(new Error('Boom')); render(); @@ -243,12 +249,13 @@ describe(' CSV upload flow', () => { await user.click(screen.getByRole('button', { name: /^Submit$/i })); await waitFor(() => expect(postMultipleScores).toHaveBeenCalled()); + await waitFor(() => expect(showError).toHaveBeenCalledWith('An error occurred. Please try later.')); // The catch branch swallows the error → no Summary table is rendered. expect(screen.queryByText('Summary')).not.toBeInTheDocument(); }); it('handles a FileReader read error during parsing without uploading', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await waitFor(() => expect(getCourseTasks).toHaveBeenCalled()); @@ -262,14 +269,13 @@ describe(' CSV upload flow', () => { await user.click(screen.getByRole('button', { name: /^Submit$/i })); // parseFiles rejects → handleSubmit catch → the network call never happens. - await waitFor(() => { - expect(screen.queryByText('Summary')).not.toBeInTheDocument(); - }); + await waitFor(() => expect(showError).toHaveBeenCalledWith('An error occurred. Please try later.')); + expect(screen.queryByText('Summary')).not.toBeInTheDocument(); expect(postMultipleScores).not.toHaveBeenCalled(); }); it('does not submit when no file has been selected (form validation blocks it)', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await waitFor(() => expect(getCourseTasks).toHaveBeenCalled()); @@ -286,7 +292,7 @@ describe(' CSV upload flow', () => { }); it('clears previous results when switching tabs', async () => { - const user = userEvent.setup(); + const user = setupUser(); postMultipleScores.mockResolvedValue([{ status: 'created', value: undefined }]); render(); await waitFor(() => expect(getCourseTasks).toHaveBeenCalled()); diff --git a/client/src/modules/SubmitScores/SubmitScorePage.test.tsx b/client/src/modules/SubmitScores/SubmitScorePage.test.tsx index 2bb7004efc..f543fc80be 100644 --- a/client/src/modules/SubmitScores/SubmitScorePage.test.tsx +++ b/client/src/modules/SubmitScores/SubmitScorePage.test.tsx @@ -43,8 +43,7 @@ const { getCourseTasks } = vi.hoisted(() => ({ }), })); -vi.mock('@client/api', async () => ({ - ...(await vi.importActual('@client/api')), +vi.mock('@client/api', () => ({ CoursesTasksApi: function CoursesTasksApi() { return { getCourseTasks }; }, @@ -80,24 +79,17 @@ vi.mock('@client/shared/components/PageLayout', () => ({ describe('', () => { beforeEach(() => vi.clearAllMocks()); - it('renders the page with both "Upload CSV" and "Manual Entry" tabs', async () => { + it('loads course tasks, shows CSV by default and switches to Manual Entry', async () => { render(); expect(screen.getByRole('heading', { name: /submit scores/i })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: /upload csv/i })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: /manual entry/i })).toBeInTheDocument(); - }); - - it('shows the CSV tab by default with the file uploader and uploading rules', async () => { - render(); - // Default tab — CSV. expect(screen.getByText(/uploading rules/i)).toBeInTheDocument(); expect(screen.getByRole('button', { name: /select files/i })).toBeInTheDocument(); - }); - it('switches to the Manual Entry tab and shows manual form controls', async () => { - render(); + await waitFor(() => expect(getCourseTasks).toHaveBeenCalledWith(42)); fireEvent.click(screen.getByRole('tab', { name: /manual entry/i })); @@ -107,12 +99,4 @@ describe('', () => { // One initial row → one student input. expect(screen.getAllByTestId('student-input')).toHaveLength(1); }); - - it('fetches course tasks on mount', async () => { - render(); - - await waitFor(() => { - expect(getCourseTasks).toHaveBeenCalledWith(42); - }); - }); }); diff --git a/client/src/modules/Tasks/components/CrossCheckTaskCriteriaPanel/CrossCheckTaskCriteriaPanel.test.tsx b/client/src/modules/Tasks/components/CrossCheckTaskCriteriaPanel/CrossCheckTaskCriteriaPanel.test.tsx index 1546016792..e35219e373 100644 --- a/client/src/modules/Tasks/components/CrossCheckTaskCriteriaPanel/CrossCheckTaskCriteriaPanel.test.tsx +++ b/client/src/modules/Tasks/components/CrossCheckTaskCriteriaPanel/CrossCheckTaskCriteriaPanel.test.tsx @@ -15,66 +15,21 @@ const renderPanel = (dataCriteria: CriteriaDto[] = [], setDataCriteria = vi.fn() }; describe('Criteria For Cross-Check Task', () => { - test.each` - label - ${LABELS.crossCheckCriteria} - `('should render fields with $label label', async ({ label }) => { + test('renders the criteria fields without a table or export controls when empty', () => { renderPanel(); - const field = await screen.findByText(label); - expect(field).toBeInTheDocument(); + expect(screen.getByText(LABELS.crossCheckCriteria)).toBeInTheDocument(); + expect(screen.getByText('Criteria Type')).toBeInTheDocument(); + expect(screen.queryByRole('separator')).not.toBeInTheDocument(); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /export json/i })).not.toBeInTheDocument(); }); - // AddCriteriaForCrossCheck - test('should render "Criteria Type" field', async () => { - renderPanel(); - - const field = await screen.findByText('Criteria Type'); - expect(field).toBeInTheDocument(); - }); - - // Divider - test('should render divider', async () => { - renderPanel([criteriaMock]); - - const divider = await screen.findByRole('separator'); - expect(divider).toBeInTheDocument(); - }); - - test('should not render divider when no dataCriteria', async () => { - renderPanel(); - - const divider = screen.queryByRole('separator'); - expect(divider).not.toBeInTheDocument(); - }); - - // EditableTable - test('should render criteria table', async () => { + test('renders the divider, criteria table and export button when criteria exist', () => { renderPanel([criteriaMock]); - const table = await screen.findByRole('table'); - expect(table).toBeInTheDocument(); - }); - - test('should not render criteria table when no dataCriteria', async () => { - renderPanel(); - - const table = screen.queryByRole('table'); - expect(table).not.toBeInTheDocument(); - }); - - // ExportJSONButton - test('should render "Export JSON" button', async () => { - renderPanel([criteriaMock]); - - const button = await screen.findByRole('button', { name: /export json/i }); - expect(button).toBeInTheDocument(); - }); - - test('should not render "Export JSON" button when no dataCriteria', async () => { - renderPanel(); - - const button = screen.queryByRole('button', { name: /export json/i }); - expect(button).not.toBeInTheDocument(); + expect(screen.getByRole('separator')).toBeInTheDocument(); + expect(screen.getByRole('table')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /export json/i })).toBeInTheDocument(); }); }); diff --git a/client/src/modules/Tasks/components/GitHubPanel/GitHubPanel.test.tsx b/client/src/modules/Tasks/components/GitHubPanel/GitHubPanel.test.tsx index da1f783421..9f3c992b02 100644 --- a/client/src/modules/Tasks/components/GitHubPanel/GitHubPanel.test.tsx +++ b/client/src/modules/Tasks/components/GitHubPanel/GitHubPanel.test.tsx @@ -12,59 +12,20 @@ const renderPanel = () => { }; describe('GitHub', () => { - test.each` - label - ${LABELS.repoUrl} - ${LABELS.expectedRepoName} - `('should render fields with $label label', async ({ label }) => { - renderPanel(); - - const field = await screen.findByText(label); - expect(field).toBeInTheDocument(); - }); - - test('should render "Pull Request required" checkbox', async () => { - renderPanel(); - - const checkbox = await screen.findByRole('checkbox', { name: /pull request required/i }); - expect(checkbox).toBeInTheDocument(); - }); - - test.each` - placeholder - ${PLACEHOLDERS.sourceGithubRepoUrl} - ${PLACEHOLDERS.githubRepoName} - `('should render field with $placeholder placeholder', async ({ placeholder }) => { - renderPanel(); - - const field = await screen.findByPlaceholderText(placeholder); - expect(field).toBeInTheDocument(); - }); - - test('should render error message on invalid source GitHub repo URL input', async () => { + test('renders its fields and validates the source repository URL', async () => { renderPanel(); + expect(await screen.findByText(LABELS.repoUrl)).toBeInTheDocument(); + expect(screen.getByText(LABELS.expectedRepoName)).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: /pull request required/i })).toBeInTheDocument(); const field = await screen.findByPlaceholderText(PLACEHOLDERS.sourceGithubRepoUrl); - expect(field).toBeInTheDocument(); + expect(screen.getByPlaceholderText(PLACEHOLDERS.githubRepoName)).toBeInTheDocument(); fireEvent.change(field, { target: { value: 'http://github.com/i-vasilich-i' } }); - const errorMessage = await screen.findByText(ERROR_MESSAGES.sourceGithubRepoUrl); - expect(errorMessage).toBeInTheDocument(); expect(errorMessage).toHaveTextContent(ERROR_MESSAGES.sourceGithubRepoUrl); - }); - - test('should not render error message on valid source GitHub repo URL input', async () => { - renderPanel(); - - const field = await screen.findByPlaceholderText(PLACEHOLDERS.sourceGithubRepoUrl); - expect(field).toBeInTheDocument(); fireEvent.change(field, { target: { value: 'https://github.com/rolling-scopes-school/task1' } }); - - await waitFor(() => { - const errorMessage = screen.queryByText(ERROR_MESSAGES.sourceGithubRepoUrl); - expect(errorMessage).not.toBeInTheDocument(); - }); + await waitFor(() => expect(screen.queryByText(ERROR_MESSAGES.sourceGithubRepoUrl)).not.toBeInTheDocument()); }); }); diff --git a/client/src/modules/Tasks/components/JsonAttributesPanel/JsonAttributesPanel.test.tsx b/client/src/modules/Tasks/components/JsonAttributesPanel/JsonAttributesPanel.test.tsx index b621aa0e3e..58e086361c 100644 --- a/client/src/modules/Tasks/components/JsonAttributesPanel/JsonAttributesPanel.test.tsx +++ b/client/src/modules/Tasks/components/JsonAttributesPanel/JsonAttributesPanel.test.tsx @@ -12,22 +12,14 @@ const renderPanel = () => { }; describe('JSON Attributes', () => { - test('should render attributes textarea', async () => { + test('should render attributes textarea and validate invalid JSON', async () => { renderPanel(); const textarea = await screen.findByRole('textbox'); expect(textarea).toBeInTheDocument(); expect(textarea).toHaveProperty('placeholder', PLACEHOLDERS.jsonAttributes); - }); - - test('should render error message on invalid JSON input', async () => { - renderPanel(); - const invalidJson = `{ name: 'Pit' }`; - - const textarea = await screen.findByRole('textbox'); - expect(textarea).toBeInTheDocument(); - fireEvent.change(textarea, { target: { value: invalidJson } }); + fireEvent.change(textarea, { target: { value: `{ name: 'Pit' }` } }); const errorMessage = await screen.findByText(ERROR_MESSAGES.invalidJson); expect(errorMessage).toBeInTheDocument(); diff --git a/client/src/modules/Tasks/components/TaskModal/TaskModal.test.tsx b/client/src/modules/Tasks/components/TaskModal/TaskModal.test.tsx index 87b2d0fa58..e46dfa9e12 100644 --- a/client/src/modules/Tasks/components/TaskModal/TaskModal.test.tsx +++ b/client/src/modules/Tasks/components/TaskModal/TaskModal.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { generateTasksData } from '@client/modules/Tasks/utils/test-utils'; import { FormValues } from '@client/modules/Tasks/types'; import { @@ -14,68 +14,33 @@ import { ModalProps, TaskModal } from './TaskModal'; const mockData = generateData(); describe('TaskModal', () => { - test('should render modal with proper title', () => { + test('should render the edit title, fields, placeholders and settings panels', () => { render(); - const modal = screen.getByRole('dialog'); - expect(modal).toBeInTheDocument(); - - const title = screen.getByText(MODAL_TITLES.edit); - expect(title).toBeInTheDocument(); - }); - - test('should render labels', () => { - render(); - - const name = screen.getByLabelText(LABELS.name); - const taskType = screen.getByLabelText(LABELS.taskType); - const discipline = screen.getByLabelText(LABELS.discipline); - const tags = screen.getByLabelText(LABELS.tags); - const descriptionUrl = screen.getByLabelText(LABELS.descriptionUrl); - const summary = screen.getByLabelText(LABELS.summary); - const skills = screen.getByLabelText(LABELS.skills); - - expect(name).toBeInTheDocument(); - expect(taskType).toBeInTheDocument(); - expect(discipline).toBeInTheDocument(); - expect(tags).toBeInTheDocument(); - expect(descriptionUrl).toBeInTheDocument(); - expect(summary).toBeInTheDocument(); - expect(skills).toBeInTheDocument(); - }); - - test('should render "Used in courses" card', () => { - render(); - - const card = screen.getByText(LABELS.usedInCourses); - expect(card).toBeInTheDocument(); - }); - - // Inputs - test('should render input placeholders', () => { - render(); - - const name = screen.getByPlaceholderText(PLACEHOLDERS.name); - const descriptionUrl = screen.getByPlaceholderText(PLACEHOLDERS.descriptionUrl); - const summary = screen.getByPlaceholderText(PLACEHOLDERS.summary); - - expect(name).toBeInTheDocument(); - expect(descriptionUrl).toBeInTheDocument(); - expect(summary).toBeInTheDocument(); - }); - - // Selects - test('should render select placeholders', () => { - render(); - const taskType = screen.getByText(PLACEHOLDERS.taskType); - const discipline = screen.getByText(PLACEHOLDERS.discipline); - const tags = screen.getByText(PLACEHOLDERS.tags); - const skills = screen.getByText(PLACEHOLDERS.skills); - - expect(taskType).toBeInTheDocument(); - expect(discipline).toBeInTheDocument(); - expect(tags).toBeInTheDocument(); - expect(skills).toBeInTheDocument(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText(MODAL_TITLES.edit)).toBeInTheDocument(); + for (const label of [ + LABELS.name, + LABELS.taskType, + LABELS.discipline, + LABELS.tags, + LABELS.descriptionUrl, + LABELS.summary, + LABELS.skills, + ]) { + expect(screen.getByLabelText(label)).toBeInTheDocument(); + } + for (const placeholder of [PLACEHOLDERS.name, PLACEHOLDERS.descriptionUrl, PLACEHOLDERS.summary]) { + expect(screen.getByPlaceholderText(placeholder)).toBeInTheDocument(); + } + for (const title of [ + LABELS.usedInCourses, + TASK_SETTINGS_HEADERS.crossCheckCriteria, + TASK_SETTINGS_HEADERS.github, + TASK_SETTINGS_HEADERS.jsonAttributes, + ]) { + expect(screen.getByText(title)).toBeInTheDocument(); + } }); describe('incorrect input handling', () => { @@ -100,9 +65,18 @@ describe('TaskModal', () => { }); test('should render error messages on required fields', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); + for (const placeholder of [ + PLACEHOLDERS.taskType, + PLACEHOLDERS.discipline, + PLACEHOLDERS.tags, + PLACEHOLDERS.skills, + ]) { + expect(screen.getByText(placeholder)).toBeInTheDocument(); + } + const save = screen.getByRole('button', { name: /save/i }); expect(save).toBeInTheDocument(); @@ -123,18 +97,6 @@ describe('TaskModal', () => { }); }); - test('should render task setting panel headers', () => { - render(); - - const crossCheckCriteria = screen.getByText(TASK_SETTINGS_HEADERS.crossCheckCriteria); - const github = screen.getByText(TASK_SETTINGS_HEADERS.github); - const jsonAttributes = screen.getByText(TASK_SETTINGS_HEADERS.jsonAttributes); - - expect(crossCheckCriteria).toBeInTheDocument(); - expect(github).toBeInTheDocument(); - expect(jsonAttributes).toBeInTheDocument(); - }); - test('renders an empty courses card when the task is not used in any course', () => { // formData without courses → the `courses?.length ? … : ` Empty branch; tasks with // no tags/skills → the `task.tags || []` / `task.skills || []` fallbacks. @@ -175,7 +137,7 @@ describe('TaskModal', () => { }); test('resets dependent settings when the task type changes', async () => { - const user = userEvent.setup(); + const user = setupUser(); const setDataCriteria = vi.fn(); const props = generateData(true); props.setDataCriteria = setDataCriteria; @@ -191,7 +153,7 @@ describe('TaskModal', () => { }); test('clears criteria and closes the modal on cancel', async () => { - const user = userEvent.setup(); + const user = setupUser(); const toggleModal = vi.fn(); const setDataCriteria = vi.fn(); const props = generateData(); diff --git a/client/src/modules/Tasks/components/TaskSettings/TaskSettings.test.tsx b/client/src/modules/Tasks/components/TaskSettings/TaskSettings.test.tsx index c7bbcbd5c9..0e1c72e027 100644 --- a/client/src/modules/Tasks/components/TaskSettings/TaskSettings.test.tsx +++ b/client/src/modules/Tasks/components/TaskSettings/TaskSettings.test.tsx @@ -13,15 +13,15 @@ const renderTaskSettings = (dataCriteria: CriteriaDto[] = [], setDataCriteria = }; describe('TaskSettings', () => { - test.each` - header - ${TASK_SETTINGS_HEADERS.crossCheckCriteria} - ${TASK_SETTINGS_HEADERS.github} - ${TASK_SETTINGS_HEADERS.jsonAttributes} - `('should render task setting panel $header', ({ header }) => { + it('renders every task setting panel', () => { renderTaskSettings(); - const panel = screen.getByText(header); - expect(panel).toBeInTheDocument(); + for (const header of [ + TASK_SETTINGS_HEADERS.crossCheckCriteria, + TASK_SETTINGS_HEADERS.github, + TASK_SETTINGS_HEADERS.jsonAttributes, + ]) { + expect(screen.getByText(header)).toBeInTheDocument(); + } }); }); diff --git a/client/src/modules/Tasks/components/TasksTable/TasksTable.test.tsx b/client/src/modules/Tasks/components/TasksTable/TasksTable.test.tsx index d0b3f55c99..043076d387 100644 --- a/client/src/modules/Tasks/components/TasksTable/TasksTable.test.tsx +++ b/client/src/modules/Tasks/components/TasksTable/TasksTable.test.tsx @@ -1,6 +1,6 @@ import assert from 'node:assert'; import { fireEvent, render, screen, within, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { TaskDto } from '@client/api'; import { TasksTable } from './TasksTable'; import { ColumnName } from '@client/modules/Tasks/types'; @@ -14,42 +14,44 @@ const renderTasksTable = (data: TaskDto[] = generateTasksData(1), handleEditItem describe('TasksTable', () => { const [mockData] = generateTasksData(1); - test.each` - label - ${ColumnName.Id} - ${ColumnName.Name} - ${ColumnName.Discipline} - ${ColumnName.Tags} - ${ColumnName.Skills} - ${ColumnName.Type} - ${ColumnName.UsedInCourses} - ${ColumnName.DescriptionURL} - ${ColumnName.PRRequired} - ${ColumnName.RepoName} - ${ColumnName.Actions} - `('should render column "$label"', ({ label }: { label: ColumnName }) => { + test('should render all columns', () => { renderTasksTable(); - expect(screen.getByText(label)).toBeInTheDocument(); + for (const label of [ + ColumnName.Id, + ColumnName.Name, + ColumnName.Discipline, + ColumnName.Tags, + ColumnName.Skills, + ColumnName.Type, + ColumnName.UsedInCourses, + ColumnName.DescriptionURL, + ColumnName.PRRequired, + ColumnName.RepoName, + ColumnName.Actions, + ]) { + expect(screen.getByText(label)).toBeInTheDocument(); + } }); - test.each` - value - ${mockData?.id} - ${mockData?.name} - ${mockData?.discipline.name} - ${mockData?.tags[0]} - ${mockData?.tags[1]} - ${mockData?.skills[0]} - ${mockData?.skills[1]} - ${mockData?.type} - ${mockData?.githubRepoName} - ${mockData?.courses[0]?.name} - `('should render data field "$value"', ({ value }) => { + test('should render all data fields', () => { renderTasksTable(); - const [dataField] = screen.getAllByText(value ?? ''); - expect(dataField).toBeInTheDocument(); + for (const value of [ + mockData?.id, + mockData?.name, + mockData?.discipline.name, + mockData?.tags[0], + mockData?.tags[1], + mockData?.skills[0], + mockData?.skills[1], + mockData?.type, + mockData?.githubRepoName, + mockData?.courses[0]?.name, + ]) { + const [dataField] = screen.getAllByText(value ?? ''); + expect(dataField).toBeInTheDocument(); + } }); test('should render description link fields', () => { @@ -65,15 +67,6 @@ describe('TasksTable', () => { }); }); - test('should render "Edit" link fields', () => { - const data = generateTasksData(); - - renderTasksTable(data); - - const links = screen.getAllByText(/edit/i); - expect(links).toHaveLength(data.length); - }); - test('should call handleEditItem on "Edit" click with proper record', () => { const handleEditItem = vi.fn(); const data = generateTasksData(); @@ -81,6 +74,7 @@ describe('TasksTable', () => { renderTasksTable(data, handleEditItem); const links = screen.getAllByText('Edit'); + expect(links).toHaveLength(data.length); data.forEach((task, i) => { const link = links[i]; @@ -105,25 +99,7 @@ describe('TasksTable', () => { }); describe('filter & search data', () => { - test('should check filter in dropdown when tag is selected', async () => { - const tag = TASK_TYPES[0]?.name ?? ''; - const data = generateTasksData(); - renderTasksTable(data); - - const columnHeader = screen.getByLabelText(/type/i); - - const tagFilterBtn = within(columnHeader).getByRole('button', { name: /filter/i }); - fireEvent.click(tagFilterBtn); - - const filtersDropdown = await screen.findByRole('menu'); - const menuItem = within(filtersDropdown).getByRole('menuitem', { name: new RegExp(tag, 'i') }); - fireEvent.click(menuItem); - - const checkbox = within(menuItem).getByRole('checkbox'); - expect(checkbox).toBeChecked(); - }); - - test('should reset filter on Reset click', async () => { + test('should select a filter and clear it on Reset click', async () => { const tag = TASK_TYPES[0]?.name ?? ''; const data = generateTasksData(); renderTasksTable(data); @@ -240,38 +216,13 @@ describe('TasksTable', () => { expect(rows).toHaveLength(notAssignedCount); }); - test('should render only data filtered by Name column search', async () => { - const user = userEvent.setup(); + test('should filter by Name and restore all data when search is cleared', async () => { + const user = setupUser(); const data = generateTasksData(); const searchQuery = data[0]?.name ?? ''; renderTasksTable(data); - // Check that all items rendered - const table = screen.getByRole('table'); - const rows = within(table).getAllByText(/edit/i); - expect(rows).toHaveLength(data.length); - - // Find and click search button for column - const searchButton = screen.getByRole('button', { name: /search/i }); - await user.click(searchButton); - - // Type search query inside search input - const searchInput = await screen.findByRole('textbox'); - await user.type(searchInput, searchQuery); - fireEvent.keyDown(searchInput, { key: 'Enter', keyCode: 13 }); - - // Find the line with search query and no others - const item = await screen.findByText(searchQuery); - expect(item).toBeInTheDocument(); - const secondTaskName = data[1]?.name ?? 'non-existent'; - await waitFor(() => expect(screen.queryByText(secondTaskName)).not.toBeInTheDocument()); - }); - - test('should render all data when search query is cleared', async () => { - const user = userEvent.setup(); - const data = generateTasksData(); - const searchQuery = data[0]?.name ?? ''; - renderTasksTable(data); + expect(within(screen.getByRole('table')).getAllByText(/edit/i)).toHaveLength(data.length); // Find and click search button for column const searchButton = screen.getByRole('button', { name: /search/i }); diff --git a/client/src/modules/Tasks/pages/TasksPage/TasksPage.test.tsx b/client/src/modules/Tasks/pages/TasksPage/TasksPage.test.tsx index 38542d9681..83dc2c76ae 100644 --- a/client/src/modules/Tasks/pages/TasksPage/TasksPage.test.tsx +++ b/client/src/modules/Tasks/pages/TasksPage/TasksPage.test.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { ReactNode } from 'react'; +import { setupUser } from '@client/__tests__/setupUser'; import { generateTasksData } from '@client/modules/Tasks/utils/test-utils'; import { FormValues } from '@client/modules/Tasks/types'; import { ModalProps } from '@client/modules/Tasks/components'; @@ -110,15 +110,11 @@ describe('TasksPage', () => { createTaskCriteria.mockResolvedValue({ data: {} }); }); - it('should render the page title and the Add Task button', () => { + it('should fetch and render the tasks in the table', async () => { render(); expect(screen.getByRole('heading', { name: 'Manage Tasks' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /add task/i })).toBeInTheDocument(); - }); - - it('should fetch and render the tasks in the table', async () => { - render(); await waitFor(() => expect(getTasks).toHaveBeenCalled()); @@ -126,45 +122,21 @@ describe('TasksPage', () => { expect(await screen.findByText(firstTaskName)).toBeInTheDocument(); }); - it('should open the create modal when Add Task is clicked', async () => { - const user = userEvent.setup(); - render(); - - await waitFor(() => expect(getTasks).toHaveBeenCalled()); - - await user.click(screen.getByRole('button', { name: /add task/i })); - - const dialog = await screen.findByRole('dialog'); - expect(dialog).toHaveTextContent('mode: create'); - }); - - it('should fetch criteria and open the edit modal when Edit is clicked', async () => { - const user = userEvent.setup(); - render(); - - await waitFor(() => expect(getTasks).toHaveBeenCalled()); - - const [editLink] = await screen.findAllByText('Edit'); - await user.click(editLink as HTMLElement); - - await waitFor(() => expect(getTaskCriteria).toHaveBeenCalledWith(TASKS[0]?.id)); - const dialog = await screen.findByRole('dialog'); - expect(dialog).toHaveTextContent('mode: edit'); - }); - it('should close the modal when cancel is triggered', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await waitFor(() => expect(getTasks).toHaveBeenCalled()); await user.click(screen.getByRole('button', { name: /add task/i })); + expect(await screen.findByRole('dialog')).toHaveTextContent('mode: create'); + await user.click(await screen.findByRole('button', { name: 'cancel-modal' })); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); }); it('should create a task and its criteria when submitting a valid new task', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await waitFor(() => expect(getTasks).toHaveBeenCalled()); @@ -184,7 +156,7 @@ describe('TasksPage', () => { }); it('should update the task and existing criteria when submitting a valid edit', async () => { - const user = userEvent.setup(); + const user = setupUser(); getTaskCriteria .mockResolvedValueOnce({ data: { criteria: [] } }) // on edit open .mockResolvedValueOnce({ data: { criteria: [{ type: 'title', text: 'c1' }] } }); // during submit @@ -201,7 +173,7 @@ describe('TasksPage', () => { }); it('should create criteria during edit when the task has none yet', async () => { - const user = userEvent.setup(); + const user = setupUser(); getTaskCriteria .mockResolvedValueOnce({ data: { criteria: [] } }) // on edit open .mockResolvedValueOnce({ data: { criteria: null } }); // during submit @@ -216,7 +188,7 @@ describe('TasksPage', () => { }); it('should not submit when a required field is missing', async () => { - const user = userEvent.setup(); + const user = setupUser(); submitValues.current = { ...VALID_VALUES, name: undefined }; render(); @@ -229,7 +201,7 @@ describe('TasksPage', () => { }); it('should swallow API errors during submit without crashing', async () => { - const user = userEvent.setup(); + const user = setupUser(); createTask.mockRejectedValue(new Error('boom')); render(); @@ -243,13 +215,15 @@ describe('TasksPage', () => { }); it('should not call updateTask when the edited task has a falsy id', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await waitFor(() => expect(getTasks).toHaveBeenCalled()); // First task has id 0 (falsy) → handleModalSubmit returns before updating. const editLinks = await screen.findAllByText('Edit'); await user.click(editLinks[0] as HTMLElement); + await waitFor(() => expect(getTaskCriteria).toHaveBeenCalledWith(TASKS[0]?.id)); + expect(await screen.findByRole('dialog')).toHaveTextContent('mode: edit'); await user.click(await screen.findByRole('button', { name: 'submit-modal' })); await waitFor(() => expect(getTaskCriteria).toHaveBeenCalled()); @@ -257,7 +231,7 @@ describe('TasksPage', () => { }); it('should block submit when a non-title criterion has zero score', async () => { - const user = userEvent.setup(); + const user = setupUser(); getTaskCriteria.mockResolvedValue({ data: { criteria: [{ type: 'subtask', text: 'st', max: 0 }] } }); render(); @@ -271,7 +245,7 @@ describe('TasksPage', () => { }); it('defaults criteria to an empty list when the edited task returns no criteria field', async () => { - const user = userEvent.setup(); + const user = setupUser(); // getTaskCriteria with no `criteria` key on edit-open → `data.criteria ?? []` fallback. getTaskCriteria.mockResolvedValueOnce({ data: {} }); render(); @@ -286,7 +260,7 @@ describe('TasksPage', () => { }); it('applies createRecord defaults for omitted optional fields', async () => { - const user = userEvent.setup(); + const user = setupUser(); // Only the required fields are present → the `?? ''` / `?? []` defaults in createRecord engage. submitValues.current = { name: 'Minimal Task', diff --git a/client/src/modules/TeamDistribution/components/SubmitScoreModal/SubmitScoreModal.test.tsx b/client/src/modules/TeamDistribution/components/SubmitScoreModal/SubmitScoreModal.test.tsx index be3b3181a3..9f530b384c 100644 --- a/client/src/modules/TeamDistribution/components/SubmitScoreModal/SubmitScoreModal.test.tsx +++ b/client/src/modules/TeamDistribution/components/SubmitScoreModal/SubmitScoreModal.test.tsx @@ -1,10 +1,22 @@ -import { screen, render, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { act, screen, render, waitFor, within } from '@testing-library/react'; +import { setupUser } from '@client/__tests__/setupUser'; import { message } from 'antd'; -import { CoursesTasksApi, TeamDistributionApi, TeamDistributionDto } from '@client/api'; +import { TeamDistributionDto } from '@client/api'; import SubmitScoreModal from './SubmitScoreModal'; -vi.mock('@client/api'); +const { getCourseTasks, submitScore } = vi.hoisted(() => ({ + getCourseTasks: vi.fn(), + submitScore: vi.fn(), +})); + +vi.mock('@client/api', () => ({ + CoursesTasksApi: function CoursesTasksApi() { + return { getCourseTasks }; + }, + TeamDistributionApi: function TeamDistributionApi() { + return { submitScore }; + }, +})); const mockError = vi.fn(); const mockSuccess = vi.fn(); @@ -16,9 +28,6 @@ vi.mock('@client/modules/Course/contexts', () => ({ useActiveCourseContext: () => ({ course: { id: 42, name: 'RS Course' } }), })); -const getCourseTasks = vi.mocked(CoursesTasksApi.prototype.getCourseTasks); -const submitScore = vi.mocked(TeamDistributionApi.prototype.submitScore); - const distribution = { id: 7, name: 'Spring distribution' } as TeamDistributionDto; const courseTasks = [ @@ -33,19 +42,14 @@ describe('', () => { submitScore.mockResolvedValue({} as never); }); - it('is closed (not rendered) when distribution is null', () => { - render(); + it('is closed (not rendered) when distribution is null', async () => { + // eslint-disable-next-line testing-library/no-unnecessary-act -- Await mount effects after the synchronous render + await act(async () => { + render(); + }); expect(screen.queryByText('Submit Score')).not.toBeInTheDocument(); }); - it('opens and loads the course task options when a distribution is provided', async () => { - render(); - - expect(await screen.findByText('Submit Score')).toBeInTheDocument(); - await waitFor(() => expect(getCourseTasks).toHaveBeenCalledWith(42)); - expect(screen.getByRole('combobox')).toBeInTheDocument(); - }); - it('shows an empty-state message when there are no tasks', async () => { getCourseTasks.mockResolvedValue({ data: [] } as never); render(); @@ -65,7 +69,7 @@ describe('', () => { }); it('warns and does not submit when no task is selected', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByRole('combobox'); @@ -76,7 +80,7 @@ describe('', () => { }); it('submits the selected task score for the team distribution', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); const combobox = await screen.findByRole('combobox'); @@ -91,7 +95,7 @@ describe('', () => { }); it('shows an error message when score submission fails', async () => { - const user = userEvent.setup(); + const user = setupUser(); submitScore.mockRejectedValue(new Error('fail')); render(); const combobox = await screen.findByRole('combobox'); @@ -104,10 +108,12 @@ describe('', () => { }); it('calls onClose when the modal is cancelled', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onClose = vi.fn(); render(); - await screen.findByRole('combobox'); + expect(await screen.findByRole('combobox')).toBeInTheDocument(); + expect(screen.getByText('Submit Score')).toBeInTheDocument(); + expect(getCourseTasks).toHaveBeenCalledWith(42); const dialog = screen.getByRole('dialog'); await user.click(within(dialog).getByRole('button', { name: /cancel/i })); diff --git a/client/src/modules/TeamDistribution/components/SubmitScoreModal/SubmitScoreModal.tsx b/client/src/modules/TeamDistribution/components/SubmitScoreModal/SubmitScoreModal.tsx index 53c109d88b..c181a9777b 100644 --- a/client/src/modules/TeamDistribution/components/SubmitScoreModal/SubmitScoreModal.tsx +++ b/client/src/modules/TeamDistribution/components/SubmitScoreModal/SubmitScoreModal.tsx @@ -46,7 +46,7 @@ export default function SubmitScoreModal({ distribution, onClose }: Props) { > - + After submission, reverting changes will be impossible. Please be careful when selecting the task. The same score will be given to all team members. diff --git a/client/src/modules/TeamDistribution/components/TeamDistributionCard/Actions.test.tsx b/client/src/modules/TeamDistribution/components/TeamDistributionCard/Actions.test.tsx index 61c81e06fc..8b4a8a16f0 100644 --- a/client/src/modules/TeamDistribution/components/TeamDistributionCard/Actions.test.tsx +++ b/client/src/modules/TeamDistribution/components/TeamDistributionCard/Actions.test.tsx @@ -36,26 +36,19 @@ describe('Actions', () => { mockOnDeleteRegister.mockClear(); }); - it('should render a register button when the distribution is available', () => { + it('renders the available registration actions and calls register', () => { renderActions(distribution); const registerButton = screen.getByRole('button', { name: /register/i, }); expect(registerButton).toBeInTheDocument(); - }); - - it('should call register when the register button is clicked', () => { - renderActions(distribution); - - const registerButton = screen.getByRole('button', { - name: /register/i, - }); + expect(screen.getByText('Register before 2022-01-03 00:00')).toHaveClass('ant-typography-danger'); fireEvent.click(registerButton); expect(mockOnRegister).toHaveBeenCalledWith(1); }); - it('should render a disabled download button when the distribution is completed', () => { + it('renders the completed registration actions before the end date', () => { const completedDistribution = { ...distribution, registrationStatus: TeamDistributionDtoRegistrationStatusEnum.Completed, @@ -67,17 +60,8 @@ describe('Actions', () => { }); expect(registeredButton).toBeInTheDocument(); expect(registeredButton).toBeDisabled(); - }); - - it('should render a cancel registration link when the distribution is completed and end date has not passed', () => { - const completedDistribution = { - ...distribution, - registrationStatus: TeamDistributionDtoRegistrationStatusEnum.Completed, - }; - renderActions(completedDistribution); - - const cancel = screen.getByText(/cancel/i); - expect(cancel).toBeInTheDocument(); + expect(screen.getByText(/cancel/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /connect with teams/i })).toBeInTheDocument(); }); it('should render the "Registration is closed" text when the distribution is completed and end date has passed', () => { @@ -121,12 +105,6 @@ describe('Actions', () => { expect(screen.getByText('Registration is closed')).toBeInTheDocument(); }); - it('should render a warning text when the end date is within 48 hours of the current time', () => { - renderActions(distribution); - - expect(screen.getByText('Register before 2022-01-03 00:00')).toHaveClass('ant-typography-danger'); - }); - it('should render connect with teams button for managers', () => { renderActions(distribution, true); @@ -135,13 +113,4 @@ describe('Actions', () => { }); expect(registerButton).toBeInTheDocument(); }); - - it('should render connect with teams when registration status is completed', () => { - renderActions({ ...distribution, registrationStatus: TeamDistributionDtoRegistrationStatusEnum.Completed }); - - const registerButton = screen.getByRole('button', { - name: /connect with teams/i, - }); - expect(registerButton).toBeInTheDocument(); - }); }); diff --git a/client/src/modules/TeamDistribution/components/TeamDistributionCard/CardTitle.test.tsx b/client/src/modules/TeamDistribution/components/TeamDistributionCard/CardTitle.test.tsx index bc3fbcb14e..0bb5d81b3e 100644 --- a/client/src/modules/TeamDistribution/components/TeamDistributionCard/CardTitle.test.tsx +++ b/client/src/modules/TeamDistribution/components/TeamDistributionCard/CardTitle.test.tsx @@ -12,18 +12,16 @@ const distribution = { } as TeamDistributionDto; describe('CardTitle', () => { - it('should display distribution name', () => { - render(); - expect(screen.getByText('test name')).toBeInTheDocument(); - }); + it('renders distribution details across registration states', () => { + const { rerender } = render(); - it('should display min score when it is not 0 and registrationStatus not completed or distributed', () => { - render(); + expect(screen.getByText('test name')).toBeInTheDocument(); expect(screen.getByText(`Min score ${distribution.minTotalScore}`)).toBeInTheDocument(); - }); + expect(screen.getByText(`${distribution.strictTeamSize} members`)).toBeInTheDocument(); + expect(screen.getByText(/2023-01-24/i)).toBeInTheDocument(); + expect(screen.getByText(/2023-01-31/i)).toBeInTheDocument(); - it('should not display min score when it is 0', () => { - render( + rerender( { />, ); expect(screen.queryByText('Min score 0')).not.toBeInTheDocument(); - }); - it.each` - registrationStatus - ${TeamDistributionDtoRegistrationStatusEnum.Completed} - ${TeamDistributionDtoRegistrationStatusEnum.Distributed} - `('should not display min score when registrationStatus is $registrationStatus', ({ registrationStatus }) => { - render( + rerender( , ); expect(screen.queryByText(`Min score ${distribution.minTotalScore}`)).not.toBeInTheDocument(); - }); + expect(screen.getByText('without team')).toBeInTheDocument(); - it.each` - registrationStatus | text - ${TeamDistributionDtoRegistrationStatusEnum.Completed} | ${'without team'} - ${TeamDistributionDtoRegistrationStatusEnum.Distributed} | ${'distributed'} - `('should render tag with $text when registrationStatus is $registrationStatus', ({ registrationStatus, text }) => { - render( + rerender( , ); - expect(screen.getByText(text)).toBeInTheDocument(); - }); - - it('should display strict team size', () => { - render(); - expect(screen.getByText(`${distribution.strictTeamSize} members`)).toBeInTheDocument(); - }); - - it('should display distribution period', () => { - render(); - expect(screen.getByText(/2023-01-24/i)).toBeInTheDocument(); - expect(screen.getByText(/2023-01-31/i)).toBeInTheDocument(); + expect(screen.queryByText(`Min score ${distribution.minTotalScore}`)).not.toBeInTheDocument(); + expect(screen.getByText('distributed')).toBeInTheDocument(); }); }); diff --git a/client/src/modules/TeamDistribution/components/TeamDistributionCard/TeamDistributionCard.test.tsx b/client/src/modules/TeamDistribution/components/TeamDistributionCard/TeamDistributionCard.test.tsx index 7822338f1e..14b32f0ae7 100644 --- a/client/src/modules/TeamDistribution/components/TeamDistributionCard/TeamDistributionCard.test.tsx +++ b/client/src/modules/TeamDistribution/components/TeamDistributionCard/TeamDistributionCard.test.tsx @@ -34,55 +34,31 @@ function renderCard(distribution: TeamDistributionDto, isManager = false) { } describe('TeamDistributionCard', () => { - it('should render the distribution name and description', () => { + it('should render distribution details and read-more link without manager controls', () => { renderCard(distribution); expect(screen.getByText(distribution.name)).toBeInTheDocument(); expect(screen.getByText(distribution.description)).toBeInTheDocument(); - }); - - it('should render the distribution period', () => { - renderCard(distribution); - expect(screen.getByText(/2022-01-01/)).toBeInTheDocument(); expect(screen.getByText(/2022-01-31/)).toBeInTheDocument(); - }); - - it('should render the edit and delete buttons for managers', () => { - renderCard(distribution, true); - - expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument(); - }); - - it('should not render the edit and delete buttons for non-managers', () => { - renderCard(distribution); - expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: /read more/i })).toBeInTheDocument(); }); - it('should call the onDelete function when the delete button is clicked', () => { + it('should render manager controls and call their handlers', () => { renderCard(distribution, true); - fireEvent.click(screen.getByRole('button', { name: /delete/i })); + const deleteButton = screen.getByRole('button', { name: /delete/i }); + const editButton = screen.getByRole('button', { name: /edit/i }); + expect(deleteButton).toBeInTheDocument(); + expect(editButton).toBeInTheDocument(); + fireEvent.click(deleteButton); expect(mockOnDelete).toHaveBeenCalledWith(distribution.id); - }); - - it('should call the onEdit function when the edit button is clicked', () => { - renderCard(distribution, true); - - fireEvent.click(screen.getByRole('button', { name: /edit/i })); - + fireEvent.click(editButton); expect(mockOnEdit).toHaveBeenCalledWith(distribution); }); - it('should render read more link when distribution has descriptionUrl', () => { - renderCard(distribution); - - expect(screen.getByRole('link', { name: /read more/i })).toBeInTheDocument(); - }); - it('should not render read more link when distribution has not descriptionUrl', () => { renderCard({ ...distribution, descriptionUrl: '' }); expect(screen.queryByRole('link', { name: /read more/i })).not.toBeInTheDocument(); diff --git a/client/src/modules/TeamDistribution/components/TeamDistributionCard/renderers.test.tsx b/client/src/modules/TeamDistribution/components/TeamDistributionCard/renderers.test.tsx index 0de228759a..3c105e3318 100644 --- a/client/src/modules/TeamDistribution/components/TeamDistributionCard/renderers.test.tsx +++ b/client/src/modules/TeamDistribution/components/TeamDistributionCard/renderers.test.tsx @@ -2,33 +2,33 @@ import { render, screen } from '@testing-library/react'; import { TeamDistributionDtoRegistrationStatusEnum } from '@client/api'; import { RenderMinTotalScore, RenderRegistrationStatus } from './renderers'; +vi.mock('@ant-design/icons/ClockCircleOutlined', () => ({ default: () => null })); +vi.mock('antd', () => ({ + Tag: ({ children }: React.PropsWithChildren) => {children}, + Typography: { Text: ({ children }: React.PropsWithChildren) => {children} }, +})); + describe('RenderRegistrationStatus', () => { - it('renders a green "distributed" tag for the distributed status', () => { - render(); + it('renders each registration status', () => { + const { container, rerender } = render( + , + ); expect(screen.getByText('distributed')).toBeInTheDocument(); - }); - it('renders a "without team" tag for the completed status', () => { - render(); + rerender(); expect(screen.getByText('without team')).toBeInTheDocument(); - }); - it('renders nothing for any other registration status (default branch)', () => { - const { container } = render( - , - ); + rerender(); expect(container).toBeEmptyDOMElement(); }); }); describe('RenderMinTotalScore', () => { - it('renders the min-score label when a score is provided', () => { - render(); + it('renders a provided score and nothing for zero', () => { + const { container, rerender } = render(); expect(screen.getByText('Min score 120')).toBeInTheDocument(); - }); - it('renders nothing when the score is zero', () => { - const { container } = render(); + rerender(); expect(container).toBeEmptyDOMElement(); }); }); diff --git a/client/src/modules/TeamDistribution/components/TeamDistributionModal/TeamDistributionModal.test.tsx b/client/src/modules/TeamDistribution/components/TeamDistributionModal/TeamDistributionModal.test.tsx index 8169bc6f08..21e0374dd3 100644 --- a/client/src/modules/TeamDistribution/components/TeamDistributionModal/TeamDistributionModal.test.tsx +++ b/client/src/modules/TeamDistribution/components/TeamDistributionModal/TeamDistributionModal.test.tsx @@ -1,10 +1,18 @@ import { screen, render, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import dayjs from 'dayjs'; -import { TeamDistributionApi, TeamDistributionDto } from '@client/api'; +import { setupUser } from '@client/__tests__/setupUser'; +import { TeamDistributionDto } from '@client/api'; import TeamDistributionModal from './TeamDistributionModal'; -vi.mock('@client/api'); +const { updateTeamDistribution, createTeamDistribution } = vi.hoisted(() => ({ + updateTeamDistribution: vi.fn(), + createTeamDistribution: vi.fn(), +})); +vi.mock('@client/api', () => ({ + TeamDistributionApi: function TeamDistributionApi() { + return { updateTeamDistribution, createTeamDistribution }; + }, +})); // DatePicker.RangePicker is a brittle widget in jsdom — stub it with a button that // emits a fixed [start, end] dayjs range through the onChange that Form.Item injects. @@ -26,9 +34,6 @@ vi.mock('antd', async () => { return { ...antd, DatePicker }; }); -const updateTeamDistribution = vi.mocked(TeamDistributionApi.prototype.updateTeamDistribution); -const createTeamDistribution = vi.mocked(TeamDistributionApi.prototype.createTeamDistribution); - function renderModal(overrides: Partial[0]> = {}) { const onSubmit = vi.fn().mockResolvedValue(undefined); const onCancel = vi.fn(); @@ -43,11 +48,6 @@ describe('', () => { updateTeamDistribution.mockResolvedValue({} as never); }); - it('renders the create copy when no data is provided', () => { - renderModal(); - expect(screen.getByText(/you are creating a group distribution event/i)).toBeInTheDocument(); - }); - it('renders the edit copy and pre-fills the name when editing', () => { const data = { id: 9, name: 'Existing Event', description: 'desc' } as TeamDistributionDto; renderModal({ data }); @@ -56,15 +56,16 @@ describe('', () => { }); it('calls onCancel when the cancel button is clicked', async () => { - const user = userEvent.setup(); + const user = setupUser(); const { onCancel } = renderModal(); + expect(screen.getByText(/you are creating a group distribution event/i)).toBeInTheDocument(); const dialog = screen.getByRole('dialog'); await user.click(within(dialog).getByRole('button', { name: /cancel/i })); expect(onCancel).toHaveBeenCalled(); }); it('blocks submit and shows validation errors for empty required fields', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderModal(); await user.click(screen.getByRole('button', { name: /^ok$/i })); @@ -75,7 +76,7 @@ describe('', () => { }); it('rejects an invalid description URL', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderModal(); await user.type(screen.getByLabelText('Description Url'), 'not-a-url'); @@ -86,7 +87,7 @@ describe('', () => { }); it('creates a new distribution with the mapped payload when no id is present', async () => { - const user = userEvent.setup(); + const user = setupUser(); const { onSubmit } = renderModal(); await user.type(screen.getByLabelText('Name'), 'New Distribution'); @@ -108,7 +109,7 @@ describe('', () => { }); it('updates an existing distribution when an id is present', async () => { - const user = userEvent.setup(); + const user = setupUser(); const data = { id: 9, name: 'Existing Event', diff --git a/client/src/modules/TeamDistribution/components/TeamDistributionModal/TeamDistributionModal.tsx b/client/src/modules/TeamDistribution/components/TeamDistributionModal/TeamDistributionModal.tsx index cd7ff8d979..5eeff498c7 100644 --- a/client/src/modules/TeamDistribution/components/TeamDistributionModal/TeamDistributionModal.tsx +++ b/client/src/modules/TeamDistribution/components/TeamDistributionModal/TeamDistributionModal.tsx @@ -33,7 +33,7 @@ const { Option } = Select; const teamDistributionApi = new TeamDistributionApi(); -function getInitialValues(data: TeamDistributionDto) { +function getInitialValues(data: Partial = {}) { const timeZone = 'UTC'; return { ...data, @@ -102,7 +102,7 @@ export default function TeamDistributionModal({ data, onCancel, courseId, onSubm form.resetFields(); }} > - + You are {data ? 'editing' : 'creating'} a group distribution event. Fill out the form to add it to the schedule. @@ -110,7 +110,7 @@ export default function TeamDistributionModal({ data, onCancel, courseId, onSubm - + + , ); @@ -102,11 +88,11 @@ describe('ModalSubmitForm', () => { // A required field left empty makes validateFields() reject; the `.catch(() => null)` // returns null and the `if (values == null) return` guard short-circuits submit. const submit = vi.fn(); - const user = userEvent.setup(); + const user = setupUser(); render( - + , ); @@ -119,7 +105,7 @@ describe('ModalSubmitForm', () => { it('resets the form and calls close when the Cancel button is clicked', async () => { const close = vi.fn(); - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole('button', { name: /Cancel/ })); @@ -130,11 +116,11 @@ describe('ModalSubmitForm', () => { it('forwards form value changes to onChange', async () => { // onValuesChange -> onChange?.(form.getFieldsValue()) const onChange = vi.fn(); - const user = userEvent.setup(); + const user = setupUser(); render( - + , ); @@ -150,7 +136,7 @@ describe('ModalSubmitForm', () => { render( - + , ); @@ -164,7 +150,7 @@ describe('ModalSubmitForm', () => { render( - + , ); diff --git a/client/src/shared/components/Forms/PreparedComment.test.tsx b/client/src/shared/components/Forms/PreparedComment.test.tsx index 27d53c1450..5fe0c26e7f 100644 --- a/client/src/shared/components/Forms/PreparedComment.test.tsx +++ b/client/src/shared/components/Forms/PreparedComment.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import PreparedComment, { markdownLabel } from './PreparedComment'; // react-markdown is an ESM/heavy renderer; stub it to a passthrough so we can @@ -7,38 +7,41 @@ vi.mock('react-markdown', () => ({ default: ({ children }: { children: string }) =>
    {children}
    , })); vi.mock('remark-gfm', () => ({ default: () => {} })); +vi.mock('antd', () => ({ + Typography: { Text: ({ children }: React.PropsWithChildren) =>
    {children}
    }, +})); describe('PreparedComment', () => { - it('renders plain text split into lines when there is no markdown marker', () => { - render(); - - expect(screen.getByText('line one')).toBeInTheDocument(); - expect(screen.getByText('line two')).toBeInTheDocument(); - expect(screen.queryByTestId('markdown')).not.toBeInTheDocument(); - }); - - it('renders the markdown branch (without the marker) when text starts with the markdown label', () => { - render(); - - const md = screen.getByTestId('markdown'); + it('renders plain, markdown, empty, and undefined initial text', () => { + render( + <> +
    + +
    +
    + +
    +
    + +
    +
    + +
    + , + ); + + const plain = within(screen.getByTestId('plain')); + expect(plain.getByText('line one')).toBeInTheDocument(); + expect(plain.getByText('line two')).toBeInTheDocument(); + expect(plain.queryByTestId('markdown')).not.toBeInTheDocument(); + + const marked = within(screen.getByTestId('marked')); + const md = marked.getByTestId('markdown'); expect(md).toBeInTheDocument(); expect(md).toHaveTextContent('# Heading'); expect(md).not.toHaveTextContent(markdownLabel.trim()); - }); - - it('handles empty text gracefully', () => { - const { container } = render(); - - expect(container).toBeInTheDocument(); - expect(screen.queryByTestId('markdown')).not.toBeInTheDocument(); - }); - - it('falls back to an empty string when text is null/undefined', () => { - // Exercises the `useState(text ?? '')` nullish fallback and the falsy guard - // in the effect (`text && ...`) — no markdown branch, no crash. - const { container } = render(); - expect(container).toBeInTheDocument(); - expect(screen.queryByTestId('markdown')).not.toBeInTheDocument(); + expect(within(screen.getByTestId('empty')).queryByTestId('markdown')).not.toBeInTheDocument(); + expect(within(screen.getByTestId('undefined')).queryByTestId('markdown')).not.toBeInTheDocument(); }); }); diff --git a/client/src/shared/components/Forms/ScoreInput.test.tsx b/client/src/shared/components/Forms/ScoreInput.test.tsx index 61696a9332..ac940eca07 100644 --- a/client/src/shared/components/Forms/ScoreInput.test.tsx +++ b/client/src/shared/components/Forms/ScoreInput.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { Button, Form } from 'antd'; import { ScoreInput } from './ScoreInput'; @@ -17,19 +17,6 @@ function renderScoreInput(props: Parameters[0] = {}, onFinish } describe('ScoreInput', () => { - it('renders a spinbutton with a default "Score" label when no max is provided', () => { - renderScoreInput(); - - expect(screen.getByRole('spinbutton')).toBeInTheDocument(); - expect(screen.getByLabelText('Score')).toBeInTheDocument(); - }); - - it('derives the max-points label from the maxScore prop', () => { - renderScoreInput({ maxScore: 80 }); - - expect(screen.getByLabelText('Score (Max 80 points)')).toBeInTheDocument(); - }); - it('falls back to the courseTask.maxScore when maxScore prop is absent', () => { renderScoreInput({ courseTask: { id: 1, maxScore: 45 } }); @@ -49,10 +36,10 @@ describe('ScoreInput', () => { }); it('lets the user type a score and submits it', async () => { - const user = userEvent.setup(); - const { onFinish } = renderScoreInput({ maxScore: 100 }); + const user = setupUser(); + const { onFinish } = renderScoreInput({ maxScore: 80 }); - const input = screen.getByRole('spinbutton'); + const input = screen.getByLabelText('Score (Max 80 points)'); await user.type(input, '42'); await user.click(screen.getByRole('button', { name: /submit/i })); @@ -60,9 +47,11 @@ describe('ScoreInput', () => { }); it('shows a required-error and blocks submit when left empty', async () => { - const user = userEvent.setup(); - const { onFinish } = renderScoreInput({ maxScore: 100 }); + const user = setupUser(); + const { onFinish } = renderScoreInput(); + expect(screen.getByRole('spinbutton')).toBeInTheDocument(); + expect(screen.getByLabelText('Score')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: /submit/i })); expect(await screen.findByText('Please enter score')).toBeInTheDocument(); @@ -70,7 +59,7 @@ describe('ScoreInput', () => { }); it('clamps a value typed above the configured max on blur', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderScoreInput({ maxScore: 50 }); const input = screen.getByRole('spinbutton'); @@ -81,7 +70,7 @@ describe('ScoreInput', () => { }); it('clamps a negative value to the configured minimum of 0 on blur', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderScoreInput({ maxScore: 50 }); const input = screen.getByRole('spinbutton'); diff --git a/client/src/shared/components/Forms/__tests__/CourseTaskSelect.test.tsx b/client/src/shared/components/Forms/__tests__/CourseTaskSelect.test.tsx index 069bef353a..43cfe0d2b2 100644 --- a/client/src/shared/components/Forms/__tests__/CourseTaskSelect.test.tsx +++ b/client/src/shared/components/Forms/__tests__/CourseTaskSelect.test.tsx @@ -341,15 +341,20 @@ describe('CourseTaskSelect', () => { // Passing onChange exercises the `onChange ? { onChange } : {}` true branch and // wires it onto the Select so picking an option forwards the task id. const onChange = vi.fn(); + const data = [ + { ...(ActiveCodewarsData[0] as CourseTaskDto), id: 9001, name: 'No End Date', studentEndDate: '' }, + ...ActiveCodewarsData, + ]; render( - + , ); fireEvent.mouseDown(screen.getByRole('combobox')); // antd wires the select handler on the `.ant-select-item-option` wrapper. + expect(await screen.findByText('No End Date')).toBeInTheDocument(); const option = await screen.findByText('Codewars Algorithms-2'); const optionWrapper = option.closest('.ant-select-item-option'); fireEvent.click(optionWrapper as Element); @@ -358,24 +363,4 @@ describe('CourseTaskSelect', () => { await waitFor(() => expect(onChange).toHaveBeenCalled()); expect(onChange.mock.calls[0][0]).toBe(451); }); - - it('keeps tasks with a missing sort date stable (sort comparator fallback)', async () => { - // A task with no studentEndDate makes the `firstDate && secondDate` guard in - // sortTasks false, hitting the `return 1` fallback path (runs during render). - const data = [ - { ...(ActiveCodewarsData[0] as CourseTaskDto), id: 9001, name: 'No End Date', studentEndDate: '' }, - { ...(ActiveCodewarsData[1] as CourseTaskDto), id: 9002, name: 'Has End Date' }, - ]; - render( -
    - - , - ); - - // Opening the dropdown reveals both options (sort kept them stable, no crash). - fireEvent.mouseDown(screen.getByRole('combobox')); - - expect(await screen.findByText('No End Date')).toBeInTheDocument(); - expect(screen.getByText('Has End Date')).toBeInTheDocument(); - }); }); diff --git a/client/src/shared/components/Forms/useGoogleMapsPlaces.test.ts b/client/src/shared/components/Forms/useGoogleMapsPlaces.test.ts index 75a9b1f845..8cf5272b97 100644 --- a/client/src/shared/components/Forms/useGoogleMapsPlaces.test.ts +++ b/client/src/shared/components/Forms/useGoogleMapsPlaces.test.ts @@ -53,11 +53,12 @@ describe('useGoogleMapsPlaces', () => { expect(result.current.loading).toBe(false); expect(result.current.initialized).toBe(false); expect(result.current.error).toBe(null); + expect(usePlacesAutocompleteModule.default).toHaveBeenCalledWith(expect.objectContaining({ defaultValue: '' })); + expect(useInterval).toHaveBeenCalledWith(expect.any(Function), 100); }); describe('location formatting', () => { it.each([ - { location: null, expected: '' }, { location: { cityName: 'Minsk', countryName: 'Belarus' } as Location, expected: 'Minsk, Belarus' }, { location: { cityName: 'São Paulo', countryName: 'Brazil' } as Location, expected: 'São Paulo, Brazil' }, ])('uses "$expected" as the autocomplete default value', ({ location, expected }) => { @@ -141,10 +142,4 @@ describe('useGoogleMapsPlaces', () => { expect(mockSetValue).toHaveBeenCalledWith('New York'); }); }); - - it('uses 100ms polling interval', () => { - renderUseGoogleMapsPlaces(); - - expect(useInterval).toHaveBeenCalledWith(expect.any(Function), 100); - }); }); diff --git a/client/src/shared/components/GithubAvatar.test.tsx b/client/src/shared/components/GithubAvatar.test.tsx index 1aeed0e106..9f961bdc63 100644 --- a/client/src/shared/components/GithubAvatar.test.tsx +++ b/client/src/shared/components/GithubAvatar.test.tsx @@ -4,28 +4,22 @@ import { GithubAvatar } from './GithubAvatar'; import { CDN_AVATARS_URL } from '@client/configs/cdn'; describe('GithubAvatar', () => { - it('renders an avatar image for a real githubId', () => { - render(); + it('renders real, missing, masked, and styled avatar states', () => { + const { container, rerender } = render(); const img = screen.getByRole('img'); expect(img).toHaveAttribute('src', `${CDN_AVATARS_URL}/octocat.png?size=96`); - }); - it('renders an empty avatar (no image) when githubId is missing', () => { - const { container } = render(); + rerender(); expect(screen.queryByRole('img')).not.toBeInTheDocument(); expect(container.querySelector('.ant-avatar')).toBeInTheDocument(); - }); - it('renders an empty avatar for gdpr-masked githubIds', () => { - render(); + rerender(); expect(screen.queryByRole('img')).not.toBeInTheDocument(); - }); - it('forwards inline styles to the avatar', () => { - const { container } = render(); + rerender(); expect(container.querySelector('.ant-avatar')).toHaveStyle({ opacity: '0.5' }); }); diff --git a/client/src/shared/components/GithubUserLink.test.tsx b/client/src/shared/components/GithubUserLink.test.tsx index 86d4a721c0..c729d19a78 100644 --- a/client/src/shared/components/GithubUserLink.test.tsx +++ b/client/src/shared/components/GithubUserLink.test.tsx @@ -1,6 +1,5 @@ /* eslint-disable testing-library/no-container, testing-library/no-node-access */ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { fireEvent, render, screen } from '@testing-library/react'; import { GithubUserLink } from './GithubUserLink'; const copyToClipboard = vi.fn(); @@ -14,49 +13,29 @@ describe('GithubUserLink', () => { copyToClipboard.mockClear(); }); - it('links to the RS App profile page and the GitHub profile page', () => { - render(); + it('renders links, labels, avatar, copy behavior, and hidden variants', () => { + const { container, rerender } = render(); const profileLink = screen.getByTitle('Open Rolling Scopes App profile page'); expect(profileLink).toHaveAttribute('href', '/profile?githubId=octocat'); const githubLink = screen.getByTitle('Open GitHub profile page'); expect(githubLink).toHaveAttribute('href', 'https://github.com/octocat'); - }); - - it('shows the githubId as the link text by default', () => { - render(); expect(screen.getByText('octocat')).toBeInTheDocument(); - }); + expect(container.querySelector('.ant-avatar')).toBeInTheDocument(); + fireEvent.click(screen.getByTitle('Copy GitHub name to clipboard')); + expect(copyToClipboard).toHaveBeenCalledWith('octocat'); - it('shows the full name instead of the githubId when provided', () => { - render(); + rerender(); expect(screen.getByText('Octo Cat')).toBeInTheDocument(); expect(screen.queryByText('octocat')).not.toBeInTheDocument(); - }); - - it('renders the avatar by default and hides it when isUserIconHidden is set', () => { - const { rerender, container } = render(); - expect(container.querySelector('.ant-avatar')).toBeInTheDocument(); rerender(); expect(container.querySelector('.ant-avatar')).not.toBeInTheDocument(); - }); - - it('copies the github name to the clipboard when the copy icon is clicked', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByTitle('Copy GitHub name to clipboard')); - - expect(copyToClipboard).toHaveBeenCalledWith('octocat'); - }); - - it('omits the copy control when copyable is false', () => { - render(); + rerender(); expect(screen.queryByTitle('Copy GitHub name to clipboard')).not.toBeInTheDocument(); }); }); diff --git a/client/src/shared/components/Header.test.tsx b/client/src/shared/components/Header.test.tsx index 2b7c8f8a22..ca68166f46 100644 --- a/client/src/shared/components/Header.test.tsx +++ b/client/src/shared/components/Header.test.tsx @@ -1,6 +1,5 @@ -/* eslint-disable testing-library/no-container, testing-library/no-node-access */ -import { render, screen, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { act, render, screen, within } from '@testing-library/react'; +import { setupUser } from '@client/__tests__/setupUser'; import { useRouter } from 'next/router'; import { Header } from './Header'; import { SessionContext } from '@client/modules/Course/contexts'; @@ -32,36 +31,47 @@ vi.mock('./SolidarityUkraine', () => ({ SolidarityUkraine: () =>
    , })); +async function renderHeader(ui: React.ReactElement) { + let view: ReturnType; + // eslint-disable-next-line testing-library/no-unnecessary-act -- Await mount effects after the synchronous render + await act(async () => { + view = render(ui); + }); + return view!; +} + describe('Header', () => { beforeEach(() => { useActiveCourseContextMock.mockReturnValue({ course: { id: 1, name: 'JS Course' } }); vi.mocked(useRouter).mockReturnValue({ asPath: '/' } as ReturnType); }); - it('renders the logo, theme switch and navigation links', () => { - render(
    ); + it('renders the logo, theme switch and horizontal navigation links', async () => { + await renderHeader(
    ); expect(screen.getByAltText('Rolling Scopes School Logo')).toBeInTheDocument(); expect(screen.getByTestId('theme-switch')).toBeInTheDocument(); expect(screen.getByText('Schedule')).toBeInTheDocument(); + // The menu is rendered by the same header instance and uses the same navigation item. + // eslint-disable-next-line testing-library/no-node-access + const menu = document.querySelector('.ant-menu-horizontal'); + expect(menu).toBeInTheDocument(); + expect(within(menu as HTMLElement).getByText('Schedule')).toBeInTheDocument(); }); - it('renders the title and the course name when showCourseName is set', () => { - render(
    ); + it('shows and hides the course name with the showCourseName prop', async () => { + const { rerender } = await renderHeader(
    ); expect(screen.getByText(/Dashboard/)).toBeInTheDocument(); expect(screen.getByText(/JS Course/)).toBeInTheDocument(); - }); - - it('does not show the course name when showCourseName is not set', () => { - render(
    ); + rerender(
    ); expect(screen.queryByText(/JS Course/)).not.toBeInTheDocument(); }); it('renders the avatar dropdown and opens the profile menu on click', async () => { - const user = userEvent.setup(); - render(
    ); + const user = setupUser(); + await renderHeader(
    ); const avatarButton = screen.getByRole('button'); await user.click(avatarButton); @@ -70,8 +80,8 @@ describe('Header', () => { expect(screen.getByRole('link', { name: /logout/i })).toBeInTheDocument(); }); - it('hides the avatar dropdown when there is no logged-in session', () => { - render( + it('hides the avatar dropdown when there is no logged-in session', async () => { + await renderHeader(
    , @@ -81,28 +91,20 @@ describe('Header', () => { expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); - it('shows the carousel by default and hides it when showCarousel is false', () => { - const { rerender } = render(
    ); + it('shows the carousel by default and hides it when showCarousel is false', async () => { + const { rerender } = await renderHeader(
    ); expect(screen.getByTestId('carousel')).toBeInTheDocument(); rerender(
    ); expect(screen.queryByTestId('carousel')).not.toBeInTheDocument(); }); - it('renders the horizontal course navigation menu', () => { - const { container } = render(
    ); - - const menu = container.querySelector('.ant-menu-horizontal'); - expect(menu).toBeInTheDocument(); - expect(within(menu as HTMLElement).getByText('Schedule')).toBeInTheDocument(); - }); - - it('does not pass the course to navigation links when the course id is empty', () => { + it('does not pass the course to navigation links when the course id is empty', async () => { // course.id === 0 -> courseNotEmpty is null (the `course.id ? course : null` and // `courseNotEmpty ?? null` falsy branches). useActiveCourseContextMock.mockReturnValue({ course: { id: 0, name: '' } }); - render(
    ); + await renderHeader(
    ); // No course name rendered, header still mounts. expect(screen.getByText(/Dashboard/)).toBeInTheDocument(); @@ -112,11 +114,12 @@ describe('Header', () => { // asPath === '/profile' makes `isActive` true for the Profile entry, hitting the // active-class branch in the dropdown menu items. vi.mocked(useRouter).mockReturnValue({ asPath: '/profile' } as ReturnType); - const user = userEvent.setup(); - render(
    ); + const user = setupUser(); + await renderHeader(
    ); await user.click(screen.getByRole('button')); - expect(await screen.findByRole('link', { name: /profile/i })).toBeInTheDocument(); + const profile = await screen.findByRole('link', { name: /profile/i }); + expect(profile.className).toMatch(/menuItemActive/); }); }); diff --git a/client/src/shared/components/List/List.test.tsx b/client/src/shared/components/List/List.test.tsx index 2d4656603c..f7c3f8801e 100644 --- a/client/src/shared/components/List/List.test.tsx +++ b/client/src/shared/components/List/List.test.tsx @@ -2,64 +2,40 @@ import { render, screen } from '@testing-library/react'; import { List } from './index'; describe('List', () => { - it('renders Empty state when dataSource is an empty array', () => { - render( {String(item)}} />); + it('renders empty, populated, header, render callback, and row-key variants', () => { + const { rerender } = render( {String(item)}} />); expect(screen.getByText('No data', { selector: 'div' })).toBeInTheDocument(); - }); - it('renders items when dataSource has elements', () => { const data = ['Rumor', 'Tumor', 'Functio laesa']; + const renderItem = vi.fn((item: string, index: number) => {`${index}:${item}`}); - render( {item}} />); - - expect(screen.getByText('Rumor')).toBeInTheDocument(); - expect(screen.getByText('Tumor')).toBeInTheDocument(); - expect(screen.getByText('Functio laesa')).toBeInTheDocument(); - }); - - it('assigns data-testid to each list item based on index', () => { - const data = ['A', 'B']; - - render( {item}} />); + rerender(); + expect(screen.getByText('0:Rumor')).toBeInTheDocument(); + expect(screen.getByText('1:Tumor')).toBeInTheDocument(); + expect(screen.getByText('2:Functio laesa')).toBeInTheDocument(); expect(screen.getByTestId('list-item-0')).toBeInTheDocument(); expect(screen.getByTestId('list-item-1')).toBeInTheDocument(); - }); - - it('renders header when provided', () => { - render( {item}} header={My Header} />); - - expect(screen.getByText('My Header')).toBeInTheDocument(); - }); - - it('does not render header section when not provided', () => { - render( {item}} />); - expect(screen.queryByText('My Header')).not.toBeInTheDocument(); - }); - - it('calls renderItem with item and index', () => { - const renderItem = vi.fn((item: string, index: number) => {`${index}:${item}`}); - - render(); + expect(renderItem).toHaveBeenCalledWith('Rumor', 0); - expect(renderItem).toHaveBeenCalledWith('X', 0); - }); - - it('uses function rowKey to generate keys', () => { - const data = [{ id: 10, label: 'Foo' }]; const rowKey = (item: { id: number }) => String(item.id); - - render( {item.label}} rowKey={rowKey} />); + rerender( + {item.label}} + rowKey={rowKey} + header={My Header} + />, + ); expect(screen.getByText('Foo')).toBeInTheDocument(); - }); - - it('uses string rowKey to generate keys', () => { - const data = [{ id: 10, label: 'Bar' }]; + expect(screen.getByText('My Header')).toBeInTheDocument(); - render( {item.label}} rowKey="id" />); + rerender( + {item.label}} rowKey="id" />, + ); expect(screen.getByText('Bar')).toBeInTheDocument(); }); diff --git a/client/src/shared/components/List/ListItem.test.tsx b/client/src/shared/components/List/ListItem.test.tsx index 2744dcc9d6..c11a2cd68e 100644 --- a/client/src/shared/components/List/ListItem.test.tsx +++ b/client/src/shared/components/List/ListItem.test.tsx @@ -1,21 +1,26 @@ import { render, screen } from '@testing-library/react'; import { ListItem } from './ListItem'; +vi.mock('antd', () => ({ + Flex: ({ + children, + align: _align, + wrap: _wrap, + ...props + }: React.HTMLAttributes & { align?: string; wrap?: boolean }) =>
    {children}
    , +})); + describe('ListItem', () => { - it('renders children', () => { - render(List item content); + it('renders children and forwards class and style props', () => { + const { rerender } = render(List item content); expect(screen.getByText('List item content')).toBeInTheDocument(); - }); - it('applies custom className', () => { - render(content); + rerender(content); expect(screen.getByText('content')).toHaveClass('custom-class'); - }); - it('applies custom style', () => { - render(content); + rerender(content); expect(screen.getByText('content')).toHaveStyle({ opacity: '0.5' }); }); diff --git a/client/src/shared/components/List/ListItemMeta.test.tsx b/client/src/shared/components/List/ListItemMeta.test.tsx index fc00634aef..30cb577c7e 100644 --- a/client/src/shared/components/List/ListItemMeta.test.tsx +++ b/client/src/shared/components/List/ListItemMeta.test.tsx @@ -2,34 +2,26 @@ import { render, screen } from '@testing-library/react'; import { ListItemMeta } from './ListItemMeta'; describe('ListItemMeta', () => { - it('renders title when provided', () => { - render(); + it('renders each optional part and all parts together', () => { + const { rerender } = render(); expect(screen.getByText('Item Title')).toBeInTheDocument(); - }); - it('renders description when provided', () => { - render(); + rerender(); expect(screen.getByText('Item description')).toBeInTheDocument(); - }); - it('renders avatar when provided', () => { - render(} />); + rerender(} />); expect(screen.getByAltText('avatar')).toBeInTheDocument(); - }); - it('renders all parts together', () => { - render(} title="Title" description="Description" />); + rerender(} title="Title" description="Description" />); expect(screen.getByAltText('avatar')).toBeInTheDocument(); expect(screen.getByText('Title')).toBeInTheDocument(); expect(screen.getByText('Description')).toBeInTheDocument(); - }); - it('does not render avatar section when avatar is not provided', () => { - render(); + rerender(); expect(screen.queryByRole('img')).not.toBeInTheDocument(); }); diff --git a/client/src/shared/components/LoadingScreen.test.tsx b/client/src/shared/components/LoadingScreen.test.tsx index d8a1e4ed14..fa5001cbce 100644 --- a/client/src/shared/components/LoadingScreen.test.tsx +++ b/client/src/shared/components/LoadingScreen.test.tsx @@ -1,9 +1,14 @@ import { render, screen } from '@testing-library/react'; import { LoadingScreen } from './LoadingScreen'; +vi.mock('antd', () => ({ + theme: { useToken: () => ({ token: { colorBgContainer: '#fff' } }) }, + Spin: ({ description }: { description: React.ReactNode }) =>
    {description}
    , +})); + describe('LoadingScreen', () => { - it('renders children directly when show is false', () => { - render( + it('switches between page content and the loading overlay', () => { + const { rerender } = render(
    Page content
    , @@ -11,10 +16,8 @@ describe('LoadingScreen', () => { expect(screen.getByText('Page content')).toBeInTheDocument(); expect(screen.queryByTestId('loading-screen')).not.toBeInTheDocument(); - }); - it('renders the loading overlay when show is true', () => { - render( + rerender(
    Page content
    , diff --git a/client/src/shared/components/MentorSearch.test.tsx b/client/src/shared/components/MentorSearch.test.tsx index d913a7e6a7..ef77bef6c0 100644 --- a/client/src/shared/components/MentorSearch.test.tsx +++ b/client/src/shared/components/MentorSearch.test.tsx @@ -1,5 +1,5 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { setupUser } from '@client/__tests__/setupUser'; import { MentorSearch } from './MentorSearch'; // MentorSearch instantiates CourseMentorsApi at module load, so the mocked class @@ -21,18 +21,13 @@ describe('MentorSearch', () => { }); }); - it('renders a combobox', () => { - render(); - - expect(screen.getByRole('combobox')).toBeInTheDocument(); - }); - it('searches mentors for the given course and renders the results', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); const combobox = screen.getByRole('combobox'); - combobox.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); + expect(combobox).toBeInTheDocument(); + fireEvent.mouseDown(combobox); await user.type(combobox, 'men'); await waitFor(() => expect(searchMentors).toHaveBeenCalledWith(42, 'men')); diff --git a/client/src/shared/components/PageLayout.test.tsx b/client/src/shared/components/PageLayout.test.tsx index 1b9182a9bd..1f9272c235 100644 --- a/client/src/shared/components/PageLayout.test.tsx +++ b/client/src/shared/components/PageLayout.test.tsx @@ -12,89 +12,67 @@ vi.mock('./Sider/AdminSider', () => ({ })); describe('PageLayout', () => { - it('renders the header and children when there is no error', () => { - render( + it('renders content, error, loading, and custom layout states', () => { + const { container, rerender } = render(
    Body content
    , ); - expect(screen.getByTestId('header')).toHaveTextContent('Dashboard'); expect(screen.getByText('Body content')).toBeInTheDocument(); - }); - it('renders a 500 result with a back-home link when an error is provided', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - render( + rerender(
    Body content
    , ); - expect(screen.getByText('Sorry, something went wrong.')).toBeInTheDocument(); expect(screen.getByRole('link', { name: /back home/i })).toHaveAttribute('href', '/'); expect(screen.queryByText('Body content')).not.toBeInTheDocument(); errorSpy.mockRestore(); - }); - it('shows a spinner while loading', () => { - const { container } = render( + rerender(
    Body content
    , ); - expect(container.querySelector('.ant-spin-spinning')).toBeInTheDocument(); - }); - it('applies the provided background and removes the content margin when withMargin is false', () => { - // background prop -> `props.background ? props.background : token...` true branch; - // withMargin={false} -> `withMargin ? { margin: 16 } : undefined` false branch. - const { container } = render( + rerender(
    Body content
    , ); - - const layout = container.querySelector('.ant-layout') as HTMLElement; - expect(layout).toHaveStyle({ background: 'rgb(255, 0, 0)' }); + expect(container.querySelector('.ant-layout')).toHaveStyle({ background: 'rgb(255, 0, 0)' }); expect(screen.getByText('Body content')).toBeInTheDocument(); }); }); describe('PageLayoutSimple', () => { - it('renders children inside the responsive grid when there is data', () => { - render( + it('renders data, no-data, and custom-background states', () => { + const { container, rerender } = render(
    Simple body
    , ); - expect(screen.getByText('Simple body')).toBeInTheDocument(); expect(screen.queryByText('no data')).not.toBeInTheDocument(); - }); - it('renders a "no data" message when noData is set', () => { - render( + rerender(
    Simple body
    , ); - expect(screen.getByText('no data')).toBeInTheDocument(); expect(screen.queryByText('Simple body')).not.toBeInTheDocument(); - }); - it('applies the provided background', () => { - // background prop -> the true branch of `props.background ? ... : token...`. - const { container } = render( + rerender(
    Simple body
    , ); - - const layout = container.querySelector('.ant-layout') as HTMLElement; - expect(layout).toHaveStyle({ background: 'rgb(0, 0, 255)' }); + expect(container.querySelector('.ant-layout')).toHaveStyle({ background: 'rgb(0, 0, 255)' }); }); }); @@ -105,7 +83,6 @@ describe('AdminPageLayout', () => {
    Admin body
    , ); - expect(screen.getByTestId('header')).toHaveTextContent('Admin'); expect(screen.getByTestId('admin-sider')).toBeInTheDocument(); expect(screen.getByText('Admin body')).toBeInTheDocument(); diff --git a/client/src/shared/components/PersonSelect.test.tsx b/client/src/shared/components/PersonSelect.test.tsx index 65c48936a9..4f187b43fb 100644 --- a/client/src/shared/components/PersonSelect.test.tsx +++ b/client/src/shared/components/PersonSelect.test.tsx @@ -1,5 +1,5 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { setupUser } from '@client/__tests__/setupUser'; import { PersonSelect } from './PersonSelect'; const DATA = [ @@ -9,31 +9,18 @@ const DATA = [ function openSelect() { const combobox = screen.getByRole('combobox'); - combobox.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); + fireEvent.mouseDown(combobox); return combobox; } describe('PersonSelect', () => { - it('renders a searchable combobox with a placeholder', () => { - render(); + it('renders, preselects, and selects people by id', async () => { + const user = setupUser(); + const onChange = vi.fn(); + render(); expect(screen.getByRole('combobox')).toBeInTheDocument(); - }); - - it('renders an option per person keyed by id by default', async () => { - render(); - - openSelect(); - - expect(await screen.findByText(/Alice A/)).toBeInTheDocument(); expect(screen.getByText(/Bob B/)).toBeInTheDocument(); - }); - - it('selects a person by id and calls onChange', async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - render(); - openSelect(); await user.click(await screen.findByText(/Alice A/)); @@ -42,7 +29,7 @@ describe('PersonSelect', () => { }); it('keys options by githubId when keyField is githubId', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onChange = vi.fn(); render(); @@ -51,11 +38,4 @@ describe('PersonSelect', () => { expect(onChange.mock.calls[0][0]).toBe('bob'); }); - - it('preselects the provided default value', () => { - render(); - - // antd renders the selected option's content in the selector - expect(screen.getByText(/Bob B/)).toBeInTheDocument(); - }); }); diff --git a/client/src/shared/components/Rating.test.tsx b/client/src/shared/components/Rating.test.tsx index 166dac534e..5745203bf0 100644 --- a/client/src/shared/components/Rating.test.tsx +++ b/client/src/shared/components/Rating.test.tsx @@ -3,32 +3,22 @@ import { render, screen } from '@testing-library/react'; import { Rating } from './Rating'; describe('Rating', () => { - it('renders the numeric rating with two decimals when no tooltips are provided', () => { - render(); + it('renders the rate widget and numeric and tooltip labels', () => { + const { container, rerender } = render(); expect(screen.getByText('3.46')).toBeInTheDocument(); - }); + expect(container.querySelector('.ant-rate')).toBeInTheDocument(); - it('renders the matching tooltip label instead of a number when tooltips are provided', () => { const tooltips = ['Terrible', 'Bad', 'Ok', 'Good', 'Great']; - render(); + rerender(); // Math.round(4) - 1 === index 3 -> "Good" expect(screen.getByText('Good')).toBeInTheDocument(); expect(screen.queryByText('4.00')).not.toBeInTheDocument(); - }); - it('rounds the rating to the nearest tooltip index', () => { - const tooltips = ['One', 'Two', 'Three']; - render(); + rerender(); // Math.round(2.4) - 1 === index 1 -> "Two" expect(screen.getByText('Two')).toBeInTheDocument(); }); - - it('renders the antd rate widget', () => { - const { container } = render(); - - expect(container.querySelector('.ant-rate')).toBeInTheDocument(); - }); }); diff --git a/client/src/shared/components/ScoreCard.test.tsx b/client/src/shared/components/ScoreCard.test.tsx index 054661f802..976ba49bd7 100644 --- a/client/src/shared/components/ScoreCard.test.tsx +++ b/client/src/shared/components/ScoreCard.test.tsx @@ -1,47 +1,30 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { fireEvent, render, screen } from '@testing-library/react'; import { ScoreCard } from './ScoreCard'; import styles from './ScoreCard.module.css'; -describe('ScoreCard', () => { - it('renders its value', () => { - render(); - - expect(screen.getByText('7')).toBeInTheDocument(); - }); +vi.mock('antd', () => ({ theme: { useToken: () => ({ token: {} }) } })); - it('calls onSelect with the value when clicked', async () => { - const user = userEvent.setup(); +describe('ScoreCard', () => { + it('renders, selects, and applies each score class', () => { const onSelect = vi.fn(); - render(); - - await user.click(screen.getByText('5')); + const { rerender } = render(); - expect(onSelect).toHaveBeenCalledWith(5); - }); - - it('does not apply colour classes when not selected', () => { - render(); - - const card = screen.getByText('3'); + expect(screen.getByText('7')).toBeInTheDocument(); + fireEvent.click(screen.getByText('7')); + expect(onSelect).toHaveBeenCalledWith(7); + const card = screen.getByText('7'); expect(card.className).not.toContain(styles.selectedRed); expect(card.className).not.toContain(styles.selected); - }); - it('applies the red class for a selected low score (<=4)', () => { - render(); + rerender(); expect(screen.getByText('4').className).toContain(styles.selectedRed); - }); - it('applies the yellow class for a selected mid score (<=7)', () => { - render(); + rerender(); expect(screen.getByText('6').className).toContain(styles.selectedYellow); - }); - it('applies the green class for a selected high score (>7)', () => { - render(); + rerender(); expect(screen.getByText('9').className).toContain(styles.selectedGreen); }); diff --git a/client/src/shared/components/ScoreSelector.test.tsx b/client/src/shared/components/ScoreSelector.test.tsx index 1629a460d7..b18676d796 100644 --- a/client/src/shared/components/ScoreSelector.test.tsx +++ b/client/src/shared/components/ScoreSelector.test.tsx @@ -1,46 +1,27 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { fireEvent, render, screen } from '@testing-library/react'; import { ScoreSelector } from './ScoreSelector'; describe('ScoreSelector', () => { - it('renders ten score cards (1..10)', () => { - render(); + it('renders all scores, handles selection, and displays the selected value', () => { + const onChange = vi.fn(); + const { rerender } = render(); for (let i = 1; i <= 10; i++) { expect(screen.getByText(String(i))).toBeInTheDocument(); } - }); - - it('calls onChange with the clicked score', async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - render(); - - await user.click(screen.getByText('8')); + fireEvent.click(screen.getByText('8')); expect(onChange).toHaveBeenCalledWith(8); - }); - - it('does not throw when clicking without an onChange handler', async () => { - const user = userEvent.setup(); - render(); - await user.click(screen.getByText('3')); - // No assertion error means the optional-chained onChange call was safe. + rerender(); + fireEvent.click(screen.getByText('3')); expect(screen.getByText('3')).toBeInTheDocument(); - }); - it('shows the selected value in the sloth badge when a value is set', () => { - render(); + rerender(); - // "6" appears twice: the card and the sloth badge. expect(screen.getAllByText('6')).toHaveLength(2); - }); - - it('does not render the sloth badge when no value is set', () => { - render(); - // Each digit 1..10 appears exactly once (no duplicate from a badge). + rerender(); expect(screen.getAllByText('5')).toHaveLength(1); }); }); diff --git a/client/src/shared/components/Sider/AdminSider.test.tsx b/client/src/shared/components/Sider/AdminSider.test.tsx index 7a769c8f07..5e212c650c 100644 --- a/client/src/shared/components/Sider/AdminSider.test.tsx +++ b/client/src/shared/components/Sider/AdminSider.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { AdminSider } from './AdminSider'; import { SessionContext } from '@client/modules/Course/contexts'; import { Course } from '@client/services/models'; @@ -89,33 +89,13 @@ describe('AdminSider', () => { ); }; - it('renders correctly with default props', () => { + it('renders sections and navigates through their items', () => { renderComponent(); expect(screen.getByTestId('admin-sider')).toBeInTheDocument(); expect(screen.getByText('Admin Area')).toBeInTheDocument(); expect(screen.getByText('Course Management')).toBeInTheDocument(); - }); - - it('handles sidebar collapse toggle', () => { - const setIsSiderCollapsed = vi.fn(); - - vi.mocked(useLocalStorage).mockImplementation(key => { - if (key === 'isSiderCollapsed') return [false, setIsSiderCollapsed]; - return [undefined, vi.fn()]; - }); - - renderComponent(); - - const collapseButton = screen.getByRole('img', { name: 'menu-fold' }); - fireEvent.click(collapseButton); - - expect(setIsSiderCollapsed).toHaveBeenCalledWith(true); - }); - - it('navigates to correct route when menu item is clicked', () => { - renderComponent(); const adminArea = screen.getByText('Admin Area'); fireEvent.click(adminArea); @@ -124,10 +104,6 @@ describe('AdminSider', () => { fireEvent.click(adminItem); expect(router.push).toHaveBeenCalledWith('/admin1'); - }); - - it('handles course management menu items correctly', () => { - renderComponent(); const courseManagement = screen.getByText('Course Management'); fireEvent.click(courseManagement); @@ -138,6 +114,30 @@ describe('AdminSider', () => { expect(router.push).toHaveBeenCalledWith('/course1'); }); + it('handles collapse and renders the collapsed state', () => { + const setIsSiderCollapsed = vi.fn(); + + vi.mocked(useLocalStorage).mockImplementation(key => { + if (key === 'isSiderCollapsed') return [false, setIsSiderCollapsed]; + return [undefined, vi.fn()]; + }); + + const { rerender } = renderComponent(); + fireEvent.click(screen.getByRole('img', { name: 'menu-fold' })); + expect(setIsSiderCollapsed).toHaveBeenCalledWith(true); + + vi.mocked(useLocalStorage).mockImplementation(key => { + if (key === 'isSiderCollapsed') return [true, vi.fn()]; + return [[], vi.fn()]; + }); + rerender( + + + , + ); + expect(screen.getByRole('img', { name: 'menu-unfold' })).toBeInTheDocument(); + }); + it('renders correctly when no courses are provided', () => { renderComponent({ courses: [] }); @@ -156,18 +156,6 @@ describe('AdminSider', () => { expect(screen.queryByText('Course Management')).not.toBeInTheDocument(); }); - it('shows the unfold icon when the sider is collapsed', () => { - // isSiderCollapsed=true selects the MenuUnfoldOutlined icon. - vi.mocked(useLocalStorage).mockImplementation(key => { - if (key === 'isSiderCollapsed') return [true, vi.fn()]; - return [[], vi.fn()]; - }); - - renderComponent(); - - expect(screen.getByRole('img', { name: 'menu-unfold' })).toBeInTheDocument(); - }); - it('prefers the activeCourse prop when provided', () => { // Passing activeCourse exercises the left side of `props.activeCourse ?? activeCourse`. renderComponent({ activeCourse: mockCourses[0] }); diff --git a/client/src/shared/components/StudentMentorModal.test.tsx b/client/src/shared/components/StudentMentorModal.test.tsx index ab1b916974..82a6b7c64a 100644 --- a/client/src/shared/components/StudentMentorModal.test.tsx +++ b/client/src/shared/components/StudentMentorModal.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { StudentMentorModal } from './StudentMentorModal'; // The search fields are remote-search widgets with their own tests; stub them @@ -39,18 +39,8 @@ describe('StudentMentorModal', () => { expect(container).toBeEmptyDOMElement(); }); - it('renders the Student and Mentor fields when open', () => { - render(); - - expect(screen.getByText('Student/Mentor')).toBeInTheDocument(); - expect(screen.getByText('Student')).toBeInTheDocument(); - expect(screen.getByText('Mentor')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /pick student/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /pick mentor/i })).toBeInTheDocument(); - }); - it('shows validation errors when submitting without selections', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole('button', { name: /save/i })); @@ -61,11 +51,19 @@ describe('StudentMentorModal', () => { }); it('calls onOk with the selected student and mentor github ids', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); - await user.click(screen.getByRole('button', { name: /pick student/i })); - await user.click(screen.getByRole('button', { name: /pick mentor/i })); + expect(screen.getByText('Student/Mentor')).toBeInTheDocument(); + expect(screen.getByText('Student')).toBeInTheDocument(); + expect(screen.getByText('Mentor')).toBeInTheDocument(); + const studentButton = screen.getByRole('button', { name: /pick student/i }); + const mentorButton = screen.getByRole('button', { name: /pick mentor/i }); + expect(studentButton).toBeInTheDocument(); + expect(mentorButton).toBeInTheDocument(); + + await user.click(studentButton); + await user.click(mentorButton); await user.click(screen.getByRole('button', { name: /save/i })); diff --git a/client/src/shared/components/StudentSearch.test.tsx b/client/src/shared/components/StudentSearch.test.tsx index 74c3e5a837..ce1bc6946d 100644 --- a/client/src/shared/components/StudentSearch.test.tsx +++ b/client/src/shared/components/StudentSearch.test.tsx @@ -1,5 +1,5 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { act, render, screen } from '@testing-library/react'; +import { setupUser } from '@client/__tests__/setupUser'; import { StudentSearch } from './StudentSearch'; // Use vi.hoisted so the mocked class can reference the spy at module-eval time. @@ -14,36 +14,36 @@ vi.mock('@client/services/course', () => ({ describe('StudentSearch', () => { beforeEach(() => { + vi.useFakeTimers(); searchStudents.mockReset(); searchStudents.mockResolvedValue([{ id: 1, githubId: 'student-x', name: 'Student X', mentor: null }]); }); - it('renders a combobox', () => { - render(); - - expect(screen.getByRole('combobox')).toBeInTheDocument(); + afterEach(() => { + vi.useRealTimers(); }); it('searches students via the course service and renders the results', async () => { - const user = userEvent.setup(); + const user = setupUser({ advanceTimers: vi.advanceTimersByTimeAsync }); render(); const combobox = screen.getByRole('combobox'); - combobox.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); + expect(combobox).toBeInTheDocument(); await user.type(combobox, 'stu'); - await waitFor(() => expect(searchStudents).toHaveBeenCalledWith('stu', false)); - expect(await screen.findByText(/Student X/)).toBeInTheDocument(); + await act(() => vi.advanceTimersByTimeAsync(300)); + expect(searchStudents).toHaveBeenCalledWith('stu', false); + expect(screen.getByText(/Student X/)).toBeInTheDocument(); }); it('forwards the onlyStudentsWithoutMentorShown flag to the service', async () => { - const user = userEvent.setup(); + const user = setupUser({ advanceTimers: vi.advanceTimersByTimeAsync }); render(); const combobox = screen.getByRole('combobox'); - combobox.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); await user.type(combobox, 'stu'); - await waitFor(() => expect(searchStudents).toHaveBeenCalledWith('stu', true)); + await act(() => vi.advanceTimersByTimeAsync(300)); + expect(searchStudents).toHaveBeenCalledWith('stu', true); }); }); diff --git a/client/src/shared/components/Table/PersonCell.test.tsx b/client/src/shared/components/Table/PersonCell.test.tsx index f837b0dfa7..e4d103d363 100644 --- a/client/src/shared/components/Table/PersonCell.test.tsx +++ b/client/src/shared/components/Table/PersonCell.test.tsx @@ -9,36 +9,22 @@ vi.mock('react-use', () => ({ describe('PersonCell', () => { const person = { name: 'Octo Cat', githubId: 'octocat', cityName: 'Minsk', countryName: 'Belarus' }; - it('renders the github profile link for the person', () => { - render(); + it('renders profile and location details for each supported state', () => { + const { rerender } = render(); expect(screen.getByTitle('Open Rolling Scopes App profile page')).toHaveAttribute( 'href', '/profile?githubId=octocat', ); - }); - - it('renders name and city, joined by a comma', () => { - render(); - expect(screen.getByText(/Octo Cat/)).toBeInTheDocument(); expect(screen.getByText(/, Minsk/)).toBeInTheDocument(); - }); + expect(screen.queryByText(/Belarus/)).not.toBeInTheDocument(); - it('appends the country when showCountry is set', () => { - render(); + rerender(); expect(screen.getByText(/, Belarus/)).toBeInTheDocument(); - }); - - it('does not render the country by default', () => { - render(); - - expect(screen.queryByText(/Belarus/)).not.toBeInTheDocument(); - }); - it('omits the comma separator when the city is missing', () => { - render(); + rerender(); expect(screen.getByText('NoCity')).toBeInTheDocument(); expect(screen.queryByText(/, /)).not.toBeInTheDocument(); diff --git a/client/src/shared/components/Table/columns.test.tsx b/client/src/shared/components/Table/columns.test.tsx index e110de2cd5..a78c03a2d5 100644 --- a/client/src/shared/components/Table/columns.test.tsx +++ b/client/src/shared/components/Table/columns.test.tsx @@ -1,6 +1,5 @@ /* eslint-disable testing-library/no-container, testing-library/no-node-access */ import { fireEvent, render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { getColumnSearchProps } from './columns'; type DropdownProps = Parameters['filterDropdown']>>[0]; @@ -27,115 +26,57 @@ function renderDropdown( } describe('getColumnSearchProps', () => { - it('renders a search input with a placeholder using the label', () => { - renderDropdown({}, ['name', 'Full Name']); + it('uses the label or dataIndex in the search placeholder', () => { + const { unmount } = renderDropdown({}, ['name', 'Full Name']); expect(screen.getByPlaceholderText('Search Full Name')).toBeInTheDocument(); - }); - - it('falls back to the dataIndex in the placeholder when no label is given', () => { + unmount(); renderDropdown({}, ['githubId']); expect(screen.getByPlaceholderText('Search githubId')).toBeInTheDocument(); }); - it('updates selected keys as the user types', async () => { - const user = userEvent.setup(); - const { setSelectedKeys } = renderDropdown(); - - await user.type(screen.getByRole('textbox'), 'a'); + it('handles dropdown actions, filtering, icons, and focus', () => { + const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => { + cb(0); + return 0; + }); + const { setSelectedKeys, confirm, clearFilters, config } = renderDropdown(); + const input = screen.getByRole('textbox') as HTMLInputElement; + fireEvent.change(input, { target: { value: 'a' } }); expect(setSelectedKeys).toHaveBeenCalledWith(['a']); - }); - - it('confirms the filter when the search button is clicked', async () => { - const user = userEvent.setup(); - const { confirm } = renderDropdown(); - - await user.click(screen.getByRole('button', { name: /search/i })); - - expect(confirm).toHaveBeenCalled(); - }); - - it('confirms the filter when Enter is pressed in the input', () => { - const { confirm } = renderDropdown(); - - // The handler checks e.keyCode === 13, so fire a keyDown with an explicit keyCode. - fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter', keyCode: 13 }); - - expect(confirm).toHaveBeenCalled(); - }); - - it('clears the filter and confirms on Reset', async () => { - const user = userEvent.setup(); - const { clearFilters, confirm } = renderDropdown(); - - await user.click(screen.getByRole('button', { name: /reset/i })); - + fireEvent.click(screen.getByRole('button', { name: /search/i })); + fireEvent.keyDown(input, { key: 'Escape', keyCode: 27 }); + fireEvent.keyDown(input, { key: 'Enter', keyCode: 13 }); + fireEvent.click(screen.getByRole('button', { name: /reset/i })); expect(clearFilters).toHaveBeenCalled(); - expect(confirm).toHaveBeenCalled(); - }); - - it('renders a highlighted filter icon when filtered', () => { - const { config } = renderDropdown(); - const { container } = render(<>{config.filterIcon?.(true, {} as never)}); - - expect(container.querySelector('.anticon-search')).toBeInTheDocument(); - }); - - it('renders a non-highlighted filter icon when not filtered', () => { - // filtered=false -> the `filtered ? '#1677ff' : undefined` else branch. - const { config } = renderDropdown(); - const { container } = render(<>{config.filterIcon?.(false, {} as never)}); - - expect(container.querySelector('.anticon-search')).toBeInTheDocument(); - }); - - it('onFilter treats a missing field value as an empty string', () => { - // record lacks the dataIndex field -> `get(record, field) || ''` falls back to ''. - const { config } = renderDropdown(); - + expect(confirm).toHaveBeenCalledTimes(3); + + const { container } = render( + <> + {config.filterIcon?.(true, {} as never)} + {config.filterIcon?.(false, {} as never)} + , + ); + expect(container.querySelectorAll('.anticon-search')).toHaveLength(2); expect(config.onFilter?.('a', {} as never)).toBe(false); - }); - - it('onFilter matches a record on a single dataIndex (case-insensitive)', () => { - const { config } = renderDropdown(); - expect(config.onFilter?.('AL', { name: 'Alice' } as never)).toBe(true); expect(config.onFilter?.('zz', { name: 'Alice' } as never)).toBe(false); - }); - - it('onFilter returns false for null filter values', () => { - const { config } = renderDropdown(); - expect(config.onFilter?.(null as never, { name: 'Alice' } as never)).toBe(false); - }); + expect( + getColumnSearchProps(['name', 'githubId']).onFilter?.('octo', { + name: 'Alice', + githubId: 'octocat', + } as never), + ).toBe(true); - it('onFilter checks any of multiple dataIndex fields', () => { - const config = getColumnSearchProps(['name', 'githubId']); - - expect(config.onFilter?.('octo', { name: 'Alice', githubId: 'octocat' } as never)).toBe(true); - }); - - it('focuses and selects the search input when the filter dropdown opens', async () => { - const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => { - cb(0); - return 0; - }); - const { config } = renderDropdown(); - - // The rendered Input registers its ref; opening should select its text. - const input = screen.getByRole('textbox') as HTMLInputElement; const selectSpy = vi.spyOn(input, 'select'); - config.filterDropdownProps?.onOpenChange?.(true); expect(selectSpy).toHaveBeenCalled(); - - // Closing should not attempt to select. selectSpy.mockClear(); config.filterDropdownProps?.onOpenChange?.(false); expect(selectSpy).not.toHaveBeenCalled(); - rafSpy.mockRestore(); }); }); diff --git a/client/src/shared/components/Table/renderers.test.tsx b/client/src/shared/components/Table/renderers.test.tsx index 5f53aa2f4b..7de5731ed7 100644 --- a/client/src/shared/components/Table/renderers.test.tsx +++ b/client/src/shared/components/Table/renderers.test.tsx @@ -86,15 +86,9 @@ describe('Table renderers (string outputs)', () => { }); describe('Table renderers (cross-check status)', () => { - it('returns N/A for non cross-check checkers', () => { + it('renders non-cross-check, initial, and completed statuses', () => { expect(crossCheckStatusRenderer('done' as never, { checker: 'auto-test' as never })).toBe('N/A'); - }); - - it('returns "Not distributed" for the initial status', () => { expect(crossCheckStatusRenderer('initial' as never, { checker: 'crossCheck' as never })).toBe('Not distributed'); - }); - - it('renders a capitalized status span otherwise', () => { render(<>{crossCheckStatusRenderer('completed' as never, { checker: 'crossCheck' as never })}); expect(screen.getByText('completed')).toBeInTheDocument(); }); @@ -174,33 +168,25 @@ describe('Table renderers (JSX outputs)', () => { }); describe('urlRenderer', () => { - it('returns false for empty urls', () => { + it('renders empty, GitHub, YouTube, and generic URLs', () => { expect(urlRenderer('')).toBe(false); - }); - it('renders a github icon for github links', () => { - const { container } = render(<>{urlRenderer('https://github.com/x')}); + const { container, rerender } = render(<>{urlRenderer('https://github.com/x')}); expect(container.querySelector('.anticon-github')).toBeInTheDocument(); - }); - it('renders a youtube icon for youtube links', () => { - const { container } = render(<>{urlRenderer('https://youtu.be/x')}); + rerender(<>{urlRenderer('https://youtu.be/x')}); expect(container.querySelector('.anticon-youtube')).toBeInTheDocument(); - }); - it('renders a chrome icon for other links', () => { - const { container } = render(<>{urlRenderer('https://example.com')}); + rerender(<>{urlRenderer('https://example.com')}); expect(container.querySelector('.anticon-chrome')).toBeInTheDocument(); }); }); describe('tagsCoursesRendererWithRemainingNumber', () => { - it('returns undefined when there are no courses', () => { + it('renders empty, multiple-course, and single-course values', () => { expect(tagsCoursesRendererWithRemainingNumber(undefined, { courses: [] } as never)).toBeUndefined(); - }); - it('renders the first course and a "+N More" tag when there are extras', () => { - render( + const { rerender } = render( <> {tagsCoursesRendererWithRemainingNumber(undefined, { courses: [{ name: 'Course A', isActive: true }, { name: 'Course B' }, { name: 'Course C' }], @@ -210,10 +196,8 @@ describe('tagsCoursesRendererWithRemainingNumber', () => { expect(screen.getByText('Course A')).toBeInTheDocument(); expect(screen.getByText('+ 2 More')).toBeInTheDocument(); - }); - it('renders just the first course when there are no extras', () => { - render(<>{tagsCoursesRendererWithRemainingNumber(undefined, { courses: [{ name: 'Solo' }] } as never)}); + rerender(<>{tagsCoursesRendererWithRemainingNumber(undefined, { courses: [{ name: 'Solo' }] } as never)}); expect(screen.getByText('Solo')).toBeInTheDocument(); expect(screen.queryByText(/More/)).not.toBeInTheDocument(); @@ -226,43 +210,40 @@ describe('coloredDateRenderer', () => { return render(<>{renderer('2023-05-04T00:00:00.000Z', item as never)}); }; - it('renders the formatted date text', () => { - renderColored({ startDate: '2023-05-01', endDate: '2023-05-10', score: null, tag: 'task' }); - expect(screen.getByText('2023-05-04')).toBeInTheDocument(); - }); - - it('renders an info tooltip icon for self-study tasks', () => { - const { container } = renderColored({ + it('renders formatted, info, warning, success, and past date states', () => { + const { container, rerender } = renderColored({ startDate: '2023-05-01', endDate: '2023-05-10', score: null, - tag: 'self-study', + tag: 'task', }); + expect(screen.getByText('2023-05-04')).toBeInTheDocument(); + + const baseRenderer = coloredDateRenderer('UTC', 'YYYY-MM-DD', 'end', 'Self-study info'); + rerender( + <> + {baseRenderer('2023-05-04T00:00:00.000Z', { + startDate: '2023-05-01', + endDate: '2023-05-10', + tag: 'self-study', + score: null, + } as never)} + , + ); expect(container.querySelector('.anticon-info-circle')).toBeInTheDocument(); - }); - it('applies a warning color when the deadline is within 48 hours (end date)', () => { const soon = new Date(Date.now() + 10 * 60 * 60 * 1000).toISOString(); const renderer = coloredDateRenderer('UTC', 'YYYY-MM-DD', 'end', 'info'); - const { container } = render( - <>{renderer(soon, { startDate: '2000-01-01', endDate: soon, score: null, tag: 'task' } as never)}, - ); + rerender(<>{renderer(soon, { startDate: '2000-01-01', endDate: soon, score: null, tag: 'task' } as never)}); expect(container.querySelector('.ant-typography-warning')).toBeInTheDocument(); - }); - it('applies a success color for a current task on the start column', () => { const start = new Date(Date.now() - 60 * 60 * 1000).toISOString(); const end = new Date(Date.now() + 1000 * 60 * 60 * 24 * 5).toISOString(); - const renderer = coloredDateRenderer('UTC', 'YYYY-MM-DD', 'start', 'info'); - const { container } = render( - <>{renderer(start, { startDate: start, endDate: end, score: null, tag: 'task' } as never)}, - ); + const startRenderer = coloredDateRenderer('UTC', 'YYYY-MM-DD', 'start', 'info'); + rerender(<>{startRenderer(start, { startDate: start, endDate: end, score: null, tag: 'task' } as never)}); expect(container.querySelector('.ant-typography-success')).toBeInTheDocument(); - }); - it('applies a secondary color for a scored (past) task', () => { - const renderer = coloredDateRenderer('UTC', 'YYYY-MM-DD', 'end', 'info'); - const { container } = render( + rerender( <> {renderer('2020-01-01', { startDate: '2019-01-01', endDate: '2020-01-01', score: 80, tag: 'task' } as never)} , diff --git a/client/src/shared/components/ThemeSwitch.test.tsx b/client/src/shared/components/ThemeSwitch.test.tsx index 3812ecc1cb..8fdbde3f70 100644 --- a/client/src/shared/components/ThemeSwitch.test.tsx +++ b/client/src/shared/components/ThemeSwitch.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { AppTheme } from '@client/providers/ThemeProvider'; import ThemeSwitch from './ThemeSwitch'; import { useTheme } from '@client/hooks'; @@ -27,62 +27,43 @@ describe('ThemeSwitch', () => { mockTheme(); }); - it('shows the auto-theme icon when autoTheme is enabled', () => { + it('shows the icon for auto, light, and dark themes', () => { mockTheme({ autoTheme: true }); - render(); + const { rerender } = render(); expect(screen.getByRole('img', { name: 'skin' })).toBeInTheDocument(); - }); - it('shows the light-theme icon when a light theme is active and autoTheme is off', () => { mockTheme({ autoTheme: false, theme: AppTheme.Light }); - render(); - + rerender(); expect(screen.getByRole('img', { name: 'sun' })).toBeInTheDocument(); - }); - it('shows the dark-theme icon when a dark theme is active and autoTheme is off', () => { mockTheme({ autoTheme: false, theme: AppTheme.Dark }); - render(); - + rerender(); expect(screen.getByRole('img', { name: 'moon' })).toBeInTheDocument(); }); // The dropdown trigger shows the active-theme icon (skin when autoTheme is on). // Menu items are labelled only by their icons (moon/sun/skin), so we open the // menu and pick items by order: [0] dark, [1] light, [2] auto. - async function openMenuItems(user: ReturnType) { + async function openMenuItems(user: ReturnType) { await user.click(screen.getByRole('img', { name: 'skin' })); return screen.findAllByRole('menuitem'); } - it('switches to dark theme from the dropdown menu', async () => { - const user = userEvent.setup(); + it('switches among dark, light, and automatic themes', async () => { + const user = setupUser(); render(); - const items = await openMenuItems(user); + let items = await openMenuItems(user); await user.click(items[0]); - expect(themeChange).toHaveBeenCalledWith(AppTheme.Dark); - }); - - it('switches to light theme from the dropdown menu', async () => { - const user = userEvent.setup(); - render(); - const items = await openMenuItems(user); + items = await openMenuItems(user); await user.click(items[1]); - expect(themeChange).toHaveBeenCalledWith(AppTheme.Light); - }); - - it('toggles auto theme from the dropdown menu', async () => { - const user = userEvent.setup(); - render(); - const items = await openMenuItems(user); + items = await openMenuItems(user); await user.click(items[2]); - expect(changeAutoTheme).toHaveBeenCalledTimes(1); }); }); diff --git a/client/src/shared/components/Timer.test.tsx b/client/src/shared/components/Timer.test.tsx index 604f6a1947..d4ccbf22c7 100644 --- a/client/src/shared/components/Timer.test.tsx +++ b/client/src/shared/components/Timer.test.tsx @@ -7,7 +7,7 @@ describe('Timer', () => { }); afterEach(() => { - vi.runOnlyPendingTimers(); + act(() => vi.runOnlyPendingTimers()); vi.useRealTimers(); }); diff --git a/client/src/shared/components/TooltipedButton.test.tsx b/client/src/shared/components/TooltipedButton.test.tsx index f804ab9e62..6904f9321d 100644 --- a/client/src/shared/components/TooltipedButton.test.tsx +++ b/client/src/shared/components/TooltipedButton.test.tsx @@ -1,6 +1,5 @@ /* eslint-disable testing-library/no-container, testing-library/no-node-access */ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { act, render, screen } from '@testing-library/react'; import { TooltipedButton } from './TooltipedButton'; describe('TooltipedButton', () => { @@ -12,37 +11,23 @@ describe('TooltipedButton', () => { disabled: false, }; - it('renders the button with its text', () => { - render(); + it('renders its enabled, disabled, loading, and open-tooltip states', () => { + vi.useFakeTimers(); + const { container, rerender } = render(); - expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument(); - }); - - it('shows the tooltip text when open is true', async () => { - render(); - - expect(await screen.findByText('Helpful hint')).toBeInTheDocument(); - }); - - it('disables the button when disabled is true', () => { - render(); + const button = screen.getByRole('button', { name: /confirm/i }); + expect(button).toBeInTheDocument(); + expect(button).toBeEnabled(); + rerender(); expect(screen.getByRole('button', { name: /confirm/i })).toBeDisabled(); - }); - - it('shows a loading indicator when loading is true', () => { - const { container } = render(); + rerender(); expect(container.querySelector('.ant-btn-loading')).toBeInTheDocument(); - }); - - it('renders an enabled button that can be focused/clicked when not disabled', async () => { - const user = userEvent.setup(); - render(); - const button = screen.getByRole('button', { name: /confirm/i }); - await user.click(button); - - expect(button).toBeEnabled(); + rerender(); + act(() => vi.runOnlyPendingTimers()); + expect(screen.getByText('Helpful hint')).toBeInTheDocument(); + vi.useRealTimers(); }); }); diff --git a/client/src/shared/components/UserSearch.test.tsx b/client/src/shared/components/UserSearch.test.tsx index 0461649820..3bdd3ece1c 100644 --- a/client/src/shared/components/UserSearch.test.tsx +++ b/client/src/shared/components/UserSearch.test.tsx @@ -1,5 +1,5 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { setupUser } from '@client/__tests__/setupUser'; import { UserSearch } from './UserSearch'; import type { SearchStudent } from '@client/services/course'; @@ -10,15 +10,21 @@ const PEOPLE = [ function openSelect() { const combobox = screen.getByRole('combobox'); - fireMouseDown(combobox); + fireEvent.mouseDown(combobox); return combobox; } -function fireMouseDown(el: Element) { - el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); +async function finishSearch() { + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); } describe('UserSearch', () => { + afterEach(() => { + if (vi.isFakeTimers()) vi.clearAllTimers(); + vi.useRealTimers(); + }); it('renders a searchable combobox', () => { render(); @@ -26,7 +32,7 @@ describe('UserSearch', () => { }); it('uses the default values to filter locally when no searchFn is provided', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); const combobox = openSelect(); @@ -41,7 +47,7 @@ describe('UserSearch', () => { }); it('calls the provided searchFn and renders the returned options', async () => { - const user = userEvent.setup(); + const user = setupUser(); const searchFn = vi.fn().mockResolvedValue(PEOPLE); render(); @@ -54,7 +60,7 @@ describe('UserSearch', () => { }); it('passes onlyStudentsWithoutMentorShown to the searchFn', async () => { - const user = userEvent.setup(); + const user = setupUser(); const searchFn = vi.fn().mockResolvedValue([]); render(); @@ -65,7 +71,7 @@ describe('UserSearch', () => { }); it('shows the current mentor warning when showMentor is set and a mentor exists', async () => { - const user = userEvent.setup(); + const user = setupUser(); const withMentor: SearchStudent[] = [ { id: 3, @@ -84,7 +90,7 @@ describe('UserSearch', () => { }); it('selects an option by github id when keyField is githubId', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onChange = vi.fn(); const searchFn = vi.fn().mockResolvedValue(PEOPLE); render(); @@ -105,32 +111,37 @@ describe('UserSearch', () => { // The unique search result disappearing (replaced by default values) proves the // else branch ran rather than the mount effect. const searchFn = vi.fn().mockResolvedValue([{ id: 9, githubId: 'zoe', name: 'Zoe Z' }]); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); render(); const combobox = openSelect(); fireEvent.change(combobox, { target: { value: 'zoe' } }); - expect(await screen.findByText(/Zoe Z/)).toBeInTheDocument(); + await finishSearch(); + expect(screen.getByText(/Zoe Z/)).toBeInTheDocument(); fireEvent.change(combobox, { target: { value: ' ' } }); + await finishSearch(); // The unique searchFn result is gone and the default values are shown instead. - await waitFor(() => expect(screen.queryByText(/Zoe Z/)).not.toBeInTheDocument(), { timeout: 2000 }); + expect(screen.queryByText(/Zoe Z/)).not.toBeInTheDocument(); expect(screen.getByText(/Alice A/)).toBeInTheDocument(); }); it('returns no matches from the built-in search when there are no default values', async () => { // No searchFn and no defaultValues: defaultSearch's `defaultValues?.filter(...) ?? []` // takes the nullish fallback (line 75). + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); render(); const combobox = openSelect(); fireEvent.change(combobox, { target: { value: 'abc' } }); + await finishSearch(); - await waitFor(() => expect(screen.queryByText(/Alice A/)).not.toBeInTheDocument(), { timeout: 2000 }); + expect(screen.queryByText(/Alice A/)).not.toBeInTheDocument(); }); it('does not call searchFn for a whitespace-only query (falls back to default values)', async () => { - const user = userEvent.setup(); + const user = setupUser(); const searchFn = vi.fn().mockResolvedValue(PEOPLE); render(); diff --git a/client/src/shared/hooks/useModal/useModalForm.test.tsx b/client/src/shared/hooks/useModal/useModalForm.test.tsx index fdc14e4fea..b4205adec1 100644 --- a/client/src/shared/hooks/useModal/useModalForm.test.tsx +++ b/client/src/shared/hooks/useModal/useModalForm.test.tsx @@ -2,15 +2,12 @@ import { renderHook, act } from '@testing-library/react'; import { useModalForm } from './useModalForm'; describe('useModalForm', () => { - it('should return the correct initial state', () => { + it('handles create, open, close, and edit states', () => { const { result } = renderHook(() => useModalForm<{ id: number }>()); expect(result.current.mode).toBe('create'); expect(result.current.open).toBe(false); expect(result.current.formData).toBe(undefined); - }); - it('should toggle the modal when toggle is called', () => { - const { result } = renderHook(() => useModalForm<{ id: number }>()); act(() => { result.current.toggle(); }); @@ -19,22 +16,13 @@ describe('useModalForm', () => { result.current.toggle(); }); expect(result.current.open).toBe(false); - }); - it('should set the mode to "edit" and form data when toggle is called with data', () => { - const { result } = renderHook(() => useModalForm<{ id: number }>()); act(() => { result.current.toggle({ id: 1 }); }); expect(result.current.mode).toBe('edit'); expect(result.current.formData).toEqual({ id: 1 }); - }); - it('should set the mode to "create" and form data to null when toggle is called without data', () => { - const { result } = renderHook(() => useModalForm<{ id: number }>()); - act(() => { - result.current.toggle({ id: 1 }); - }); act(() => { result.current.toggle(); }); diff --git a/client/vitest.config.mts b/client/vitest.config.mts index f9bd9d86ef..b82bfd26a4 100644 --- a/client/vitest.config.mts +++ b/client/vitest.config.mts @@ -1,14 +1,49 @@ import path from 'node:path'; +import { isBuiltin } from 'node:module'; import { defineConfig, mergeConfig } from 'vitest/config'; import shared from '../vitest.shared.mjs'; -// Pin the timezone in the MAIN vitest process so worker threads inherit UTC at -// init. The `threads` pool (set below) starts ~2x faster than `forks`, but -// threads inherit the parent process timezone — vitest's `test.env.TZ` only -// reliably applies to `forks`. Setting it here (before the pool is created) keeps -// date/calendar assertions deterministic under threads. Cross-platform, no shell prefix. +// Set UTC before workers start because threads inherit the parent timezone. process.env.TZ = 'UTC'; +// Keep this list explicit: some service and .test.ts files need browser APIs. +const nodeTests = [ + 'src/data/interviews/__tests__/templateValidator.test.ts', + 'src/domain/course.test.ts', + 'src/domain/interview.test.ts', + 'src/domain/user.helpers.test.ts', + 'src/modules/AutoTest/utils/map.test.ts', + 'src/modules/CrossCheck/components/SolutionReview/helpers.test.ts', + 'src/modules/CrossCheck/utils/arrayMoveImmutable.test.ts', + 'src/modules/CrossCheck/utils/getCriteriaStatusColor.test.ts', + 'src/modules/Home/data/loadHomeData.test.ts', + 'src/modules/Interviews/data/getInterviewData.test.ts', + 'src/modules/Interviews/data/getStageInterviewData.test.ts', + 'src/modules/Interviews/pages/StageInterviewFeedback/feedbackTemplateHandler.test.ts', + 'src/modules/MentorsHallOfFame/services/mentors-hall-of-fame.service.test.ts', + 'src/modules/Opportunities/pages/PublicPage/getServerSideProps.test.ts', + 'src/modules/Opportunities/transformers/splitDataForForms.test.ts', + 'src/modules/Opportunities/transformers/transformFieldsData.test.ts', + 'src/modules/Opportunities/transformers/transformInitialCvData.test.ts', + 'src/modules/Score/data/getExportCsvUrl.test.ts', + 'src/modules/Score/data/isExportEnabled.test.ts', + 'src/modules/SubmitScores/utils.test.ts', + 'src/modules/Tasks/utils/test-utils.test.ts', + 'src/services/cdn.test.ts', + 'src/services/courses.test.ts', + 'src/services/features.test.ts', + 'src/services/files.test.ts', + 'src/services/formatter.test.ts', + 'src/services/gratitude.test.ts', + 'src/services/mentorRegistry.test.ts', + 'src/services/routes.test.ts', + 'src/services/validators.test.ts', + 'src/shared/utils/queryParams-utils.test.ts', + 'src/shared/utils/text-utils.test.ts', + 'src/utils/optionalQueryString.test.ts', + 'src/utils/profilePageUtils.test.ts', +]; + export default mergeConfig( shared, defineConfig({ @@ -21,21 +56,48 @@ export default mergeConfig( }, }, test: { - environment: 'jsdom', - include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], - setupFiles: ['src/setupTests.ts'], - // `threads` starts workers far faster than the default `forks` pool and - // shares the V8 code cache across files, roughly halving module-import time - // for this antd-heavy suite. (Per-file isolation is kept — the suite's - // per-file vi.mock usage is not safe with isolate:false.) + projects: [ + { + test: { + name: 'node', + environment: 'node', + include: nodeTests, + }, + }, + { + // Bundle ESM exports so named icon imports survive dependency optimization. + resolve: { mainFields: ['module', 'main'] }, + test: { + name: 'dom', + environment: 'jsdom', + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + exclude: nodeTests, + setupFiles: ['src/setupTests.ts'], + deps: { + optimizer: { + client: { + enabled: true, + // Share dayjs plugin state and React contexts with unbundled imports. + exclude: ['react-dom', '@ant-design/cssinjs'], + rolldownOptions: { + platform: 'node', + external: id => id === 'dayjs' || isBuiltin(id), + }, + include: ['antd', '@ant-design/icons', 'react-markdown', 'remark-gfm'], + }, + }, + }, + }, + }, + ], + // Keep file isolation: tests use different module mocks and browser state. pool: 'threads', // antd v6 in jsdom is CPU-heavy; under coverage instrumentation + parallelism // the slowest Table/Form-validation tests can exceed 30s on busy/CI runners. testTimeout: 60000, hookTimeout: 60000, - // Retry transient flakes (antd async validation/Table renders occasionally - // timing out under load). A genuine failure still fails all attempts. - retry: 2, + // Surface flaky failures instead of hiding them behind repeated minute-long attempts. + retry: 0, env: { TZ: 'UTC', }, @@ -55,19 +117,12 @@ export default mergeConfig( 'src/styles/**', 'src/shared/components/Icons/**', 'src/**/*.stories.tsx', - // NOTE: do not exclude `src/**/index.ts` — v8's exclude matcher also - // drops component `index.tsx` files (95 real components), which must - // count toward the target. Pure barrels are mostly covered transitively. + // Keep barrels in coverage so the measured file set stays unchanged. 'src/**/*.d.ts', 'src/setupTests.ts', ], reportsDirectory: './coverage', - // Coverage floor enforced in CI via `test:ci`. Actual coverage is - // ~95.8% stmts / 93.4% branches / 95.7% funcs / 95.8% lines — all four - // metrics clear the flat-90 goal with margin. Floor is a flat 90 (free - // above 90, fail below); remaining uncovered branches are genuinely - // unreachable defensive code (jsdom-impossible guards, antd internals, - // dead `?? []`/`|| ''` fallbacks). + // Enforce the same coverage floor across both projects in CI. thresholds: { statements: 90, branches: 90, @@ -75,37 +130,6 @@ export default mergeConfig( lines: 90, }, }, - deps: { - optimizer: { - web: { - include: [ - 'react-markdown', - 'vfile', - 'unist-util-stringify-position', - 'remark-parse', - 'remark-rehype', - 'mdast-util-from-markdown', - 'mdast-util-to-hast', - 'unified', - 'bail', - 'is-plain-obj', - 'trough', - 'micromark', - 'parse-entities', - 'character-entities', - 'property-information', - 'comma-separated-tokens', - 'hast-util-whitespace', - 'space-separated-tokens', - 'decode-named-character-reference', - 'ccount', - 'escape-string-regexp', - 'markdown-table', - 'trim-lines', - ], - }, - }, - }, }, }), ); diff --git a/nestjs/src/alerts/alerts.controller.spec.ts b/nestjs/src/alerts/alerts.controller.spec.ts index 6a9bbee3ef..7dd2809308 100644 --- a/nestjs/src/alerts/alerts.controller.spec.ts +++ b/nestjs/src/alerts/alerts.controller.spec.ts @@ -1,13 +1,13 @@ import type { Mocked } from 'vitest'; import { Test, TestingModule } from '@nestjs/testing'; -import { Alert } from '@entities/alert'; +import { Alert, AlertType } from '@entities/alert'; import { AlertsController } from './alerts.controller'; import { AlertsService } from './alerts.service'; import { AlertDto, CreateAlertDto, UpdateAlertDto } from './dto'; const mockAlert = { id: 1, - type: 'warning', + type: AlertType.WARN, text: 'Maintenance window tonight', enabled: true, courseId: 5, @@ -45,7 +45,12 @@ describe('AlertsController', () => { describe('create', () => { it('delegates to the service and wraps the result in an AlertDto', async () => { - const dto: CreateAlertDto = { type: 'warning', text: 'Maintenance window tonight', enabled: true, courseId: 5 }; + const dto: CreateAlertDto = { + type: AlertType.WARN, + text: 'Maintenance window tonight', + enabled: true, + courseId: 5, + }; service.create.mockResolvedValue(mockAlert); const result = await controller.create(dto); @@ -54,7 +59,7 @@ describe('AlertsController', () => { expect(result).toBeInstanceOf(AlertDto); expect(result).toEqual({ id: 1, - type: 'warning', + type: AlertType.WARN, text: 'Maintenance window tonight', enabled: true, courseId: 5, @@ -73,7 +78,7 @@ describe('AlertsController', () => { expect(service.findAll).toHaveBeenCalledWith({ enabled: true }); expect(result).toHaveLength(1); expect(result[0]).toBeInstanceOf(AlertDto); - expect(result[0].id).toBe(1); + expect(result[0]!.id).toBe(1); }); it('forwards a false enabled flag and returns an empty list', async () => { diff --git a/nestjs/src/alerts/alerts.service.spec.ts b/nestjs/src/alerts/alerts.service.spec.ts index 7a409e9ec8..d30afd77a0 100644 --- a/nestjs/src/alerts/alerts.service.spec.ts +++ b/nestjs/src/alerts/alerts.service.spec.ts @@ -2,7 +2,7 @@ import type { Mocked } from 'vitest'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { Alert } from '@entities/alert'; +import { Alert, AlertType } from '@entities/alert'; import { AlertsService } from './alerts.service'; import { CreateAlertDto } from './dto/create-alert.dto'; import { UpdateAlertDto } from './dto/update-alert.dto'; @@ -10,7 +10,7 @@ import { UpdateAlertDto } from './dto/update-alert.dto'; const mockAlert = { id: 1, text: 'Maintenance window tonight', - type: 'warning', + type: AlertType.WARN, courseId: 5, enabled: true, } as Partial as Alert; diff --git a/nestjs/src/certificates/certificate-requests.spec.ts b/nestjs/src/certificates/certificate-requests.spec.ts index 5369f79bee..81bdb05fea 100644 --- a/nestjs/src/certificates/certificate-requests.spec.ts +++ b/nestjs/src/certificates/certificate-requests.spec.ts @@ -115,7 +115,10 @@ describe('certificate requests', () => { describe('createCourseCertificates', () => { it('returns empty list without AWS call when criteria matched no students', async () => { - vi.spyOn(service as never, 'findStudentIdsByCriteria' as never).mockResolvedValue([] as never); + vi.spyOn( + service as unknown as { findStudentIdsByCriteria: CertificationsService['findStudentIdsByCriteria'] }, + 'findStudentIdsByCriteria', + ).mockResolvedValue([]); const result = await controller.createCourseCertificates(5, { criteria: { minTotalScore: 100 } }); @@ -124,7 +127,10 @@ describe('certificate requests', () => { }); it('builds requests for matched students and posts them to the AWS gateway', async () => { - vi.spyOn(service as never, 'findStudentIdsByCriteria' as never).mockResolvedValue([42] as never); + vi.spyOn( + service as unknown as { findStudentIdsByCriteria: CertificationsService['findStudentIdsByCriteria'] }, + 'findStudentIdsByCriteria', + ).mockResolvedValue([42]); const qb = createQb([mockStudent]); studentRepository.createQueryBuilder.mockReturnValue(qb); @@ -139,7 +145,10 @@ describe('certificate requests', () => { }); it('targets students without certificates when criteria are empty', async () => { - vi.spyOn(service as never, 'findStudentIdsByCriteria' as never).mockResolvedValue([] as never); + vi.spyOn( + service as unknown as { findStudentIdsByCriteria: CertificationsService['findStudentIdsByCriteria'] }, + 'findStudentIdsByCriteria', + ).mockResolvedValue([]); const qb = createQb([]); studentRepository.createQueryBuilder.mockReturnValue(qb); diff --git a/nestjs/src/certificates/certificates.service.spec.ts b/nestjs/src/certificates/certificates.service.spec.ts index a3a34d1f25..6eb67e35b2 100644 --- a/nestjs/src/certificates/certificates.service.spec.ts +++ b/nestjs/src/certificates/certificates.service.spec.ts @@ -291,7 +291,10 @@ describe('CertificationsService', () => { }; it('short-circuits when criteria are non-empty but match no students', async () => { - vi.spyOn(service as never, 'findStudentIdsByCriteria' as never).mockResolvedValue([] as never); + vi.spyOn( + service as unknown as { findStudentIdsByCriteria: CertificationsService['findStudentIdsByCriteria'] }, + 'findStudentIdsByCriteria', + ).mockResolvedValue([]); const result = await service.buildCourseCertificateRequests(5, { criteria: { minTotalScore: 100 } }); @@ -299,7 +302,10 @@ describe('CertificationsService', () => { }); it('builds requests for the matched student ids', async () => { - vi.spyOn(service as never, 'findStudentIdsByCriteria' as never).mockResolvedValue([42] as never); + vi.spyOn( + service as unknown as { findStudentIdsByCriteria: CertificationsService['findStudentIdsByCriteria'] }, + 'findStudentIdsByCriteria', + ).mockResolvedValue([42]); const qb = makeQb([student]); studentRepository.createQueryBuilder.mockReturnValue(qb); @@ -317,18 +323,24 @@ describe('CertificationsService', () => { }); it('falls back to primarySkillName when the matched student course has no discipline', async () => { - vi.spyOn(service as never, 'findStudentIdsByCriteria' as never).mockResolvedValue([42] as never); + vi.spyOn( + service as unknown as { findStudentIdsByCriteria: CertificationsService['findStudentIdsByCriteria'] }, + 'findStudentIdsByCriteria', + ).mockResolvedValue([42]); const noDiscipline = { ...student, course: { ...student.course, discipline: undefined } }; const qb = makeQb([noDiscipline]); studentRepository.createQueryBuilder.mockReturnValue(qb); const result = await service.buildCourseCertificateRequests(5, { criteria: {} }); - expect(result.requests[0].coursePrimarySkill).toBe('JavaScript'); + expect(result.requests[0]!.coursePrimarySkill).toBe('JavaScript'); }); it('targets students without certificates when criteria are empty', async () => { - vi.spyOn(service as never, 'findStudentIdsByCriteria' as never).mockResolvedValue([] as never); + vi.spyOn( + service as unknown as { findStudentIdsByCriteria: CertificationsService['findStudentIdsByCriteria'] }, + 'findStudentIdsByCriteria', + ).mockResolvedValue([]); const qb = makeQb([]); studentRepository.createQueryBuilder.mockReturnValue(qb); @@ -343,7 +355,10 @@ describe('CertificationsService', () => { }); it('handles a fully empty data object (no criteria, no templateId)', async () => { - vi.spyOn(service as never, 'findStudentIdsByCriteria' as never).mockResolvedValue([] as never); + vi.spyOn( + service as unknown as { findStudentIdsByCriteria: CertificationsService['findStudentIdsByCriteria'] }, + 'findStudentIdsByCriteria', + ).mockResolvedValue([]); const qb = makeQb([]); studentRepository.createQueryBuilder.mockReturnValue(qb); diff --git a/nestjs/src/contributors/contributors.controller.spec.ts b/nestjs/src/contributors/contributors.controller.spec.ts index a6c4619c5f..ed247cea5b 100644 --- a/nestjs/src/contributors/contributors.controller.spec.ts +++ b/nestjs/src/contributors/contributors.controller.spec.ts @@ -70,7 +70,7 @@ describe('ContributorsController', () => { expect(service.getAll).toHaveBeenCalledTimes(1); expect(result).toHaveLength(1); expect(result[0]).toBeInstanceOf(ContributorDto); - expect(result[0].id).toBe(1); + expect(result[0]!.id).toBe(1); }); it('returns an empty list when there are no contributors', async () => { diff --git a/nestjs/src/core/decorators/student-id.decorator.spec.ts b/nestjs/src/core/decorators/student-id.decorator.spec.ts index 703d1e2860..b52418b718 100644 --- a/nestjs/src/core/decorators/student-id.decorator.spec.ts +++ b/nestjs/src/core/decorators/student-id.decorator.spec.ts @@ -12,7 +12,7 @@ const getParamDecoratorFactory = (decorator: () => ParameterDecorator): ParamFac } const args = Reflect.getMetadata(ROUTE_ARGS_METADATA, TestController, 'test'); - const key = Object.keys(args)[0]; + const key = Object.keys(args)[0]!; return args[key].factory as ParamFactory; }; diff --git a/nestjs/src/core/pino.spec.ts b/nestjs/src/core/pino.spec.ts index 9470d14c64..89588f2e91 100644 --- a/nestjs/src/core/pino.spec.ts +++ b/nestjs/src/core/pino.spec.ts @@ -19,7 +19,7 @@ const loadGetPinoHttp = async (env: Record) => { delete process.env.RSSHCOOL_AWS_REGION; Object.assign(process.env, env); - const mod = await import('./pino'); + const mod = await import('./pino.js'); const result = mod.getPinoHttp(); process.env = original; diff --git a/nestjs/src/courses/course-access.service.spec.ts b/nestjs/src/courses/course-access.service.spec.ts index e429fd0105..e14dfd64f6 100644 --- a/nestjs/src/courses/course-access.service.spec.ts +++ b/nestjs/src/courses/course-access.service.spec.ts @@ -27,7 +27,7 @@ const mockStudent = { id: 101, userId: 5005, isExpelled: false, - expellingReason: null, + expellingReason: '', } as Partial as Student; const mockCourse = { @@ -190,7 +190,7 @@ describe('CourseAccessService', () => { }, select: ['id'], }); - const [[arg]] = courseRepository.find.mock.calls; + const [arg] = courseRepository.find.mock.calls[0]!; expect(arg.where).not.toHaveProperty('id'); }); @@ -200,7 +200,7 @@ describe('CourseAccessService', () => { await service.getUserAllowedCourseIds(user, [], 2030); - const [[arg]] = courseRepository.find.mock.calls; + const [arg] = courseRepository.find.mock.calls[0]!; // Between encodes the boundaries as _value: [start, end] const range = arg.where.startDate as { _value: [Date, Date] }; expect(range._value[0]).toEqual(new Date('2030')); diff --git a/nestjs/src/courses/course-mentors/course-mentors.controller.spec.ts b/nestjs/src/courses/course-mentors/course-mentors.controller.spec.ts index ce85fa48ac..b8e9c0094c 100644 --- a/nestjs/src/courses/course-mentors/course-mentors.controller.spec.ts +++ b/nestjs/src/courses/course-mentors/course-mentors.controller.spec.ts @@ -1,3 +1,4 @@ +import type { CurrentRequest } from '../../auth'; import { ForbiddenException, StreamableFile } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { CourseMentorsController } from './course-mentors.controller'; @@ -13,7 +14,7 @@ const courseMentorsService = { searchMentors: vi.fn(), }; -const createReq = (user: Record) => ({ user }) as never; +const createReq = (user: Pick) => ({ user }) as CurrentRequest; describe('CourseMentorsController', () => { let controller: CourseMentorsController; diff --git a/nestjs/src/courses/course-mentors/mentor-stats.spec.ts b/nestjs/src/courses/course-mentors/mentor-stats.spec.ts index 65eb3082fc..c6651a0ce4 100644 --- a/nestjs/src/courses/course-mentors/mentor-stats.spec.ts +++ b/nestjs/src/courses/course-mentors/mentor-stats.spec.ts @@ -125,7 +125,7 @@ describe('CourseMentorsService stats & search', () => { await service.getMentorsWithStats(5); - const ctQb = courseTaskRepository.createQueryBuilder.mock.results[0].value; + const ctQb = courseTaskRepository.createQueryBuilder.mock.results[0]!.value; expect(courseTaskRepository.createQueryBuilder).toHaveBeenCalledWith('c'); expect(ctQb.where).toHaveBeenCalledWith({ checker: 'mentor', courseId: 5, disabled: false }); expect(ctQb.andWhere).toHaveBeenCalledWith('c.studentEndDate < NOW()'); @@ -158,7 +158,7 @@ describe('CourseMentorsService stats & search', () => { checked: 4, }, }); - expect(mentor.taskResultsStats?.lastUpdatedDate).toEqual(new Date('2024-05-01T00:00:00.000Z')); + expect(mentor!.taskResultsStats?.lastUpdatedDate).toEqual(new Date('2024-05-01T00:00:00.000Z')); }); it('defaults missing checked count to 0 and null last-checked date', async () => { @@ -172,7 +172,7 @@ describe('CourseMentorsService stats & search', () => { const [mentor] = await service.getMentorsWithStats(5); - expect(mentor.taskResultsStats).toMatchObject({ + expect(mentor!.taskResultsStats).toMatchObject({ total: 0, // 1 active student * 0 tasks checked: 0, lastUpdatedDate: null, @@ -237,7 +237,7 @@ describe('CourseMentorsService stats & search', () => { // buildTaskResultSubQuery is invoked twice (checked count + last checked dates), // each appending a 0 to preserve a non-empty IN (...) list. - const subQb = taskResultRepository.createQueryBuilder.mock.results[0].value; + const subQb = taskResultRepository.createQueryBuilder.mock.results[0]!.value; expect(subQb.where).toHaveBeenCalledWith('t.courseTaskId IN (:...ids)', { ids: [100, 101, 0] }); }); }); diff --git a/nestjs/src/courses/course-mentors/mentor-status.spec.ts b/nestjs/src/courses/course-mentors/mentor-status.spec.ts index bd51279bf7..8b9cb0dbfb 100644 --- a/nestjs/src/courses/course-mentors/mentor-status.spec.ts +++ b/nestjs/src/courses/course-mentors/mentor-status.spec.ts @@ -84,13 +84,13 @@ describe('CourseMentorsService expel/restore', () => { await service.expelMentor(5, 'john-doe'); - const mentorQb = mentorsRepository.createQueryBuilder.mock.results[0].value; + const mentorQb = mentorsRepository.createQueryBuilder.mock.results[0]!.value; expect(mentorQb.where).toHaveBeenCalledWith('user.githubId = :githubId', { githubId: 'john-doe' }); expect(mentorQb.andWhere).toHaveBeenCalledWith('mentor.courseId = :courseId', { courseId: 5 }); expect(studentRepository.update).toHaveBeenCalledWith({ mentorId: 7 }, { mentorId: null }); expect(mentorsRepository.update).toHaveBeenCalledWith(7, { isExpelled: true }); // pending (no feedback) interviews of the mentor are canceled - const interviewsQb = stageInterviewRepository.createQueryBuilder.mock.results[0].value; + const interviewsQb = stageInterviewRepository.createQueryBuilder.mock.results[0]!.value; expect(interviewsQb.where).toHaveBeenCalledWith('f.id IS NULL'); expect(interviewsQb.andWhere).toHaveBeenCalledWith('s.mentorId = :mentorId', { mentorId: 7 }); expect(stageInterviewRepository.update).toHaveBeenCalledWith([100, 101], { isCanceled: true }); diff --git a/nestjs/src/courses/course-schedule/course-schedule.service.spec.ts b/nestjs/src/courses/course-schedule/course-schedule.service.spec.ts index b6f2022f1a..fcddccd6df 100644 --- a/nestjs/src/courses/course-schedule/course-schedule.service.spec.ts +++ b/nestjs/src/courses/course-schedule/course-schedule.service.spec.ts @@ -451,7 +451,7 @@ describe('CourseScheduleService (branch coverage)', () => { deps.teamDistributionStudentRepository.find.mockResolvedValue([]); const [item] = await deps.service.getAll(333, 777); - return item.score; + return item!.score; } it('prefers the task result score', async () => { @@ -559,7 +559,7 @@ describe('CourseScheduleService (branch coverage)', () => { descriptionUrl: 'desc-url', type: CourseScheduleDataSource.CourseTask, }); - expect(schedule[0].organizer).toMatchObject({ id: 9, githubId: 'jane', name: 'Jane Roe' }); + expect(schedule[0]!.organizer).toMatchObject({ id: 9, githubId: 'jane', name: 'Jane Roe' }); }); it('sets organizer to null when the task has no taskOwner', async () => { @@ -571,7 +571,7 @@ describe('CourseScheduleService (branch coverage)', () => { const [item] = await deps.service.getAll(333); - expect(item.organizer).toBeNull(); + expect(item!.organizer).toBeNull(); }); it('expands a cross-check task into submit and review items', async () => { @@ -621,7 +621,7 @@ describe('CourseScheduleService (branch coverage)', () => { type: CourseScheduleDataSource.CourseEvent, descriptionUrl: 'ev-url', }); - expect(schedule[0].organizer).toMatchObject({ githubId: 'org', name: 'Org Anizer' }); + expect(schedule[0]!.organizer).toMatchObject({ githubId: 'org', name: 'Org Anizer' }); }); it('maps team distributions to schedule items', async () => { @@ -748,7 +748,7 @@ describe('CourseScheduleService (branch coverage)', () => { deps.teamDistributionStudentRepository.find.mockResolvedValue(students); const [item] = await deps.service.getAll(333, studentId); - return item.status; + return item!.status; } it('is Future when current time is before the start date', async () => { @@ -860,7 +860,7 @@ describe('CourseScheduleService (branch coverage)', () => { deps.courseEventRepository.find.mockResolvedValue([]); deps.courseTeamDistributionRepository.find.mockResolvedValue([]); const [item] = await deps.service.getAll(333); - return item.tag; + return item!.tag; } it('tags selfeducation tasks as Test', async () => { @@ -909,12 +909,12 @@ describe('CourseScheduleService (branch coverage)', () => { it('tags self-study events as SelfStudy', async () => { const item = await eventResult({ event: { name: 'E', descriptionUrl: 'u', type: EventType.SelfStudy } }); - expect(item.tag).toBe(CourseScheduleItemTag.SelfStudy); + expect(item!.tag).toBe(CourseScheduleItemTag.SelfStudy); }); it('tags any other event type as Lecture', async () => { const item = await eventResult({ event: { name: 'E', descriptionUrl: 'u', type: EventType.Workshop } }); - expect(item.tag).toBe(CourseScheduleItemTag.Lecture); + expect(item!.tag).toBe(CourseScheduleItemTag.Lecture); }); it('marks an event Archived when its end time has passed', async () => { @@ -922,30 +922,30 @@ describe('CourseScheduleService (branch coverage)', () => { dateTime: PAST, endTime: PAST.getTime().toString(), }); - expect(item.status).toBe(CourseScheduleItemStatus.Archived); + expect(item!.status).toBe(CourseScheduleItemStatus.Archived); }); it('marks an event Available when it started but has not ended', async () => { const item = await eventResult({ dateTime: PAST, endTime: FUTURE.getTime().toString() }); - expect(item.status).toBe(CourseScheduleItemStatus.Available); + expect(item!.status).toBe(CourseScheduleItemStatus.Available); }); it('marks an event Future when it has not started yet', async () => { const item = await eventResult({ dateTime: FUTURE, endTime: null }); - expect(item.status).toBe(CourseScheduleItemStatus.Future); + expect(item!.status).toBe(CourseScheduleItemStatus.Future); }); it('derives the end time from duration when endTime is absent', async () => { // started in the past, default duration keeps it in the past -> Archived const item = await eventResult({ dateTime: PAST, endTime: null, duration: 1 }); - expect(item.status).toBe(CourseScheduleItemStatus.Archived); + expect(item!.status).toBe(CourseScheduleItemStatus.Archived); }); it('uses the default 60 minute duration when duration is null', async () => { // started a moment ago without a duration -> still within the default window const recent = new Date(NOW.getTime() - 30 * 60 * 1000); const item = await eventResult({ dateTime: recent, endTime: null, duration: null }); - expect(item.status).toBe(CourseScheduleItemStatus.Available); + expect(item!.status).toBe(CourseScheduleItemStatus.Available); }); }); @@ -984,7 +984,7 @@ describe('CourseScheduleService (branch coverage)', () => { deps.taskCheckerRepository.find.mockResolvedValue([]); deps.teamDistributionStudentRepository.find.mockResolvedValue([]); const [item] = await deps.service.getAll(333, opts.studentId); - return item.status; + return item!.status; } it('is Archived when start or end date is missing', async () => { @@ -1104,7 +1104,7 @@ describe('CourseScheduleService (branch coverage)', () => { await deps.service.copyFromTo(1, 2); const dayMs = 7 * 24 * 60 * 60 * 1000; - const savedTask = deps.courseTaskRepository.save.mock.calls[0][0]; + const savedTask = deps.courseTaskRepository.save.mock.calls[0]![0]; expect(savedTask).not.toHaveProperty('id'); expect(savedTask.courseId).toBe(2); expect(savedTask.crossCheckEndDate).toEqual(new Date(new Date('2022-01-05T00:00:00.000Z').getTime() + dayMs)); @@ -1113,14 +1113,14 @@ describe('CourseScheduleService (branch coverage)', () => { expect(savedTask.mentorStartDate).toBeNull(); expect(savedTask.studentRegistrationStartDate).toBeNull(); - const savedEvent = deps.courseEventRepository.save.mock.calls[0][0]; + const savedEvent = deps.courseEventRepository.save.mock.calls[0]![0]; expect(savedEvent.courseId).toBe(2); expect(savedEvent.date).toBeNull(); expect(savedEvent.time).toBeNull(); expect(savedEvent.dateTime).toEqual(new Date(new Date('2022-01-04T00:00:00.000Z').getTime() + dayMs)); expect(savedEvent.endTime).toBeNull(); - const savedTd = deps.teamDistribution.save.mock.calls[0][0]; + const savedTd = deps.teamDistribution.save.mock.calls[0]![0]; expect(savedTd.courseId).toBe(2); expect(savedTd.startDate).toEqual(new Date(new Date('2022-01-06T00:00:00.000Z').getTime() + dayMs)); expect(savedTd.endDate).toEqual(new Date(new Date('2022-01-07T00:00:00.000Z').getTime() + dayMs)); @@ -1148,7 +1148,7 @@ describe('CourseScheduleService (branch coverage)', () => { await deps.service.copyFromTo(1, 2); - const savedTd = deps.teamDistribution.save.mock.calls[0][0]; + const savedTd = deps.teamDistribution.save.mock.calls[0]![0]; expect(savedTd.startDate).toBeNull(); expect(savedTd.endDate).toBeNull(); }); @@ -1180,7 +1180,7 @@ describe('CourseScheduleService (branch coverage)', () => { await deps.service.copyFromTo(1, 2); const dayMs = 24 * 60 * 60 * 1000; - const savedTask = deps.courseTaskRepository.save.mock.calls[0][0]; + const savedTask = deps.courseTaskRepository.save.mock.calls[0]![0]; expect(savedTask.studentStartDate).toEqual(new Date(new Date('2022-01-02T00:00:00.000Z').getTime() + dayMs)); }); diff --git a/nestjs/src/courses/course-students/course-students-service.spec.ts b/nestjs/src/courses/course-students/course-students-service.spec.ts index a11b8582e8..27a31f3a63 100644 --- a/nestjs/src/courses/course-students/course-students-service.spec.ts +++ b/nestjs/src/courses/course-students/course-students-service.spec.ts @@ -191,7 +191,7 @@ describe('CourseStudentsService', () => { const result = await service.getStudentsWithDetails(5, true); - expect(result[0].mentor).toEqual({ + expect(result[0]!.mentor).toEqual({ isActive: false, // mentor.isExpelled => inactive name: '', id: 9, @@ -329,7 +329,7 @@ describe('CourseStudentsService', () => { const result = await service.searchCourseStudents(5, 'do', false); - expect(result[0].name).toBe('Doe'); + expect(result[0]!.name).toBe('Doe'); }); }); diff --git a/nestjs/src/courses/course-tasks/course-tasks.controller.spec.ts b/nestjs/src/courses/course-tasks/course-tasks.controller.spec.ts index bb3c45da93..3c05f3256f 100644 --- a/nestjs/src/courses/course-tasks/course-tasks.controller.spec.ts +++ b/nestjs/src/courses/course-tasks/course-tasks.controller.spec.ts @@ -150,7 +150,9 @@ describe('CourseTasksController', () => { describe('getAllExtended', () => { it('maps detailed service results to CourseTaskDetailedDto instances', async () => { - service.getAllDetailed.mockResolvedValue([mockCourseTask]); + service.getAllDetailed.mockResolvedValue([ + { ...mockCourseTask, resultsCount: 0, interviewResultsCount: 0, stageInterviewResultsCount: 0 }, + ]); const req = {} as CurrentRequest; const result = await controller.getAllExtended(req, 5); diff --git a/nestjs/src/courses/course-tasks/course-tasks.service.spec.ts b/nestjs/src/courses/course-tasks/course-tasks.service.spec.ts index 08fe73a82c..985b9ab9c5 100644 --- a/nestjs/src/courses/course-tasks/course-tasks.service.spec.ts +++ b/nestjs/src/courses/course-tasks/course-tasks.service.spec.ts @@ -71,7 +71,7 @@ describe('CourseTasksService', () => { await service.getAll(5, Status.Started); - const where = courseTaskRepository.find.mock.calls[0][0].where; + const where = courseTaskRepository.find.mock.calls[0]![0].where; expect(where.studentStartDate).toEqual(LessThanOrEqual(expect.any(String))); expect(where.studentEndDate).toBeUndefined(); }); @@ -81,7 +81,7 @@ describe('CourseTasksService', () => { await service.getAll(5, Status.InProgress); - const where = courseTaskRepository.find.mock.calls[0][0].where; + const where = courseTaskRepository.find.mock.calls[0]![0].where; expect(where.studentStartDate).toEqual(LessThanOrEqual(expect.any(String))); expect(where.studentEndDate).toEqual(MoreThan(expect.any(String))); }); @@ -91,7 +91,7 @@ describe('CourseTasksService', () => { await service.getAll(5, Status.Finished); - const where = courseTaskRepository.find.mock.calls[0][0].where; + const where = courseTaskRepository.find.mock.calls[0]![0].where; expect(where.studentEndDate).toEqual(LessThan(expect.any(String))); expect(where.studentStartDate).toBeUndefined(); }); @@ -101,7 +101,7 @@ describe('CourseTasksService', () => { await service.getAll(5, undefined, true); - expect(courseTaskRepository.find.mock.calls[0][0].cache).toBe(60 * 1000); + expect(courseTaskRepository.find.mock.calls[0]![0].cache).toBe(60 * 1000); }); it('passes a checker filter through', async () => { @@ -109,7 +109,7 @@ describe('CourseTasksService', () => { await service.getAll(5, undefined, false, Checker.AutoTest); - expect(courseTaskRepository.find.mock.calls[0][0].where.checker).toBe(Checker.AutoTest); + expect(courseTaskRepository.find.mock.calls[0]![0].where.checker).toBe(Checker.AutoTest); }); }); @@ -219,7 +219,7 @@ describe('CourseTasksService', () => { const result = await service.getUpdatedTasks(5, 3); - const args = courseTaskRepository.find.mock.calls[0][0]; + const args = courseTaskRepository.find.mock.calls[0]![0]; expect(args.where.courseId).toBe(5); expect(args.where.updatedDate).toEqual(MoreThanOrEqual(expect.any(String))); expect(args.relations).toEqual(['task']); @@ -233,12 +233,12 @@ describe('CourseTasksService', () => { await service.getTasksPendingDeadline(5); - const where = courseTaskRepository.find.mock.calls[0][0].where; + const where = courseTaskRepository.find.mock.calls[0]![0].where; expect(where).toMatchObject({ courseId: 5, disabled: false }); expect(where.studentStartDate).toEqual(LessThanOrEqual(expect.any(String))); expect(where.studentEndDate).toEqual(Between(expect.any(String), expect.any(String))); - expect(courseTaskRepository.find.mock.calls[0][0].relations).toEqual(['task', 'taskSolutions']); - expect(courseTaskRepository.find.mock.calls[0][0].order).toEqual({ studentEndDate: 'ASC' }); + expect(courseTaskRepository.find.mock.calls[0]![0].relations).toEqual(['task', 'taskSolutions']); + expect(courseTaskRepository.find.mock.calls[0]![0].order).toEqual({ studentEndDate: 'ASC' }); }); it('honours custom deadlineWithinHours and safeBuffer options', async () => { @@ -246,7 +246,7 @@ describe('CourseTasksService', () => { await service.getTasksPendingDeadline(5, { deadlineWithinHours: 48, safeBuffer: 2 }); - const where = courseTaskRepository.find.mock.calls[0][0].where; + const where = courseTaskRepository.find.mock.calls[0]![0].where; expect(where.studentEndDate).toEqual(Between(expect.any(String), expect.any(String))); }); }); @@ -257,7 +257,7 @@ describe('CourseTasksService', () => { await service.getCrossCheckTasksPendingDeadline(5); - const args = courseTaskRepository.find.mock.calls[0][0]; + const args = courseTaskRepository.find.mock.calls[0]![0]; expect(args.where).toMatchObject({ courseId: 5, disabled: false, @@ -275,7 +275,7 @@ describe('CourseTasksService', () => { await service.getCrossCheckTasksPendingDeadline(5, { deadlineWithinHours: 48, safeBuffer: 2 }); - const where = courseTaskRepository.find.mock.calls[0][0].where; + const where = courseTaskRepository.find.mock.calls[0]![0].where; expect(where.crossCheckEndDate).toEqual(Between(expect.any(String), expect.any(String))); }); }); diff --git a/nestjs/src/courses/cross-checks/cross-check-assignments.spec.ts b/nestjs/src/courses/cross-checks/cross-check-assignments.spec.ts index 19256f16a6..491244f27d 100644 --- a/nestjs/src/courses/cross-checks/cross-check-assignments.spec.ts +++ b/nestjs/src/courses/cross-checks/cross-check-assignments.spec.ts @@ -99,6 +99,9 @@ describe('CourseCrossCheckService.getTaskSolutionAssignments', () => { {} as never, {} as never, {} as never, + {} as never, + {} as never, + {} as never, ); const result = await service.getTaskSolutionAssignments(32, 15); diff --git a/nestjs/src/courses/cross-checks/cross-check-messages.spec.ts b/nestjs/src/courses/cross-checks/cross-check-messages.spec.ts index ad61cfe484..d5ec8f2f5e 100644 --- a/nestjs/src/courses/cross-checks/cross-check-messages.spec.ts +++ b/nestjs/src/courses/cross-checks/cross-check-messages.spec.ts @@ -24,6 +24,9 @@ describe('CourseCrossCheckService.saveMessage / updateMessage', () => { { update: mockUpdate, createQueryBuilder: vi.fn(() => qb) } as never, {} as never, {} as never, + {} as never, + {} as never, + {} as never, ); } diff --git a/nestjs/src/courses/cross-checks/cross-check-result-submit.spec.ts b/nestjs/src/courses/cross-checks/cross-check-result-submit.spec.ts index 0d7dc7a683..a914e92eb1 100644 --- a/nestjs/src/courses/cross-checks/cross-check-result-submit.spec.ts +++ b/nestjs/src/courses/cross-checks/cross-check-result-submit.spec.ts @@ -45,6 +45,9 @@ describe('CourseCrossCheckService.saveResult', () => { { update: mockUpdate, insert: mockInsert, createQueryBuilder: vi.fn(() => qb) } as never, {} as never, {} as never, + {} as never, + {} as never, + {} as never, ); } @@ -113,6 +116,9 @@ describe('CourseCrossCheckService.saveSolutionComments', () => { {} as never, {} as never, {} as never, + {} as never, + {} as never, + {} as never, ); } diff --git a/nestjs/src/courses/cross-checks/cross-check-result.spec.ts b/nestjs/src/courses/cross-checks/cross-check-result.spec.ts index e49c5bc1a3..a1ed1b1b30 100644 --- a/nestjs/src/courses/cross-checks/cross-check-result.spec.ts +++ b/nestjs/src/courses/cross-checks/cross-check-result.spec.ts @@ -48,6 +48,8 @@ describe('CourseCrossCheckService.getResult', () => { {} as never, { createQueryBuilder: vi.fn(() => mockStudentQb) } as never, { findOne: mockUserFindOne } as never, + {} as never, + {} as never, ); } @@ -128,6 +130,8 @@ describe('CourseCrossCheckService.getTaskSolutionChecker', () => { {} as never, {} as never, {} as never, + {} as never, + {} as never, ); const result = await service.getTaskSolutionChecker(31, 32, 15); diff --git a/nestjs/src/courses/cross-checks/cross-check-solution-write.spec.ts b/nestjs/src/courses/cross-checks/cross-check-solution-write.spec.ts index abf8db8e2a..a2ff8a8b2a 100644 --- a/nestjs/src/courses/cross-checks/cross-check-solution-write.spec.ts +++ b/nestjs/src/courses/cross-checks/cross-check-solution-write.spec.ts @@ -25,6 +25,9 @@ describe('CourseCrossCheckService.saveSolution', () => { {} as never, {} as never, {} as never, + {} as never, + {} as never, + {} as never, ); } @@ -88,6 +91,9 @@ describe('CourseCrossCheckService.deleteSolution', () => { {} as never, {} as never, {} as never, + {} as never, + {} as never, + {} as never, ); await service.deleteSolution(31, 15); diff --git a/nestjs/src/courses/cross-checks/cross-check-solution.spec.ts b/nestjs/src/courses/cross-checks/cross-check-solution.spec.ts index 3d733b81ed..7c75184589 100644 --- a/nestjs/src/courses/cross-checks/cross-check-solution.spec.ts +++ b/nestjs/src/courses/cross-checks/cross-check-solution.spec.ts @@ -44,6 +44,9 @@ describe('CourseCrossCheckService.queryStudentByGithubId', () => { {} as never, {} as never, { createQueryBuilder: vi.fn(() => qb) } as never, + {} as never, + {} as never, + {} as never, ); return { service, calls }; } @@ -79,6 +82,9 @@ describe('CourseCrossCheckService.getCourseTask', () => { {} as never, { createQueryBuilder: vi.fn(() => qb) } as never, {} as never, + {} as never, + {} as never, + {} as never, ); const result = await service.getCourseTask(15); diff --git a/nestjs/src/courses/cross-checks/cross-check-task-details.spec.ts b/nestjs/src/courses/cross-checks/cross-check-task-details.spec.ts index 6ba7ba320e..4a6ca7f610 100644 --- a/nestjs/src/courses/cross-checks/cross-check-task-details.spec.ts +++ b/nestjs/src/courses/cross-checks/cross-check-task-details.spec.ts @@ -34,6 +34,10 @@ describe('CourseCrossCheckService.getTaskDetails', () => { {} as never, {} as never, { createQueryBuilder: vi.fn(() => qb) } as never, + {} as never, + {} as never, + {} as never, + {} as never, ); return { service, calls }; } diff --git a/nestjs/src/courses/interviews/interview-distribution.spec.ts b/nestjs/src/courses/interviews/interview-distribution.spec.ts index c0fb3f22c2..714f59ef4d 100644 --- a/nestjs/src/courses/interviews/interview-distribution.spec.ts +++ b/nestjs/src/courses/interviews/interview-distribution.spec.ts @@ -91,7 +91,7 @@ describe('InterviewsService (distribution, lookups, registration)', () => { const result = await service.getAll(5, {}); - const arg = repos.courseTask.find.mock.calls[0][0]; + const arg = repos.courseTask.find.mock.calls[0]![0]; expect(arg.where).toMatchObject({ courseId: 5, disabled: false }); // In([TaskType.Interview]) wraps the type value expect(arg.where.type._value).toEqual([TaskType.Interview]); @@ -104,7 +104,7 @@ describe('InterviewsService (distribution, lookups, registration)', () => { await service.getAll(5, { disabled: true, types: [TaskType.StageInterview] }); - const arg = repos.courseTask.find.mock.calls[0][0]; + const arg = repos.courseTask.find.mock.calls[0]![0]; expect(arg.where.disabled).toBe(true); expect(arg.where.type._value).toEqual([TaskType.StageInterview]); }); @@ -190,8 +190,8 @@ describe('InterviewsService (distribution, lookups, registration)', () => { const [pair] = await service.getInterviewPairs(7); - expect(pair.status).toBe(InterviewStatus.NotCompleted); - expect(pair.result).toBeNull(); + expect(pair!.status).toBe(InterviewStatus.NotCompleted); + expect(pair!.result).toBeNull(); }); it('builds names from a single name part when the other is missing', async () => { @@ -200,8 +200,8 @@ describe('InterviewsService (distribution, lookups, registration)', () => { const [pair] = await service.getInterviewPairs(7); - expect(pair.interviewer.name).toBe('Mentor'); - expect(pair.student.name).toBe('Doe'); + expect(pair!.interviewer.name).toBe('Mentor'); + expect(pair!.student.name).toBe('Doe'); }); }); @@ -246,7 +246,7 @@ describe('InterviewsService (distribution, lookups, registration)', () => { const result = await service.getStageInterviewAvailableStudents(5); expect(result).toHaveLength(1); - expect(result[0].isGoodCandidate).toBe(false); + expect(result[0]!.isGoodCandidate).toBe(false); }); it('includes a student whose interviews are all canceled', async () => { @@ -256,7 +256,7 @@ describe('InterviewsService (distribution, lookups, registration)', () => { const result = await service.getStageInterviewAvailableStudents(5); expect(result).toHaveLength(1); - expect(result[0].id).toBe(42); + expect(result[0]!.id).toBe(42); }); it('excludes a student with an active (non-canceled, non-completed) interview', async () => { @@ -295,10 +295,10 @@ describe('InterviewsService (distribution, lookups, registration)', () => { const [student] = await service.getStageInterviewAvailableStudents(5); - expect(student.isGoodCandidate).toBe(true); - expect(student.rating).toBe(88); - expect(student.maxScore).toBe(100); - expect(student.feedbackVersion).toBe(3); + expect(student!.isGoodCandidate).toBe(true); + expect(student!.rating).toBe(88); + expect(student!.maxScore).toBe(100); + expect(student!.feedbackVersion).toBe(3); }); }); diff --git a/nestjs/src/courses/interviews/interview-lists.spec.ts b/nestjs/src/courses/interviews/interview-lists.spec.ts index 7c642b32fd..743cd36d93 100644 --- a/nestjs/src/courses/interviews/interview-lists.spec.ts +++ b/nestjs/src/courses/interviews/interview-lists.spec.ts @@ -41,8 +41,14 @@ describe('InterviewsService.getUserInterviewDetails', () => { }); it('returns stage interviews first, then regular interviews, for both user types', async () => { - vi.spyOn(service as never, 'getRegularInterviewDetails' as never).mockResolvedValue(regular as never); - vi.spyOn(service as never, 'getStageInterviewDetails' as never).mockResolvedValue(stage as never); + vi.spyOn( + service as unknown as { getRegularInterviewDetails: InterviewsService['getRegularInterviewDetails'] }, + 'getRegularInterviewDetails', + ).mockResolvedValue(regular as never); + vi.spyOn( + service as unknown as { getStageInterviewDetails: InterviewsService['getStageInterviewDetails'] }, + 'getStageInterviewDetails', + ).mockResolvedValue(stage as never); const studentResult = await service.getUserInterviewDetails(5, 'john-doe', 'student'); expect(studentResult).toEqual([...stage, ...regular]); diff --git a/nestjs/src/courses/interviews/interview-user-details.spec.ts b/nestjs/src/courses/interviews/interview-user-details.spec.ts index 6da82ee356..aea4a4def0 100644 --- a/nestjs/src/courses/interviews/interview-user-details.spec.ts +++ b/nestjs/src/courses/interviews/interview-user-details.spec.ts @@ -193,8 +193,7 @@ describe('InterviewsService.getUserInterviewDetails (real private methods)', () const [details] = await service.getUserInterviewDetails(5, 'john-doe', 'student'); - expect(details.status).toBe(InterviewStatus.Canceled); - expect(details.result).toBeNull(); + expect(details).toMatchObject({ status: InterviewStatus.Canceled, result: null }); }); it('builds interviewer/student names from a single present name part', async () => { @@ -208,8 +207,7 @@ describe('InterviewsService.getUserInterviewDetails (real private methods)', () const [details] = await service.getUserInterviewDetails(5, 'john-doe', 'student'); - expect(details.interviewer.name).toBe('Mentor'); - expect(details.student.name).toBe('Doe'); + expect(details).toMatchObject({ interviewer: { name: 'Mentor' }, student: { name: 'Doe' } }); }); it('reports a not-completed stage interview status when neither completed nor canceled', async () => { @@ -219,6 +217,6 @@ describe('InterviewsService.getUserInterviewDetails (real private methods)', () const [details] = await service.getUserInterviewDetails(5, 'mentor-x', 'mentor'); - expect(details.status).toBe(InterviewStatus.NotCompleted); + expect(details).toMatchObject({ status: InterviewStatus.NotCompleted }); }); }); diff --git a/nestjs/src/courses/interviews/interviewer-students.spec.ts b/nestjs/src/courses/interviews/interviewer-students.spec.ts index d2f025a2ac..7affa7da8e 100644 --- a/nestjs/src/courses/interviews/interviewer-students.spec.ts +++ b/nestjs/src/courses/interviews/interviewer-students.spec.ts @@ -38,8 +38,8 @@ const createQb = (method: 'getOne' | 'getMany', result: unknown) => { getOne: vi.fn(), getMany: vi.fn(), }; - Object.keys(qb).forEach(k => qb[k].mockReturnValue(qb)); - qb[method].mockResolvedValue(result); + Object.keys(qb).forEach(k => qb[k]!.mockReturnValue(qb)); + qb[method]!.mockResolvedValue(result); return qb; }; @@ -114,7 +114,9 @@ describe('InterviewsService.getInterviewStudentsByMentor', () => { }; studentRepository.createQueryBuilder.mockReturnValue(createQb('getMany', [expelled])); - const [student] = await service.getInterviewStudentsByMentor(5, 7, 'mentor-x'); + const students = await service.getInterviewStudentsByMentor(5, 7, 'mentor-x'); + expect(students).not.toBeNull(); + const student = students![0]!; expect(student.cityName).toBe(''); expect(student.countryName).toBe(''); diff --git a/nestjs/src/courses/interviews/stage-interview-distribution.spec.ts b/nestjs/src/courses/interviews/stage-interview-distribution.spec.ts index 52a6a7ed9f..65c3030329 100644 --- a/nestjs/src/courses/interviews/stage-interview-distribution.spec.ts +++ b/nestjs/src/courses/interviews/stage-interview-distribution.spec.ts @@ -292,7 +292,7 @@ describe('stage-interview-distribution', () => { const result = distributeStudentsRandomly([mentor(1, 1, true)], [student(1, 90), student(2, 10), student(3, 50)]); expect(result).toHaveLength(1); - expect(result[0].student).toEqual({ id: 2 }); + expect(result[0]!.student).toEqual({ id: 2 }); }); it('a lowGrade mentor with a single student picks that student (minScore stays 0 on first item)', () => { diff --git a/nestjs/src/courses/interviews/stage-interviews.spec.ts b/nestjs/src/courses/interviews/stage-interviews.spec.ts index 2353aaf37f..1369928d96 100644 --- a/nestjs/src/courses/interviews/stage-interviews.spec.ts +++ b/nestjs/src/courses/interviews/stage-interviews.spec.ts @@ -216,8 +216,8 @@ describe('StageInterviewsService', () => { const [result] = await service.findMany(5); - expect(result.completed).toBe(true); - expect(result.status).toBe(1); // InterviewStatus.Completed + expect(result!.completed).toBe(true); + expect(result!.status).toBe(1); // InterviewStatus.Completed }); it('maps a canceled (not completed) interview to Canceled status', async () => { @@ -227,7 +227,7 @@ describe('StageInterviewsService', () => { const [result] = await service.findMany(5); - expect(result.status).toBe(2); // InterviewStatus.Canceled + expect(result!.status).toBe(2); // InterviewStatus.Canceled }); it('falls back to undefined city/country and "any" preference when fields are null', async () => { @@ -244,11 +244,11 @@ describe('StageInterviewsService', () => { const [result] = await service.findMany(5); - expect(result.student.cityName).toBeUndefined(); - expect(result.student.countryName).toBeUndefined(); - expect(result.interviewer.cityName).toBeUndefined(); - expect(result.interviewer.countryName).toBeUndefined(); - expect(result.interviewer.preference).toBe('any'); + expect(result!.student.cityName).toBeUndefined(); + expect(result!.student.countryName).toBeUndefined(); + expect(result!.interviewer.cityName).toBeUndefined(); + expect(result!.interviewer.countryName).toBeUndefined(); + expect(result!.interviewer.preference).toBe('any'); }); it('returns an empty array when there are no interviews', async () => { @@ -268,8 +268,8 @@ describe('StageInterviewsService', () => { const [result] = await service.findMany(5); - expect(result.student.name).toBe('Doe'); // firstName null -> only lastName - expect(result.interviewer.name).toBe('Mentor'); // lastName null -> only firstName + expect(result!.student.name).toBe('Doe'); // firstName null -> only lastName + expect(result!.interviewer.name).toBe('Mentor'); // lastName null -> only firstName }); }); @@ -315,9 +315,9 @@ describe('StageInterviewsService', () => { const [result] = await service.findByInterviewer(5, 'mentor-x'); - expect(result.status).toBe(1); // Completed - expect(result.result).toBe('yes'); - expect(result.decision).toBe('yes'); + expect(result!.status).toBe(1); // Completed + expect(result!.result).toBe('yes'); + expect(result!.decision).toBe('yes'); }); it('maps a canceled interview to Canceled status', async () => { @@ -325,7 +325,7 @@ describe('StageInterviewsService', () => { const [result] = await service.findByInterviewer(5, 'mentor-x'); - expect(result.status).toBe(2); // Canceled + expect(result!.status).toBe(2); // Canceled }); }); diff --git a/nestjs/src/courses/mentors/mentors.service.spec.ts b/nestjs/src/courses/mentors/mentors.service.spec.ts index e1e07ebb41..c5c0e712ee 100644 --- a/nestjs/src/courses/mentors/mentors.service.spec.ts +++ b/nestjs/src/courses/mentors/mentors.service.spec.ts @@ -122,11 +122,12 @@ describe('MentorsService', () => { }); it('falls back to empty strings/array for null user fields and missing students', () => { - const result = MentorsService.convertMentorToMentorBasic({ + const mentor = Object.assign(new Mentor(), { id: 8, isExpelled: false, user: { githubId: 'no-name', firstName: null, lastName: null, cityName: null, countryName: null }, - } as Partial as Mentor); + }); + const result = MentorsService.convertMentorToMentorBasic(mentor); expect(result).toMatchObject({ name: '(Empty)', @@ -276,8 +277,8 @@ describe('MentorsService', () => { const [dto] = await service.getStudentsTasks(7, 5); - expect(dto.status).toBe(SolutionItemStatus.InReview); - expect(dto.resultScore).toBeNull(); + expect(dto!.status).toBe(SolutionItemStatus.InReview); + expect(dto!.resultScore).toBeNull(); }); it('returns RandomTask when no mentor is assigned and there is no score', async () => { @@ -289,7 +290,7 @@ describe('MentorsService', () => { const [dto] = await service.getStudentsTasks(7, 5); - expect(dto.status).toBe(SolutionItemStatus.RandomTask); + expect(dto!.status).toBe(SolutionItemStatus.RandomTask); }); it('treats a zero score as Done (0 is a real result, not "no score")', async () => { @@ -301,8 +302,8 @@ describe('MentorsService', () => { const [dto] = await service.getStudentsTasks(7, 5); - expect(dto.status).toBe(SolutionItemStatus.Done); - expect(dto.resultScore).toBe(0); + expect(dto!.status).toBe(SolutionItemStatus.Done); + expect(dto!.resultScore).toBe(0); }); it('returns an empty list when there are no solutions', async () => { diff --git a/nestjs/src/courses/score/write-score.service.spec.ts b/nestjs/src/courses/score/write-score.service.spec.ts index 481ae20762..0913b8a42d 100644 --- a/nestjs/src/courses/score/write-score.service.spec.ts +++ b/nestjs/src/courses/score/write-score.service.spec.ts @@ -72,7 +72,7 @@ describe('WriteScoreService', () => { const after = Date.now(); expect(taskResultRepository.save).toHaveBeenCalledTimes(1); - const saved = taskResultRepository.save.mock.calls[0][0] as Partial; + const saved = taskResultRepository.save.mock.calls[0]![0] as Partial; expect(saved).toMatchObject({ courseTaskId: 20, studentId: 10, @@ -97,7 +97,7 @@ describe('WriteScoreService', () => { it('sets lastCheckerId to undefined when authorId is not provided (defaults to 0)', async () => { await service.saveScoreWithStatus(10, 20, { score: 90, comment: 'great' }); - const saved = taskResultRepository.save.mock.calls[0][0] as Partial; + const saved = taskResultRepository.save.mock.calls[0]![0] as Partial; expect(saved.lastCheckerId).toBeUndefined(); expect(saved.historicalScores![0]!.authorId).toBe(0); }); @@ -105,14 +105,14 @@ describe('WriteScoreService', () => { it('sets lastCheckerId to undefined when authorId is 0 (not > 0)', async () => { await service.saveScoreWithStatus(10, 20, { authorId: 0, score: 90, comment: 'great' }); - const saved = taskResultRepository.save.mock.calls[0][0] as Partial; + const saved = taskResultRepository.save.mock.calls[0]![0] as Partial; expect(saved.lastCheckerId).toBeUndefined(); }); it('passes githubPrUrl through as undefined when not provided', async () => { await service.saveScoreWithStatus(10, 20, { score: 90, comment: 'great' }); - const saved = taskResultRepository.save.mock.calls[0][0] as Partial; + const saved = taskResultRepository.save.mock.calls[0]![0] as Partial; expect(saved.githubPrUrl).toBeUndefined(); }); diff --git a/nestjs/src/courses/stats/course-stats.service.spec.ts b/nestjs/src/courses/stats/course-stats.service.spec.ts index 4a59be5c07..188affd580 100644 --- a/nestjs/src/courses/stats/course-stats.service.spec.ts +++ b/nestjs/src/courses/stats/course-stats.service.spec.ts @@ -3,7 +3,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Student } from '@entities/student'; import { CourseTask, Mentor, StageInterview, TaskInterviewResult, TaskResult } from '@entities/index'; -import { TaskType } from '@entities/task'; +import { Checker, CrossCheckStatus } from '@entities/courseTask'; +import { Task, TaskType } from '@entities/task'; import { CourseStatsService } from './course-stats.service'; import { CourseTasksService } from '../course-tasks'; import { CourseTaskDto } from '../course-tasks/dto'; @@ -40,8 +41,8 @@ const mockCourseTaskEntity = { type: TaskType.JSTask, maxScore: 100, scoreWeight: 1, - checker: 'auto', - crossCheckStatus: 'initial', + checker: Checker.AutoTest, + crossCheckStatus: CrossCheckStatus.Initial, crossCheckEndDate: null, pairsCount: null, submitText: null, @@ -51,8 +52,8 @@ const mockCourseTaskEntity = { studentStartDate: null, studentEndDate: null, studentRegistrationStartDate: null, - task: { name: 'Task name', descriptionUrl: 'http://example.com', type: TaskType.JSTask }, -} as Partial as CourseTask; + task: { name: 'Task name', descriptionUrl: 'http://example.com', type: TaskType.JSTask } as Task, +} as CourseTask; describe('CourseStatsService', () => { let service: CourseStatsService; @@ -507,7 +508,7 @@ describe('CourseStatsService', () => { }); expect(result.courseTasks).toHaveLength(1); expect(result.courseTasks[0]).toBeInstanceOf(CourseTaskDto); - expect(result.courseTasks[0].id).toBe(10); + expect(result.courseTasks[0]!.id).toBe(10); }); it('merges stats and countries across multiple course ids by summing matching keys', async () => { diff --git a/nestjs/src/courses/task-verifications/task-verifications.service.test.ts b/nestjs/src/courses/task-verifications/task-verifications.service.test.ts index e77a0b1feb..4a0ab7798d 100644 --- a/nestjs/src/courses/task-verifications/task-verifications.service.test.ts +++ b/nestjs/src/courses/task-verifications/task-verifications.service.test.ts @@ -182,8 +182,8 @@ describe('TaskVerificationsService', () => { const result = await service.getAnswersByAttempts(1, 2); expect(result).toHaveLength(1); - expect(result[0].questions).toHaveLength(1); - expect(result[0].questions[0]).toMatchObject({ + expect(result[0]!.questions).toHaveLength(1); + expect(result[0]!.questions[0]).toMatchObject({ question: 'Q1', answers: ['a', 'b'], selectedAnswers: [1], @@ -191,8 +191,8 @@ describe('TaskVerificationsService', () => { answersType: 'image', questionImage: 'i.png', }); - expect(result[0].score).toBe(50); - expect(result[0].maxScore).toBe(100); + expect(result[0]!.score).toBe(50); + expect(result[0]!.maxScore).toBe(100); }); it('should wrap a non-array incorrect answer value into an array of selected answers', async () => { @@ -203,7 +203,7 @@ describe('TaskVerificationsService', () => { const result = await service.getAnswersByAttempts(1, 2); - expect(result[0].questions[0].selectedAnswers).toEqual([1, 2]); + expect(result[0]!.questions[0]!.selectedAnswers).toEqual([1, 2]); }); it('should drop incorrect answers whose question is missing from the task attributes', async () => { @@ -217,7 +217,7 @@ describe('TaskVerificationsService', () => { const result = await service.getAnswersByAttempts(1, 2); - expect(result[0].questions).toHaveLength(0); + expect(result[0]!.questions).toHaveLength(0); }); }); diff --git a/nestjs/src/courses/tasks/tasks.service.spec.ts b/nestjs/src/courses/tasks/tasks.service.spec.ts index dbd6e3a1b8..0e6b898997 100644 --- a/nestjs/src/courses/tasks/tasks.service.spec.ts +++ b/nestjs/src/courses/tasks/tasks.service.spec.ts @@ -101,7 +101,7 @@ describe('TasksService', () => { task: expect.objectContaining({ id: 10 }), }); expect(pending?.[0]).not.toHaveProperty('studentHasSolution'); - expect(pending?.[0].course).not.toHaveProperty('students'); + expect(pending?.[0]!.course).not.toHaveProperty('students'); }); it('excludes a student who already has a solution for the task', async () => { @@ -163,7 +163,7 @@ describe('TasksService', () => { const pending = result.get(100); expect(pending).toHaveLength(1); - expect(pending?.[0].task.id).toBe(11); + expect(pending?.[0]!.task.id).toBe(11); }); it('aggregates pending tasks for the same userId across multiple courses', async () => { @@ -236,7 +236,7 @@ describe('TasksService', () => { task: expect.objectContaining({ id: 10 }), crossCheckEndDate: '2024-02-01', }); - expect(result.get(100)?.[0].course).not.toHaveProperty('students'); + expect(result.get(100)?.[0]!.course).not.toHaveProperty('students'); expect(result.get(200)).toHaveLength(1); }); diff --git a/nestjs/src/courses/team-distribution/distribute-students.service.test.ts b/nestjs/src/courses/team-distribution/distribute-students.service.test.ts index fa1f8cc763..130a0bd493 100644 --- a/nestjs/src/courses/team-distribution/distribute-students.service.test.ts +++ b/nestjs/src/courses/team-distribution/distribute-students.service.test.ts @@ -279,7 +279,7 @@ describe('DistributeStudentsService', () => { expect(teamService.generatePassword).toHaveBeenCalledTimes(1); const savedTeams = queryRunner.manager.save.mock.calls.find(call => call[0] === Team)?.[1] as Team[]; expect(savedTeams).toHaveLength(1); - expect(savedTeams[0].students.map(s => s.id).sort()).toEqual([1, 2]); + expect(savedTeams[0]!.students.map(s => s.id).sort()).toEqual([1, 2]); // Lead is the lowest-rank student (id 2 with rank 1). expect((savedTeams[0] as Team & { teamLeadId: number }).teamLeadId).toBe(2); }); diff --git a/nestjs/src/courses/team-distribution/team-distribution-student.service.test.ts b/nestjs/src/courses/team-distribution/team-distribution-student.service.test.ts index 6da8e10c9d..871b103619 100644 --- a/nestjs/src/courses/team-distribution/team-distribution-student.service.test.ts +++ b/nestjs/src/courses/team-distribution/team-distribution-student.service.test.ts @@ -421,7 +421,7 @@ describe('TeamDistributionStudentService', () => { await service.getStudentsByTeamDistributionId(5, { search: 'john', page: 2, limit: 25 }); // search branch adds a Brackets condition via andWhere; paginate receives the supplied page/limit - const bracketsArg = qb.andWhere.mock.calls.map(c => c[0]).find(a => a && typeof a === 'object'); + const bracketsArg = qb.andWhere!.mock.calls.map(c => c[0]).find(a => a && typeof a === 'object'); expect(bracketsArg).toBeDefined(); expect(paginateModule.paginate).toHaveBeenCalledWith(qb, { page: 2, limit: 25 }); diff --git a/nestjs/src/courses/team-distribution/team.service.test.ts b/nestjs/src/courses/team-distribution/team.service.test.ts index b1c001b10e..b3fcc8d35b 100644 --- a/nestjs/src/courses/team-distribution/team.service.test.ts +++ b/nestjs/src/courses/team-distribution/team.service.test.ts @@ -133,7 +133,7 @@ describe('TeamService', () => { await service.create(data); - const saved = repository.save.mock.calls[0][0] as Partial; + const saved = repository.save.mock.calls[0]![0] as Partial; expect(saved.teamLeadId).toBe(2); expect(saved.password).toHaveLength(6); }); @@ -144,7 +144,7 @@ describe('TeamService', () => { await service.create(data); - const saved = repository.save.mock.calls[0][0] as Partial; + const saved = repository.save.mock.calls[0]![0] as Partial; expect(saved.teamLeadId).toBeUndefined(); expect(saved.password).toHaveLength(6); }); @@ -154,7 +154,7 @@ describe('TeamService', () => { await service.create({ name: 'NoStudents' }); - const saved = repository.save.mock.calls[0][0] as Partial; + const saved = repository.save.mock.calls[0]![0] as Partial; expect(saved.teamLeadId).toBeUndefined(); }); }); @@ -194,7 +194,7 @@ describe('TeamService', () => { expect(teamDistributionStudentService.findByStudentIds).toHaveBeenCalledWith([2, 3], 7); const saved = teamDistributionStudentService.saveTeamDistributionStudents.mock - .calls[0][0] as TeamDistributionStudent[]; + .calls[0]![0] as TeamDistributionStudent[]; expect(saved).toEqual( expect.arrayContaining([ expect.objectContaining({ studentId: 2, distributed: false }), @@ -281,7 +281,7 @@ describe('TeamService', () => { describe('getStudentsCountInTeam', () => { it('should return the numeric count of students', async () => { - qb.getRawOne.mockResolvedValueOnce({ studentsCount: '4' }); + qb.getRawOne!.mockResolvedValueOnce({ studentsCount: '4' }); const result = await service.getStudentsCountInTeam(1); @@ -293,7 +293,7 @@ describe('TeamService', () => { describe('findTeamWithStudentsById', () => { it('should build the query and return the loaded team', async () => { const team = buildTeam(); - qb.getOneOrFail.mockResolvedValueOnce(team); + qb.getOneOrFail!.mockResolvedValueOnce(team); const result = await service.findTeamWithStudentsById(1); @@ -315,7 +315,7 @@ describe('TeamService', () => { await service.save(1, dto, 7, 9); expect(editSpy).toHaveBeenCalledWith(team, [2], 7, 9); - const saved = repository.save.mock.calls[0][0] as Team; + const saved = repository.save.mock.calls[0]![0] as Team; expect(saved.teamLeadId).toBe(2); expect(saved.students.map(s => s.id)).toEqual([2]); }); @@ -367,7 +367,7 @@ describe('TeamService', () => { describe('findAllByDistributionId', () => { it('should return all teams for the distribution', async () => { const teams = [buildTeam()]; - qb.getMany.mockResolvedValueOnce(teams); + qb.getMany!.mockResolvedValueOnce(teams); const result = await service.findAllByDistributionId(1); @@ -389,7 +389,7 @@ describe('TeamService', () => { describe('findByDistributionId', () => { it('should paginate without a search filter', async () => { - qb.getManyAndCount.mockResolvedValueOnce([[buildTeam()], 1]); + qb.getManyAndCount!.mockResolvedValueOnce([[buildTeam()], 1]); const result = await service.findByDistributionId(1, { page: 1, limit: 10 }); @@ -401,12 +401,12 @@ describe('TeamService', () => { it('should pre-filter by matching team ids when a search term is provided', async () => { // First query (matching team ids) -> getMany; second (page) -> getManyAndCount. - qb.getMany.mockResolvedValueOnce([{ id: 11 }, { id: 12 }] as Team[]); - qb.getManyAndCount.mockResolvedValueOnce([[buildTeam({ id: 11 })], 1]); + qb.getMany!.mockResolvedValueOnce([{ id: 11 }, { id: 12 }] as Team[]); + qb.getManyAndCount!.mockResolvedValueOnce([[buildTeam({ id: 11 })], 1]); // Execute the Brackets factory so the inner search-condition builder runs. const innerQb = { where: vi.fn(() => innerQb), orWhere: vi.fn(() => innerQb) }; - qb.andWhere.mockImplementation((arg: unknown) => { + qb.andWhere!.mockImplementation((arg: unknown) => { if (arg && typeof arg === 'object' && 'whereFactory' in arg) { (arg as { whereFactory: (b: typeof innerQb) => void }).whereFactory(innerQb); } @@ -424,7 +424,7 @@ describe('TeamService', () => { }); it('should fall back to default page and limit values', async () => { - qb.getManyAndCount.mockResolvedValueOnce([[], 0]); + qb.getManyAndCount!.mockResolvedValueOnce([[], 0]); const result = await service.findByDistributionId(1, {}); @@ -441,7 +441,7 @@ describe('TeamService', () => { teamLeadId: 1, students: [buildStudent({ id: 1, rank: 1 }), buildStudent({ id: 2, rank: 2 })], }); - qb.getOneOrFail.mockResolvedValueOnce(team); + qb.getOneOrFail!.mockResolvedValueOnce(team); teamDistributionStudentService.getTeamDistributionStudent.mockResolvedValueOnce({ id: 99, } as TeamDistributionStudent); @@ -467,7 +467,7 @@ describe('TeamService', () => { buildStudent({ id: 3, rank: 2 }), ], }); - qb.getOneOrFail.mockResolvedValueOnce(team); + qb.getOneOrFail!.mockResolvedValueOnce(team); teamDistributionStudentService.getTeamDistributionStudent.mockResolvedValueOnce({ id: 99, } as TeamDistributionStudent); @@ -480,7 +480,7 @@ describe('TeamService', () => { it('should set teamLeadId to 0 when the removed lead was the last student', async () => { const team = buildTeam({ id: 1, teamLeadId: 1, students: [buildStudent({ id: 1, rank: 1 })] }); - qb.getOneOrFail.mockResolvedValueOnce(team); + qb.getOneOrFail!.mockResolvedValueOnce(team); teamDistributionStudentService.getTeamDistributionStudent.mockResolvedValueOnce({ id: 99, } as TeamDistributionStudent); @@ -493,7 +493,7 @@ describe('TeamService', () => { it('should roll back and throw InternalServerErrorException on transaction failure', async () => { const team = buildTeam({ id: 1, teamLeadId: 1, students: [buildStudent({ id: 1, rank: 1 })] }); - qb.getOneOrFail.mockResolvedValueOnce(team); + qb.getOneOrFail!.mockResolvedValueOnce(team); queryRunner.manager.save.mockRejectedValueOnce(new Error('db down')); await expect(service.deleteStudentFromTeam(1, 1, 5)).rejects.toBeInstanceOf(InternalServerErrorException); @@ -541,7 +541,7 @@ describe('TeamService', () => { }); const oneStudentTeam = buildTeam({ id: 2, students: [buildStudent({ id: 3 })] }); const emptyTeam = buildTeam({ id: 3, students: [] }); - qb.getMany.mockResolvedValueOnce([fullTeam, oneStudentTeam, emptyTeam]); + qb.getMany!.mockResolvedValueOnce([fullTeam, oneStudentTeam, emptyTeam]); const result = await service.getTeamsAvailableForDistribute(1, 2); @@ -551,7 +551,7 @@ describe('TeamService', () => { it('should return an empty list when every team is full', async () => { const fullTeam = buildTeam({ id: 1, students: [buildStudent({ id: 1 }), buildStudent({ id: 2 })] }); - qb.getMany.mockResolvedValueOnce([fullTeam]); + qb.getMany!.mockResolvedValueOnce([fullTeam]); const result = await service.getTeamsAvailableForDistribute(1, 2); diff --git a/nestjs/src/cron/score-recalculation.service.spec.ts b/nestjs/src/cron/score-recalculation.service.spec.ts index 535b8ded50..2e0e770eac 100644 --- a/nestjs/src/cron/score-recalculation.service.spec.ts +++ b/nestjs/src/cron/score-recalculation.service.spec.ts @@ -76,7 +76,7 @@ describe('ScoreRecalculationService.recalculateTotalScore', () => { await service.recalculateTotalScore([{ id: 1, name: 'c' } as never]); expect(save).toHaveBeenCalledTimes(1); - const saved = save.mock.calls[0][0] as Array<{ + const saved = save.mock.calls[0]![0] as Array<{ id: number; totalScore: number; crossCheckScore: number; @@ -92,9 +92,9 @@ describe('ScoreRecalculationService.recalculateTotalScore', () => { expect(byId[103]).toMatchObject({ totalScore: 3, crossCheckScore: 0 }); // ranks: 10,10,3 -> 101 & 102 tie at rank 1, 103 at rank 3 - expect(byId[101].rank).toBe(1); - expect(byId[102].rank).toBe(1); - expect(byId[103].rank).toBe(3); + expect(byId[101]!.rank).toBe(1); + expect(byId[102]!.rank).toBe(1); + expect(byId[103]!.rank).toBe(3); }); it('saves only changed students (skips those whose score and rank are already correct)', async () => { @@ -151,7 +151,7 @@ describe('ScoreRecalculationService.recalculateTotalScore', () => { await service.recalculateTotalScore([{ id: 1, name: 'c' } as never]); - const saved = save.mock.calls[0][0] as Array<{ id: number; totalScore: number }>; + const saved = save.mock.calls[0]![0] as Array<{ id: number; totalScore: number }>; // pre-screening score 7 floored, weight of courseTask 1 = 1 -> total 7 expect(saved.find(s => s.id === 300)).toMatchObject({ totalScore: 7 }); }); @@ -181,7 +181,7 @@ describe('ScoreRecalculationService.recalculateTotalScore', () => { await service.recalculateTotalScore([{ id: 1, name: 'c' } as never]); - const saved = save.mock.calls[0][0] as Array<{ id: number; totalScore: number }>; + const saved = save.mock.calls[0]![0] as Array<{ id: number; totalScore: number }>; // pre-screening (7) is filtered out because courseTaskId 1 already exists -> only existing 4 counts expect(saved.find(s => s.id === 301)).toMatchObject({ totalScore: 4 }); }); @@ -203,7 +203,7 @@ describe('ScoreRecalculationService.recalculateTotalScore', () => { await service.recalculateTotalScore([{ id: 1, name: 'c' } as never]); - const saved = save.mock.calls[0][0] as Array<{ id: number; totalScore: number; rank: number }>; + const saved = save.mock.calls[0]![0] as Array<{ id: number; totalScore: number; rank: number }>; expect(saved.find(s => s.id === 302)).toMatchObject({ totalScore: 0, rank: 1 }); }); @@ -230,8 +230,8 @@ describe('ScoreRecalculationService.recalculateTotalScore', () => { await promise; expect(save).toHaveBeenCalledTimes(2); // 500 + 1 - expect(save.mock.calls[0][0]).toHaveLength(500); - expect(save.mock.calls[1][0]).toHaveLength(1); + expect(save.mock.calls[0]![0]).toHaveLength(500); + expect(save.mock.calls[1]![0]).toHaveLength(1); expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 10_000); }); }); diff --git a/nestjs/src/devtools/devtools.service.spec.ts b/nestjs/src/devtools/devtools.service.spec.ts index c17c2d8565..95b0b80315 100644 --- a/nestjs/src/devtools/devtools.service.spec.ts +++ b/nestjs/src/devtools/devtools.service.spec.ts @@ -110,8 +110,8 @@ describe('DevtoolsService', () => { const result = await service.getUsers(); - expect(result[0].mentor).toEqual([undefined]); - expect(result[0].student).toEqual([undefined]); + expect(result[0]!.mentor).toEqual([undefined]); + expect(result[0]!.student).toEqual([undefined]); }); }); diff --git a/nestjs/src/discord-servers/discord-servers.controller.spec.ts b/nestjs/src/discord-servers/discord-servers.controller.spec.ts index ea6380c43f..084bfbcafc 100644 --- a/nestjs/src/discord-servers/discord-servers.controller.spec.ts +++ b/nestjs/src/discord-servers/discord-servers.controller.spec.ts @@ -51,7 +51,7 @@ describe('DiscordServersController', () => { gratitudeUrl: 'https://discord.gg/gratitude', mentorsChatUrl: 'https://discord.gg/mentors', }; - service.create.mockResolvedValue(mockDiscordServer); + service.create.mockResolvedValue({ ...mockDiscordServer, ...dto }); const result = await controller.create(dto); @@ -77,7 +77,7 @@ describe('DiscordServersController', () => { expect(service.getAll).toHaveBeenCalledTimes(1); expect(result).toHaveLength(1); expect(result[0]).toBeInstanceOf(DiscordServerDto); - expect(result[0].id).toBe(1); + expect(result[0]!.id).toBe(1); }); it('returns an empty list when there are no servers', async () => { @@ -129,7 +129,7 @@ describe('DiscordServersController', () => { gratitudeUrl: 'https://discord.gg/gratitude', mentorsChatUrl: 'https://discord.gg/mentors', }; - service.update.mockResolvedValue(mockDiscordServer); + service.update.mockResolvedValue({ ...mockDiscordServer, ...dto }); const result = await controller.update(1, dto); diff --git a/nestjs/src/notifications/notifications.service.spec.ts b/nestjs/src/notifications/notifications.service.spec.ts index 389a8ecf5f..957f9a617b 100644 --- a/nestjs/src/notifications/notifications.service.spec.ts +++ b/nestjs/src/notifications/notifications.service.spec.ts @@ -7,6 +7,7 @@ import { HttpService } from '@nestjs/axios'; import { Notification } from '@entities/notification'; import { NotificationChannelSettings } from '@entities/notificationChannelSettings'; import { ConfigService } from '../config'; +import { UpdateNotificationDto } from './dto/update-notification.dto'; import { NotificationsService } from './notifications.service'; const mockNotification = { @@ -146,7 +147,7 @@ describe('NotificationsService', () => { notificationsRepository.findOne.mockResolvedValue(null); notificationsRepository.save.mockResolvedValue(mockNotification); - const dto = { + const dto: UpdateNotificationDto = { id: 'taskGrade', name: 'Task grade', enabled: true, @@ -222,7 +223,7 @@ describe('NotificationsService', () => { expect(result?.template.body).toContain('Hello John'); expect(result?.template.body).toContain(''); // Subject is copied verbatim from the EmailTemplate and is NOT run through Handlebars - expect((result?.template as { subject: string }).subject).toBe('Subject {{name}}'); + expect(result?.template).toEqual(expect.objectContaining({ subject: 'Subject {{name}}' })); }); it('escapes HTML by default and preserves it when noEscape is set', () => { @@ -288,7 +289,7 @@ describe('NotificationsService', () => { }); expect(publishSpy).toHaveBeenCalledWith( expect.objectContaining({ - notificationId: 'taskGrade', + notificationId: 'taskGrade' as const, channelId: ['email'], userId: 1, data: expect.objectContaining({ diff --git a/nestjs/src/opportunities/opportunities.controller.spec.ts b/nestjs/src/opportunities/opportunities.controller.spec.ts index 269b399c9d..90b6df287c 100644 --- a/nestjs/src/opportunities/opportunities.controller.spec.ts +++ b/nestjs/src/opportunities/opportunities.controller.spec.ts @@ -232,7 +232,7 @@ describe('OpportunitiesController', () => { expect(service.getApplicantResumes).toHaveBeenCalled(); expect(result).toHaveLength(1); expect(result[0]).toBeInstanceOf(ApplicantResumeDto); - expect(result[0].githubId).toBe('john'); + expect(result[0]!.githubId).toBe('john'); }); it('returns an empty array when there are no applicants', async () => { diff --git a/nestjs/src/profile/endorsement.service.spec.ts b/nestjs/src/profile/endorsement.service.spec.ts index 9c9a2118d1..623a6765c6 100644 --- a/nestjs/src/profile/endorsement.service.spec.ts +++ b/nestjs/src/profile/endorsement.service.spec.ts @@ -111,8 +111,8 @@ describe('EndorsementService', () => { await service.getEndorsmentData('john-doe'); - const studentWhere = studentRepository.count.mock.calls[0][0].where; - const interviewWhere = taskInterviewResultRepository.count.mock.calls[0][0].where; + const studentWhere = studentRepository.count.mock.calls[0]![0].where; + const interviewWhere = taskInterviewResultRepository.count.mock.calls[0]![0].where; // In(...) is opaque, so assert the count was invoked with a mentorId filter object. expect(studentWhere).toHaveProperty('mentorId'); expect(interviewWhere).toHaveProperty('mentorId'); diff --git a/nestjs/src/profile/profile-info/profile-info.queries.spec.ts b/nestjs/src/profile/profile-info/profile-info.queries.spec.ts index 914cff6c40..bd4960c2b6 100644 --- a/nestjs/src/profile/profile-info/profile-info.queries.spec.ts +++ b/nestjs/src/profile/profile-info/profile-info.queries.spec.ts @@ -79,7 +79,7 @@ describe('ProfileInfoService raw queries', () => { describe('getStudentCourses', () => { it('returns the raw rows', async () => { ds.getRepository({ name: 'User' } as never); // prime builder - ds.builders.User.getRawMany.mockResolvedValue([{ courseId: 1 }, { courseId: 2 }]); + ds.builders.User!.getRawMany!.mockResolvedValue([{ courseId: 1 }, { courseId: 2 }]); const result = await service.getStudentCourses('john-doe'); @@ -88,7 +88,7 @@ describe('ProfileInfoService raw queries', () => { it('returns null when the query yields a nullish result', async () => { ds.getRepository({ name: 'User' } as never); - ds.builders.User.getRawMany.mockResolvedValue(null); + ds.builders.User!.getRawMany!.mockResolvedValue(null); const result = await service.getStudentCourses('john-doe'); @@ -100,18 +100,18 @@ describe('ProfileInfoService raw queries', () => { it('merges registered + registry courses, dedupes, and returns the list', async () => { // registered mentor course ids (Mentor.getMany) ds.getRepository({ name: 'Mentor' } as never); - ds.builders.Mentor.getMany.mockResolvedValue([{ courseId: 1 }]); + ds.builders.Mentor!.getMany!.mockResolvedValue([{ courseId: 1 }]); // registry record (MentorRegistry.getOne) ds.getRepository({ name: 'MentorRegistry' } as never); - ds.builders.MentorRegistry.getOne.mockResolvedValue({ + ds.builders.MentorRegistry!.getOne!.mockResolvedValue({ preferedCourses: ['2', '1'], technicalMentoring: ['JS'], }); // disciplines + courses-by-discipline use repository.find ds.getRepository({ name: 'Discipline' } as never); - ds.finds.Discipline.mockResolvedValue([{ id: 10 }]); + ds.finds.Discipline!.mockResolvedValue([{ id: 10 }]); ds.getRepository({ name: 'Course' } as never); - ds.finds.Course.mockResolvedValue([{ id: 3 }]); + ds.finds.Course!.mockResolvedValue([{ id: 3 }]); const result = await service.getMentorCourses('john-doe'); @@ -124,13 +124,13 @@ describe('ProfileInfoService raw queries', () => { it('returns null when no mentor courses exist anywhere', async () => { ds.getRepository({ name: 'Mentor' } as never); - ds.builders.Mentor.getMany.mockResolvedValue([]); + ds.builders.Mentor!.getMany!.mockResolvedValue([]); ds.getRepository({ name: 'MentorRegistry' } as never); - ds.builders.MentorRegistry.getOne.mockResolvedValue(null); + ds.builders.MentorRegistry!.getOne!.mockResolvedValue(null); ds.getRepository({ name: 'Discipline' } as never); - ds.finds.Discipline.mockResolvedValue([]); + ds.finds.Discipline!.mockResolvedValue([]); ds.getRepository({ name: 'Course' } as never); - ds.finds.Course.mockResolvedValue([]); + ds.finds.Course!.mockResolvedValue([]); const result = await service.getMentorCourses('john-doe'); @@ -141,7 +141,7 @@ describe('ProfileInfoService raw queries', () => { describe('getConfigurableProfilePermissions', () => { it('returns the raw permissions row', async () => { ds.getRepository({ name: 'ProfilePermissions' } as never); - ds.builders.ProfilePermissions.getRawOne.mockResolvedValue({ isProfileVisible: true }); + ds.builders.ProfilePermissions!.getRawOne!.mockResolvedValue({ isProfileVisible: true }); const result = await service.getConfigurableProfilePermissions('john-doe'); @@ -150,7 +150,7 @@ describe('ProfileInfoService raw queries', () => { it('falls back to an empty object when there is no row', async () => { ds.getRepository({ name: 'ProfilePermissions' } as never); - ds.builders.ProfilePermissions.getRawOne.mockResolvedValue(undefined); + ds.builders.ProfilePermissions!.getRawOne!.mockResolvedValue(undefined); const result = await service.getConfigurableProfilePermissions('john-doe'); @@ -162,7 +162,7 @@ describe('ProfileInfoService raw queries', () => { it('returns the raw relations row', async () => { ds.getRepository({ name: 'Student' } as never); const row = { student: 'john-doe', mentors: ['m1'] }; - ds.builders.Student.getRawOne.mockResolvedValue(row); + ds.builders.Student!.getRawOne!.mockResolvedValue(row); const result = await service.getRelationsRoles('viewer', 'john-doe'); @@ -171,7 +171,7 @@ describe('ProfileInfoService raw queries', () => { it('returns null when there are no relations', async () => { ds.getRepository({ name: 'Student' } as never); - ds.builders.Student.getRawOne.mockResolvedValue(undefined); + ds.builders.Student!.getRawOne!.mockResolvedValue(undefined); const result = await service.getRelationsRoles('viewer', 'john-doe'); @@ -202,7 +202,7 @@ describe('ProfileInfoService raw queries', () => { it('returns full info with contacts when all permissions are visible', async () => { ds.getRepository({ name: 'User' } as never); - ds.builders.User.getRawOne.mockResolvedValue(rawUser); + ds.builders.User!.getRawOne!.mockResolvedValue(rawUser); const result = await service.getUserInfo('john-doe', allTrue); @@ -230,7 +230,7 @@ describe('ProfileInfoService raw queries', () => { it('omits gated fields and returns undefined contacts when nothing is visible', async () => { ds.getRepository({ name: 'User' } as never); // Simulate a query that only selected the always-present columns. - ds.builders.User.getRawOne.mockResolvedValue({ + ds.builders.User!.getRawOne!.mockResolvedValue({ firstName: '', lastName: '', countryName: 'Poland', @@ -252,7 +252,7 @@ describe('ProfileInfoService raw queries', () => { it('shows contacts when only one contact permission (e.g. phone) is visible', async () => { ds.getRepository({ name: 'User' } as never); - ds.builders.User.getRawOne.mockResolvedValue({ + ds.builders.User!.getRawOne!.mockResolvedValue({ firstName: 'John', lastName: 'Doe', countryName: null, @@ -268,7 +268,7 @@ describe('ProfileInfoService raw queries', () => { it('throws NotFoundException when the user row is missing', async () => { ds.getRepository({ name: 'User' } as never); - ds.builders.User.getRawOne.mockResolvedValue(null); + ds.builders.User!.getRawOne!.mockResolvedValue(null); await expect(service.getUserInfo('ghost', allTrue)).rejects.toBeInstanceOf(NotFoundException); await expect(service.getUserInfo('ghost', allTrue)).rejects.toThrow('User with githubId ghost not found'); @@ -278,7 +278,7 @@ describe('ProfileInfoService raw queries', () => { describe('getMentorStats', () => { it('maps aggregated mentor rows into per-course stats with students', async () => { ds.getRepository({ name: 'Mentor' } as never); - ds.builders.Mentor.getRawMany.mockResolvedValue([ + ds.builders.Mentor!.getRawMany!.mockResolvedValue([ { courseName: 'RS 2024', courseLocationName: 'Minsk', @@ -306,7 +306,7 @@ describe('ProfileInfoService raw queries', () => { it('returns undefined students when the course has no students (first id is falsy)', async () => { ds.getRepository({ name: 'Mentor' } as never); - ds.builders.Mentor.getRawMany.mockResolvedValue([ + ds.builders.Mentor!.getRawMany!.mockResolvedValue([ { courseName: 'RS 2024', courseLocationName: 'Minsk', @@ -327,7 +327,7 @@ describe('ProfileInfoService raw queries', () => { describe('getPublicFeedback', () => { it('maps feedback rows including the resolved author name', async () => { ds.getRepository({ name: 'Feedback' } as never); - ds.builders.Feedback.getRawMany.mockResolvedValue([ + ds.builders.Feedback!.getRawMany!.mockResolvedValue([ { feedbackDate: '2024-01-01', badgeId: 'badge', @@ -352,7 +352,7 @@ describe('ProfileInfoService raw queries', () => { it('returns an empty array when there is no feedback', async () => { ds.getRepository({ name: 'Feedback' } as never); - ds.builders.Feedback.getRawMany.mockResolvedValue([]); + ds.builders.Feedback!.getRawMany!.mockResolvedValue([]); const result = await service.getPublicFeedback('john-doe'); @@ -363,7 +363,7 @@ describe('ProfileInfoService raw queries', () => { describe('getStageInterviewFeedback', () => { it('maps a modern feedback (with version) using the stored interview score', async () => { ds.getRepository({ name: 'StageInterview' } as never); - ds.builders.StageInterview.getRawMany.mockResolvedValue([ + ds.builders.StageInterview!.getRawMany!.mockResolvedValue([ { feedbackVersion: 2, decision: 'yes', @@ -400,7 +400,7 @@ describe('ProfileInfoService raw queries', () => { it('defaults a modern feedback score to 0 when interviewScore is nullish', async () => { ds.getRepository({ name: 'StageInterview' } as never); - ds.builders.StageInterview.getRawMany.mockResolvedValue([ + ds.builders.StageInterview!.getRawMany!.mockResolvedValue([ { feedbackVersion: 1, decision: 'no', @@ -419,8 +419,8 @@ describe('ProfileInfoService raw queries', () => { const result = await service.getStageInterviewFeedback('john-doe'); - expect(result[0].score).toBe(0); - expect(result[0].version).toBe(1); + expect(result[0]!.score).toBe(0); + expect(result[0]!.version).toBe(1); }); it('parses a legacy feedback (no version) via the legacy ratings calculator', async () => { @@ -435,7 +435,7 @@ describe('ProfileInfoService raw queries', () => { dataStructures: { b: 5 }, }, }; - ds.builders.StageInterview.getRawMany.mockResolvedValue([ + ds.builders.StageInterview!.getRawMany!.mockResolvedValue([ { feedbackVersion: null, decision: 'yes', @@ -454,10 +454,10 @@ describe('ProfileInfoService raw queries', () => { const result = await service.getStageInterviewFeedback('john-doe'); - expect(result[0].version).toBe(0); + expect(result[0]!.version).toBe(0); // resume.score short-circuits getInterviewRatings -> score === resume.score - expect(result[0].score).toBe(80); - expect(result[0].feedback).toMatchObject({ + expect(result[0]!.score).toBe(80); + expect(result[0]!.feedback).toMatchObject({ english: 'b2', comment: 'good resume', skills: { htmlCss: 5 }, @@ -472,7 +472,7 @@ describe('ProfileInfoService raw queries', () => { resume: { score: 0 }, skills: { htmlCss: { level: 5 }, common: { a: 5 }, dataStructures: { b: 5 } }, }; - ds.builders.StageInterview.getRawMany.mockResolvedValue([ + ds.builders.StageInterview!.getRawMany!.mockResolvedValue([ { feedbackVersion: null, decision: 'yes', @@ -491,7 +491,7 @@ describe('ProfileInfoService raw queries', () => { const result = await service.getStageInterviewFeedback('john-doe'); - expect(result[0].feedback).toMatchObject({ english: 'a2' }); + expect(result[0]!.feedback).toMatchObject({ english: 'a2' }); }); }); @@ -522,7 +522,7 @@ describe('ProfileInfoService raw queries', () => { it('maps and orders tasks by end date, exposing expelling reason when permitted', async () => { ds.getRepository({ name: 'Student' } as never); - ds.builders.Student.getRawMany.mockResolvedValue([{ ...baseRow }]); + ds.builders.Student!.getRawMany!.mockResolvedValue([{ ...baseRow }]); const result = await service.getStudentStats('john-doe', allTrue); @@ -538,14 +538,14 @@ describe('ProfileInfoService raw queries', () => { mentor: { githubId: 'max', name: 'Max M' }, }); // tasks ordered ascending by end date: t2 (Jan 1) then t1 (Jan 2) - expect(stat.tasks.map(t => t.name)).toEqual(['t2', 't1']); + expect(stat!.tasks.map(t => t.name)).toEqual(['t2', 't1']); // endDate is stripped from the output - expect(stat.tasks[0]).not.toHaveProperty('endDate'); + expect(stat!.tasks[0]).not.toHaveProperty('endDate'); }); it('hides the expelling reason when not permitted but still flags self-expelled', async () => { ds.getRepository({ name: 'Student' } as never); - ds.builders.Student.getRawMany.mockResolvedValue([ + ds.builders.Student!.getRawMany!.mockResolvedValue([ { ...baseRow, expellingReason: 'Self expelled from the course - bored' }, ]); @@ -554,13 +554,13 @@ describe('ProfileInfoService raw queries', () => { isExpellingReasonVisible: false, }); - expect(result[0].expellingReason).toBeUndefined(); - expect(result[0].isSelfExpelled).toBe(true); + expect(result[0]!.expellingReason).toBeUndefined(); + expect(result[0]!.isSelfExpelled).toBe(true); }); it('includes core-js interview details when isCoreJsFeedbackVisible is true', async () => { ds.getRepository({ name: 'Student' } as never); - ds.builders.Student.getRawMany.mockResolvedValue([ + ds.builders.Student!.getRawMany!.mockResolvedValue([ { ...baseRow, taskInterviewFormAnswers: [{ q: 'a' }, null], @@ -573,17 +573,17 @@ describe('ProfileInfoService raw queries', () => { const result = await service.getStudentStats('john-doe', allTrue); - const orderedFirst = result[0].tasks[0]; // t2 (Jan 1) — second array index, all interview data null - const orderedSecond = result[0].tasks[1]; // t1 (Jan 2) — first array index, has interview data - expect(orderedFirst.interviewer).toBeUndefined(); - expect(orderedSecond.interviewer).toEqual({ name: 'Ivan I', githubId: 'ivan' }); - expect(orderedSecond.interviewFormAnswers).toEqual({ q: 'a' }); - expect(orderedSecond.interviewDate).toBe('2024-01-03'); + const orderedFirst = result[0]!.tasks[0]; // t2 (Jan 1) — second array index, all interview data null + const orderedSecond = result[0]!.tasks[1]; // t1 (Jan 2) — first array index, has interview data + expect(orderedFirst!.interviewer).toBeUndefined(); + expect(orderedSecond!.interviewer).toEqual({ name: 'Ivan I', githubId: 'ivan' }); + expect(orderedSecond!.interviewFormAnswers).toEqual({ q: 'a' }); + expect(orderedSecond!.interviewDate).toBe('2024-01-03'); }); it('returns an empty array when the student has no rows', async () => { ds.getRepository({ name: 'Student' } as never); - ds.builders.Student.getRawMany.mockResolvedValue([]); + ds.builders.Student!.getRawMany!.mockResolvedValue([]); const result = await service.getStudentStats('john-doe', allFalse); diff --git a/nestjs/src/profile/profile.service.spec.ts b/nestjs/src/profile/profile.service.spec.ts index 68ea811641..7da4509945 100644 --- a/nestjs/src/profile/profile.service.spec.ts +++ b/nestjs/src/profile/profile.service.spec.ts @@ -275,7 +275,7 @@ describe('ProfileService', () => { contactsEmail: 'good@example.com', } as never); - const setArg = qb.set.mock.calls[0][0]; + const setArg = qb.set.mock.calls[0]![0]; expect(setArg).toMatchObject({ firstName: 'John', lastName: 'Doe', aboutMyself: 'hi' }); // undefined fields removed by omitBy(isUndefined) expect(setArg).not.toHaveProperty('contactsPhone'); @@ -288,7 +288,7 @@ describe('ProfileService', () => { await service.updateProfileFlat(11, { aboutMyself: 'hi' } as never); - const setArg = qb.set.mock.calls[0][0]; + const setArg = qb.set.mock.calls[0]![0]; expect(setArg).not.toHaveProperty('firstName'); expect(setArg).not.toHaveProperty('lastName'); }); @@ -299,7 +299,7 @@ describe('ProfileService', () => { await service.updateProfileFlat(11, { name: 'Cher' } as never); - expect(qb.set.mock.calls[0][0]).toMatchObject({ firstName: 'Cher', lastName: '' }); + expect(qb.set.mock.calls[0]![0]).toMatchObject({ firstName: 'Cher', lastName: '' }); }); }); @@ -448,7 +448,7 @@ describe('ProfileService', () => { }), ); // anonymised githubId is generated with a gdpr- prefix - const updatePayload = userRepository.update.mock.calls[0][1]; + const updatePayload = userRepository.update.mock.calls[0]![1]; expect(updatePayload.githubId).toMatch(/^gdpr-[a-z0-9_-]+$/); expect(notificationConnectionsRepository.delete).toHaveBeenCalledWith({ userId: 11 }); expect(resumeRepository.delete).toHaveBeenCalledWith({ userId: 11 }); diff --git a/nestjs/src/registry/create-registration.spec.ts b/nestjs/src/registry/create-registration.spec.ts index f2a756658c..f581a30c76 100644 --- a/nestjs/src/registry/create-registration.spec.ts +++ b/nestjs/src/registry/create-registration.spec.ts @@ -13,7 +13,12 @@ import { CoursesService } from 'src/courses/courses.service'; import { NotificationsService } from 'src/notifications/notifications.service'; // Fixtures mirrored from server/src/routes/registry/__test__/createRegistration.test.ts to prove business-logic equivalence -const mockUser = { id: 11, githubId: 'john-doe', mentors: [], students: [] }; +const mockUser = { + id: 11, + githubId: 'john-doe', + mentors: [] as Pick[], + students: [] as Pick[], +}; const mockCourse = { id: 5 }; const authUser = { id: 11, githubId: 'john-doe' }; diff --git a/nestjs/src/registry/registry.service.spec.ts b/nestjs/src/registry/registry.service.spec.ts index e3f04b525b..c8a8a029fa 100644 --- a/nestjs/src/registry/registry.service.spec.ts +++ b/nestjs/src/registry/registry.service.spec.ts @@ -256,7 +256,7 @@ describe('RegistryService (uncovered methods)', () => { await service.filterMentorRegistries({ ...baseArgs, preselectedCourses: [1, 2] }); - const call = calls.andWhere.find(([sql]) => typeof sql === 'string' && sql.includes('preselectedCourses')); + const call = calls.andWhere!.find(([sql]) => typeof sql === 'string' && sql.includes('preselectedCourses')); expect(call).toBeDefined(); expect(call?.[1]).toEqual({ preselectedCourses: [1, 2] }); }); @@ -274,7 +274,7 @@ describe('RegistryService (uncovered methods)', () => { await service.filterMentorRegistries({ ...baseArgs, preferedCourses: [3] }); - const call = calls.andWhere.find( + const call = calls.andWhere!.find( ([sql]) => typeof sql === 'string' && sql.includes('preferedCourses') && !sql.includes('&&'), ); expect(call?.[1]).toEqual({ preferedCourses: [3] }); @@ -285,7 +285,7 @@ describe('RegistryService (uncovered methods)', () => { await service.filterMentorRegistries({ ...baseArgs, technicalMentoring: ['nodejs'] }); - const call = calls.andWhere.find(([sql]) => typeof sql === 'string' && sql.includes('technicalMentoring')); + const call = calls.andWhere!.find(([sql]) => typeof sql === 'string' && sql.includes('technicalMentoring')); expect(call?.[1]).toEqual({ technicalMentoring: ['nodejs'] }); }); @@ -295,7 +295,7 @@ describe('RegistryService (uncovered methods)', () => { const bracketsArg = await runFilterAndExtractBrackets(service, { ...baseArgs, coursesIds: [5] }, qb, calls); expect(bracketsArg).toBeDefined(); - const whereCall = calls.where.find(([sql]) => typeof sql === 'string' && sql.includes('&&')); + const whereCall = calls.where!.find(([sql]) => typeof sql === 'string' && sql.includes('&&')); expect(whereCall?.[1]).toEqual({ coursesIds: [5] }); // orWhere (disciplineNames branch) must not fire expect(calls.orWhere ?? []).toEqual([]); @@ -320,7 +320,7 @@ describe('RegistryService (uncovered methods)', () => { calls, ); - expect(calls.where.some(([sql]) => typeof sql === 'string' && sql.includes('&&'))).toBe(true); + expect(calls.where!.some(([sql]) => typeof sql === 'string' && sql.includes('&&'))).toBe(true); expect(calls.orWhere?.some(([sql]) => typeof sql === 'string' && sql.includes('technicalMentoring'))).toBe(true); }); diff --git a/nestjs/src/schedule/schedule.service.spec.ts b/nestjs/src/schedule/schedule.service.spec.ts index d4bf056ec4..ef3297120b 100644 --- a/nestjs/src/schedule/schedule.service.spec.ts +++ b/nestjs/src/schedule/schedule.service.spec.ts @@ -376,10 +376,10 @@ describe('ScheduleService', () => { expect(courseService.getByIds).toHaveBeenCalledWith([5], expect.any(Object)); expect(result).toHaveLength(1); - const [userId, courses] = result[0]; + const [userId, courses] = result[0]!; expect(userId).toBe(42); - expect(courses[0].course).toBe(course); - expect(courses[0].changes[0]).toMatchObject({ isNew: true, type: 'task', name: 'Task 200' }); + expect(courses[0]!.course).toBe(course); + expect(courses[0]!.changes[0]).toMatchObject({ isNew: true, type: 'task', name: 'Task 200' }); }); it('skips users that have no matching course aliases', async () => { @@ -416,7 +416,7 @@ describe('ScheduleService', () => { const result = await service.getChangedCoursesRecipients(2); - expect(result[0][1][0].changes[0]).toMatchObject({ isRemoved: true, type: 'task' }); + expect(result[0]![1][0]!.changes[0]).toMatchObject({ isRemoved: true, type: 'task' }); }); it('treats a disabled-task update as a removal', async () => { @@ -434,7 +434,7 @@ describe('ScheduleService', () => { const result = await service.getChangedCoursesRecipients(2); - expect(result[0][1][0].changes[0]).toMatchObject({ isRemoved: true }); + expect(result[0]![1][0]!.changes[0]).toMatchObject({ isRemoved: true }); }); it('records an updated entry merging previous and new fields', async () => { @@ -452,7 +452,7 @@ describe('ScheduleService', () => { const result = await service.getChangedCoursesRecipients(2); - expect(result[0][1][0].changes[0]).toMatchObject({ + expect(result[0]![1][0]!.changes[0]).toMatchObject({ type: 'event', place: 'New', placeOld: 'Old', @@ -477,7 +477,7 @@ describe('ScheduleService', () => { const result = await service.getChangedCoursesRecipients(2); expect(courseService.getByIds).toHaveBeenCalledWith([5], expect.any(Object)); - expect(result[0][1][0].changes[0]).toMatchObject({ isRemoved: true, name: 'Event 300' }); + expect(result[0]![1][0]!.changes[0]).toMatchObject({ isRemoved: true, name: 'Event 300' }); }); it('reuses an existing course bucket for multiple changes of the same course', async () => { @@ -509,7 +509,7 @@ describe('ScheduleService', () => { // both changes land in the single course-a bucket for user 42 expect(courseService.getByIds).toHaveBeenCalledWith([5], expect.any(Object)); - expect(result[0][1][0].changes).toHaveLength(2); + expect(result[0]![1][0]!.changes).toHaveLength(2); }); it('merges a later update onto an earlier change for the same entry key', async () => { @@ -537,7 +537,7 @@ describe('ScheduleService', () => { const result = await service.getChangedCoursesRecipients(2); // single entry key -> one merged change carrying the place update over the insert - const changes = result[0][1][0].changes; + const changes = result[0]![1][0]!.changes; expect(changes).toHaveLength(1); expect(changes[0]).toMatchObject({ isNew: true, place: 'New', placeOld: 'Old' }); }); @@ -616,7 +616,7 @@ describe('ScheduleService', () => { const result = await service.getChangedCoursesRecipients(2); - expect(result[0][1][0].changes[0]).toMatchObject({ isCrossCheckStarted: true, type: 'task', name: 'Task 200' }); + expect(result[0]![1][0]!.changes[0]).toMatchObject({ isCrossCheckStarted: true, type: 'task', name: 'Task 200' }); }); it('defaults the change name to an empty string when no task/event entry is found', async () => { @@ -635,7 +635,7 @@ describe('ScheduleService', () => { const result = await service.getChangedCoursesRecipients(2); - expect(result[0][1][0].changes[0]).toMatchObject({ name: '' }); + expect(result[0]![1][0]!.changes[0]).toMatchObject({ name: '' }); }); }); }); diff --git a/nestjs/src/user-groups/user-groups.service.spec.ts b/nestjs/src/user-groups/user-groups.service.spec.ts index 6294d8f2ad..8853f2178f 100644 --- a/nestjs/src/user-groups/user-groups.service.spec.ts +++ b/nestjs/src/user-groups/user-groups.service.spec.ts @@ -66,11 +66,11 @@ describe('UserGroupsService', () => { expect(usersService.getUsersByUserIds).toHaveBeenCalledWith([10, 20]); // sorted alphabetically: Alpha before Zebra expect(result.map(g => g.name)).toEqual(['Alpha', 'Zebra']); - expect(result[0].users).toEqual([ + expect(result[0]!.users).toEqual([ { id: 10, githubId: 'github-a', name: 'Anna Apple' }, { id: 10, githubId: 'github-a', name: 'Anna Apple' }, ]); - expect(result[1].users).toEqual([ + expect(result[1]!.users).toEqual([ { id: 10, githubId: 'github-a', name: 'Anna Apple' }, { id: 20, githubId: 'github-b', name: 'Bob Berry' }, ]); @@ -83,7 +83,7 @@ describe('UserGroupsService', () => { const result = await service.getAll(); - expect(result[0].users).toEqual([{ id: -1, githubId: 'UNKNOWN', name: '' }]); + expect(result[0]!.users).toEqual([{ id: -1, githubId: 'UNKNOWN', name: '' }]); }); it('should handle groups with no users', async () => { @@ -94,7 +94,7 @@ describe('UserGroupsService', () => { const result = await service.getAll(); expect(usersService.getUsersByUserIds).toHaveBeenCalledWith([]); - expect(result[0].users).toEqual([]); + expect(result[0]!.users).toEqual([]); }); it('should join only the non-empty parts of the user name', async () => { @@ -105,7 +105,7 @@ describe('UserGroupsService', () => { const result = await service.getAll(); - expect(result[0].users).toEqual([{ id: 30, githubId: 'github-c', name: 'Cleo' }]); + expect(result[0]!.users).toEqual([{ id: 30, githubId: 'github-c', name: 'Cleo' }]); }); }); diff --git a/nestjs/src/users-notifications/users.notifications.controller.spec.ts b/nestjs/src/users-notifications/users.notifications.controller.spec.ts index f8286c5211..e20a506208 100644 --- a/nestjs/src/users-notifications/users.notifications.controller.spec.ts +++ b/nestjs/src/users-notifications/users.notifications.controller.spec.ts @@ -70,7 +70,9 @@ describe('UsersNotificationsController', () => { it('adds a synthetic always-enabled discord connection from the profile', async () => { userNotificationsService.getUserConnections.mockResolvedValue([]); authService.getLoginStateByUserId.mockResolvedValue(null); - usersService.getUserByUserId.mockResolvedValue({ discord: { id: 999 } } as Partial as User); + usersService.getUserByUserId.mockResolvedValue({ + discord: { id: '999', username: 'john-doe', discriminator: '0' }, + } as Partial as User); const result = await controller.getUserConnections(req(7)); @@ -86,7 +88,7 @@ describe('UsersNotificationsController', () => { const result = await controller.getUserConnections(req(7)); - expect(result.connections.email.lastLinkSentAt).toBeUndefined(); + expect(result.connections.email!.lastLinkSentAt).toBeUndefined(); }); }); diff --git a/nestjs/src/users-notifications/users.notifications.service.spec.ts b/nestjs/src/users-notifications/users.notifications.service.spec.ts index f3bbfd309c..289d2fc179 100644 --- a/nestjs/src/users-notifications/users.notifications.service.spec.ts +++ b/nestjs/src/users-notifications/users.notifications.service.spec.ts @@ -265,9 +265,7 @@ describe('UserNotificationsService', () => { await service.sendEventNotification(dto); // discord channel is filtered out before buildChannelMessage - const builtChannelIds = notificationsService.buildChannelMessage.mock.calls.map( - (call: [{ channelId: string }]) => call[0].channelId, - ); + const builtChannelIds = notificationsService.buildChannelMessage.mock.calls.map(call => call[0].channelId); expect(builtChannelIds).toContain('email'); expect(builtChannelIds).toContain('telegram'); expect(builtChannelIds).not.toContain('discord'); diff --git a/nestjs/src/users/users-extra.spec.ts b/nestjs/src/users/users-extra.spec.ts index 2f0a517ca4..d6572a51e1 100644 --- a/nestjs/src/users/users-extra.spec.ts +++ b/nestjs/src/users/users-extra.spec.ts @@ -310,9 +310,9 @@ describe('UsersController.searchUsers', () => { // Admin visibility => UserSearchDto exposes the real contact/city fields expect(result).toHaveLength(1); - expect(result[0].contactsEmail).toBe('john@contact.com'); - expect(result[0].primaryEmail).toBe('john@example.com'); - expect(result[0].cityName).toBe('Minsk'); + expect(result[0]!.contactsEmail).toBe('john@contact.com'); + expect(result[0]!.primaryEmail).toBe('john@example.com'); + expect(result[0]!.cityName).toBe('Minsk'); }); it('passes elevated visibility (true) for hirers, exposing contacts', async () => { @@ -321,7 +321,7 @@ describe('UsersController.searchUsers', () => { const result = await controller.searchUsers(hirerReq, 'john'); expect(result).toHaveLength(1); - expect(result[0].contactsEmail).toBe('john@contact.com'); + expect(result[0]!.contactsEmail).toBe('john@contact.com'); }); it('passes restricted visibility (false) for regular users, masking contacts', async () => { @@ -330,9 +330,9 @@ describe('UsersController.searchUsers', () => { const result = await controller.searchUsers(plainReq, 'john'); expect(result).toHaveLength(1); - expect(result[0].contactsEmail).toBeNull(); - expect(result[0].primaryEmail).toBeNull(); - expect(result[0].cityName).toBeNull(); + expect(result[0]!.contactsEmail).toBeNull(); + expect(result[0]!.primaryEmail).toBeNull(); + expect(result[0]!.cityName).toBeNull(); }); it('maps an empty result list to an empty array', async () => { diff --git a/nestjs/src/utils/shuffle.test.ts b/nestjs/src/utils/shuffle.test.ts index 7d159ea3c5..b6d50fa97a 100644 --- a/nestjs/src/utils/shuffle.test.ts +++ b/nestjs/src/utils/shuffle.test.ts @@ -1,5 +1,11 @@ +import { randomBytes } from 'crypto'; import { isShuffledArrays, shuffleRec } from './shuffle'; +vi.mock('crypto', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, randomBytes: vi.fn(actual.randomBytes) }; +}); + describe('shuffle utils', () => { describe('isShuffledArrays', () => { test.each<{ a: (number | string)[]; b: (number | string)[]; expected: boolean }>([ @@ -37,11 +43,23 @@ describe('shuffle utils', () => { }, ); + test('should retry when the first shuffle leaves the array unchanged', () => { + vi.mocked(randomBytes) + .mockImplementationOnce(() => Buffer.from([0, 1])) + .mockImplementationOnce(() => Buffer.from([0, 0])); + + expect(shuffleRec([1, 2], 1)).toEqual([2, 1]); + expect(randomBytes).toHaveBeenCalledTimes(2); + }); + test('should respect maxAttempts', () => { + vi.mocked(randomBytes).mockImplementationOnce(() => Buffer.from([0, 1])); const input = [1, 2]; const result = shuffleRec(input, 0); expect(result).toHaveLength(input.length); expect([...result].sort()).toEqual([...input].sort()); + expect(result).toEqual(input); + expect(randomBytes).toHaveBeenCalledTimes(1); }); }); }); diff --git a/nestjs/test/http/auth-github.smoke.spec.ts b/nestjs/test/http/auth-github.smoke.spec.ts index 7f228da6f6..f59884116e 100644 --- a/nestjs/test/http/auth-github.smoke.spec.ts +++ b/nestjs/test/http/auth-github.smoke.spec.ts @@ -16,10 +16,10 @@ describe.each(ADAPTERS)('github oauth in production mode [%s]', adapter => { // Fresh module graph (created in beforeAll after NODE_ENV is stubbed). let modules: { - AuthController: typeof import('src/auth/auth.controller').AuthController; - AuthService: typeof import('src/auth/auth.service').AuthService; - GithubStrategy: typeof import('src/auth/strategies/github.strategy').GithubStrategy; - ConfigService: typeof import('src/config').ConfigService; + AuthController: typeof import('../../src/auth/auth.controller.js').AuthController; + AuthService: typeof import('../../src/auth/auth.service.js').AuthService; + GithubStrategy: typeof import('../../src/auth/strategies/github.strategy.js').GithubStrategy; + ConfigService: typeof import('../../src/config/index.js').ConfigService; passport: typeof import('passport'); }; @@ -35,10 +35,10 @@ describe.each(ADAPTERS)('github oauth in production mode [%s]', adapter => { vi.stubEnv('NODE_ENV', 'production'); vi.resetModules(); modules = { - AuthController: (await import('src/auth/auth.controller')).AuthController, - AuthService: (await import('src/auth/auth.service')).AuthService, - GithubStrategy: (await import('src/auth/strategies/github.strategy')).GithubStrategy, - ConfigService: (await import('src/config')).ConfigService, + AuthController: (await import('../../src/auth/auth.controller.js')).AuthController, + AuthService: (await import('../../src/auth/auth.service.js')).AuthService, + GithubStrategy: (await import('../../src/auth/strategies/github.strategy.js')).GithubStrategy, + ConfigService: (await import('../../src/config/index.js')).ConfigService, passport: await import('passport'), }; }); diff --git a/package-lock.json b/package-lock.json index c2d073488f..6d5f258d0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,8 +24,8 @@ "@types/jest": "30.0.0", "@types/lodash": "4.17.24", "@types/node": "24.12.0", - "@vitest/coverage-v8": "~4.1.0", - "@vitest/eslint-plugin": "^1.6.11", + "@vitest/coverage-v8": "~5.0.0", + "@vitest/eslint-plugin": "^1.6.27", "dotenv": "^16.5.0", "eslint": "10.0.3", "eslint-config-turbo": "2.8.16", @@ -33,6 +33,7 @@ "eslint-plugin-boundaries": "5.4.0", "globals": "^17.4.0", "jest": "30.2.0", + "jsdom": "^29.1.1", "oxfmt": "0.38.0", "std-env": "^4.0.0", "ts-node": "10.9.2", @@ -40,10 +41,11 @@ "typescript": "5.9.3", "typescript-eslint": "8.57.0", "unplugin-swc": "^1.5.9", - "vitest": "~4.1.0" + "vite": "~8.3.0", + "vitest": "~5.0.0" }, "engines": { - "node": ">=22", + "node": "^22.13.0 || ^24.0.0 || >=26.0.0", "npm": ">=10" } }, @@ -75,15 +77,15 @@ }, "devDependencies": { "@playwright/test": "^1.51.1", - "@testing-library/jest-dom": "6.1.4", - "@testing-library/react": "14.1.2", - "@testing-library/user-event": "14.5.1", + "@testing-library/dom": "10.4.1", + "@testing-library/jest-dom": "7.0.1", + "@testing-library/react": "16.3.3", + "@testing-library/user-event": "14.6.7", "@types/aws-lambda": "8.10.126", "@types/cookie": "0.5.4", "@types/react": "18.3.18", "@types/react-dom": "18.3.5", "eslint-plugin-testing-library": "7.16.0", - "jsdom": "^29.0.2", "mq-polyfill": "1.1.8" } }, @@ -171,6 +173,70 @@ "react-dom": ">=18.0.0" } }, + "client/node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "client/node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "client/node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "client/node_modules/antd": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/antd/-/antd-6.3.1.tgz", @@ -2105,14 +2171,15 @@ } }, "node_modules/@asamuzakjp/css-color": { - "version": "5.1.8", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.8.tgz", - "integrity": "sha512-OISPR9c2uPo23rUdvfEQiLPjoMLOpEeLNnP5iGkxr6tDDxJd3NjD+6fxY0mdaMbIPUjFGL4HFOJqLvow5q4aqQ==", + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^3.1.1", - "@csstools/css-color-parser": "^4.0.2", + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" }, @@ -2121,12 +2188,13 @@ } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "7.0.8", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.8.tgz", - "integrity": "sha512-erMO6FgtM02dC24NGm0xufMzWz5OF0wXKR7BpvGD973bq/GbmR8/DbxNZbj0YevQ5hlToJaWSVK/G9/NDgGEVw==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", "dev": true, "license": "MIT", "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", @@ -2157,6 +2225,16 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/@asamuzakjp/nwsapi": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", @@ -3978,9 +4056,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -3988,9 +4066,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -4022,13 +4100,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -4320,14 +4398,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -4437,9 +4515,9 @@ } }, "node_modules/@csstools/color-helpers": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", - "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", "dev": true, "funding": [ { @@ -4457,9 +4535,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", - "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", "dev": true, "funding": [ { @@ -4481,9 +4559,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", - "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz", + "integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==", "dev": true, "funding": [ { @@ -4497,8 +4575,8 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.1.1" + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" }, "engines": { "node": ">=20.19.0" @@ -6267,9 +6345,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -6607,24 +6685,6 @@ } } }, - "node_modules/@nestjs/schematics/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@nestjs/schematics/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -6662,22 +6722,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@nestjs/schematics/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@nestjs/schematics/node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -7657,24 +7701,14 @@ "@opentelemetry/api": "^1.1.0" } }, - "node_modules/@oxc-project/runtime": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz", - "integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@oxc-project/types": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", - "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", "dev": true, "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@oxfmt/binding-android-arm-eabi": { @@ -8122,10 +8156,27 @@ "node": ">=8.x" } }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", "cpu": [ "arm64" ], @@ -8140,9 +8191,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", "cpu": [ "arm64" ], @@ -8157,9 +8208,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz", - "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", "cpu": [ "x64" ], @@ -8174,9 +8225,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz", - "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", "cpu": [ "x64" ], @@ -8191,9 +8242,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz", - "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", "cpu": [ "arm" ], @@ -8208,13 +8259,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -8225,13 +8279,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz", - "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -8242,13 +8299,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -8259,13 +8319,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -8276,13 +8339,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -8293,13 +8359,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz", - "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -8310,9 +8379,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", "cpu": [ "arm64" ], @@ -8326,44 +8395,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz", - "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz", - "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", "cpu": [ "arm64" ], @@ -8378,9 +8413,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz", - "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", "cpu": [ "x64" ], @@ -8395,9 +8430,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", - "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -9248,18 +9283,11 @@ "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==", "license": "MIT" }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, "node_modules/@swc/core": { "version": "1.15.18", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.18.tgz", "integrity": "sha512-z87aF9GphWp//fnkRsqvtY+inMVPgYW3zSlXH1kJFvRT5H/wiAn+G32qW5l3oEk63KSF1x3Ov0BfHCObAmT8RA==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -9301,7 +9329,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -9318,7 +9345,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -9335,7 +9361,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -9352,7 +9377,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -9369,7 +9393,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -9386,7 +9409,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -9403,7 +9425,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -9420,7 +9441,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -9437,7 +9457,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -9454,7 +9473,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -9468,37 +9486,37 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@swc/types": { "version": "0.1.25", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3" } }, "node_modules/@testing-library/dom": { - "version": "9.3.4", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", - "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", - "aria-query": "5.1.3", - "chalk": "^4.1.0", + "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", + "picocolors": "1.1.1", "pretty-format": "^27.0.2" }, "engines": { - "node": ">=14" + "node": ">=18" } }, "node_modules/@testing-library/dom/node_modules/ansi-regex": { @@ -9546,85 +9564,10 @@ "dev": true, "license": "MIT" }, - "node_modules/@testing-library/jest-dom": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.1.4.tgz", - "integrity": "sha512-wpoYrCYwSZ5/AxcrjLxJmCU6I5QAJXslEeSiMQqaWmP2Kzpd1LvF/qxmAIW2qposULGWq2gw30GgVNFLSc2Jnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.3.1", - "@babel/runtime": "^7.9.2", - "aria-query": "^5.0.0", - "chalk": "^3.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.5.6", - "lodash": "^4.17.15", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6", - "yarn": ">=1" - }, - "peerDependencies": { - "@jest/globals": ">= 28", - "@types/jest": ">= 28", - "jest": ">= 28", - "vitest": ">= 0.32" - }, - "peerDependenciesMeta": { - "@jest/globals": { - "optional": true - }, - "@types/jest": { - "optional": true - }, - "jest": { - "optional": true - }, - "vitest": { - "optional": true - } - } - }, - "node_modules/@testing-library/jest-dom/node_modules/chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@testing-library/react": { - "version": "14.1.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.1.2.tgz", - "integrity": "sha512-z4p7DVBTPjKM5qDZ0t5ZjzkpSNb+fZy1u6bzO7kk8oeGagpPCAtgh4cx1syrfp7a+QWkM021jGqjJaxJJnXAZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@testing-library/dom": "^9.0.0", - "@types/react-dom": "^18.0.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - } - }, "node_modules/@testing-library/user-event": { - "version": "14.5.1", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.1.tgz", - "integrity": "sha512-UCcUKrUYGj7ClomOo2SpNVvx4/fkd/2BbIHDCle8A0ax+P3bU7yJwDBDrS6ZwdTMARWTGODX1hEsCcO+7beJjg==", + "version": "14.6.7", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", + "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", "dev": true, "license": "MIT", "engines": { @@ -10132,14 +10075,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/luxon": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-2.0.9.tgz", - "integrity": "sha512-ZuzIc7aN+i2ZDMWIiSmMdubR9EMMSTdEzF6R+FckP4p6xdnOYKqknTo/k+xXQvciSXlNGIwA4OPU5X7JIFzYdA==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@types/mdast": { "version": "3.0.15", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", @@ -10929,29 +10864,27 @@ ] }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.0.tgz", - "integrity": "sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.0.tgz", + "integrity": "sha512-toMg6PZGCIa/lQNCDoASrfb1ly4hsUKXFtFYC9kD4t78o5Y6LyNJU7AENt8eHPr3quYdxaxK7hj2mnbFfUk9NA==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.0", - "ast-v8-to-istanbul": "^1.0.0", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.0.3" + "@vitest/istanbul-lib-coverage": "^1.0.0", + "@vitest/istanbul-lib-report": "^1.0.0", + "ast-v8-to-istanbul": "^1.0.5", + "magicast": "^0.5.4", + "obug": "^2.1.4", + "std-env": "^4.2.0", + "tinyrainbow": "^3.1.1" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.0", - "vitest": "4.1.0" + "@vitest/browser": "5.0.0", + "vitest": "5.0.0" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -10970,24 +10903,28 @@ } }, "node_modules/@vitest/eslint-plugin": { - "version": "1.6.11", - "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.11.tgz", - "integrity": "sha512-/m7cyD2x/TMJt6SmW6X9ZQWThCROa3AgBXJKVzTDG6MIRQkxBGLlwi4Vi+F5bcKnRKI17b3aeUzOhqBwnsjiHg==", + "version": "1.6.27", + "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.27.tgz", + "integrity": "sha512-X1RCAfwbatG4GFbJ/1PIHP9MSZMt0JJThqbYID+vS4L783k6QGlLPe421wQE8dHXuGCcenVLsydiM4qzsYWxhg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "^8.55.0", - "@typescript-eslint/utils": "^8.55.0" + "@typescript-eslint/scope-manager": "^8.58.0", + "@typescript-eslint/utils": "^8.58.0" }, "engines": { "node": ">=18" }, "peerDependencies": { + "@typescript-eslint/eslint-plugin": "*", "eslint": ">=8.57.0", "typescript": ">=5.0.0", "vitest": "*" }, "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, "typescript": { "optional": true }, @@ -10996,135 +10933,204 @@ } } }, - "node_modules/@vitest/expect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", - "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/project-service": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", + "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.0", - "@vitest/utils": "4.1.0", - "chai": "^6.2.2", - "tinyrainbow": "^3.0.3" + "@typescript-eslint/tsconfig-utils": "^8.70.0", + "@typescript-eslint/types": "^8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@vitest/mocker": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", - "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", + "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.0", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", + "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@vitest/mocker/node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/types": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz", + "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", - "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", + "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "@typescript-eslint/project-service": "8.70.0", + "@typescript-eslint/tsconfig-utils": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@vitest/runner": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", - "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz", + "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.0", - "pathe": "^2.0.3" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@vitest/snapshot": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", - "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", + "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0", - "@vitest/utils": "4.1.0", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" + "@typescript-eslint/types": "8.70.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@vitest/snapshot/node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "node_modules/@vitest/istanbul-lib-coverage": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.1.tgz", + "integrity": "sha512-k3DJZ8LhMBK9NS4SclF1ASD3OgXEWDorbIcPTRDK0/Zae6fRvu+fJRxtFdLfHsa9Y24beCdPnoNZ4LviTNstfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@vitest/istanbul-lib-report": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.1.tgz", + "integrity": "sha512-1EOLRfsTMnyAr3+kEAsP4o9dhaDlGPpD7H5iLBBeq//YpNB1VIahkPhB+eRp9N2Dkfw8oySROjE3yf9XDeaIkQ==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "@vitest/istanbul-lib-coverage": "1.0.1" + }, + "engines": { + "node": ">=22" } }, - "node_modules/@vitest/spy": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", - "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "node_modules/@vitest/mocker": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", + "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", "dev": true, "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.0", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, "funding": { "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/@vitest/utils": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", - "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "node_modules/@vitest/spy": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", + "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", "dev": true, "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.0", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.0.3" - }, "funding": { "url": "https://opencollective.com/vitest" } @@ -11636,70 +11642,13 @@ } }, "node_modules/aria-query": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", - "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "deep-equal": "^2.0.5" - } - }, - "node_modules/aria-query/node_modules/deep-equal": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", - "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.5", - "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.2", - "is-arguments": "^1.1.1", - "is-array-buffer": "^3.0.2", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "isarray": "^2.0.5", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "side-channel": "^1.0.4", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/aria-query/node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "dequal": "^2.0.3" } }, "node_modules/array-timsort": { @@ -11740,9 +11689,9 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", - "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.6.tgz", + "integrity": "sha512-fvpl29helSO2w/z7utIbrkNXILdrLwDwAMH2I/zPKlGf5244+gf+B4cyS1sANcrPY2h+hWCGSgC8N61s/+AF9A==", "dev": true, "license": "MIT", "dependencies": { @@ -11984,9 +11933,9 @@ } }, "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", "dev": true, "license": "MIT", "dependencies": { @@ -13961,24 +13910,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/degenerator": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", @@ -14200,13 +14131,13 @@ } }, "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", "dev": true, "license": "BSD-2-Clause", "engines": { - "node": ">=0.12" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" @@ -14249,34 +14180,6 @@ "node": ">= 0.4" } }, - "node_modules/es-get-iterator": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", - "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "is-arguments": "^1.1.1", - "is-map": "^2.0.2", - "is-set": "^2.0.2", - "is-string": "^1.0.7", - "isarray": "^2.0.5", - "stop-iteration-iterator": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-get-iterator/node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -14824,9 +14727,9 @@ } }, "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -15390,16 +15293,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -15637,19 +15530,6 @@ "uglify-js": "^3.1.4" } }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -16203,21 +16083,6 @@ "node": ">=8" } }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -16253,41 +16118,6 @@ "node": ">= 0.10" } }, - "node_modules/is-arguments": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -16295,39 +16125,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-buffer": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", @@ -16388,23 +16185,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -16457,19 +16237,6 @@ "node": ">=8" } }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-mobile": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-5.0.0.tgz", @@ -16486,23 +16253,6 @@ "node": ">=0.12.0" } }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -16516,112 +16266,29 @@ } }, "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, "engines": { - "node": ">= 0.4" + "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-typed-array": { @@ -16658,36 +16325,6 @@ "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==", "license": "MIT" }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -17422,28 +17059,28 @@ } }, "node_modules/jsdom": { - "version": "29.0.2", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", - "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^5.1.5", - "@asamuzakjp/dom-selector": "^7.0.6", + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.7", - "parse5": "^8.0.0", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", - "undici": "^7.24.5", + "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", @@ -17463,9 +17100,9 @@ } }, "node_modules/jsdom/node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", - "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.13.tgz", + "integrity": "sha512-i9ZylF5QNhmNfPA9l0vHAWK4kPrbIp6g9lKgaiIFsIBz2F/WNB7OLrzlNNcCOm+h42bkaSD2v1PG+IBPHhc3ZA==", "dev": true, "funding": [ { @@ -17502,9 +17139,9 @@ } }, "node_modules/jsdom/node_modules/lru-cache": { - "version": "11.3.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.2.tgz", - "integrity": "sha512-wgWa6FWQ3QRRJbIjbsldRJZxdxYngT/dO0I5Ynmlnin8qy7tC6xYzbcJjtN4wHLXtkbVwHzk0C+OejVw1XM+DQ==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -17519,13 +17156,13 @@ "license": "CC0-1.0" }, "node_modules/jsdom/node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", "dev": true, "license": "MIT", "dependencies": { - "entities": "^6.0.0" + "entities": "^8.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" @@ -17715,9 +17352,9 @@ "license": "MIT" }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -17731,23 +17368,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -17766,9 +17403,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -17787,9 +17424,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -17808,9 +17445,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -17829,9 +17466,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -17850,13 +17487,16 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -17871,13 +17511,16 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -17892,13 +17535,16 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -17913,13 +17559,16 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -17934,9 +17583,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -17955,9 +17604,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -18151,17 +17800,6 @@ "yallist": "^3.0.2" } }, - "node_modules/luxon": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-2.5.2.tgz", - "integrity": "sha512-Yg7/RDp4nedqmLgyH0LwgGRvMEKVzKbUdkBYyCosbHgJ+kaOUx0qzSiSatVc3DFygnirTPYnMM2P5dg2uH1WvA==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - } - }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -18172,15 +17810,25 @@ "lz-string": "bin/bin.js" } }, + "node_modules/magic-string": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.3.1.tgz", + "integrity": "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "node_modules/magicast": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", - "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, @@ -19193,31 +18841,6 @@ "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", "license": "MIT" }, - "node_modules/moment": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "*" - } - }, - "node_modules/moment-timezone": { - "version": "0.5.35", - "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.35.tgz", - "integrity": "sha512-cY/pBOEXepQvlgli06ttCTKcIf8cD1nmNwOKQQAdHBqYApQSpAqotBMX0RJZNgMp6i0PlZuf1mFtnlyEkwyvFw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "moment": ">= 2.9.0" - }, - "engines": { - "node": "*" - } - }, "node_modules/mq-polyfill": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/mq-polyfill/-/mq-polyfill-1.1.8.tgz", @@ -19574,64 +19197,19 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object-is": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", - "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" ], - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } }, "node_modules/on-exit-leak-free": { "version": "2.1.2", @@ -20181,13 +19759,6 @@ "node": ">=8" } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, "node_modules/pause": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", @@ -20519,7 +20090,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -21083,27 +20653,6 @@ "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "license": "Apache-2.0" }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/remark-gfm": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-3.0.1.tgz", @@ -21357,14 +20906,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", - "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.115.0", - "@rolldown/pluginutils": "1.0.0-rc.9" + "@oxc-project/types": "=0.149.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -21373,21 +20922,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.9", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", - "@rolldown/binding-darwin-x64": "1.0.0-rc.9", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" + "@rolldown/binding-android-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" } }, "node_modules/router": { @@ -21472,24 +21021,6 @@ ], "license": "MIT" }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/safe-stable-stringify": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", @@ -21673,22 +21204,6 @@ "node": ">= 0.4" } }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/set-harmonic-interval": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/set-harmonic-interval/-/set-harmonic-interval-1.0.1.tgz", @@ -22119,26 +21634,12 @@ } }, "node_modules/std-env": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", - "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -22845,16 +22346,19 @@ "license": "MIT" }, "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } }, "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", "dev": true, "license": "MIT", "engines": { @@ -22862,14 +22366,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -22897,9 +22401,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -22920,9 +22424,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { @@ -23092,9 +22596,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -23591,9 +23095,9 @@ } }, "node_modules/undici": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", - "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", "dev": true, "license": "MIT", "engines": { @@ -24035,18 +23539,17 @@ } }, "node_modules/vite": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz", - "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/runtime": "0.115.0", - "lightningcss": "^1.32.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.9", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -24062,8 +23565,8 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.0.0-alpha.31", - "esbuild": "^0.27.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", @@ -24114,9 +23617,9 @@ } }, "node_modules/vite/node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "dev": true, "funding": [ { @@ -24133,9 +23636,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -24146,9 +23649,9 @@ } }, "node_modules/vite/node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -24166,7 +23669,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -24175,38 +23678,31 @@ } }, "node_modules/vitest": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", - "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", + "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.0", - "@vitest/mocker": "4.1.0", - "@vitest/pretty-format": "4.1.0", - "@vitest/runner": "4.1.0", - "@vitest/snapshot": "4.1.0", - "@vitest/spy": "4.1.0", - "@vitest/utils": "4.1.0", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.0", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -24214,14 +23710,16 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.0", - "@vitest/browser-preview": "4.1.0", - "@vitest/browser-webdriverio": "4.1.0", - "@vitest/ui": "4.1.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.0", + "@vitest/browser-preview": "5.0.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.0", + "@vitest/coverage-v8": "5.0.0", + "@vitest/ui": "5.0.0", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -24242,6 +23740,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, @@ -24257,26 +23761,16 @@ } }, "node_modules/vitest/node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, - "node_modules/vitest/node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -24484,45 +23978,6 @@ "node": ">= 8" } }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/which-typed-array": { "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", diff --git a/package.json b/package.json index 6fc34dd2e4..77d5059508 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "client" ], "engines": { - "node": ">=22", + "node": "^22.13.0 || ^24.0.0 || >=26.0.0", "npm": ">=10" }, "scripts": { @@ -44,8 +44,8 @@ "@types/jest": "30.0.0", "@types/lodash": "4.17.24", "@types/node": "24.12.0", - "@vitest/coverage-v8": "~4.1.0", - "@vitest/eslint-plugin": "^1.6.11", + "@vitest/coverage-v8": "~5.0.0", + "@vitest/eslint-plugin": "^1.6.27", "dotenv": "^16.5.0", "eslint": "10.0.3", "eslint-config-turbo": "2.8.16", @@ -53,6 +53,7 @@ "eslint-plugin-boundaries": "5.4.0", "globals": "^17.4.0", "jest": "30.2.0", + "jsdom": "^29.1.1", "oxfmt": "0.38.0", "std-env": "^4.0.0", "ts-node": "10.9.2", @@ -60,7 +61,8 @@ "typescript": "5.9.3", "typescript-eslint": "8.57.0", "unplugin-swc": "^1.5.9", - "vitest": "~4.1.0" + "vite": "~8.3.0", + "vitest": "~5.0.0" }, "packageManager": "npm@10.7.0" }