From 001f24f5975133476a65ebef312d801afd5aa62e Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 19:10:52 +0200 Subject: [PATCH 001/406] perf(client): speed up test runs without reducing coverage --- client/README.md | 21 + client/package.json | 2 +- .../useCourseTaskSubmit.test.ts | 2 +- .../CertificateCriteriaModal.test.tsx | 57 +- .../components/CourseModal/index.test.tsx | 46 +- .../BadReview/BadReviewControllers.test.tsx | 2 +- .../components/TableView/TableView.test.tsx | 65 +- .../pages/SchedulePage/index.test.tsx | 11 +- .../Score/hooks/useScorePaging.test.tsx | 2 +- .../hooks/useSubmitTeamScore.test.tsx | 2 +- client/vitest.config.mts | 132 ++-- package-lock.json | 645 ++++++++---------- package.json | 4 +- 13 files changed, 447 insertions(+), 544 deletions(-) diff --git a/client/README.md b/client/README.md index 1564c517d..4133f0a45 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 180771a2e..45b9036bd 100644 --- a/client/package.json +++ b/client/package.json @@ -44,7 +44,7 @@ "devDependencies": { "@playwright/test": "^1.51.1", "@testing-library/jest-dom": "6.1.4", - "@testing-library/react": "14.1.2", + "@testing-library/react": "14.3.1", "@testing-library/user-event": "14.5.1", "@types/aws-lambda": "8.10.126", "@types/cookie": "0.5.4", diff --git a/client/src/modules/AutoTest/hooks/useCourseTaskSubmit/useCourseTaskSubmit.test.ts b/client/src/modules/AutoTest/hooks/useCourseTaskSubmit/useCourseTaskSubmit.test.ts index 8ce9266b4..a851848e5 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/CourseManagement/components/CertificateCriteriaModal/CertificateCriteriaModal.test.tsx b/client/src/modules/CourseManagement/components/CertificateCriteriaModal/CertificateCriteriaModal.test.tsx index 717f4a70f..f81f14746 100644 --- a/client/src/modules/CourseManagement/components/CertificateCriteriaModal/CertificateCriteriaModal.test.tsx +++ b/client/src/modules/CourseManagement/components/CertificateCriteriaModal/CertificateCriteriaModal.test.tsx @@ -20,7 +20,10 @@ const renderCertificateCriteriaModal = () => { }; describe('CertificateCriteriaModal', () => { - beforeAll(() => { + let user: ReturnType; + + beforeEach(() => { + user = userEvent.setup(); // 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 () => { @@ -91,21 +77,6 @@ describe('CertificateCriteriaModal', () => { 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/CourseModal/index.test.tsx b/client/src/modules/CourseManagement/components/CourseModal/index.test.tsx index 719ba8e5c..f65ff95e8 100644 --- a/client/src/modules/CourseManagement/components/CourseModal/index.test.tsx +++ b/client/src/modules/CourseManagement/components/CourseModal/index.test.tsx @@ -123,11 +123,16 @@ describe('', () => { copyCourse.mockResolvedValue({}); }); - it('renders the "Add Course" title and the copy-template select when creating', async () => { + it('renders the create form with its required fields and copy-template select', async () => { render(); expect(await screen.findByText('Add Course')).toBeInTheDocument(); expect(screen.getByLabelText('Copy Tasks, Schedule from:')).toBeInTheDocument(); + expect(screen.getByLabelText('Course Name')).toBeInTheDocument(); + expect(screen.getByLabelText('Full Course Name')).toBeInTheDocument(); + expect(screen.getByLabelText('Alias')).toBeInTheDocument(); + expect(screen.getByLabelText('Discord/Telegram channel')).toBeInTheDocument(); + expect(screen.getByLabelText('Disciplines')).toBeInTheDocument(); }); it('renders the "Edit Course" title and hides the copy-template select when editing', async () => { @@ -135,25 +140,10 @@ describe('', () => { expect(await screen.findByText('Edit Course')).toBeInTheDocument(); expect(await screen.findByDisplayValue('JS Course')).toBeInTheDocument(); + expect(getCourse).toHaveBeenCalledWith(7); expect(screen.queryByLabelText('Copy Tasks, Schedule from:')).not.toBeInTheDocument(); }); - it('fetches the course when editing', async () => { - render(); - - await waitFor(() => expect(getCourse).toHaveBeenCalledWith(7)); - }); - - it('renders the core required fields', async () => { - render(); - - expect(await screen.findByLabelText('Course Name')).toBeInTheDocument(); - expect(screen.getByLabelText('Full Course Name')).toBeInTheDocument(); - expect(screen.getByLabelText('Alias')).toBeInTheDocument(); - expect(screen.getByLabelText('Discord/Telegram channel')).toBeInTheDocument(); - expect(screen.getByLabelText('Disciplines')).toBeInTheDocument(); - }); - it('shows validation errors and does not submit when required fields are empty', async () => { const user = userEvent.setup(); const props = makeProps(); @@ -224,7 +214,10 @@ describe('', () => { const urlInput = await screen.findByPlaceholderText('Enter URL'); fireEvent.change(urlInput, { target: { value: 'http://evil.example.com' } }); - expect(await screen.findByText('Please enter RS APP or wearecommunity.io URL')).toBeInTheDocument(); + // Allow async validation and error rendering to finish on busy parallel runners. + expect( + await screen.findByText('Please enter RS APP or wearecommunity.io URL', {}, { timeout: 5000 }), + ).toBeInTheDocument(); }); it('rejects a registry URL whose course alias does not match the entered alias', async () => { @@ -353,6 +346,7 @@ describe('', () => { discordServerId: 5, descriptionUrl: 'https://rs.school/courses/javascript', }); + expect(record.wearecommunityUrl).toBe('https://app.rs.school/registry/student?course=newc'); expect(copyCourse).not.toHaveBeenCalled(); await waitFor(() => expect(props.onClose).toHaveBeenCalled()); }); @@ -448,22 +442,6 @@ describe('', () => { expect(record.certificateDisciplines).toEqual([]); }); - it('falls back to the RS APP registry URL when no WeAreCommunity URL is provided', async () => { - const user = userEvent.setup(); - const props = makeProps(); - render(); - - await screen.findByText('Add Course'); - await fillCreateForm(); - // leave the WeAreCommunity URL empty => createRecord uses buildRSAppStudentRegistryURL(alias) - - await user.click(screen.getByRole('button', { name: /save/i })); - - await waitFor(() => expect(createCourse).toHaveBeenCalled()); - const [record] = createCourse.mock.calls[0]; - expect(record.wearecommunityUrl).toBe('https://app.rs.school/registry/student?course=newc'); - }); - it('submits the custom description URL when "Custom" is selected', async () => { const user = userEvent.setup(); const props = makeProps(); diff --git a/client/src/modules/CrossCheckPairs/components/BadReview/BadReviewControllers.test.tsx b/client/src/modules/CrossCheckPairs/components/BadReview/BadReviewControllers.test.tsx index a587f9b97..0c9acbe61 100644 --- a/client/src/modules/CrossCheckPairs/components/BadReview/BadReviewControllers.test.tsx +++ b/client/src/modules/CrossCheckPairs/components/BadReview/BadReviewControllers.test.tsx @@ -110,7 +110,7 @@ describe('', () => { await user.click(screen.getByRole('button', { name: 'Bad comment' })); const dialog = await screen.findByRole('dialog'); - expect(dialog).toBeVisible(); + await waitFor(() => expect(dialog).toBeVisible()); await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); await waitFor(() => { diff --git a/client/src/modules/Schedule/components/TableView/TableView.test.tsx b/client/src/modules/Schedule/components/TableView/TableView.test.tsx index 82b24f5d7..7cc0cbb45 100644 --- a/client/src/modules/Schedule/components/TableView/TableView.test.tsx +++ b/client/src/modules/Schedule/components/TableView/TableView.test.tsx @@ -26,36 +26,38 @@ 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', () => { render(); - expect(screen.getByText(label)).toBeInTheDocument(); + 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(); + } }); - 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 }) => { + it('should render the data fields', () => { render(); - 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', () => { @@ -156,12 +158,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 +174,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/pages/SchedulePage/index.test.tsx b/client/src/modules/Schedule/pages/SchedulePage/index.test.tsx index 86c90ed8d..9a31ec75f 100644 --- a/client/src/modules/Schedule/pages/SchedulePage/index.test.tsx +++ b/client/src/modules/Schedule/pages/SchedulePage/index.test.tsx @@ -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 }; }, }; @@ -149,6 +152,8 @@ describe('', () => { await waitFor(() => expect(getSchedule).toHaveBeenCalledWith(42)); expect(getScheduleICalendarToken).toHaveBeenCalledWith(42); + expect(getSchedule).toHaveBeenCalledTimes(1); + expect(getScheduleICalendarToken).toHaveBeenCalledTimes(1); }); it('shows the SettingsPanel with manager actions when the user is a course manager', async () => { diff --git a/client/src/modules/Score/hooks/useScorePaging.test.tsx b/client/src/modules/Score/hooks/useScorePaging.test.tsx index 28b39aec1..475df85ad 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/TeamDistribution/hooks/useSubmitTeamScore.test.tsx b/client/src/modules/TeamDistribution/hooks/useSubmitTeamScore.test.tsx index 5d079f99b..259cc41fc 100644 --- a/client/src/modules/TeamDistribution/hooks/useSubmitTeamScore.test.tsx +++ b/client/src/modules/TeamDistribution/hooks/useSubmitTeamScore.test.tsx @@ -1,7 +1,7 @@ import { useSubmitTeamScore } from './useSubmitTeamScore'; import { TeamDistributionApi } from '@client/api'; import { renderHook } from '@testing-library/react'; -import { act } from 'react-dom/test-utils'; +import { act } from 'react'; vi.mock('@client/api'); diff --git a/client/vitest.config.mts b/client/vitest.config.mts index f9bd9d86e..8382c0886 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,50 @@ 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: [ + { + extends: true, + test: { + name: 'node', + environment: 'node', + include: nodeTests, + }, + }, + { + extends: true, + // 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', }, @@ -62,12 +126,7 @@ export default mergeConfig( '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 +134,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/package-lock.json b/package-lock.json index c2d073488..3089879f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "@types/jest": "30.0.0", "@types/lodash": "4.17.24", "@types/node": "24.12.0", - "@vitest/coverage-v8": "~4.1.0", + "@vitest/coverage-v8": "~4.1.11", "@vitest/eslint-plugin": "^1.6.11", "dotenv": "^16.5.0", "eslint": "10.0.3", @@ -40,7 +40,7 @@ "typescript": "5.9.3", "typescript-eslint": "8.57.0", "unplugin-swc": "^1.5.9", - "vitest": "~4.1.0" + "vitest": "~4.1.11" }, "engines": { "node": ">=22", @@ -76,7 +76,7 @@ "devDependencies": { "@playwright/test": "^1.51.1", "@testing-library/jest-dom": "6.1.4", - "@testing-library/react": "14.1.2", + "@testing-library/react": "14.3.1", "@testing-library/user-event": "14.5.1", "@types/aws-lambda": "8.10.126", "@types/cookie": "0.5.4", @@ -6607,24 +6607,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 +6644,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 +7623,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 +8078,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 +8113,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 +8130,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 +8147,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 +8164,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 +8181,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 +8201,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 +8221,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 +8241,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 +8261,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 +8281,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 +8301,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 +8317,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 +8335,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 +8352,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" }, @@ -9259,7 +9216,7 @@ "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 +9258,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -9318,7 +9274,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -9335,7 +9290,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -9352,7 +9306,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -9369,7 +9322,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -9386,7 +9338,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -9403,7 +9354,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -9420,7 +9370,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -9437,7 +9386,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -9454,7 +9402,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -9468,14 +9415,14 @@ "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" @@ -9603,9 +9550,9 @@ } }, "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==", + "version": "14.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz", + "integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10132,14 +10079,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,14 +10868,14 @@ ] }, "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": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.0", + "@vitest/utils": "4.1.11", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -10944,14 +10883,14 @@ "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.0", - "vitest": "4.1.0" + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -10997,31 +10936,31 @@ } }, "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==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "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", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "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==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.0", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -11030,7 +10969,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -11041,37 +10980,27 @@ } } }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, "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==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "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==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.0", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -11079,14 +11008,14 @@ } }, "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==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -11094,20 +11023,10 @@ "url": "https://opencollective.com/vitest" } }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, "node_modules/@vitest/spy": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", - "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -11115,15 +11034,15 @@ } }, "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==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -17715,9 +17634,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 +17650,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 +17685,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 +17706,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 +17727,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 +17748,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 +17769,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 +17793,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 +17817,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 +17841,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 +17865,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 +17886,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 +18082,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,6 +18092,16 @@ "lz-string": "bin/bin.js" } }, + "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/magicast": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", @@ -19193,31 +19123,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", @@ -20519,7 +20424,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, @@ -21357,14 +21261,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 +21277,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": { @@ -22862,14 +22766,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 +22801,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": { @@ -24035,18 +23939,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 +23965,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 +24017,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 +24036,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 +24049,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 +24069,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -24175,19 +24078,19 @@ } }, "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": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "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", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -24198,8 +24101,8 @@ "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", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -24215,13 +24118,15 @@ "@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", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -24242,6 +24147,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, @@ -24263,16 +24174,6 @@ "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", diff --git a/package.json b/package.json index 6fc34dd2e..92b6e63ce 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "@types/jest": "30.0.0", "@types/lodash": "4.17.24", "@types/node": "24.12.0", - "@vitest/coverage-v8": "~4.1.0", + "@vitest/coverage-v8": "~4.1.11", "@vitest/eslint-plugin": "^1.6.11", "dotenv": "^16.5.0", "eslint": "10.0.3", @@ -60,7 +60,7 @@ "typescript": "5.9.3", "typescript-eslint": "8.57.0", "unplugin-swc": "^1.5.9", - "vitest": "~4.1.0" + "vitest": "~4.1.11" }, "packageManager": "npm@10.7.0" } From 6264e8ed026959d97bcc420caa11a7460ad35ee4 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 19:29:04 +0200 Subject: [PATCH 002/406] chore(test): migrate to Vitest 5 and related dependencies --- .gitignore | 1 + CONTRIBUTING.md | 2 +- client/package.json | 8 +- client/src/setupTests.ts | 16 +- client/vitest.config.mts | 6 +- package-lock.json | 1272 +++++++++++++------------------------- package.json | 10 +- 7 files changed, 441 insertions(+), 874 deletions(-) diff --git a/.gitignore b/.gitignore index 622372e65..7622368bb 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 4567868fe..c51f57330 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/client/package.json b/client/package.json index 45b9036bd..44cbc2dd3 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.3.1", - "@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/setupTests.ts b/client/src/setupTests.ts index a308e639c..3dfa9ab32 100644 --- a/client/src/setupTests.ts +++ b/client/src/setupTests.ts @@ -1,6 +1,20 @@ -import '@testing-library/jest-dom/vitest'; +import { expect } from 'vitest'; +import * as matchers from '@testing-library/jest-dom/matchers'; +import type { TestingLibraryMatchers } from '@testing-library/jest-dom/matchers'; import matchMediaPolyfill from 'mq-polyfill'; +// jest-dom's /vitest entry still augments the Vitest 4 Assertion type. +declare module 'vitest' { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- Module augmentation requires an interface. + interface Matchers = void | Promise, T = unknown> extends TestingLibraryMatchers< + T, + R + > {} +} + +// Legacy module resolution selects jest-dom's assertion types for these runtime matchers. +expect.extend(matchers as unknown as Parameters[0]); + matchMediaPolyfill(window); // antd v6 @rc-component/util calls getComputedStyle with pseudoElt argument. diff --git a/client/vitest.config.mts b/client/vitest.config.mts index 8382c0886..b82bfd26a 100644 --- a/client/vitest.config.mts +++ b/client/vitest.config.mts @@ -58,7 +58,6 @@ export default mergeConfig( test: { projects: [ { - extends: true, test: { name: 'node', environment: 'node', @@ -66,7 +65,6 @@ export default mergeConfig( }, }, { - extends: true, // Bundle ESM exports so named icon imports survive dependency optimization. resolve: { mainFields: ['module', 'main'] }, test: { @@ -119,9 +117,7 @@ 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', ], diff --git a/package-lock.json b/package-lock.json index 3089879f2..6d5f258d0 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.11", - "@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.11" + "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.3.1", - "@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": { @@ -9205,13 +9283,6 @@ "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", @@ -9429,23 +9500,23 @@ } }, "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": { @@ -9493,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.3.1", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz", - "integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==", - "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": { @@ -10868,29 +10864,27 @@ ] }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", - "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", + "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.11", - "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.1.0" + "@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.11", - "vitest": "4.1.11" + "@vitest/browser": "5.0.0", + "vitest": "5.0.0" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -10909,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 }, @@ -10935,115 +10933,204 @@ } } }, - "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "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.11", - "@vitest/utils": "4.1.11", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" + "@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.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "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.11", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "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" + } + }, + "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" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "typescript": ">=4.8.4 <6.1.0" + } + }, + "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", + "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" } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "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.1.0" + "@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.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "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.11", - "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.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "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.11", - "@vitest/utils": "4.1.11", - "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/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "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", - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=22" } }, - "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "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": { - "@vitest/pretty-format": "4.1.11", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" + "@vitest/istanbul-lib-coverage": "1.0.1" + }, + "engines": { + "node": ">=22" + } + }, + "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/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", "funding": { "url": "https://opencollective.com/vitest" } @@ -11555,76 +11642,19 @@ } }, "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" + "dequal": "^2.0.3" } }, - "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" - } - }, - "node_modules/array-timsort": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", - "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", "dev": true, "license": "MIT" }, @@ -11659,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": { @@ -11903,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": { @@ -13880,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", @@ -14119,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" @@ -14168,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", @@ -14743,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": { @@ -15309,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", @@ -15556,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", @@ -16122,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", @@ -16172,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", @@ -16214,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", @@ -16307,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", @@ -16376,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", @@ -16405,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", @@ -16447,54 +16278,6 @@ "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", @@ -16508,41 +16291,6 @@ "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" - } - }, - "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==", - "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" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-typed-array": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", @@ -16577,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", @@ -17341,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", @@ -17382,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": [ { @@ -17421,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": { @@ -17438,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" @@ -18093,24 +17811,24 @@ } }, "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==", + "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.5.5" + "@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" } }, @@ -19479,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", @@ -20086,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", @@ -20987,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", @@ -21376,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", @@ -21577,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", @@ -22023,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", @@ -22749,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": { @@ -22824,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": { @@ -22996,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": { @@ -23495,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": { @@ -24078,38 +23678,31 @@ } }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "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.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "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.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.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" @@ -24117,16 +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.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@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" + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -24168,16 +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/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": { @@ -24385,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 92b6e63ce..77d505950 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.11", - "@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.11" + "vite": "~8.3.0", + "vitest": "~5.0.0" }, "packageManager": "npm@10.7.0" } From 0279d7109752f251bb40970273b45831f2b94ec8 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 19:29:04 +0200 Subject: [PATCH 003/406] test(client): adapt empty-link query to Testing Library 10 --- .../components/MobileItemCard/MobileItemCard.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/src/modules/Schedule/components/MobileItemCard/MobileItemCard.test.tsx b/client/src/modules/Schedule/components/MobileItemCard/MobileItemCard.test.tsx index 9790bcdd9..596448bd1 100644 --- a/client/src/modules/Schedule/components/MobileItemCard/MobileItemCard.test.tsx +++ b/client/src/modules/Schedule/components/MobileItemCard/MobileItemCard.test.tsx @@ -39,7 +39,9 @@ describe('', () => { it('falls back to an empty href when the item has no description URL', () => { render(); - const link = screen.getByRole('link', { name: 'Intro to JS' }); + 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. + const link = heading.closest('a'); expect(link).toHaveAttribute('href', ''); }); From 9b1ef142f3c1a62147a3b07aee8d495196c7b8c8 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 19:29:04 +0200 Subject: [PATCH 004/406] test: make shuffle retry coverage deterministic --- nestjs/src/utils/shuffle.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/nestjs/src/utils/shuffle.test.ts b/nestjs/src/utils/shuffle.test.ts index 7d159ea3c..b6d50fa97 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); }); }); }); From 19f8a4e297e334d608754cfdbcb4b2b026542581 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 19:37:40 +0200 Subject: [PATCH 005/406] perf(client): speed up certificate criteria button queries Query Remove task by its accessible label and assert its button role and visibility explicitly. Keep all 17 tests and identical covered source locations. Two paired one-worker coverage runs reduced median test execution from 28.91s to 9.43s. --- .../CertificateCriteriaModal.test.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/client/src/modules/CourseManagement/components/CertificateCriteriaModal/CertificateCriteriaModal.test.tsx b/client/src/modules/CourseManagement/components/CertificateCriteriaModal/CertificateCriteriaModal.test.tsx index f81f14746..c00d2d149 100644 --- a/client/src/modules/CourseManagement/components/CertificateCriteriaModal/CertificateCriteriaModal.test.tsx +++ b/client/src/modules/CourseManagement/components/CertificateCriteriaModal/CertificateCriteriaModal.test.tsx @@ -62,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 () => { @@ -71,7 +74,9 @@ 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(); From f42ce61ef668cff74227c95f1cce5ec9a1d9f79b Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 19:42:15 +0200 Subject: [PATCH 006/406] perf(client): consolidate course modal URL scenarios Keep every assertion while testing URL validation and custom-field submission as complete flows. Preserve the initial empty-form custom-field check and verify recovery from an alias error. Two paired coverage runs retain identical covered source locations and reduce median test execution from 7.37s to 6.58s. --- .../components/CourseModal/index.test.tsx | 84 +++++-------------- 1 file changed, 19 insertions(+), 65 deletions(-) diff --git a/client/src/modules/CourseManagement/components/CourseModal/index.test.tsx b/client/src/modules/CourseManagement/components/CourseModal/index.test.tsx index f65ff95e8..2f08017c5 100644 --- a/client/src/modules/CourseManagement/components/CourseModal/index.test.tsx +++ b/client/src/modules/CourseManagement/components/CourseModal/index.test.tsx @@ -159,24 +159,6 @@ describe('', () => { expect(props.onClose).not.toHaveBeenCalled(); }); - it('reveals the Custom Url input when "Custom" description URL is selected', async () => { - render(); - - // The Description Url dropdown is long and virtualized (only ~10 options render at a - // time); scroll the rc-virtual list so the trailing "Custom" option mounts. - const descUrlSelect = await screen.findByLabelText('Description Url'); - fireEvent.mouseDown(descUrlSelect); - - await waitFor(() => expect(document.querySelector('.rc-virtual-list-holder')).toBeTruthy()); - const list = document.querySelector('.rc-virtual-list-holder')!; - fireEvent.scroll(list, { target: { scrollTop: 1000 } }); - - const customOption = await within(document.body).findByText('Custom'); - fireEvent.click(customOption); - - expect(await screen.findByLabelText('Custom Url')).toBeInTheDocument(); - }); - it('hides the certificate disciplines select when "Any course" is checked', async () => { const user = userEvent.setup(); render(); @@ -220,45 +202,20 @@ describe('', () => { ).toBeInTheDocument(); }); - it('rejects a registry URL whose course alias does not match the entered alias', async () => { + it('previews the registry URL and validates its course alias', async () => { render(); const alias = await screen.findByLabelText('Alias'); fireEvent.change(alias, { target: { value: 'my-course' } }); - // Wait for the watched alias to propagate (the live registry-link preview confirms it). - await screen.findByText('https://app.rs.school/registry/student?course=my-course'); + expect(await screen.findByText('https://app.rs.school/registry/student?course=my-course')).toBeInTheDocument(); const urlInput = screen.getByPlaceholderText('Enter URL'); - // A valid registry URL but with a different course alias → alias-mismatch rejection. fireEvent.change(urlInput, { target: { value: 'https://app.rs.school/registry/student?course=other-course' } }); - expect(await screen.findByText('URL must end with my-course')).toBeInTheDocument(); - }); - it('accepts a registry URL whose course alias matches the entered alias', async () => { - render(); - - const alias = await screen.findByLabelText('Alias'); - fireEvent.change(alias, { target: { value: 'my-course' } }); - await screen.findByText('https://app.rs.school/registry/student?course=my-course'); - - const urlInput = screen.getByPlaceholderText('Enter URL'); fireEvent.change(urlInput, { target: { value: 'https://app.rs.school/registry/student?course=my-course' } }); - - // No mismatch error should appear for a matching alias. - await waitFor(() => { - expect(screen.queryByText('URL must end with my-course')).not.toBeInTheDocument(); - expect(screen.queryByText('Please enter RS APP or wearecommunity.io URL')).not.toBeInTheDocument(); - }); - }); - - it('shows the RS APP registry link preview once an alias is entered', async () => { - render(); - - const alias = await screen.findByLabelText('Alias'); - fireEvent.change(alias, { target: { value: 'my-course' } }); - - expect(await screen.findByText('https://app.rs.school/registry/student?course=my-course')).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByText('URL must end with my-course')).not.toBeInTheDocument()); + expect(screen.queryByText('Please enter RS APP or wearecommunity.io URL')).not.toBeInTheDocument(); }); it('updates the course with the built record when editing and saving', async () => { @@ -442,45 +399,42 @@ describe('', () => { expect(record.certificateDisciplines).toEqual([]); }); - it('submits the custom description URL when "Custom" is selected', async () => { + it('reveals and submits the custom description URL when "Custom" is selected', async () => { const user = userEvent.setup(); const props = makeProps(); render(); await screen.findByText('Add Course'); - // Fill required text + selects, but pick "Custom" for the description URL. + // Reveal the custom field before filling the rest of the form. + const descUrlSelect = screen.getByLabelText('Description Url'); + fireEvent.mouseDown(descUrlSelect); + await waitFor(() => expect(document.querySelector('.rc-virtual-list-holder')).toBeTruthy()); + const holder = document.querySelector('.rc-virtual-list-holder')!; + fireEvent.scroll(holder, { target: { scrollTop: 2000 } }); + fireEvent.click(await screen.findByText('Custom')); + + const customUrl = await screen.findByLabelText('Custom Url'); + expect(customUrl).toBeInTheDocument(); + fireEvent.change(customUrl, { target: { value: 'https://example.com/custom-course' } }); + fireEvent.change(screen.getByLabelText('Course Name'), { target: { value: 'New Course' } }); fireEvent.change(screen.getByLabelText('Full Course Name'), { target: { value: 'New Full Course' } }); fireEvent.change(screen.getByLabelText('Alias'), { target: { value: 'newc' } }); const discord = screen.getByLabelText('Discord/Telegram channel'); fireEvent.mouseDown(discord); - fireEvent.click(await within(document.body).findByText('RS Discord')); + fireEvent.click(await screen.findByText('RS Discord')); const disc = screen.getByLabelText('Disciplines'); fireEvent.mouseDown(disc); - const fe = await within(document.body).findAllByText('Frontend'); + const fe = await screen.findAllByText('Frontend'); fireEvent.click(fe[fe.length - 1]); fireEvent.change(screen.getByTestId('range-start'), { target: { value: '2024-01-01' } }); fireEvent.change(screen.getByTestId('range-end'), { target: { value: '2024-06-01' } }); - - // Close any dropdown left open by the previous selects so descUrl's list is the only one. fireEvent.keyDown(document.body, { key: 'Escape', code: 'Escape' }); - // Description URL -> "Custom" (scroll the virtualized list so the trailing option mounts). - const descUrlSelect = screen.getByLabelText('Description Url'); - fireEvent.mouseDown(descUrlSelect); - await waitFor(() => expect(document.querySelectorAll('.rc-virtual-list-holder').length).toBeGreaterThan(0)); - const holders = document.querySelectorAll('.rc-virtual-list-holder'); - const holder = holders[holders.length - 1]; - fireEvent.scroll(holder, { target: { scrollTop: 2000 } }); - fireEvent.click(await within(document.body).findByText('Custom')); - - const customUrl = await screen.findByLabelText('Custom Url'); - fireEvent.change(customUrl, { target: { value: 'https://example.com/custom-course' } }); - await user.click(screen.getByRole('button', { name: /save/i })); await waitFor(() => expect(createCourse).toHaveBeenCalled()); From 1aea115ddfa46b0eb52b9b0ffa88cab1cc6e5c8f Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 19:47:49 +0200 Subject: [PATCH 007/406] perf(client): consolidate discipline modal workflows Check empty and prefilled modal state within the existing create and update workflows. Keep all assertions and real typing/click interactions, and avoid importing unused generated API implementations in the API mock. Paired coverage runs retain identical covered source locations; median test execution falls from 6.76s to 4.73s. --- .../Discipline/pages/DisciplinePage.test.tsx | 32 +++---------------- 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/client/src/modules/Discipline/pages/DisciplinePage.test.tsx b/client/src/modules/Discipline/pages/DisciplinePage.test.tsx index 3b429415f..b859b5357 100644 --- a/client/src/modules/Discipline/pages/DisciplinePage.test.tsx +++ b/client/src/modules/Discipline/pages/DisciplinePage.test.tsx @@ -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 () => { + 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 })); - 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,19 +84,6 @@ 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(); render(); @@ -116,9 +91,10 @@ describe('', () => { 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 })); From aff9e0d9dc777837eca25872ba8c454639884fa0 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 19:51:17 +0200 Subject: [PATCH 008/406] perf(client): streamline prompt page test queries Locate prompt rows from their type text and explicitly assert row semantics. Retain every assertion and user interaction, combine duplicate create-modal opening coverage, and narrow the API mock. Paired coverage runs retain identical covered source locations; median test execution drops from 7.25s to 3.38s. --- .../modules/Prompts/pages/PromptPage.test.tsx | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/client/src/modules/Prompts/pages/PromptPage.test.tsx b/client/src/modules/Prompts/pages/PromptPage.test.tsx index 131b041dc..30c717bc9 100644 --- a/client/src/modules/Prompts/pages/PromptPage.test.tsx +++ b/client/src/modules/Prompts/pages/PromptPage.test.tsx @@ -42,8 +42,7 @@ const { getPrompts, createPrompt, updatePrompt, deletePrompt } = vi.hoisted(() = deletePrompt: vi.fn(), })); -vi.mock('@client/api', async () => ({ - ...(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(); 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'); @@ -104,7 +100,7 @@ describe('', () => { 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); @@ -125,7 +121,7 @@ describe('', () => { 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]); From 46e80e1244220483c0cf5b7af7bdde2cf77d0f57 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 19:55:53 +0200 Subject: [PATCH 009/406] perf(client): consolidate repeated task table checks Preserve column, field, edit, filter, and search assertions while sharing identical render states. Median isolated test time falls from 3.96s to 2.54s across two runs per version. Covered source locations remain identical. --- .../components/TasksTable/TasksTable.test.tsx | 121 ++++++------------ 1 file changed, 36 insertions(+), 85 deletions(-) diff --git a/client/src/modules/Tasks/components/TasksTable/TasksTable.test.tsx b/client/src/modules/Tasks/components/TasksTable/TasksTable.test.tsx index d0b3f55c9..64c1659f5 100644 --- a/client/src/modules/Tasks/components/TasksTable/TasksTable.test.tsx +++ b/client/src/modules/Tasks/components/TasksTable/TasksTable.test.tsx @@ -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 () => { + test('should filter by Name and restore all data when search is cleared', async () => { const user = userEvent.setup(); 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 }); From 7485f14a3b9f88ea1fff83032ae77409e13f0155 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 19:56:59 +0200 Subject: [PATCH 010/406] perf(client): streamline contributor page test queries Locate rows from their visible GitHub IDs and assert row semantics. Share create-modal checks with its close workflow and narrow the API mock. Median isolated time falls from 6.47s to 2.71s; both coverage comparisons preserve all covered locations. --- .../pages/ContributorPage.test.tsx | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/client/src/modules/Contributor/pages/ContributorPage.test.tsx b/client/src/modules/Contributor/pages/ContributorPage.test.tsx index c85d624ee..f61053781 100644 --- a/client/src/modules/Contributor/pages/ContributorPage.test.tsx +++ b/client/src/modules/Contributor/pages/ContributorPage.test.tsx @@ -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(); 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); @@ -97,7 +90,7 @@ describe('', () => { 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 () => { + it('opens the create modal and reloads the list after it closes', async () => { const user = userEvent.setup(); 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()); From 323d9acd88047948f3aa507244dcbe4c9a75c723 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 19:58:01 +0200 Subject: [PATCH 011/406] perf(client): share duplicate team modal setup Combine create-mode display assertions with cancellation and exercise manager student validation through correction. Preserve real typing and all payload checks. Median isolated time drops from 3.82s to 3.16s with identical covered source locations. --- .../components/TeamModal/TeamModal.test.tsx | 27 ++++--------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/client/src/modules/Teams/components/TeamModal/TeamModal.test.tsx b/client/src/modules/Teams/components/TeamModal/TeamModal.test.tsx index fbe9ac334..6cbf0725b 100644 --- a/client/src/modules/Teams/components/TeamModal/TeamModal.test.tsx +++ b/client/src/modules/Teams/components/TeamModal/TeamModal.test.tsx @@ -41,21 +41,18 @@ function renderModal(overrides: Partial[0]> = {}) { describe('', () => { beforeEach(() => vi.clearAllMocks()); - it('renders the Create title and Create ok button in create mode', () => { - renderModal({ mode: 'create' }); - expect(screen.getByText('Create Team')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /^create$/i })).toBeInTheDocument(); - }); - it('renders the Edit title and Edit ok button in edit mode', () => { renderModal({ mode: 'edit' }); expect(screen.getByText('Edit Team')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /^edit$/i })).toBeInTheDocument(); }); - it('calls onCancel when the cancel button is clicked', async () => { + it('renders the non-manager create modal and handles cancellation', async () => { const user = userEvent.setup(); const { onCancel } = renderModal(); + expect(screen.getByText('Create Team')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^create$/i })).toBeInTheDocument(); + expect(screen.queryByTestId('student-search')).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: /cancel/i })); expect(onCancel).toHaveBeenCalled(); }); @@ -148,7 +145,7 @@ describe('', () => { expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ studentIds: [11, 22] }), 5); }); - it('shows the Students field for managers and requires it', async () => { + it('requires students for managers and submits after they are selected', async () => { const user = userEvent.setup(); const { onSubmit } = renderModal({ isManager: true }); @@ -161,15 +158,6 @@ describe('', () => { expect(await screen.findByText('Please select students')).toBeInTheDocument(); expect(onSubmit).not.toHaveBeenCalled(); - }); - - it('submits studentIds when a manager selects students', async () => { - const user = userEvent.setup(); - const { onSubmit } = renderModal({ isManager: true, maxStudentsCount: 3 }); - - await user.type(screen.getByLabelText('Name'), 'Manager Team'); - await user.type(screen.getByLabelText('Description'), 'Created by manager'); - await user.type(screen.getByLabelText('Link to Discord server'), 'https://discord.gg/mgr'); await user.click(screen.getByRole('button', { name: /pick students/i })); await user.click(screen.getByRole('button', { name: /^create$/i })); @@ -187,9 +175,4 @@ describe('', () => { expect(warnSpy).toHaveBeenCalledWith('You can only select a maximum of 3 students.'); warnSpy.mockRestore(); }); - - it('does not render the Students field for non-managers', () => { - renderModal({ isManager: false }); - expect(screen.queryByTestId('student-search')).not.toBeInTheDocument(); - }); }); From d417c96c6f58a8cceaa6f4945c4c04bd59840411 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 19:59:33 +0200 Subject: [PATCH 012/406] perf(client): share schedule table display setup Check headers and data fields from the same initial render. Keep distinct filtering scenarios and semantic queries. Median isolated test time improves from 4.14s to 4.01s; all covered source locations are preserved. --- .../Schedule/components/TableView/TableView.test.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/client/src/modules/Schedule/components/TableView/TableView.test.tsx b/client/src/modules/Schedule/components/TableView/TableView.test.tsx index 7cc0cbb45..ca2794ce1 100644 --- a/client/src/modules/Schedule/components/TableView/TableView.test.tsx +++ b/client/src/modules/Schedule/components/TableView/TableView.test.tsx @@ -26,7 +26,7 @@ const PROPS_SETTINGS_MOCK: ScheduleSettings = { }; describe('TableView', () => { - it('should render the column headers', () => { + it('should render the column headers and data fields', () => { render(); for (const label of [ @@ -41,10 +41,6 @@ describe('TableView', () => { ]) { expect(screen.getByText(label)).toBeInTheDocument(); } - }); - - it('should render the data fields', () => { - render(); for (const value of [ 'Course Item 0', From 4280a3f77fbe1d38975ff9db755daa6f712b896b Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:00:47 +0200 Subject: [PATCH 013/406] perf(client): share course task modal render assertions Check pristine fields and defaults before cancellation, and fetched options before selection. Preserve all input scenarios and covered locations. Repeated isolated test time improves from 3.66s to 3.31s. --- .../components/CourseTaskModal/index.test.tsx | 47 +++++-------------- 1 file changed, 12 insertions(+), 35 deletions(-) diff --git a/client/src/modules/CourseManagement/components/CourseTaskModal/index.test.tsx b/client/src/modules/CourseManagement/components/CourseTaskModal/index.test.tsx index 415c81149..b33b44ab2 100644 --- a/client/src/modules/CourseManagement/components/CourseTaskModal/index.test.tsx +++ b/client/src/modules/CourseManagement/components/CourseTaskModal/index.test.tsx @@ -77,45 +77,15 @@ describe('', () => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); - it('renders the Course Task modal with its core fields', async () => { - render(); - - expect(await screen.findByText('Course Task')).toBeInTheDocument(); - expect(screen.getByLabelText('Task')).toBeInTheDocument(); - expect(screen.getByLabelText('Task Type')).toBeInTheDocument(); - expect(screen.getByLabelText('Checker')).toBeInTheDocument(); - expect(screen.getByLabelText('Score')).toBeInTheDocument(); - expect(screen.getByLabelText('Score Weight')).toBeInTheDocument(); - }); - - it('seeds default Score (100) and Score Weight (1) from getInitialValues', async () => { - render(); - - await screen.findByText('Course Task'); - // antd InputNumber exposes the numeric value via aria-valuenow on role="spinbutton". - expect(screen.getByLabelText('Score')).toHaveAttribute('aria-valuenow', '100'); - expect(screen.getByLabelText('Score Weight')).toHaveAttribute('aria-valuenow', '1'); - }); - - it('lists the fetched tasks as options in the Task select', async () => { - render(); - - const taskSelect = await screen.findByLabelText('Task'); - fireEvent.mouseDown(taskSelect); - - await waitFor(() => { - expect(within(document.body).getByText(/HTML Task/)).toBeInTheDocument(); - expect(within(document.body).getByText(/Interview/)).toBeInTheDocument(); - }); - }); - - it('auto-fills Task Type when a task is selected', async () => { + it('lists fetched tasks and auto-fills Task Type after selection', async () => { render(); const taskSelect = await screen.findByLabelText('Task'); fireEvent.mouseDown(taskSelect); const option = await within(document.body).findByText(/HTML Task/); + expect(option).toBeInTheDocument(); + expect(within(document.body).getByText(/Interview/)).toBeInTheDocument(); fireEvent.click(option); // The task's type ("htmltask" → "HTML task") flows into the Type select. @@ -253,12 +223,19 @@ describe('', () => { expect(within(document.body).queryByText(/HTML Task/)).not.toBeInTheDocument(); }); - it('calls onCancel when the cancel button is clicked on a pristine form', async () => { + it('renders core fields and defaults, then cancels a pristine form', async () => { const user = userEvent.setup(); const props = makeProps(); render(); - await screen.findByText('Course Task'); + expect(await screen.findByText('Course Task')).toBeInTheDocument(); + expect(screen.getByLabelText('Task')).toBeInTheDocument(); + expect(screen.getByLabelText('Task Type')).toBeInTheDocument(); + expect(screen.getByLabelText('Checker')).toBeInTheDocument(); + expect(screen.getByLabelText('Score')).toBeInTheDocument(); + expect(screen.getByLabelText('Score Weight')).toBeInTheDocument(); + expect(screen.getByLabelText('Score')).toHaveAttribute('aria-valuenow', '100'); + expect(screen.getByLabelText('Score Weight')).toHaveAttribute('aria-valuenow', '1'); await user.click(screen.getByRole('button', { name: /cancel/i })); expect(props.onCancel).toHaveBeenCalled(); From f47ee837fec70354f1de7719960b9802835f17bf Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:06:40 +0200 Subject: [PATCH 014/406] perf(client): streamline manual score form tests Share initial-row assertions, query labeled removal buttons, and wait for actual validation outcomes. Keep the existing message boundary mocked with an observable error call. Median isolated time improves from 4.34s to 3.40s; covered source locations remain identical. --- .../SubmitScores/ManualSubmitTab.test.tsx | 44 +++++++++---------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/client/src/modules/SubmitScores/ManualSubmitTab.test.tsx b/client/src/modules/SubmitScores/ManualSubmitTab.test.tsx index 0dd220946..fc7a7887c 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(); }); From 08b1701b316bc542a410a184e4c590877a4c5435 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:07:52 +0200 Subject: [PATCH 015/406] perf(client): target event rows by visible names Preserve all six scenarios and real interactions while avoiding repeated row accessible-name traversal. Median isolated test time improves from 2.96s to 2.76s with identical covered source locations. --- .../EventsAdminPage/EventsAdminPage.test.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/client/src/modules/EventsAdmin/pages/EventsAdminPage/EventsAdminPage.test.tsx b/client/src/modules/EventsAdmin/pages/EventsAdminPage/EventsAdminPage.test.tsx index bc26475e2..fb244475d 100644 --- a/client/src/modules/EventsAdmin/pages/EventsAdminPage/EventsAdminPage.test.tsx +++ b/client/src/modules/EventsAdmin/pages/EventsAdminPage/EventsAdminPage.test.tsx @@ -55,6 +55,13 @@ const events = [ }, ] as unknown as EventDto[]; +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, @@ -116,7 +123,7 @@ describe('', () => { 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'); @@ -135,7 +142,7 @@ describe('', () => { 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 })); @@ -150,7 +157,7 @@ describe('', () => { 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 })); @@ -166,7 +173,7 @@ describe('', () => { 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 })); From a017045d99cdaf9c20c878a05b639e447cd2ef88 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:08:45 +0200 Subject: [PATCH 016/406] perf(client): consolidate schedule page mount checks Assert fetch calls, rows, tabs, and manager controls from one initial render. Preserve non-manager, mobile, and modal workflows. Median isolated time improves from 3.37s to 3.08s with identical covered source locations. --- .../Schedule/pages/SchedulePage/index.test.tsx | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/client/src/modules/Schedule/pages/SchedulePage/index.test.tsx b/client/src/modules/Schedule/pages/SchedulePage/index.test.tsx index 9a31ec75f..4d60e1a64 100644 --- a/client/src/modules/Schedule/pages/SchedulePage/index.test.tsx +++ b/client/src/modules/Schedule/pages/SchedulePage/index.test.tsx @@ -137,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. @@ -145,20 +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); expect(getSchedule).toHaveBeenCalledTimes(1); expect(getScheduleICalendarToken).toHaveBeenCalledTimes(1); - }); - - it('shows the SettingsPanel with manager actions when the user is a course manager', async () => { - render(); - expect(await screen.findByTestId('Task')).toBeInTheDocument(); expect(screen.getByTestId('Event')).toBeInTheDocument(); }); From 8130168dc86c69d06ad90fa84cc48c378530a205 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:09:54 +0200 Subject: [PATCH 017/406] perf(client): share interview feedback navigation setup Use one provider for initial context and UI checks. Verify save payload, stepper state, and backward navigation in one flow. Median isolated time improves from 3.50s to 2.54s with all covered source locations preserved. --- .../StepContext.test.tsx | 68 +++++-------------- 1 file changed, 17 insertions(+), 51 deletions(-) diff --git a/client/src/modules/Interviews/pages/StageInterviewFeedback/StepContext.test.tsx b/client/src/modules/Interviews/pages/StageInterviewFeedback/StepContext.test.tsx index 6016195bc..4a0acc5dd 100644 --- a/client/src/modules/Interviews/pages/StageInterviewFeedback/StepContext.test.tsx +++ b/client/src/modules/Interviews/pages/StageInterviewFeedback/StepContext.test.tsx @@ -3,7 +3,7 @@ /* 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 { 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} , ); } @@ -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,6 +134,11 @@ 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 () => { @@ -158,7 +153,7 @@ 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 () => { + it('saves Introduction, updates the Theory stepper, and navigates Back without saving', async () => { const user = userEvent.setup(); renderProvider(); @@ -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' })); @@ -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( @@ -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)', () => { From 0b3f223ed0068f6f3f6c7e524d9fe5c94094aa4a Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:10:52 +0200 Subject: [PATCH 018/406] perf(client): use labeled feedback form radio queries Query radios by their labels and verify their roles. Share initial-field checks with empty-form validation. Median isolated test time falls from 3.70s to 1.85s with all input scenarios and covered source locations preserved. --- .../Feedback/components/FeedbackForm.test.tsx | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/client/src/modules/Feedback/components/FeedbackForm.test.tsx b/client/src/modules/Feedback/components/FeedbackForm.test.tsx index 6e2db4974..d7ae6db1c 100644 --- a/client/src/modules/Feedback/components/FeedbackForm.test.tsx +++ b/client/src/modules/Feedback/components/FeedbackForm.test.tsx @@ -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 = userEvent.setup(); + 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 })); @@ -123,10 +125,10 @@ describe('', () => { // 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 @@ -159,7 +161,7 @@ describe('', () => { const user = userEvent.setup(); 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 })); @@ -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 })); @@ -245,7 +247,7 @@ describe('', () => { const user = userEvent.setup(); 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 })); From 31ee723ae61611459d6b1f7a4050d063a51d9671 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:12:13 +0200 Subject: [PATCH 019/406] perf(client): share mentor registry row action setup Check default row content and copy link before inviting; reopen the real dropdown for each action from one table render. Preserve all distinct mentor and filter states. Median isolated time improves from 4.50s to 3.76s with identical covered source locations. --- .../MentorRegistryTableContainer.test.tsx | 66 +++++-------------- 1 file changed, 16 insertions(+), 50 deletions(-) diff --git a/client/src/modules/MentorRegistry/components/MentorRegistryTableContainer.test.tsx b/client/src/modules/MentorRegistry/components/MentorRegistryTableContainer.test.tsx index 064a5bc6f..dbc4ce798 100644 --- a/client/src/modules/MentorRegistry/components/MentorRegistryTableContainer.test.tsx +++ b/client/src/modules/MentorRegistry/components/MentorRegistryTableContainer.test.tsx @@ -107,12 +107,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 +114,12 @@ describe(' + ', () => { expect(screen.queryByText('octocat')).not.toBeInTheDocument(); }); - it('opens the Invite modal when the row "Invite" action is clicked', async () => { + it('renders the mentor row with its copy link and opens Invite', async () => { const user = userEvent.setup(); - const { handleModalDataChange } = renderContainer(); + 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 +129,19 @@ describe(' + ', () => { ); }); - it('triggers the Re-send action from the row dropdown (New tab)', 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('Re-send')); - - expect(handleModalDataChange).toHaveBeenCalledWith( - ModalDataMode.Resend, - expect.objectContaining({ githubId: 'octocat' }), - ); - }); - - it('triggers the Delete action from the 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 () => { @@ -303,16 +279,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. From c803f6c5860e295bd4a82555730acc6ff2af2b90 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:13:13 +0200 Subject: [PATCH 020/406] perf(client): streamline Discord admin test setup Verify empty modal fields before creation, target rows by visible names with role checks, and narrow the API mock. Median isolated test time improves from 3.00s to 2.64s with all covered source locations preserved. --- .../DiscordAdminPage.test.tsx | 32 ++++++++----------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/client/src/modules/DiscordAdmin/pages/DiscordAdminPage/DiscordAdminPage.test.tsx b/client/src/modules/DiscordAdmin/pages/DiscordAdminPage/DiscordAdminPage.test.tsx index 9ef6fec62..dbe983c30 100644 --- a/client/src/modules/DiscordAdmin/pages/DiscordAdminPage/DiscordAdminPage.test.tsx +++ b/client/src/modules/DiscordAdmin/pages/DiscordAdminPage/DiscordAdminPage.test.tsx @@ -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,20 +67,6 @@ 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 , 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(); render(); @@ -83,7 +75,9 @@ describe('', () => { 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'); @@ -104,7 +98,7 @@ describe('', () => { 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'); @@ -125,7 +119,7 @@ describe('', () => { 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 })); @@ -140,7 +134,7 @@ describe('', () => { 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 })); From 2f138a21b85b7be02c9f11c2c9d5e1c4dc84cfac Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:14:36 +0200 Subject: [PATCH 021/406] perf(client): avoid loading unused CSV test API exports Narrow the generated API mock and make error waits observe the existing message boundary. Keep all eight CSV scenarios separate. Median wall time improves from 5.48s to 5.29s; test-body time is effectively unchanged. Covered source locations remain identical. --- .../SubmitScores/SubmitScorePage.csv.test.tsx | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/client/src/modules/SubmitScores/SubmitScorePage.csv.test.tsx b/client/src/modules/SubmitScores/SubmitScorePage.csv.test.tsx index 5958c41f3..ba7d459b1 100644 --- a/client/src/modules/SubmitScores/SubmitScorePage.csv.test.tsx +++ b/client/src/modules/SubmitScores/SubmitScorePage.csv.test.tsx @@ -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', () => ({ @@ -221,10 +225,12 @@ 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 () => { @@ -243,6 +249,7 @@ 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(); }); @@ -262,9 +269,8 @@ 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(); }); From 3fbe7336e7e6f4b848290fa28738243800453500 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:16:12 +0200 Subject: [PATCH 022/406] perf(client): share default solution review assertions Check the absent detail button alongside the default score and comment. Preserve real message typing and all distinct review states. Median isolated time improves from 2.87s to 2.81s with identical covered source locations. --- .../components/SolutionReview/SolutionReview.test.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/client/src/modules/CrossCheck/components/SolutionReview/SolutionReview.test.tsx b/client/src/modules/CrossCheck/components/SolutionReview/SolutionReview.test.tsx index 320aecef2..570667a30 100644 --- a/client/src/modules/CrossCheck/components/SolutionReview/SolutionReview.test.tsx +++ b/client/src/modules/CrossCheck/components/SolutionReview/SolutionReview.test.tsx @@ -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', () => { @@ -98,12 +99,6 @@ describe('', () => { 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(); render(); From b864be9191d6c45f5f0843533b3b24283b25fa21 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:17:12 +0200 Subject: [PATCH 023/406] perf(client): share score table initial loading checks Assert loading, API calls, student rows, task columns, and summary from one initial fetch. Preserve separate paging, filtering, sorting, and settings scenarios. Median isolated time improves from 2.91s to 2.67s with identical covered source locations. --- .../components/ScoreTable/index.test.tsx | 23 ++----------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/client/src/modules/Score/components/ScoreTable/index.test.tsx b/client/src/modules/Score/components/ScoreTable/index.test.tsx index 522a6171b..5c0159d72 100644 --- a/client/src/modules/Score/components/ScoreTable/index.test.tsx +++ b/client/src/modules/Score/components/ScoreTable/index.test.tsx @@ -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); }); From 9551ddee7fa01e0adf35afcd6aa2d8c796b2e179 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:18:14 +0200 Subject: [PATCH 024/406] perf(client): consolidate task modal display checks Share identical edit-mode display assertions and verify empty select placeholders before required-field validation. Preserve every distinct input state and interaction. Median isolated time improves from 2.83s to 2.23s with identical covered source locations. --- .../components/TaskModal/TaskModal.test.tsx | 106 ++++++------------ 1 file changed, 34 insertions(+), 72 deletions(-) diff --git a/client/src/modules/Tasks/components/TaskModal/TaskModal.test.tsx b/client/src/modules/Tasks/components/TaskModal/TaskModal.test.tsx index 87b2d0fa5..06a2712d9 100644 --- a/client/src/modules/Tasks/components/TaskModal/TaskModal.test.tsx +++ b/client/src/modules/Tasks/components/TaskModal/TaskModal.test.tsx @@ -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', () => { @@ -103,6 +68,15 @@ describe('TaskModal', () => { const user = userEvent.setup(); 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. From cbc30bf8fd29ce910655a848e45d5c4f849225d9 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:19:33 +0200 Subject: [PATCH 025/406] perf(client): share notification modal workflow setup Verify create-modal display before cancellation and edit-field state before updating. Preserve all success and failure scenarios. Median isolated time improves from 3.33s to 2.99s with identical covered source locations. --- .../AdminNotificationsSettingsPage.test.tsx | 32 ++++--------------- 1 file changed, 6 insertions(+), 26 deletions(-) diff --git a/client/src/modules/Notifications/pages/AdminNotificationsPage/AdminNotificationsSettingsPage.test.tsx b/client/src/modules/Notifications/pages/AdminNotificationsPage/AdminNotificationsSettingsPage.test.tsx index 23f14d036..c79fa9031 100644 --- a/client/src/modules/Notifications/pages/AdminNotificationsPage/AdminNotificationsSettingsPage.test.tsx +++ b/client/src/modules/Notifications/pages/AdminNotificationsPage/AdminNotificationsSettingsPage.test.tsx @@ -55,30 +55,6 @@ describe('AdminNotificationsPage', () => { expect(screen.getByRole('button', { name: /add notification/i })).toBeInTheDocument(); }); - it('opens the create modal when Add Notification is clicked', async () => { - const user = userEvent.setup(); - render(); - await screen.findByText('Existing One'); - - await user.click(screen.getByRole('button', { name: /add notification/i })); - - expect(await screen.findByRole('dialog')).toBeInTheDocument(); - expect(screen.getByText('Notification Settings')).toBeInTheDocument(); - }); - - it('opens the edit modal pre-filled with the row record', async () => { - const user = userEvent.setup(); - render(); - await screen.findByText('Existing One'); - - await user.click(screen.getByText('Edit')); - - expect(await screen.findByRole('dialog')).toBeInTheDocument(); - expect(screen.getByLabelText('Id')).toHaveValue('existing'); - // Editing an existing notification disables the Id field. - expect(screen.getByLabelText('Id')).toBeDisabled(); - }); - it('creates a new notification and appends it to the table on submit', async () => { const user = userEvent.setup(); const created = makeNotification({ id: 'fresh', name: 'Fresh One' }); @@ -113,7 +89,9 @@ describe('AdminNotificationsPage', () => { await screen.findByText('Existing One'); await user.click(screen.getByText('Edit')); - await screen.findByRole('dialog'); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect(screen.getByLabelText('Id')).toHaveValue('existing'); + expect(screen.getByLabelText('Id')).toBeDisabled(); const nameInput = screen.getByLabelText('Name'); await user.clear(nameInput); @@ -179,13 +157,15 @@ describe('AdminNotificationsPage', () => { expect(screen.getByText('Existing One')).toBeInTheDocument(); }); - it('closes the create modal on cancel without saving', async () => { + it('opens the create modal and cancels without saving', async () => { const user = userEvent.setup(); render(); await screen.findByText('Existing One'); await user.click(screen.getByRole('button', { name: /add notification/i })); const dialog = await screen.findByRole('dialog'); + expect(dialog).toBeInTheDocument(); + expect(screen.getByText('Notification Settings')).toBeInTheDocument(); await user.click(within(dialog).getByRole('button', { name: /cancel/i })); From 16336f1f6caad0baf9af2c47fb11001b81f66669 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:20:45 +0200 Subject: [PATCH 026/406] perf(client): share discipline table display checks Verify default rows and actions before editing, and scope delete-button queries to the selected row. Keep real confirmation and cancellation flows. Median isolated time falls from 2.43s to 1.62s with identical covered source locations. --- .../components/DisciplineTable.test.tsx | 40 ++++++++----------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/client/src/modules/Discipline/components/DisciplineTable.test.tsx b/client/src/modules/Discipline/components/DisciplineTable.test.tsx index a3a5d9186..03f11fac4 100644 --- a/client/src/modules/Discipline/components/DisciplineTable.test.tsx +++ b/client/src/modules/Discipline/components/DisciplineTable.test.tsx @@ -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,35 +44,20 @@ describe('', () => { } }); - it('renders the column headers', () => { - render(); + it('renders headers, rows and action buttons, then edits the selected record', async () => { + const user = userEvent.setup(); + 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]); @@ -77,7 +69,7 @@ describe('', () => { 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 @@ -95,7 +87,7 @@ describe('', () => { 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'); From ed3c1c78a566714a044e7997440dcc18cf3179c3 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:21:32 +0200 Subject: [PATCH 027/406] perf(client): share question picker initial state checks Verify initial questions, ratings, and Theory labels before opening the picker. Retain the distinct Practice render and every interaction scenario. Repeated isolated runs preserve all covered source locations. --- .../QuestionList.test.tsx | 30 +++++-------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/client/src/modules/Interviews/pages/StageInterviewFeedback/QuestionList.test.tsx b/client/src/modules/Interviews/pages/StageInterviewFeedback/QuestionList.test.tsx index b15184683..c033a36d7 100644 --- a/client/src/modules/Interviews/pages/StageInterviewFeedback/QuestionList.test.tsx +++ b/client/src/modules/Interviews/pages/StageInterviewFeedback/QuestionList.test.tsx @@ -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(); 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'); From 3ba5fe8c026ecac06e19b4f73db9f9a7de4365ce Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:22:56 +0200 Subject: [PATCH 028/406] perf(client): share interview feedback display setup Check default links and inputs before navigating Back. Add observable success-reset and error-retention assertions. Median isolated test time improves from 3.00s to 2.95s with all covered source locations preserved. --- .../pages/InterviewFeedback/index.test.tsx | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/client/src/modules/Interviews/pages/InterviewFeedback/index.test.tsx b/client/src/modules/Interviews/pages/InterviewFeedback/index.test.tsx index 40e588897..ba4966787 100644 --- a/client/src/modules/Interviews/pages/InterviewFeedback/index.test.tsx +++ b/client/src/modules/Interviews/pages/InterviewFeedback/index.test.tsx @@ -5,6 +5,11 @@ 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,31 +93,6 @@ 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(); render(); @@ -157,6 +137,7 @@ 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 () => { @@ -190,14 +171,32 @@ describe('', () => { 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 () => { + it('renders the template links and inputs, then navigates Back', async () => { const user = userEvent.setup(); 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); }); @@ -230,7 +229,8 @@ describe('', () => { 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.'); }); }); From 223ab5c905a207f4d6a124783061a083154d4ee8 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:23:55 +0200 Subject: [PATCH 029/406] perf(client): consolidate expel criteria form checks Check initial fields and button state, enter valid criteria, then verify the required reason in one flow. Preserve submit, cancel, and all helper cases. Repeated isolated runs preserve all covered source locations. --- .../ExpelCriteriaModal.test.tsx | 87 +++++-------------- 1 file changed, 20 insertions(+), 67 deletions(-) diff --git a/client/src/modules/CourseManagement/components/ExpelCriteriaModal/ExpelCriteriaModal.test.tsx b/client/src/modules/CourseManagement/components/ExpelCriteriaModal/ExpelCriteriaModal.test.tsx index e7786f2fb..f49a6b2b3 100644 --- a/client/src/modules/CourseManagement/components/ExpelCriteriaModal/ExpelCriteriaModal.test.tsx +++ b/client/src/modules/CourseManagement/components/ExpelCriteriaModal/ExpelCriteriaModal.test.tsx @@ -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 = userEvent.setup(); 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 = userEvent.setup(); 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 = userEvent.setup(); renderExpelCriteriaModal(); // Enable "expel students" button From c3502fd807cea9e717cceadef0c722585192a207 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:25:15 +0200 Subject: [PATCH 030/406] perf(client): consolidate notification settings display checks Share initial create/edit field assertions with cancellation and submission, preserving channel, validation, and tab scenarios. Median isolated time improves from 3.03s to 2.31s with identical covered source locations. --- .../NotificationSettingsModal.test.tsx | 122 +++++------------- 1 file changed, 29 insertions(+), 93 deletions(-) diff --git a/client/src/modules/Notifications/components/NotificationSettingsModal.test.tsx b/client/src/modules/Notifications/components/NotificationSettingsModal.test.tsx index 241458b99..c224c94ea 100644 --- a/client/src/modules/Notifications/components/NotificationSettingsModal.test.tsx +++ b/client/src/modules/Notifications/components/NotificationSettingsModal.test.tsx @@ -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,25 +50,6 @@ 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(); render( @@ -150,17 +71,6 @@ describe('NotificationSettingsModal', () => { 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 onOk = vi.fn(); @@ -182,7 +92,10 @@ describe('NotificationSettingsModal', () => { 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 () => { + it('prefills existing settings and channel fields, then submits their values', async () => { const user = userEvent.setup(); 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)); @@ -237,11 +161,23 @@ describe('NotificationSettingsModal', () => { expect(telegramChannel.template.body).toBe('Telegram message'); }); - it('calls onCancel when the modal is dismissed without changes', async () => { + it('renders the new notification fields and tabs, then cancels without changes', async () => { const user = userEvent.setup(); 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); From a192acd483a82159abb3518bca3ca4ab3d95e633 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:26:02 +0200 Subject: [PATCH 031/406] perf(client): share cross-check table edit setup Verify initial rows, edit controls, and other-row disabled state before saving changed text. Preserve all distinct cancellation, deletion, type, and drag scenarios. Repeated isolated runs retain identical covered source locations. --- .../EditableTableForCrossCheck.test.tsx | 31 ++----------------- 1 file changed, 3 insertions(+), 28 deletions(-) diff --git a/client/src/modules/CrossCheck/EditableTableForCrossCheck.test.tsx b/client/src/modules/CrossCheck/EditableTableForCrossCheck.test.tsx index ee7a3e1d3..ce126bfdb 100644 --- a/client/src/modules/CrossCheck/EditableTableForCrossCheck.test.tsx +++ b/client/src/modules/CrossCheck/EditableTableForCrossCheck.test.tsx @@ -56,36 +56,23 @@ function getRow(text: string) { } describe(' (CrossCheck editable criteria)', () => { - it('renders a row per criteria with Type/Max/Text and Edit/Delete actions', () => { + it('renders rows, enters edit mode, disables other edits and saves changed text', async () => { + const user = userEvent.setup(); render(); expect(screen.getByText('First criteria')).toBeInTheDocument(); expect(screen.getByText('A title row')).toBeInTheDocument(); expect(screen.getAllByText('Edit')).toHaveLength(2); expect(screen.getAllByText('Delete')).toHaveLength(2); - }); - - it('enters edit mode for a row and shows Save/Cancel plus editable inputs', async () => { - const user = userEvent.setup(); - render(); const row = getRow('First criteria'); await user.click(within(row).getByText('Edit')); - // Save/Cancel replace Edit/Delete for the editing row. expect(within(row).getByText('Save')).toBeInTheDocument(); expect(within(row).getByText('Cancel')).toBeInTheDocument(); - // Editable Text becomes a textarea and Max becomes a spinbutton. expect(within(row).getByRole('textbox')).toBeInTheDocument(); expect(within(row).getByRole('spinbutton')).toBeInTheDocument(); - }); - - it('saves an edited Text value back into the data', async () => { - const user = userEvent.setup(); - render(); - - const row = getRow('First criteria'); - await user.click(within(row).getByText('Edit')); + expect(within(getRow('A title row')).getByText('Edit')).toHaveClass('ant-typography-disabled'); const textarea = within(row).getByRole('textbox'); await user.clear(textarea); @@ -171,18 +158,6 @@ describe(' (CrossCheck editable criteria)', () => { }); }); - it('disables Edit/Delete on other rows while one row is being edited', async () => { - const user = userEvent.setup(); - render(); - - await user.click(within(getRow('First criteria')).getByText('Edit')); - - // The other row's Edit link is disabled (antd marks it with a disabled class). - const otherRow = getRow('A title row'); - const otherEdit = within(otherRow).getByText('Edit'); - expect(otherEdit).toHaveClass('ant-typography-disabled'); - }); - it('reorders rows when a drag-end event fires (dnd handler wiring)', async () => { render(); From ce2e377effbe9e8958cc45dbc55379dfb80d3f79 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:27:21 +0200 Subject: [PATCH 032/406] perf(client): target user group rows by visible name Keep all six scenarios and real interactions while avoiding repeated row accessible-name traversal. Median isolated time improves from 2.99s to 2.78s with identical covered source locations. --- .../UserGroupsAdminPage.test.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/client/src/modules/UserGroupsAdmin/pages/UserGroupsAdminPage/UserGroupsAdminPage.test.tsx b/client/src/modules/UserGroupsAdmin/pages/UserGroupsAdminPage/UserGroupsAdminPage.test.tsx index 05f1c7ade..e61fb189b 100644 --- a/client/src/modules/UserGroupsAdmin/pages/UserGroupsAdminPage/UserGroupsAdminPage.test.tsx +++ b/client/src/modules/UserGroupsAdmin/pages/UserGroupsAdminPage/UserGroupsAdminPage.test.tsx @@ -68,6 +68,13 @@ const groups = [ }, ] as unknown as UserGroupDto[]; +function getGroupRow(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(); @@ -114,7 +121,7 @@ describe('', () => { render(); await screen.findByText('Admins'); - const row = screen.getByRole('row', { name: /Admins/ }); + const row = getGroupRow('Admins'); await user.click(within(row).getByText('Edit')); await screen.findByText('User Group'); const dialog = screen.getByRole('dialog'); @@ -133,7 +140,7 @@ describe('', () => { render(); await screen.findByText('Admins'); - const row = screen.getByRole('row', { name: /Admins/ }); + const row = getGroupRow('Admins'); await user.click(within(row).getByText('Delete')); await user.click(await screen.findByRole('button', { name: /^ok$/i })); @@ -148,7 +155,7 @@ describe('', () => { render(); await screen.findByText('Admins'); - const row = screen.getByRole('row', { name: /Admins/ }); + const row = getGroupRow('Admins'); await user.click(within(row).getByText('Delete')); await user.click(await screen.findByRole('button', { name: /^ok$/i })); @@ -163,7 +170,7 @@ describe('', () => { render(); await screen.findByText('Admins'); - const row = screen.getByRole('row', { name: /Admins/ }); + const row = getGroupRow('Admins'); await user.click(within(row).getByText('Edit')); await screen.findByText('User Group'); await user.click(screen.getByRole('button', { name: /save/i })); From a29b284f97cdf985f765ede3f04a9d0cb968e457 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:28:08 +0200 Subject: [PATCH 033/406] perf(client): share course event modal setup Check default fields before cancellation and template prefill before submitting. Preserve all distinct input, edit, validation and search scenarios. Repeated isolated runs preserve all covered source locations. --- .../CourseEventModal/index.test.tsx | 36 +++++-------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/client/src/modules/CourseManagement/components/CourseEventModal/index.test.tsx b/client/src/modules/CourseManagement/components/CourseEventModal/index.test.tsx index d84da58b3..ad4515d75 100644 --- a/client/src/modules/CourseManagement/components/CourseEventModal/index.test.tsx +++ b/client/src/modules/CourseManagement/components/CourseEventModal/index.test.tsx @@ -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(); @@ -188,21 +178,6 @@ 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(); 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 () => { + it('renders the new-event fields and cancels a pristine form', async () => { const user = userEvent.setup(); 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(); From 6999e887ebbc0955d87669e804818acad971a6e2 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:29:06 +0200 Subject: [PATCH 034/406] perf(client): simplify interview form radio queries Share initial radio-count assertions with nested selection and query labeled radios with explicit role checks. Preserve all distinct input and validation cases. Repeated isolated runs retain identical covered source locations. --- .../StageInterviewFeedback/FormItem.test.tsx | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/client/src/modules/Interviews/pages/StageInterviewFeedback/FormItem.test.tsx b/client/src/modules/Interviews/pages/StageInterviewFeedback/FormItem.test.tsx index 243a3a72e..3326c421c 100644 --- a/client/src/modules/Interviews/pages/StageInterviewFeedback/FormItem.test.tsx +++ b/client/src/modules/Interviews/pages/StageInterviewFeedback/FormItem.test.tsx @@ -36,6 +36,12 @@ 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(); @@ -240,30 +246,19 @@ describe('FormItem Radio + nested conditional (real 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 @@ -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); From 00c064a42c5eb7f6a97418820cb9d881fb172f52 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:30:08 +0200 Subject: [PATCH 035/406] perf(client): streamline contributor table queries Locate rows from visible GitHub IDs with explicit role assertions and share display checks with editing. Preserve real edit/delete interactions. Median isolated time improves from 1.97s to 0.87s with identical covered source locations. --- .../components/ContributorsTable.test.tsx | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/client/src/modules/Contributor/components/ContributorsTable.test.tsx b/client/src/modules/Contributor/components/ContributorsTable.test.tsx index 4e40dba8e..5f3d7453b 100644 --- a/client/src/modules/Contributor/components/ContributorsTable.test.tsx +++ b/client/src/modules/Contributor/components/ContributorsTable.test.tsx @@ -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 = userEvent.setup(); + 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); @@ -38,7 +39,7 @@ describe('', () => { 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]); From 0ab655836e44f0a48d2456d48305d46ec1dfbe15 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:31:15 +0200 Subject: [PATCH 036/406] perf(client): share tasks page mount and modal checks Check title during loading, create mode before cancellation, and edit mode before the existing falsy-ID guard. Preserve all criteria and payload scenarios. Repeated isolated runs retain identical covered source locations. --- .../Tasks/pages/TasksPage/TasksPage.test.tsx | 36 +++---------------- 1 file changed, 5 insertions(+), 31 deletions(-) diff --git a/client/src/modules/Tasks/pages/TasksPage/TasksPage.test.tsx b/client/src/modules/Tasks/pages/TasksPage/TasksPage.test.tsx index 38542d968..19605b3fa 100644 --- a/client/src/modules/Tasks/pages/TasksPage/TasksPage.test.tsx +++ b/client/src/modules/Tasks/pages/TasksPage/TasksPage.test.tsx @@ -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,32 +122,6 @@ 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(); render(); @@ -159,6 +129,8 @@ describe('TasksPage', () => { 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()); }); @@ -250,6 +222,8 @@ describe('TasksPage', () => { // 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()); From 5bb0f85d2230df79485c35e0f5aca66c556ef0cf Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:32:08 +0200 Subject: [PATCH 037/406] perf(client): streamline prompt table row queries Locate rows by visible prompt type with explicit role assertions and share display checks with editing. Preserve real edit/delete interactions. Median isolated time improves from 1.86s to 0.84s with identical covered source locations. --- .../Prompts/components/PromptTable.test.tsx | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/client/src/modules/Prompts/components/PromptTable.test.tsx b/client/src/modules/Prompts/components/PromptTable.test.tsx index 10d2a4d05..d9aaf8c80 100644 --- a/client/src/modules/Prompts/components/PromptTable.test.tsx +++ b/client/src/modules/Prompts/components/PromptTable.test.tsx @@ -8,20 +8,23 @@ const data = [ { id: 2, type: 'gratitude', temperature: 0.7, text: 'B' }, ] as unknown as PromptDto[]; -describe('', () => { - it('renders a row per prompt with its type', () => { - render(); - - expect(screen.getByText('summary')).toBeInTheDocument(); - expect(screen.getByText('gratitude')).toBeInTheDocument(); - }); +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!; +} - it('calls handleUpdate with the row record when the edit button is clicked', async () => { +describe('', () => { + it('renders prompt rows and calls handleUpdate for the selected record', async () => { const user = userEvent.setup(); const handleUpdate = vi.fn(); render(); - const row = screen.getByRole('row', { name: /summary/ }); + expect(screen.getByText('summary')).toBeInTheDocument(); + expect(screen.getByText('gratitude')).toBeInTheDocument(); + + const row = getPromptRow('summary'); const [editBtn] = within(row).getAllByRole('button'); await user.click(editBtn); @@ -33,7 +36,7 @@ describe('', () => { const handleDelete = vi.fn().mockResolvedValue(undefined); render(); - const row = screen.getByRole('row', { name: /gratitude/ }); + const row = getPromptRow('gratitude'); const buttons = within(row).getAllByRole('button'); await user.click(buttons[1]); From d04320eebb45d5b28610660b3f201d6a205831fe Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:33:08 +0200 Subject: [PATCH 038/406] perf(client): narrow team distribution API mocks Mock only the two used methods and share create-copy assertions with cancellation. Preserve distinct edit fixtures and real form interactions. Repeated local runs preserve all covered source locations and reduce startup overhead. --- .../TeamDistributionModal.test.tsx | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/client/src/modules/TeamDistribution/components/TeamDistributionModal/TeamDistributionModal.test.tsx b/client/src/modules/TeamDistribution/components/TeamDistributionModal/TeamDistributionModal.test.tsx index 8169bc6f0..15b90089d 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 { 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 }); @@ -58,6 +58,7 @@ describe('', () => { it('calls onCancel when the cancel button is clicked', async () => { const user = userEvent.setup(); 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(); From 115b3f7f72c502ecab692c824852e8634b29d448 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:38:58 +0200 Subject: [PATCH 039/406] test: advance UserSearch debounce in direct-change cases --- .../src/shared/components/UserSearch.test.tsx | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/client/src/shared/components/UserSearch.test.tsx b/client/src/shared/components/UserSearch.test.tsx index 046164982..1265c1b65 100644 --- a/client/src/shared/components/UserSearch.test.tsx +++ b/client/src/shared/components/UserSearch.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; 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(); @@ -105,28 +111,33 @@ 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 () => { From 4f49d65c0e73013ada566356b81b9367a2a84a98 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:39:40 +0200 Subject: [PATCH 040/406] test: share EducationCard deletion and cancel setup --- .../Profile/__test__/EducationCard.test.tsx | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/client/src/components/Profile/__test__/EducationCard.test.tsx b/client/src/components/Profile/__test__/EducationCard.test.tsx index f17508743..a65acb137 100644 --- a/client/src/components/Profile/__test__/EducationCard.test.tsx +++ b/client/src/components/Profile/__test__/EducationCard.test.tsx @@ -118,23 +118,12 @@ describe('EducationCard', () => { expect(screen.getByText('(Empty)')).toBeInTheDocument(); }); - it('deletes a university entry (handleDelete)', async () => { + it('deletes a university and restores it on cancel', async () => { const user = userEvent.setup(); 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(); From c7ad55a422e389e8a3a4dabf4dd82ef70f56409e Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:40:32 +0200 Subject: [PATCH 041/406] test: share ReviewsTable header and modal setup --- .../ReviewsTable/ReviewsTable.test.tsx | 38 +++++++------------ 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/client/src/modules/MentorTasksReview/components/ReviewsTable/ReviewsTable.test.tsx b/client/src/modules/MentorTasksReview/components/ReviewsTable/ReviewsTable.test.tsx index 42c406956..55631a453 100644 --- a/client/src/modules/MentorTasksReview/components/ReviewsTable/ReviewsTable.test.tsx +++ b/client/src/modules/MentorTasksReview/components/ReviewsTable/ReviewsTable.test.tsx @@ -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,7 +110,7 @@ describe('MentorReviewsTable', () => { expect(within(table).queryByText('checker-github')).not.toBeInTheDocument(); }); - it('should open the assign-reviewer modal with the clicked review', async () => { + it('should open the clicked review and close the modal from inside it', async () => { const user = userEvent.setup(); renderTable(); @@ -122,13 +119,6 @@ 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(); From a067f6b06b36fe1915993df082aeabcf0769ca7d Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:41:23 +0200 Subject: [PATCH 042/406] test: reuse SubmitScorePage initial render for tab checks --- .../SubmitScores/SubmitScorePage.test.tsx | 22 +++---------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/client/src/modules/SubmitScores/SubmitScorePage.test.tsx b/client/src/modules/SubmitScores/SubmitScorePage.test.tsx index 2bb7004ef..f543fc80b 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); - }); - }); }); From afbec44b9ef406bfb18edbee58aba8a9e075f02a Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:42:43 +0200 Subject: [PATCH 043/406] test: consolidate TaskSolutionsTable renders and scope submit --- .../TaskSolutionsTable.test.tsx | 80 ++++++------------- 1 file changed, 25 insertions(+), 55 deletions(-) diff --git a/client/src/modules/Mentor/components/TaskSolutionsTable/TaskSolutionsTable.test.tsx b/client/src/modules/Mentor/components/TaskSolutionsTable/TaskSolutionsTable.test.tsx index 89c0598df..5c6068120 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(); }); }); }); From 1971f52423d75686963718dd3ffd776255bf2c40 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:43:26 +0200 Subject: [PATCH 044/406] test: share EventsModal initial state and option checks --- .../components/EventsModal.test.tsx | 28 ++++++------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/client/src/modules/EventsAdmin/components/EventsModal.test.tsx b/client/src/modules/EventsAdmin/components/EventsModal.test.tsx index 8cd5dc082..a1b592e6e 100644 --- a/client/src/modules/EventsAdmin/components/EventsModal.test.tsx +++ b/client/src/modules/EventsAdmin/components/EventsModal.test.tsx @@ -46,23 +46,6 @@ 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 props = makeProps(); @@ -83,7 +66,11 @@ describe('', () => { 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 () => { + it('renders empty create fields and cancels when untouched', async () => { const user = userEvent.setup(); 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(); From 6b7be9941b2a1f0f055bb3b21262cc63a9142a3f Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:44:24 +0200 Subject: [PATCH 045/406] test: consolidate PersonalInfo initial form checks --- .../Cards/PersonalInfo/PersonalInfo.test.tsx | 128 ++++++------------ 1 file changed, 38 insertions(+), 90 deletions(-) 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 286500148..2535dcba8 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(); }); }); From e2c14710af2006ebc6d405c9eebe2cef197396f3 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:45:05 +0200 Subject: [PATCH 046/406] test: share GeneralInfoForm label and value checks --- .../EditCv/GeneralInfoForm/index.test.tsx | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/client/src/modules/Opportunities/components/EditCv/GeneralInfoForm/index.test.tsx b/client/src/modules/Opportunities/components/EditCv/GeneralInfoForm/index.test.tsx index 00d48422b..f6b24d5ac 100644 --- a/client/src/modules/Opportunities/components/EditCv/GeneralInfoForm/index.test.tsx +++ b/client/src/modules/Opportunities/components/EditCv/GeneralInfoForm/index.test.tsx @@ -16,7 +16,7 @@ const mockUserData = { }; describe('GeneralInfoForm', () => { - test('should render form items with proper values', async () => { + test('should render form items with proper values and labels', async () => { render(); const name = await screen.findByDisplayValue(mockUserData.name); @@ -40,24 +40,21 @@ describe('GeneralInfoForm', () => { expect(avatarLink).toBeInTheDocument(); expect(selfIntroLink).toBeInTheDocument(); expect(notes).toBeInTheDocument(); - }); - test.each` - label - ${'Name'} - ${'Desired position'} - ${'Locations'} - ${'Select your English level'} - ${'Military service'} - ${'Ready to start work from'} - ${'Ready to work full time'} - ${'Photo'} - ${'Self introduction video'} - ${'About me'} - `('should render field with $label label', async ({ label }) => { - render(); - const fieldLabel = await screen.findByLabelText(label); - expect(fieldLabel).toBeInTheDocument(); + for (const label of [ + 'Name', + 'Desired position', + 'Locations', + 'Select your English level', + 'Military service', + 'Ready to start work from', + 'Ready to work full time', + 'Photo', + 'Self introduction video', + 'About me', + ]) { + expect(screen.getByLabelText(label)).toBeInTheDocument(); + } }); test('should render form items with proper placeholders', async () => { From 844ba28c4f7fcfcc905de882e927b5618572543f Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:46:27 +0200 Subject: [PATCH 047/406] test: share MarkdownInput empty preview and toggle flow --- .../components/Forms/MarkdownInput.test.tsx | 30 ++++++------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/client/src/shared/components/Forms/MarkdownInput.test.tsx b/client/src/shared/components/Forms/MarkdownInput.test.tsx index dc31489b1..252e47664 100644 --- a/client/src/shared/components/Forms/MarkdownInput.test.tsx +++ b/client/src/shared/components/Forms/MarkdownInput.test.tsx @@ -34,12 +34,20 @@ function ResettableMarkdownInput() { const LONG_COMMENT = 'This is a detailed markdown comment well over thirty characters.'; describe('MarkdownInput', () => { - it('renders the comment textarea and a Preview toggle in write mode', () => { + it('renders write controls, previews the empty warning and toggles back', async () => { + const user = userEvent.setup(); renderMarkdownInput(); expect(screen.getByLabelText(/Comment \(markdown syntax is supported\)/i)).toBeInTheDocument(); expect(screen.getByRole('button', { name: /preview/i })).toBeInTheDocument(); expect(screen.getByRole('link', { name: /about markdown/i })).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /preview/i })); + expect(screen.getByText('Please leave a comment')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /write/i })); + + expect(screen.getByRole('textbox')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /preview/i })).toBeInTheDocument(); }); it('switches to preview mode and renders the typed text through react-markdown', async () => { @@ -58,15 +66,6 @@ describe('MarkdownInput', () => { expect(screen.getByRole('button', { name: /write/i })).toBeInTheDocument(); }); - it('shows "Please leave a comment" in preview when the field is empty', async () => { - const user = userEvent.setup(); - renderMarkdownInput(); - - await user.click(screen.getByRole('button', { name: /preview/i })); - - expect(screen.getByText('Please leave a comment')).toBeInTheDocument(); - }); - it('shows "Please leave a detailed comment" in preview when text is shorter than 30 chars', async () => { const user = userEvent.setup(); const { container } = renderMarkdownInput(); @@ -81,17 +80,6 @@ describe('MarkdownInput', () => { expect(reminder).toHaveTextContent('Please leave a detailed comment'); }); - it('toggles back to write mode from preview', async () => { - const user = userEvent.setup(); - renderMarkdownInput(); - - await user.click(screen.getByRole('button', { name: /preview/i })); - await user.click(screen.getByRole('button', { name: /write/i })); - - expect(screen.getByRole('textbox')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /preview/i })).toBeInTheDocument(); - }); - it('clears the text and leaves preview mode when the form is reset', async () => { const user = userEvent.setup(); render(); From 6e50bf7dd0752c0c6258a95ea0516515dfde00ca Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:47:14 +0200 Subject: [PATCH 048/406] test: share TeamsHeader tab and status fixtures --- .../TeamsHeader/TeamsHeader.test.tsx | 28 ++++++------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/client/src/modules/Teams/components/TeamsHeader/TeamsHeader.test.tsx b/client/src/modules/Teams/components/TeamsHeader/TeamsHeader.test.tsx index dbe53c25c..34d6965e4 100644 --- a/client/src/modules/Teams/components/TeamsHeader/TeamsHeader.test.tsx +++ b/client/src/modules/Teams/components/TeamsHeader/TeamsHeader.test.tsx @@ -33,35 +33,22 @@ function renderHeader(overrides: Partial[0]> = {} } describe('', () => { - it('renders the base tabs (available teams + students without team)', () => { - renderHeader(); - expect(screen.getByRole('tab', { name: /available teams/i })).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: /students without team/i })).toBeInTheDocument(); - expect(screen.queryByRole('tab', { name: /my team/i })).not.toBeInTheDocument(); - }); - it('adds the "My team" tab when the student has a team', () => { renderHeader({ distribution: makeDistribution({ myTeam: { id: 1 } as never }) }); expect(screen.getByRole('tab', { name: /my team/i })).toBeInTheDocument(); }); - it('calls setActiveTab when a tab is clicked', async () => { + it('renders base tabs and calls setActiveTab when a tab is clicked', async () => { const user = userEvent.setup(); const { setActiveTab } = renderHeader(); + expect(screen.getByRole('tab', { name: /available teams/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /students without team/i })).toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: /my team/i })).not.toBeInTheDocument(); + await user.click(screen.getByRole('tab', { name: /students without team/i })); expect(setActiveTab).toHaveBeenCalledWith('students'); }); - it('shows the "without team" status tag for a student without a team', () => { - renderHeader({ isStudent: true, distribution: makeDistribution({ myTeam: undefined }) }); - expect(screen.getByText('without team')).toBeInTheDocument(); - }); - - it('shows the "distributed" status tag for a student with a team', () => { - renderHeader({ isStudent: true, distribution: makeDistribution({ myTeam: { id: 1 } as never }) }); - expect(screen.getByText('distributed')).toBeInTheDocument(); - }); - it('renders student action cards (create / join) and wires their handlers', async () => { const user = userEvent.setup(); const { handleCreateTeam, handleJoinTeam } = renderHeader({ @@ -70,6 +57,8 @@ describe('', () => { distribution: makeDistribution({ myTeam: undefined }), }); + expect(screen.getByText('without team')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /create team/i })); expect(await screen.findByText(/are you sure you want to create team\?/i)).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: /^ok$/i })); @@ -95,8 +84,9 @@ describe('', () => { expect(handleCreateTeam).not.toHaveBeenCalled(); }); - it('does not render any action cards when the student already has a team', () => { + it('shows distributed status and no action cards when the student has a team', () => { renderHeader({ isStudent: true, distribution: makeDistribution({ myTeam: { id: 1 } as never }) }); + expect(screen.getByText('distributed')).toBeInTheDocument(); expect(screen.queryByText('Team management')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /create team/i })).not.toBeInTheDocument(); }); From 6921fb7957d8f60c967a04438fb9abbaad63565a Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:47:55 +0200 Subject: [PATCH 049/406] test: share PromptModal defaults and edit setup --- .../Prompts/components/PromptModal.test.tsx | 28 ++++++------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/client/src/modules/Prompts/components/PromptModal.test.tsx b/client/src/modules/Prompts/components/PromptModal.test.tsx index 82baaeac3..5f68acb29 100644 --- a/client/src/modules/Prompts/components/PromptModal.test.tsx +++ b/client/src/modules/Prompts/components/PromptModal.test.tsx @@ -11,8 +11,7 @@ const { createPrompt, updatePrompt } = vi.hoisted(() => ({ updatePrompt: vi.fn(), })); -vi.mock('@client/api', async () => ({ - ...(await vi.importActual('@client/api')), +vi.mock('@client/api', () => ({ PromptsApi: function PromptsApi() { return { createPrompt, updatePrompt }; }, @@ -37,22 +36,6 @@ describe('', () => { updatePrompt.mockResolvedValue({}); }); - it('renders the "Add prompt" title with a default temperature when creating', () => { - render(); - - expect(screen.getByText('Add prompt')).toBeInTheDocument(); - expect(screen.getByLabelText('Type')).toHaveValue(''); - expect(screen.getByLabelText('Temperature')).toHaveValue('0.5'); - }); - - it('renders the "Edit prompt" title and prefills fields when editing', () => { - render(); - - expect(screen.getByText('Edit prompt')).toBeInTheDocument(); - expect(screen.getByLabelText('Type')).toHaveValue('summary'); - expect(screen.getByLabelText('Text')).toHaveValue('Existing text'); - }); - it('shows validation errors and does not submit when required fields are empty', async () => { const user = userEvent.setup(); const props = makeProps(); @@ -88,7 +71,10 @@ describe('', () => { const props = makeProps({ data: editPrompt }); render(); + expect(screen.getByText('Edit prompt')).toBeInTheDocument(); + expect(screen.getByLabelText('Type')).toHaveValue('summary'); const text = screen.getByLabelText('Text'); + expect(text).toHaveValue('Existing text'); await user.clear(text); await user.type(text, 'New body'); await user.click(screen.getByRole('button', { name: /ok/i })); @@ -115,11 +101,15 @@ describe('', () => { errorSpy.mockRestore(); }); - it('calls onCancel when Cancel is clicked', async () => { + it('renders create defaults and calls onCancel when Cancel is clicked', async () => { const user = userEvent.setup(); const props = makeProps(); render(); + expect(screen.getByText('Add prompt')).toBeInTheDocument(); + expect(screen.getByLabelText('Type')).toHaveValue(''); + expect(screen.getByLabelText('Temperature')).toHaveValue('0.5'); + await user.click(screen.getByRole('button', { name: /cancel/i })); expect(props.onCancel).toHaveBeenCalled(); From e58a6033e535583e7156675aa1f0c41728c91bab Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:49:00 +0200 Subject: [PATCH 050/406] test: share SubmitReviewModal display and cancel setup --- .../SubmitReviewModal.test.tsx | 36 +++++-------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/client/src/modules/Mentor/components/SubmitReviewModal/SubmitReviewModal.test.tsx b/client/src/modules/Mentor/components/SubmitReviewModal/SubmitReviewModal.test.tsx index e1343aae5..8e753bb3c 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); From c4dd5d97d46c4ea91b51d1991d62e3ac27524a63 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:49:46 +0200 Subject: [PATCH 051/406] test: share StudentsTable render and filter assertions --- .../components/StudentsTable/index.test.tsx | 63 ++++++------------- 1 file changed, 19 insertions(+), 44 deletions(-) diff --git a/client/src/modules/Students/components/StudentsTable/index.test.tsx b/client/src/modules/Students/components/StudentsTable/index.test.tsx index a6cca2fbd..3f5cb1856 100644 --- a/client/src/modules/Students/components/StudentsTable/index.test.tsx +++ b/client/src/modules/Students/components/StudentsTable/index.test.tsx @@ -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,22 +130,6 @@ 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 props = makeProps(); @@ -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 })); From 996ebaf463dc973be70b491738d90ea4466a52d2 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:50:37 +0200 Subject: [PATCH 052/406] test: avoid redundant MainCard input clicks after clearing --- .../src/components/Profile/__test__/MainCard.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/src/components/Profile/__test__/MainCard.test.tsx b/client/src/components/Profile/__test__/MainCard.test.tsx index 0ca564304..8c899e6fd 100644 --- a/client/src/components/Profile/__test__/MainCard.test.tsx +++ b/client/src/components/Profile/__test__/MainCard.test.tsx @@ -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' })); @@ -119,7 +119,7 @@ describe('MainCard', () => { 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()); @@ -135,7 +135,7 @@ describe('MainCard', () => { 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()); @@ -153,11 +153,11 @@ describe('MainCard', () => { 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(); }); From 11617148ba3b6b93320c155587c2e2240dfe2e8a Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:52:34 +0200 Subject: [PATCH 053/406] test: consolidate consent modal checks and advance tooltip timers --- .../components/NoConsentView/index.test.tsx | 160 +++++------------- 1 file changed, 41 insertions(+), 119 deletions(-) diff --git a/client/src/modules/Opportunities/components/NoConsentView/index.test.tsx b/client/src/modules/Opportunities/components/NoConsentView/index.test.tsx index 45c9e4ff9..7c8cfb577 100644 --- a/client/src/modules/Opportunities/components/NoConsentView/index.test.tsx +++ b/client/src/modules/Opportunities/components/NoConsentView/index.test.tsx @@ -1,145 +1,67 @@ -import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { act, render, screen, waitFor, fireEvent } from '@testing-library/react'; import { NoConsentView, confirmationModalInfo } from '../NoConsentView'; +async function finishTooltipTransition() { + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); +} + describe('NoConsentView', () => { + afterEach(() => { + if (vi.isFakeTimers()) vi.clearAllTimers(); + vi.useRealTimers(); + }); it('should render 403 correctly', () => { render(); expect(screen.getByText("This user doesn't have CV yet")).toBeInTheDocument(); }); - it('should render initial owner view correctly', () => { - render(); + it('renders the owner view, opens the consent details and cancels', async () => { + const giveConsent = vi.fn(); + render(); - const title = screen.getByRole('heading', { name: "You don't have a CV yet." }); + expect(screen.getByRole('heading', { name: "You don't have a CV yet." })).toBeInTheDocument(); const createCvButton = screen.getByRole('button', { name: 'plus Create CV' }); - - expect(title).toBeInTheDocument(); expect(createCvButton).toBeInTheDocument(); - }); - - it('should show confirmation modal', async () => { - render(); - - const createCvButton = screen.getByRole('button', { name: 'plus Create CV' }); - - fireEvent.click(createCvButton); - - const modal = await screen.findByRole('dialog'); - const modalTitle = await screen.findByText(confirmationModalInfo.en.header); - - expect(modal).toBeInTheDocument(); - expect(modalTitle).toBeInTheDocument(); - - // close modal - fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); - - await waitFor(() => { - const modal = screen.queryByRole('dialog'); - expect(modal).not.toBeInTheDocument(); - }); - }); - - it('should render tooltip', async () => { - render(); - - const createCvButton = screen.getByRole('button', { name: 'plus Create CV' }); - - fireEvent.click(createCvButton); - - const titleTooltipIcon = await screen.findByTestId(confirmationModalInfo.ru.header); - expect(titleTooltipIcon).toBeInTheDocument(); - - fireEvent.mouseEnter(titleTooltipIcon); - - await waitFor(() => { - expect(titleTooltipIcon).toHaveAttribute('data-testid', confirmationModalInfo.ru.header); - }); - - // close modal - fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); - - await waitFor(() => { - const modal = screen.queryByRole('dialog'); - expect(modal).not.toBeInTheDocument(); - }); - }); - - it.each` - text - ${confirmationModalInfo.en.availableDataList[0]} - ${confirmationModalInfo.en.availableDataList[1]} - ${confirmationModalInfo.en.availableDataList[2]} - ${confirmationModalInfo.en.availableDataList[3]} - `('should render visible text $text', async ({ text }) => { - render(); - - const createCvButton = screen.getByRole('button', { name: 'plus Create CV' }); - fireEvent.click(createCvButton); - await waitFor(() => { + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText(confirmationModalInfo.en.header)).toBeInTheDocument(); + for (const text of confirmationModalInfo.en.availableDataList) { expect(screen.getByText(text)).toBeInTheDocument(); - }); + } - // close modal - fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + const cancelButton = screen.getByRole('button', { name: 'Cancel' }); + expect(cancelButton).toBeInTheDocument(); + fireEvent.click(cancelButton); - await waitFor(() => { - const modal = screen.queryByRole('dialog'); - expect(modal).not.toBeInTheDocument(); - }); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(giveConsent).not.toHaveBeenCalled(); }); - it.each` - text - ${confirmationModalInfo.ru.availableDataList[0]} - ${confirmationModalInfo.ru.availableDataList[1]} - ${confirmationModalInfo.ru.availableDataList[2]} - ${confirmationModalInfo.ru.availableDataList[3]} - `('should render tooltip $text', async ({ text }) => { + it('shows the translated header and detail tooltips', async () => { render(); - - const createCvButton = screen.getByRole('button', { name: 'plus Create CV' }); - - fireEvent.click(createCvButton); - - const tooltipIcon = await screen.findByTestId(text); - expect(tooltipIcon).toBeInTheDocument(); - - fireEvent.mouseEnter(tooltipIcon); - - await waitFor(() => { + fireEvent.click(screen.getByRole('button', { name: 'plus Create CV' })); + await screen.findByRole('dialog'); + vi.useFakeTimers(); + + for (const text of [confirmationModalInfo.ru.header, ...confirmationModalInfo.ru.availableDataList]) { + const tooltipIcon = screen.getByTestId(text); + expect(tooltipIcon).toBeInTheDocument(); + fireEvent.mouseEnter(tooltipIcon); + await finishTooltipTransition(); + expect(screen.getByRole('tooltip', { name: text })).toHaveTextContent(text); expect(tooltipIcon).toHaveAttribute('data-testid', text); - }); + fireEvent.mouseLeave(tooltipIcon); + await finishTooltipTransition(); + expect(screen.queryByRole('tooltip', { name: text })).not.toBeInTheDocument(); + } - // close modal fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); - - await waitFor(() => { - const modal = screen.queryByRole('dialog'); - expect(modal).not.toBeInTheDocument(); - }); - }); - - it('should handle cancel correctly', async () => { - render(); - - const createCvButton = screen.getByRole('button', { name: 'plus Create CV' }); - expect(createCvButton).toBeInTheDocument(); - - fireEvent.click(createCvButton); - - const cancelButton = await screen.findByRole('button', { name: 'Cancel' }); - expect(cancelButton).toBeInTheDocument(); - - // close modal - fireEvent.click(cancelButton); - - await waitFor(() => { - const modal = screen.queryByRole('dialog'); - expect(modal).not.toBeInTheDocument(); - }); + await finishTooltipTransition(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); it('should handle consent correctly', async () => { From 596745c5bf8a6eddf719097a25b36d76b744de24 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:53:20 +0200 Subject: [PATCH 054/406] test: share Students page load and drawer flow --- .../modules/Students/Pages/Students.test.tsx | 36 +++---------------- 1 file changed, 5 insertions(+), 31 deletions(-) diff --git a/client/src/modules/Students/Pages/Students.test.tsx b/client/src/modules/Students/Pages/Students.test.tsx index af0960c8f..f3c6ba59c 100644 --- a/client/src/modules/Students/Pages/Students.test.tsx +++ b/client/src/modules/Students/Pages/Students.test.tsx @@ -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 () => { - 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 () => { + it('loads students, opens the selected details and closes the drawer', async () => { + const user = userEvent.setup(); 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 })); From 2f7906b9e49b5e2c5d588242646004d53d7801c5 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:55:29 +0200 Subject: [PATCH 055/406] test: share TeamDistributions display and modal checks --- .../TeamDistributions.test.tsx | 48 ++++--------------- 1 file changed, 9 insertions(+), 39 deletions(-) diff --git a/client/src/modules/TeamDistribution/pages/TeamDistributions/TeamDistributions.test.tsx b/client/src/modules/TeamDistribution/pages/TeamDistributions/TeamDistributions.test.tsx index 94b880444..5fabe092e 100644 --- a/client/src/modules/TeamDistribution/pages/TeamDistributions/TeamDistributions.test.tsx +++ b/client/src/modules/TeamDistribution/pages/TeamDistributions/TeamDistributions.test.tsx @@ -118,6 +118,7 @@ describe('', () => { await waitFor(() => expect(getCourseTeamDistributions).toHaveBeenCalledWith(42)); expect(await screen.findByText('Spring Distribution')).toBeInTheDocument(); + expect(screen.queryByTestId('submit-score-modal')).not.toBeInTheDocument(); // Welcome card shows the non-manager headline for a plain session. expect(screen.getByText('Become a member of the team!')).toBeInTheDocument(); }); @@ -139,22 +140,6 @@ describe('', () => { expect(screen.queryByText('Spring Distribution')).not.toBeInTheDocument(); }); - it('shows the manager welcome headline and renders the manager submit-score modal', async () => { - sessionValue.isAdmin = true; - render(); - - expect(await screen.findByText('Create student teams to solve group tasks!')).toBeInTheDocument(); - // Manager-only SubmitScoreModal is mounted (closed initially). - expect(screen.getByTestId('submit-score-modal')).toHaveAttribute('data-open', 'false'); - }); - - it('does not mount the submit-score modal for a non-manager', async () => { - render(); - - await screen.findByText('Spring Distribution'); - expect(screen.queryByTestId('submit-score-modal')).not.toBeInTheDocument(); - }); - it('opens the create-distribution modal from the welcome card (manager)', async () => { sessionValue.isAdmin = true; const user = userEvent.setup(); @@ -165,18 +150,13 @@ describe('', () => { expect(modalForm.toggle).toHaveBeenCalled(); }); - it('renders the create/edit modal when the modal-form flag is open', async () => { + it('cancels the create/edit modal through its onCancel handler', async () => { modalForm.open = true; + const user = userEvent.setup(); render(); expect(await screen.findByTestId('distribution-modal')).toBeInTheDocument(); expect(screen.getByText('edit:new')).toBeInTheDocument(); - }); - - it('cancels the create/edit modal through its onCancel handler', async () => { - modalForm.open = true; - const user = userEvent.setup(); - render(); await user.click(await screen.findByRole('button', { name: 'cancel-modal' })); @@ -230,21 +210,6 @@ describe('', () => { ); }); - it('opens the submit-score modal for the chosen distribution via the card action (manager)', async () => { - sessionValue.isAdmin = true; - const user = userEvent.setup(); - render(); - - await screen.findByText('Spring Distribution'); - // The card exposes a "Submit score" action for managers. - await user.click(screen.getByRole('button', { name: /submit score/i })); - - await waitFor(() => - expect(within(screen.getByTestId('submit-score-modal')).getByText('Spring Distribution')).toBeInTheDocument(), - ); - expect(screen.getByTestId('submit-score-modal')).toHaveAttribute('data-open', 'true'); - }); - it('registers for a distribution and shows a success toast', async () => { getCourseTeamDistributions.mockResolvedValue({ data: [makeDistribution({ registrationStatus: 'available' })], @@ -310,16 +275,21 @@ describe('', () => { ); }); - it('closes the submit-score modal through its onClose handler (manager)', async () => { + it('shows manager welcome, opens the chosen distribution and closes its score modal', async () => { sessionValue.isAdmin = true; const user = userEvent.setup(); render(); + expect(await screen.findByText('Create student teams to solve group tasks!')).toBeInTheDocument(); + expect(screen.getByTestId('submit-score-modal')).toHaveAttribute('data-open', 'false'); + // Open the modal for the card's distribution, then close it. await screen.findByText('Spring Distribution'); await user.click(screen.getByRole('button', { name: /submit score/i })); await waitFor(() => expect(screen.getByTestId('submit-score-modal')).toHaveAttribute('data-open', 'true')); + expect(within(screen.getByTestId('submit-score-modal')).getByText('Spring Distribution')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'close-score' })); await waitFor(() => expect(screen.getByTestId('submit-score-modal')).toHaveAttribute('data-open', 'false')); From 0c5d4ea3a32260ffc1765c84662dde49b76fb035 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:57:58 +0200 Subject: [PATCH 056/406] test: share Exercise rendering and validation flows --- .../components/Exercise/Exercise.test.tsx | 49 +++++-------------- 1 file changed, 13 insertions(+), 36 deletions(-) diff --git a/client/src/modules/AutoTest/components/Exercise/Exercise.test.tsx b/client/src/modules/AutoTest/components/Exercise/Exercise.test.tsx index e7af780de..073015bcb 100644 --- a/client/src/modules/AutoTest/components/Exercise/Exercise.test.tsx +++ b/client/src/modules/AutoTest/components/Exercise/Exercise.test.tsx @@ -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,16 +57,13 @@ describe('Exercise', () => { expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument(); }); - it('should render the submit button', () => { - renderExercise(CourseTaskDetailedDtoTypeEnum.Jstask); - - 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); + expect(screen.getByText(/will run tests in the following repository/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /submit/i })); await waitFor(() => expect(submitMock).toHaveBeenCalledTimes(1)); @@ -88,36 +73,28 @@ describe('Exercise', () => { const user = userEvent.setup(); 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 () => { + it('should show the missing-answer error and clear it after a valid answer', async () => { const user = userEvent.setup(); 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 () => { From 0c12801d906cdedb478160e622044418c788280f Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 20:59:11 +0200 Subject: [PATCH 057/406] test: share CrossCheckSubmit load and selection checks --- .../Student/CrossCheckSubmit/index.test.tsx | 23 +++++-------------- 1 file changed, 6 insertions(+), 17 deletions(-) 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 a1d4bdbd5..e229ae77c 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')); }); From 3bbcac2e8f9f672e9ee6868fe169239edd9c16b7 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:00:01 +0200 Subject: [PATCH 058/406] test: share StudentStatsCard modal and owner checks --- .../__test__/StudentStatsCard.test.tsx | 40 +++---------------- 1 file changed, 6 insertions(+), 34 deletions(-) diff --git a/client/src/components/Profile/__test__/StudentStatsCard.test.tsx b/client/src/components/Profile/__test__/StudentStatsCard.test.tsx index 58493b956..8e47d505f 100644 --- a/client/src/components/Profile/__test__/StudentStatsCard.test.tsx +++ b/client/src/components/Profile/__test__/StudentStatsCard.test.tsx @@ -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(); 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/ })); @@ -239,13 +230,6 @@ describe('StudentStatsCard', () => { 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 () => { + it('hides leave controls for a non-owner and opens and closes course statistics', async () => { const user = userEvent.setup(); 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(); - }); }); From dc1fe8ff955a944b1ff7c0fcbab257001fd906c2 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:01:08 +0200 Subject: [PATCH 059/406] test: share HeroesRadarTab load and filter checks --- .../components/Heroes/HeroesRadarTab.test.tsx | 51 +++++++------------ 1 file changed, 17 insertions(+), 34 deletions(-) diff --git a/client/src/components/Heroes/HeroesRadarTab.test.tsx b/client/src/components/Heroes/HeroesRadarTab.test.tsx index 48ec9ab60..9b2a5b577 100644 --- a/client/src/components/Heroes/HeroesRadarTab.test.tsx +++ b/client/src/components/Heroes/HeroesRadarTab.test.tsx @@ -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(); 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' })); @@ -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?'); From 5cd14c9de2e4b604524b89f451be7f26b2968dc3 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:02:03 +0200 Subject: [PATCH 060/406] test: share CoursesListModal display and selection setup --- .../CoursesListModal/index.test.tsx | 31 +++++-------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/client/src/modules/CourseManagement/components/CoursesListModal/index.test.tsx b/client/src/modules/CourseManagement/components/CoursesListModal/index.test.tsx index d457bfeae..d0d29aba3 100644 --- a/client/src/modules/CourseManagement/components/CoursesListModal/index.test.tsx +++ b/client/src/modules/CourseManagement/components/CoursesListModal/index.test.tsx @@ -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 }; }, @@ -41,14 +40,6 @@ describe('', () => { 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,18 +56,6 @@ 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 props = makeProps(); @@ -98,6 +77,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 +88,15 @@ describe('', () => { }); }); - it('calls onCancel when the modal cancel button is clicked', async () => { + it('renders the title and course selector and calls onCancel', async () => { const user = userEvent.setup(); 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); From 3646119c9945977c081125d7ed275d48c6b26c5e Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:03:04 +0200 Subject: [PATCH 061/406] test: avoid redundant ContactsCard renders and input clicks --- .../Profile/__test__/ContactsCard.test.tsx | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/client/src/components/Profile/__test__/ContactsCard.test.tsx b/client/src/components/Profile/__test__/ContactsCard.test.tsx index 34494aed1..e0496b9b4 100644 --- a/client/src/components/Profile/__test__/ContactsCard.test.tsx +++ b/client/src/components/Profile/__test__/ContactsCard.test.tsx @@ -92,11 +92,6 @@ 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(); @@ -111,7 +106,9 @@ describe('ContactsCard', () => { />, ); - 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); }); @@ -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' })); @@ -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()); @@ -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' })); From 2c3d7a12c59df6e88b809f22d8a766eb61afa800 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:03:58 +0200 Subject: [PATCH 062/406] test: share DiscordServersModal initial and edit checks --- .../components/DiscordServersModal.test.tsx | 30 +++++++------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/client/src/modules/DiscordAdmin/components/DiscordServersModal.test.tsx b/client/src/modules/DiscordAdmin/components/DiscordServersModal.test.tsx index 2bdd40310..1abe3196c 100644 --- a/client/src/modules/DiscordAdmin/components/DiscordServersModal.test.tsx +++ b/client/src/modules/DiscordAdmin/components/DiscordServersModal.test.tsx @@ -33,23 +33,6 @@ 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 props = makeProps(); @@ -87,19 +70,28 @@ describe('', () => { 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 () => { + it('renders empty create fields and cancels when untouched', async () => { const user = userEvent.setup(); 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(); From 641a70566908330dafe8f2b7004f0bcdfa9c50c8 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:04:46 +0200 Subject: [PATCH 063/406] test: share MessageSendingPanel open and cancel checks --- .../MessageSendingPanel.test.tsx | 27 ++++++------------- 1 file changed, 8 insertions(+), 19 deletions(-) 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 8e89d622c..c68d38fce 100644 --- a/client/src/modules/CrossCheck/components/SolutionReview/MessageSendingPanel/MessageSendingPanel.test.tsx +++ b/client/src/modules/CrossCheck/components/SolutionReview/MessageSendingPanel/MessageSendingPanel.test.tsx @@ -34,22 +34,21 @@ function renderPanel(props: Partial = {}) { } describe('', () => { - it('renders a collapsed "Leave a message" input initially', () => { - renderPanel(); - - expect(screen.getByPlaceholderText('Leave a message')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /Send message/ })).not.toBeInTheDocument(); - }); - - it('opens the editing panel when the collapsed input is clicked', async () => { + it('renders collapsed controls, opens on click and cancels', async () => { const user = userEvent.setup(); renderPanel(); - await user.click(screen.getByPlaceholderText('Leave a message')); + const collapsed = screen.getByPlaceholderText('Leave a message'); + expect(collapsed).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Send message/ })).not.toBeInTheDocument(); + 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 () => { @@ -63,16 +62,6 @@ describe('', () => { 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 { onFinish } = renderPanel(); From e75c1328a60f692c85f4bfd835cd5d1b71bc6d50 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:05:52 +0200 Subject: [PATCH 064/406] test: share TeamsSection display checks and scope edit action --- .../TeamsSection/TeamsSection.test.tsx | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/client/src/modules/Teams/components/TeamsSection/TeamsSection.test.tsx b/client/src/modules/Teams/components/TeamsSection/TeamsSection.test.tsx index ce5c81d4b..e8e405661 100644 --- a/client/src/modules/Teams/components/TeamsSection/TeamsSection.test.tsx +++ b/client/src/modules/Teams/components/TeamsSection/TeamsSection.test.tsx @@ -1,4 +1,4 @@ -import { screen, render, waitFor } from '@testing-library/react'; +import { screen, render, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { TeamApi, TeamDistributionDetailedDto, TeamDto } from '@client/api'; import TeamSection from './TeamsSection'; @@ -67,16 +67,7 @@ describe('', () => { // member count "1 of 3" expect(screen.getByText(/1 of 3/)).toBeInTheDocument(); expect(getTeams).toHaveBeenCalledWith(100, 5, 10, 1, ''); - }); - - it('renders the distribution name in the section title', async () => { - renderSection(); - expect(await screen.findByText('Spring teams')).toBeInTheDocument(); - }); - - it('hides the Action column (Edit team) for non-managers', async () => { - renderSection(false); - await screen.findByText('Alpha Team'); + expect(screen.getByText('Spring teams')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /edit team/i })).not.toBeInTheDocument(); }); @@ -85,9 +76,13 @@ describe('', () => { const { toggleTeamModal } = renderSection(true); await screen.findByText('Alpha Team'); - const editButtons = screen.getAllByRole('button', { name: /edit team/i }); - expect(editButtons.length).toBeGreaterThan(0); - await user.click(editButtons[0]); + // Scope the action to its team instead of scanning every row's buttons. + // eslint-disable-next-line testing-library/no-node-access + const row = screen.getByText('Alpha Team').closest('tr'); + expect(row).toHaveRole('row'); + const editButton = within(row!).getByRole('button', { name: /edit team/i }); + expect(editButton).toBeInTheDocument(); + await user.click(editButton); expect(toggleTeamModal).toHaveBeenCalledWith(teams[0]); }); From 6208d0cd0b28b481c87cc0113f5df07796743cd2 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:07:13 +0200 Subject: [PATCH 065/406] test: consolidate ContactInfo initial field assertions --- .../Cards/ContactInfo/ContactInfo.test.tsx | 83 +++++-------------- 1 file changed, 21 insertions(+), 62 deletions(-) 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 fcd7bba44..461bfcac3 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: { From a08ce8529037ea81deabb54aa5873d774e9017db Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:08:00 +0200 Subject: [PATCH 066/406] test: share UserGroupsModal create and validation setup --- .../components/UserGroupsModal.test.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/client/src/modules/UserGroupsAdmin/components/UserGroupsModal.test.tsx b/client/src/modules/UserGroupsAdmin/components/UserGroupsModal.test.tsx index b05090351..44a073c40 100644 --- a/client/src/modules/UserGroupsAdmin/components/UserGroupsModal.test.tsx +++ b/client/src/modules/UserGroupsAdmin/components/UserGroupsModal.test.tsx @@ -51,19 +51,15 @@ describe('', () => { expect(container).toBeEmptyDOMElement(); }); - it('renders the title plus name, users and roles fields when creating', () => { - render(); - - expect(screen.getByText('User Group')).toBeInTheDocument(); - expect(screen.getByLabelText('Name')).toHaveValue(''); - expect(screen.getByLabelText('user-search')).toHaveTextContent(''); - }); - it('shows validation errors and does not submit when fields are empty', async () => { const user = userEvent.setup(); const props = makeProps(); render(); + expect(screen.getByText('User Group')).toBeInTheDocument(); + expect(screen.getByLabelText('Name')).toHaveValue(''); + expect(screen.getByLabelText('user-search')).toHaveTextContent(''); + await user.click(screen.getByRole('button', { name: /save/i })); expect(await screen.findByText('Please enter user group name')).toBeInTheDocument(); From 7a54ff4fc73e02510539d1e42cccb05d97e0b29d Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:09:00 +0200 Subject: [PATCH 067/406] test: simplify criteria score queries and avoid repeated clicks --- .../CrossCheck/AddCriteriaForCrossCheck.test.tsx | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/client/src/modules/CrossCheck/AddCriteriaForCrossCheck.test.tsx b/client/src/modules/CrossCheck/AddCriteriaForCrossCheck.test.tsx index 2bb8080bd..a5e5b5b50 100644 --- a/client/src/modules/CrossCheck/AddCriteriaForCrossCheck.test.tsx +++ b/client/src/modules/CrossCheck/AddCriteriaForCrossCheck.test.tsx @@ -1,7 +1,6 @@ -/* eslint-disable testing-library/no-node-access */ // Complements `__tests__/AddCriteriaForCrossCheck.test.tsx` (basic render + save) // by covering the per-type payload branches and the canSave validation paths. -import { render, screen, within } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { AddCriteriaForCrossCheck } from './AddCriteriaForCrossCheck'; @@ -43,11 +42,9 @@ describe(' payload branches', () => { await user.type(screen.getByPlaceholderText('Add description'), 'Subtask text'); expect(addButton).toBeDisabled(); - const maxScoreInput = within(screen.getByText('Add Max Score').closest('.ant-form-item') as HTMLElement).getByRole( - 'spinbutton', - ); + const maxScoreInput = screen.getByRole('spinbutton'); await user.clear(maxScoreInput); - await user.type(maxScoreInput, '5'); + await user.type(maxScoreInput, '5', { skipClick: true }); expect(addButton).toBeEnabled(); await user.click(addButton); @@ -64,11 +61,9 @@ describe(' payload branches', () => { expect(screen.getByText('Add Max Penalty')).toBeInTheDocument(); await user.type(screen.getByPlaceholderText('Add description'), 'Penalty text'); - const penaltyInput = within(screen.getByText('Add Max Penalty').closest('.ant-form-item') as HTMLElement).getByRole( - 'spinbutton', - ); + const penaltyInput = screen.getByRole('spinbutton'); await user.clear(penaltyInput); - await user.type(penaltyInput, '4'); + await user.type(penaltyInput, '4', { skipClick: true }); await user.click(screen.getByRole('button', { name: 'Add New Criteria' })); From b5c41180cc9bf8999c231aa47a0526b6cc50dca3 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:10:14 +0200 Subject: [PATCH 068/406] test: share AutoTests initial state and tab switching --- .../pages/AutoTests/AutoTests.test.tsx | 27 +++---------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/client/src/modules/AutoTest/pages/AutoTests/AutoTests.test.tsx b/client/src/modules/AutoTest/pages/AutoTests/AutoTests.test.tsx index f2770b458..8987e5ddd 100644 --- a/client/src/modules/AutoTest/pages/AutoTests/AutoTests.test.tsx +++ b/client/src/modules/AutoTest/pages/AutoTests/AutoTests.test.tsx @@ -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 = userEvent.setup(); 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); @@ -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 }); From 4a1eed6c06894d5e8f84d9b1eb9ff6f2ae7fa5c0 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:11:07 +0200 Subject: [PATCH 069/406] test: share AdditionalInfo display and submission setup --- .../AdditionalInfo/AdditionalInfo.test.tsx | 42 ++++--------------- 1 file changed, 8 insertions(+), 34 deletions(-) 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 c741988c2..1d81c676b 100644 --- a/client/src/modules/Registry/components/Cards/AdditionalInfo/AdditionalInfo.test.tsx +++ b/client/src/modules/Registry/components/Cards/AdditionalInfo/AdditionalInfo.test.tsx @@ -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 = userEvent.setup(); 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 = userEvent.setup(); renderAdditionalInfo({ ...mockValues, dataProcessing: 0 }); const button = await screen.findByRole('button', { name: /submit/i }); From 8a555eb5927567e69c1e4062c269dd22bb948c23 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:12:28 +0200 Subject: [PATCH 070/406] test: share mentor student display and feedback navigation checks --- .../Mentor/pages/Students/Students.test.tsx | 28 +++++++------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/client/src/modules/Mentor/pages/Students/Students.test.tsx b/client/src/modules/Mentor/pages/Students/Students.test.tsx index b170bd558..d1fc0df52 100644 --- a/client/src/modules/Mentor/pages/Students/Students.test.tsx +++ b/client/src/modules/Mentor/pages/Students/Students.test.tsx @@ -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 () => { + it('should render student details and navigate to Give Feedback on click', async () => { const user = userEvent.setup(); 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 } }), From 2ba4682151f955b26aadc5348b869a1e953b42f4 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:13:24 +0200 Subject: [PATCH 071/406] test: share bad review selection and modal checks --- .../BadReview/BadReviewControllers.test.tsx | 54 +++++-------------- 1 file changed, 12 insertions(+), 42 deletions(-) diff --git a/client/src/modules/CrossCheckPairs/components/BadReview/BadReviewControllers.test.tsx b/client/src/modules/CrossCheckPairs/components/BadReview/BadReviewControllers.test.tsx index 0c9acbe61..fc93b2872 100644 --- a/client/src/modules/CrossCheckPairs/components/BadReview/BadReviewControllers.test.tsx +++ b/client/src/modules/CrossCheckPairs/components/BadReview/BadReviewControllers.test.tsx @@ -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 = userEvent.setup(); 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 selectTask(user, 'Task One'); + await user.click(taskOne); - 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,6 +66,10 @@ 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 () => { @@ -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'); - 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(); - }); - }); }); From 8f26d9859a27490a8ad5826ca4352b220b38a82f Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:14:38 +0200 Subject: [PATCH 072/406] test: share InviteMentorsModal display and selection checks --- .../components/InviteMentorsModal.test.tsx | 42 ++++++------------- 1 file changed, 12 insertions(+), 30 deletions(-) diff --git a/client/src/modules/MentorRegistry/components/InviteMentorsModal.test.tsx b/client/src/modules/MentorRegistry/components/InviteMentorsModal.test.tsx index fe74bcdd1..d1e55d32f 100644 --- a/client/src/modules/MentorRegistry/components/InviteMentorsModal.test.tsx +++ b/client/src/modules/MentorRegistry/components/InviteMentorsModal.test.tsx @@ -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,33 +53,6 @@ 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(); render(); @@ -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')); @@ -126,6 +101,13 @@ describe('', () => { const user = userEvent.setup(); 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(); From 16a274604b771a2291d215892c15cdc80159d02b Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:15:27 +0200 Subject: [PATCH 073/406] test: share DisciplineModal defaults and edit setup --- .../components/DisciplineModal.test.tsx | 28 ++++++------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/client/src/modules/Discipline/components/DisciplineModal.test.tsx b/client/src/modules/Discipline/components/DisciplineModal.test.tsx index 6f013a033..a9a78628b 100644 --- a/client/src/modules/Discipline/components/DisciplineModal.test.tsx +++ b/client/src/modules/Discipline/components/DisciplineModal.test.tsx @@ -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(); @@ -94,9 +77,11 @@ describe('', () => { 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' })); @@ -125,6 +110,11 @@ describe('', () => { 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(); From 2975069f0fb833940c0ae899773051bd897ee712 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:16:19 +0200 Subject: [PATCH 074/406] test: share MentorTasksReview initial load assertions --- .../pages/MentorTasksReview.test.tsx | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/client/src/modules/MentorTasksReview/pages/MentorTasksReview.test.tsx b/client/src/modules/MentorTasksReview/pages/MentorTasksReview.test.tsx index 65113288d..44c010bc5 100644 --- a/client/src/modules/MentorTasksReview/pages/MentorTasksReview.test.tsx +++ b/client/src/modules/MentorTasksReview/pages/MentorTasksReview.test.tsx @@ -102,17 +102,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 +121,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 () => { From a83280d5618a55c14cc844d4e169920f0814526d Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:17:15 +0200 Subject: [PATCH 075/406] test: share EventsTable display checks and scope row actions --- .../components/EventsTable.test.tsx | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/client/src/modules/EventsAdmin/components/EventsTable.test.tsx b/client/src/modules/EventsAdmin/components/EventsTable.test.tsx index b5674ee20..cc2001991 100644 --- a/client/src/modules/EventsAdmin/components/EventsTable.test.tsx +++ b/client/src/modules/EventsAdmin/components/EventsTable.test.tsx @@ -22,22 +22,26 @@ 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 = userEvent.setup(); + 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]); @@ -48,7 +52,7 @@ describe('', () => { 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 })); From 770d994da5ff9e137ddfa23948619111768c01bd Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:18:07 +0200 Subject: [PATCH 076/406] test: streamline team score modal setup and API mocks --- .../SubmitScoreModal.test.tsx | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/client/src/modules/TeamDistribution/components/SubmitScoreModal/SubmitScoreModal.test.tsx b/client/src/modules/TeamDistribution/components/SubmitScoreModal/SubmitScoreModal.test.tsx index be3b3181a..faee33bcd 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 { 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 = [ @@ -38,14 +47,6 @@ describe('', () => { 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(); @@ -107,7 +108,9 @@ describe('', () => { const user = userEvent.setup(); 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 })); From aae0c4ea4ebd3628311a58ac3276385ca62ac081 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:21:52 +0200 Subject: [PATCH 077/406] test: optimize task performance card setup --- .../TaskPerformanceCard.test.tsx | 24 +++---------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/client/src/modules/CourseStatistics/components/TaskPerformanceCard/TaskPerformanceCard.test.tsx b/client/src/modules/CourseStatistics/components/TaskPerformanceCard/TaskPerformanceCard.test.tsx index 81c8dcb23..25a82072c 100644 --- a/client/src/modules/CourseStatistics/components/TaskPerformanceCard/TaskPerformanceCard.test.tsx +++ b/client/src/modules/CourseStatistics/components/TaskPerformanceCard/TaskPerformanceCard.test.tsx @@ -54,8 +54,7 @@ const { getTaskPerformance } = vi.hoisted(() => ({ getTaskPerformance: vi.fn(), })); -vi.mock('@client/api', async () => ({ - ...(await vi.importActual('@client/api')), +vi.mock('@client/api', () => ({ CourseStatsApi: function CourseStatsApi() { return { getTaskPerformance }; }, @@ -89,22 +88,14 @@ const performance = { describe('', () => { beforeEach(() => vi.clearAllMocks()); - it('renders the title and the task select', () => { + it('shows the initial empty state and lists the provided tasks', () => { render(); expect(screen.getByText('Task Performance')).toBeInTheDocument(); expect(screen.getByRole('combobox')).toBeInTheDocument(); - }); - - it('shows the empty-state prompt before a task is selected', () => { - render(); expect(screen.getByText(/no data available for this task/i)).toBeInTheDocument(); expect(screen.queryByTestId('donut-chart')).not.toBeInTheDocument(); - }); - - it('lists the provided tasks as select options', () => { - render(); fireEvent.mouseDown(screen.getByRole('combobox')); @@ -151,7 +142,7 @@ describe('', () => { return lastDonutConfig.current; } - it('maps each performance type to its human-readable description via the tooltip item', async () => { + it('maps performance descriptions, handles unknown types and renders tooltip HTML', async () => { const chartConfig = await captureChartConfig(); const formatItem = chartConfig?.tooltip?.items?.[0]; @@ -164,17 +155,8 @@ describe('', () => { expect(formatItem?.({ type: 'High', value: 4 }).name).toMatch(/71% and 90%/); expect(formatItem?.({ type: 'Exceptional', value: 5 }).name).toMatch(/91% and 99%/); expect(formatItem?.({ type: 'Perfect', value: 6 }).name).toMatch(/perfect score of 100%/); - }); - - it('falls back to the Unknown description for an unrecognised type', async () => { - const chartConfig = await captureChartConfig(); - const formatItem = chartConfig?.tooltip?.items?.[0]; expect(formatItem?.({ type: 'Mystery', value: 9 }).name).toBe('Unknown performance category'); - }); - - it('renders the tooltip HTML containing the mapped name and value', async () => { - const chartConfig = await captureChartConfig(); // `render` here is the antd chart tooltip renderer, not Testing Library's render. // eslint-disable-next-line testing-library/render-result-naming-convention const tooltipHtml = chartConfig?.interaction?.tooltip?.render?.(null, { From 08bf212b61b7c6822bedc82652c4ff6092c8b7c4 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:23:01 +0200 Subject: [PATCH 078/406] test: share criteria form initial-state setup --- .../components/CriteriaForm.test.tsx | 30 +++++++------------ 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/client/src/modules/CrossCheck/components/CriteriaForm.test.tsx b/client/src/modules/CrossCheck/components/CriteriaForm.test.tsx index 7121cbcff..46b0ff9e3 100644 --- a/client/src/modules/CrossCheck/components/CriteriaForm.test.tsx +++ b/client/src/modules/CrossCheck/components/CriteriaForm.test.tsx @@ -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 = userEvent.setup(); + 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,6 +58,12 @@ 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 onChange = vi.fn(); From e00df8c2d08200ff3a8a4c2ec86f49e99672a4b6 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:23:49 +0200 Subject: [PATCH 079/406] test: share settings drawer initial-state setup --- .../components/SettingsDrawer/index.test.tsx | 38 +++++++------------ 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/client/src/modules/Score/components/SettingsDrawer/index.test.tsx b/client/src/modules/Score/components/SettingsDrawer/index.test.tsx index 69db5050a..8613bf7be 100644 --- a/client/src/modules/Score/components/SettingsDrawer/index.test.tsx +++ b/client/src/modules/Score/components/SettingsDrawer/index.test.tsx @@ -38,31 +38,6 @@ 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 props = makeProps(); @@ -80,8 +55,21 @@ describe('', () => { 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')); From 21598951ec1d40fbff63dfb778ded634824e86b0 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:25:07 +0200 Subject: [PATCH 080/406] test: optimize cross-check pair table queries --- .../data/getCrossCheckPairsColumns.test.tsx | 39 +++++++++---------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/client/src/modules/CrossCheckPairs/data/getCrossCheckPairsColumns.test.tsx b/client/src/modules/CrossCheckPairs/data/getCrossCheckPairsColumns.test.tsx index 2ce01aa95..eef8ea1eb 100644 --- a/client/src/modules/CrossCheckPairs/data/getCrossCheckPairsColumns.test.tsx +++ b/client/src/modules/CrossCheckPairs/data/getCrossCheckPairsColumns.test.tsx @@ -1,5 +1,5 @@ import { Table } from 'antd'; -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; 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 })]); @@ -60,7 +52,14 @@ describe('getCrossCheckPairsColumns', () => { 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(); }); }); From 691ac0d195782281b487dfb759f578be3b8ed804 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:26:04 +0200 Subject: [PATCH 081/406] test: share leave-course modal initial-state setup --- .../Profile/__test__/StudentLeaveCourse.test.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/client/src/components/Profile/__test__/StudentLeaveCourse.test.tsx b/client/src/components/Profile/__test__/StudentLeaveCourse.test.tsx index 375f28078..de9c8cd1d 100644 --- a/client/src/components/Profile/__test__/StudentLeaveCourse.test.tsx +++ b/client/src/components/Profile/__test__/StudentLeaveCourse.test.tsx @@ -19,14 +19,6 @@ 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 onOk = vi.fn(); @@ -55,6 +47,11 @@ describe('StudentLeaveCourse', () => { 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); }); From 084da93e505058b20681c50d6eec43fa119f5a72 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:26:50 +0200 Subject: [PATCH 082/406] test: share status tab count and order setup --- .../components/StatusTabs/StatusTabs.test.tsx | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/client/src/modules/Schedule/components/StatusTabs/StatusTabs.test.tsx b/client/src/modules/Schedule/components/StatusTabs/StatusTabs.test.tsx index 5a7f37c6b..dd97bbf0e 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')); From 41c7305b4c0f5bc2792b8d7117d753aa8d862e8d Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:27:48 +0200 Subject: [PATCH 083/406] test: share cross-check criteria panel fixtures --- .../CrossCheckTaskCriteriaPanel.test.tsx | 65 +++---------------- 1 file changed, 10 insertions(+), 55 deletions(-) diff --git a/client/src/modules/Tasks/components/CrossCheckTaskCriteriaPanel/CrossCheckTaskCriteriaPanel.test.tsx b/client/src/modules/Tasks/components/CrossCheckTaskCriteriaPanel/CrossCheckTaskCriteriaPanel.test.tsx index 154601679..e35219e37 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(); }); }); From 81201f79e301fda10251e3f82e878a7579f5d380 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:28:39 +0200 Subject: [PATCH 084/406] test: share task solution modal setup --- .../SubmitTaskSolution.test.tsx | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/client/src/modules/StudentDashboard/components/SubmitTaskSolution/SubmitTaskSolution.test.tsx b/client/src/modules/StudentDashboard/components/SubmitTaskSolution/SubmitTaskSolution.test.tsx index f50865c93..2fd34032d 100644 --- a/client/src/modules/StudentDashboard/components/SubmitTaskSolution/SubmitTaskSolution.test.tsx +++ b/client/src/modules/StudentDashboard/components/SubmitTaskSolution/SubmitTaskSolution.test.tsx @@ -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 () => { + 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 }); 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 From 5ac53ee52b54d7a908a506baa46de768250fac9a Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:29:35 +0200 Subject: [PATCH 085/406] test: avoid redundant about-card input clicks --- client/src/components/Profile/__test__/AboutCard.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/src/components/Profile/__test__/AboutCard.test.tsx b/client/src/components/Profile/__test__/AboutCard.test.tsx index 34a3afaae..0a6db89bf 100644 --- a/client/src/components/Profile/__test__/AboutCard.test.tsx +++ b/client/src/components/Profile/__test__/AboutCard.test.tsx @@ -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' })); @@ -41,7 +41,7 @@ describe('AboutCard', () => { 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' })); @@ -57,7 +57,7 @@ describe('AboutCard', () => { 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(); From f5f45c928792f0634f236e66312a7118ed5563ad Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:30:33 +0200 Subject: [PATCH 086/406] test: share populated contact form setup --- .../EditCv/ContactsForm/index.test.tsx | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/client/src/modules/Opportunities/components/EditCv/ContactsForm/index.test.tsx b/client/src/modules/Opportunities/components/EditCv/ContactsForm/index.test.tsx index 80a7860ab..45d4ed27e 100644 --- a/client/src/modules/Opportunities/components/EditCv/ContactsForm/index.test.tsx +++ b/client/src/modules/Opportunities/components/EditCv/ContactsForm/index.test.tsx @@ -13,25 +13,25 @@ const mockContactsList = { }; describe('ContactsForm', () => { - test.each` - value | placeholder | labelText - ${mockContactsList.email} | ${'Email'} | ${'Email'} - ${mockContactsList.githubUsername} | ${'GitHub username'} | ${'GitHub'} - ${mockContactsList.linkedin} | ${'LinkedIn username'} | ${'LinkedIn'} - ${mockContactsList.phone} | ${'+12025550111'} | ${'Phone'} - ${mockContactsList.skype} | ${'Skype id'} | ${'Skype'} - ${mockContactsList.telegram} | ${'Telegram public name'} | ${'Telegram'} - ${mockContactsList.website} | ${'Enter your website URL'} | ${'Website'} - `('form field should have proper value, placeholder and label', async ({ value, placeholder, labelText }) => { + test('renders each contact with its value, placeholder and label', () => { render(); - const fieldDisplayedValue = await screen.findByDisplayValue(value); - const fieldPlaceholder = await screen.findByPlaceholderText(placeholder); - const fieldLabel = await screen.findByLabelText(labelText); + const fields = [ + [mockContactsList.email, 'Email', 'Email'], + [mockContactsList.githubUsername, 'GitHub username', 'GitHub'], + [mockContactsList.linkedin, 'LinkedIn username', 'LinkedIn'], + [mockContactsList.phone, '+12025550111', 'Phone'], + [mockContactsList.skype, 'Skype id', 'Skype'], + [mockContactsList.telegram, 'Telegram public name', 'Telegram'], + [mockContactsList.website, 'Enter your website URL', 'Website'], + ]; - expect(fieldDisplayedValue).toBeInTheDocument(); - expect(fieldPlaceholder).toBeInTheDocument(); - expect(fieldLabel).toBeInTheDocument(); + for (const [value, placeholder, labelText] of fields) { + const field = screen.getByLabelText(labelText); + expect(field).toBeInTheDocument(); + expect(field).toHaveValue(value); + expect(field).toHaveAttribute('placeholder', placeholder); + } }); test('shows a validation error for an invalid phone number', async () => { From f371a399210a15faf8b3dbc1593453daa7f30d27 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:31:24 +0200 Subject: [PATCH 087/406] test: share verification information task fixture --- .../VerificationInformation/VerificationInformation.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/modules/AutoTest/components/VerificationInformation/VerificationInformation.test.tsx b/client/src/modules/AutoTest/components/VerificationInformation/VerificationInformation.test.tsx index cfb09e5fc..49946cd50 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', () => { From 810877fd4e756e7d849743acd30312137f84cea9 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:32:17 +0200 Subject: [PATCH 088/406] test: share assign reviewer modal display setup --- .../AssignReviewerModal.test.tsx | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/client/src/modules/MentorTasksReview/components/AssignReviewerModal/AssignReviewerModal.test.tsx b/client/src/modules/MentorTasksReview/components/AssignReviewerModal/AssignReviewerModal.test.tsx index 209a5515d..5eb8e646e 100644 --- a/client/src/modules/MentorTasksReview/components/AssignReviewerModal/AssignReviewerModal.test.tsx +++ b/client/src/modules/MentorTasksReview/components/AssignReviewerModal/AssignReviewerModal.test.tsx @@ -66,20 +66,6 @@ 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 { onSubmit } = renderModal(); @@ -127,6 +113,16 @@ describe('AssignReviewerModal', () => { const user = userEvent.setup(); 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(); From f6ea8c710f245d5d1cfed86b8639ccb56d7d28bd Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:33:06 +0200 Subject: [PATCH 089/406] test: share join team modal initial-state setup --- .../components/JoinTeamModal/JoinTeamModal.test.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/client/src/modules/Teams/components/JoinTeamModal/JoinTeamModal.test.tsx b/client/src/modules/Teams/components/JoinTeamModal/JoinTeamModal.test.tsx index 542191a86..59fbae53c 100644 --- a/client/src/modules/Teams/components/JoinTeamModal/JoinTeamModal.test.tsx +++ b/client/src/modules/Teams/components/JoinTeamModal/JoinTeamModal.test.tsx @@ -12,17 +12,15 @@ function renderModal() { describe('', () => { beforeEach(() => vi.clearAllMocks()); - it('renders the modal with the password field and Join button', () => { - renderModal(); + it('calls onCancel when the cancel button is clicked', async () => { + const user = userEvent.setup(); + const { onCancel } = renderModal(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByText('Join team')).toBeInTheDocument(); expect(screen.getByLabelText('Team password')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /join/i })).toBeInTheDocument(); - }); - it('calls onCancel when the cancel button is clicked', async () => { - const user = userEvent.setup(); - const { onCancel } = renderModal(); await user.click(screen.getByRole('button', { name: /cancel/i })); expect(onCancel).toHaveBeenCalled(); }); From 0fe3e5b6a364db8a7b55a4b43e282ccfb489b94e Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:34:04 +0200 Subject: [PATCH 090/406] test: share registry general section form setup --- .../GeneralSection/GeneralSection.test.tsx | 36 +++++-------------- 1 file changed, 8 insertions(+), 28 deletions(-) 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 c4f41abf0..f11a2ed41 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(); }); }); From 07d0050fd2d8d532fc964acb53a21a309d0d5de2 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:35:05 +0200 Subject: [PATCH 091/406] test: optimize contributor modal setup --- .../components/ContributorModal.test.tsx | 28 +++++-------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/client/src/modules/Contributor/components/ContributorModal.test.tsx b/client/src/modules/Contributor/components/ContributorModal.test.tsx index 7bcddc800..7c4dbc7cd 100644 --- a/client/src/modules/Contributor/components/ContributorModal.test.tsx +++ b/client/src/modules/Contributor/components/ContributorModal.test.tsx @@ -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,23 +44,6 @@ 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 onClose = vi.fn(); @@ -83,10 +65,12 @@ describe('', () => { 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(() => @@ -116,7 +100,9 @@ describe('', () => { 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(); From d67fb3619e13b6129a6c2019d45147983d1d332f Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:36:09 +0200 Subject: [PATCH 092/406] test: share users admin search setup --- .../UsersAdminPage/UsersAdminPage.test.tsx | 29 +++++-------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/client/src/modules/UsersAdmin/pages/UsersAdminPage/UsersAdminPage.test.tsx b/client/src/modules/UsersAdmin/pages/UsersAdminPage/UsersAdminPage.test.tsx index c8b308923..a577ad913 100644 --- a/client/src/modules/UsersAdmin/pages/UsersAdminPage/UsersAdminPage.test.tsx +++ b/client/src/modules/UsersAdmin/pages/UsersAdminPage/UsersAdminPage.test.tsx @@ -22,8 +22,7 @@ vi.mock('@client/modules/Course/contexts', () => ({ // useUsersSearch instantiates UsersApi at module scope. const { searchUsers } = vi.hoisted(() => ({ searchUsers: vi.fn() })); -vi.mock('@client/api', async () => ({ - ...(await vi.importActual('@client/api')), +vi.mock('@client/api', () => ({ UsersApi: function UsersApi() { return { searchUsers }; }, @@ -51,15 +50,6 @@ describe('', () => { searchUsers.mockResolvedValue({ data: results }); }); - it('renders the search form and no list before searching', () => { - render(); - - expect(screen.getByPlaceholderText('Search by github or name')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /search/i })).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: /users/i })).toBeInTheDocument(); - expect(searchUsers).not.toHaveBeenCalled(); - }); - it('searches and renders the returned users with their populated fields', async () => { const user = userEvent.setup(); render(); @@ -74,27 +64,22 @@ describe('', () => { expect(screen.getByText('@octo')).toBeInTheDocument(); // Mentor field joins course names; empty student list renders nothing. expect(screen.getByText('RS 2024')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'octocat' })).toHaveAttribute('href', '/profile?githubId=octocat'); }); it('does not call the API when the search box is empty', async () => { const user = userEvent.setup(); render(); + expect(screen.getByPlaceholderText('Search by github or name')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /search/i })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /users/i })).toBeInTheDocument(); + expect(searchUsers).not.toHaveBeenCalled(); + await user.click(screen.getByRole('button', { name: /search/i })); // searchUsers short-circuits on empty input, so the list never appears. await waitFor(() => expect(searchUsers).not.toHaveBeenCalled()); expect(screen.queryByText('octocat')).not.toBeInTheDocument(); }); - - it('links each result to the user profile page', async () => { - const user = userEvent.setup(); - render(); - - await user.type(screen.getByPlaceholderText('Search by github or name'), 'octo'); - await user.click(screen.getByRole('button', { name: /search/i })); - - const link = await screen.findByRole('link', { name: 'octocat' }); - expect(link).toHaveAttribute('href', '/profile?githubId=octocat'); - }); }); From e9d6ba8add04df645514ce3a445fac2ea5b0217a Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:37:08 +0200 Subject: [PATCH 093/406] test: share profile obfuscation mismatch setup --- .../ObfuscateConfirmationModal.test.tsx | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/client/src/components/Profile/__test__/ObfuscateConfirmationModal.test.tsx b/client/src/components/Profile/__test__/ObfuscateConfirmationModal.test.tsx index 20f9c4dc0..42e73bc51 100644 --- a/client/src/components/Profile/__test__/ObfuscateConfirmationModal.test.tsx +++ b/client/src/components/Profile/__test__/ObfuscateConfirmationModal.test.tsx @@ -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,18 +42,6 @@ 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(); renderModal({ githubId: 'octocat' }); @@ -81,6 +74,9 @@ 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(); }); From 5c57c37e1c0b13122d45e8f64b8c2c50771a1760 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:38:17 +0200 Subject: [PATCH 094/406] test: share schedule settings panel fixtures --- .../SettingsPanel/SettingsPanel.test.tsx | 45 +++++++------------ 1 file changed, 16 insertions(+), 29 deletions(-) diff --git a/client/src/modules/Schedule/components/SettingsPanel/SettingsPanel.test.tsx b/client/src/modules/Schedule/components/SettingsPanel/SettingsPanel.test.tsx index 3f5e33179..51a8b377a 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(); + } }, ); }); From d8f70885e4f81c42aaab17eb2ee81f6fc8e8dbd3 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:39:15 +0200 Subject: [PATCH 095/406] test: share task card display and navigation setup --- .../components/TaskCard/TaskCard.test.tsx | 20 ++++--------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/client/src/modules/AutoTest/components/TaskCard/TaskCard.test.tsx b/client/src/modules/AutoTest/components/TaskCard/TaskCard.test.tsx index c85db4d0f..962f8309a 100644 --- a/client/src/modules/AutoTest/components/TaskCard/TaskCard.test.tsx +++ b/client/src/modules/AutoTest/components/TaskCard/TaskCard.test.tsx @@ -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(); @@ -64,6 +48,10 @@ describe('TaskCard', () => { 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)); From 586030b3b85581970c328dbadbe143a702ae8994 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:40:19 +0200 Subject: [PATCH 096/406] test: target contact form input by label --- .../components/Profile/__test__/ContactsCardForm.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/src/components/Profile/__test__/ContactsCardForm.test.tsx b/client/src/components/Profile/__test__/ContactsCardForm.test.tsx index 2d14233bd..e108e9810 100644 --- a/client/src/components/Profile/__test__/ContactsCardForm.test.tsx +++ b/client/src/components/Profile/__test__/ContactsCardForm.test.tsx @@ -36,8 +36,8 @@ describe('ContactsCardForm', () => { 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]; @@ -50,8 +50,8 @@ describe('ContactsCardForm', () => { 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)); From b87af7323426bc3f1316b3b3e2e2c3e083fc3977 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:41:32 +0200 Subject: [PATCH 097/406] test: share mentor stats card display setup --- .../Profile/__test__/MentorStatsCard.test.tsx | 47 ++++--------------- 1 file changed, 10 insertions(+), 37 deletions(-) diff --git a/client/src/components/Profile/__test__/MentorStatsCard.test.tsx b/client/src/components/Profile/__test__/MentorStatsCard.test.tsx index 47e7a876a..eab19440a 100644 --- a/client/src/components/Profile/__test__/MentorStatsCard.test.tsx +++ b/client/src/components/Profile/__test__/MentorStatsCard.test.tsx @@ -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(); - }); - - 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', () => { From 662255e9f21de70ca369340426101df5127c5b22 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:42:51 +0200 Subject: [PATCH 098/406] test: share students without team search setup --- .../StudentsWithoutTeamSection.test.tsx | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/client/src/modules/Teams/components/StudentsWithoutTeamSection/StudentsWithoutTeamSection.test.tsx b/client/src/modules/Teams/components/StudentsWithoutTeamSection/StudentsWithoutTeamSection.test.tsx index 3c0f99f4d..5e02ce574 100644 --- a/client/src/modules/Teams/components/StudentsWithoutTeamSection/StudentsWithoutTeamSection.test.tsx +++ b/client/src/modules/Teams/components/StudentsWithoutTeamSection/StudentsWithoutTeamSection.test.tsx @@ -52,27 +52,17 @@ describe('', () => { deleteStudent.mockResolvedValue({} as never); }); - it('loads and renders students without a team', async () => { - renderSection(); - expect(await screen.findByText('Lonely Student')).toBeInTheDocument(); - expect(getStudentsWithoutTeam).toHaveBeenCalledWith(100, 5, 10, 1, ''); - }); - it('re-fetches with the search term when searching', async () => { const user = userEvent.setup(); renderSection(); - await screen.findByText('Lonely Student'); + expect(await screen.findByText('Lonely Student')).toBeInTheDocument(); + expect(getStudentsWithoutTeam).toHaveBeenCalledWith(100, 5, 10, 1, ''); + expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument(); await user.type(screen.getByPlaceholderText('input search text'), 'Lonely{enter}'); await waitFor(() => expect(getStudentsWithoutTeam).toHaveBeenCalledWith(100, 5, 10, 1, 'Lonely')); }); - it('does not render a delete action for non-managers', async () => { - renderSection(false); - await screen.findByText('Lonely Student'); - expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument(); - }); - it('confirms and deletes a student for managers', async () => { const user = userEvent.setup(); const { reloadDistribution } = renderSection(true); From 1a9d7a9436c7112b8b677ccb1f37b86c6434e2ff Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:44:03 +0200 Subject: [PATCH 099/406] test: share student interview interaction setup --- .../components/StudentInterview.test.tsx | 40 +++++-------------- 1 file changed, 9 insertions(+), 31 deletions(-) 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 0074b7f7e..37be16705 100644 --- a/client/src/modules/Mentor/pages/Interviews/components/StudentInterview.test.tsx +++ b/client/src/modules/Mentor/pages/Interviews/components/StudentInterview.test.tsx @@ -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 = userEvent.setup(); 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, { @@ -155,7 +131,9 @@ describe('StudentInterview', () => { const user = userEvent.setup(); 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', From ffe19b2b23442317fda70243b5f2fd5899c26561 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:45:26 +0200 Subject: [PATCH 100/406] test: share comment input initial-state setup --- .../components/Forms/CommentInput.test.tsx | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/client/src/shared/components/Forms/CommentInput.test.tsx b/client/src/shared/components/Forms/CommentInput.test.tsx index 4ed86e677..246816798 100644 --- a/client/src/shared/components/Forms/CommentInput.test.tsx +++ b/client/src/shared/components/Forms/CommentInput.test.tsx @@ -19,11 +19,17 @@ function renderCommentInput(props: Parameters[0] = {}, onFi const LONG_COMMENT = 'This is a detailed comment that is well over thirty characters long.'; describe('CommentInput', () => { - it('renders a "Comment" labelled textarea', () => { - renderCommentInput(); + it('shows a required error and blocks submit when empty', async () => { + const user = userEvent.setup(); + const { onFinish } = renderCommentInput(); expect(screen.getByLabelText('Comment')).toBeInTheDocument(); expect(screen.getByRole('textbox')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /submit/i })); + + expect(await screen.findByText('Please leave a detailed comment')).toBeInTheDocument(); + expect(onFinish).not.toHaveBeenCalled(); }); it('lets the user type a comment and submits its value', async () => { @@ -39,16 +45,6 @@ describe('CommentInput', () => { await waitFor(() => expect(onFinish).toHaveBeenCalledWith({ comment: LONG_COMMENT })); }); - it('shows a required error and blocks submit when empty', async () => { - const user = userEvent.setup(); - const { onFinish } = renderCommentInput(); - - await user.click(screen.getByRole('button', { name: /submit/i })); - - expect(await screen.findByText('Please leave a detailed comment')).toBeInTheDocument(); - expect(onFinish).not.toHaveBeenCalled(); - }); - it('shows the min-length error when the comment is shorter than 30 characters', async () => { const user = userEvent.setup(); const { onFinish } = renderCommentInput(); From f0e8d01381010f4b3b2f9e5d6755f08bc8f8954f Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:46:31 +0200 Subject: [PATCH 101/406] test: share team students table display setup --- .../components/StudentsTable/StudentsTable.test.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/client/src/modules/Teams/components/StudentsTable/StudentsTable.test.tsx b/client/src/modules/Teams/components/StudentsTable/StudentsTable.test.tsx index a3f75aaf3..f3dbc6605 100644 --- a/client/src/modules/Teams/components/StudentsTable/StudentsTable.test.tsx +++ b/client/src/modules/Teams/components/StudentsTable/StudentsTable.test.tsx @@ -43,15 +43,12 @@ describe('', () => { const githubLink = screen.getByRole('link', { name: 'alice-gh' }); expect(githubLink).toHaveAttribute('href', 'https://github.com/alice-gh'); expect(screen.getByText('alice@example.com')).toBeInTheDocument(); - }); - - it('renders the name as a CV link only when the student has a cvUuid', () => { - render(); const aliceLink = screen.getByRole('link', { name: /Alice Lead/i }); expect(aliceLink).toHaveAttribute('href', expect.stringContaining('/cv/uuid-alice')); // Bob has no cvUuid -> plain text, not a link expect(screen.queryByRole('link', { name: /Bob Member/i })).not.toBeInTheDocument(); + expect(screen.queryByText('Action')).not.toBeInTheDocument(); }); it('marks the team lead with a tag and renders the discord username link', () => { @@ -68,11 +65,6 @@ describe('', () => { expect(screen.queryByText('alice@example.com')).not.toBeInTheDocument(); }); - it('does not render a delete column when onDelete is not provided', () => { - render(); - expect(screen.queryByText('Action')).not.toBeInTheDocument(); - }); - it('renders the combined mobile student/contacts columns on an xs viewport', () => { // antd marks the Student/Contacts columns responsive to the `xs` breakpoint only. // Force a narrow viewport so those mobile renderers are exercised. From 120608114ccd54bcd79cfeaf8b2b37abf1c9344b Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:47:38 +0200 Subject: [PATCH 102/406] test: share select mentor modal setup --- .../components/SelectMentorModal.test.tsx | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) 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 4c9f411dc..056794bb7 100644 --- a/client/src/modules/Mentor/pages/Interviews/components/SelectMentorModal.test.tsx +++ b/client/src/modules/Mentor/pages/Interviews/components/SelectMentorModal.test.tsx @@ -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 = userEvent.setup(); + 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/ })); @@ -70,6 +66,8 @@ describe('SelectMentorModal', () => { // 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(); - }); }); From 62f825dc3cb2505c7963f874432c9456f2abf210 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:48:47 +0200 Subject: [PATCH 103/406] test: share dashboard mentor card fixtures --- .../components/MentorCard/MentorCard.test.tsx | 45 +++++++------------ 1 file changed, 15 insertions(+), 30 deletions(-) diff --git a/client/src/modules/StudentDashboard/components/MentorCard/MentorCard.test.tsx b/client/src/modules/StudentDashboard/components/MentorCard/MentorCard.test.tsx index c6e71a9f4..2a1352f17 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 }); From 8105fc5b441d0231a0bad58a049edbc9c2601061 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:49:56 +0200 Subject: [PATCH 104/406] test: share student registration success setup --- .../useStudentData/useStudentData.test.tsx | 41 +++---------------- 1 file changed, 6 insertions(+), 35 deletions(-) diff --git a/client/src/modules/Registry/hooks/useStudentData/useStudentData.test.tsx b/client/src/modules/Registry/hooks/useStudentData/useStudentData.test.tsx index 1e1ba5ad8..2d86be1f8 100644 --- a/client/src/modules/Registry/hooks/useStudentData/useStudentData.test.tsx +++ b/client/src/modules/Registry/hooks/useStudentData/useStudentData.test.tsx @@ -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 () => { From b4376d4c105c835ba6806b47d9c8512e60768e13 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:51:08 +0200 Subject: [PATCH 105/406] test: share mentor options form setup --- client/src/components/MentorOptions.test.tsx | 31 +++++--------------- 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/client/src/components/MentorOptions.test.tsx b/client/src/components/MentorOptions.test.tsx index c3c303d5d..e5f1a89f5 100644 --- a/client/src/components/MentorOptions.test.tsx +++ b/client/src/components/MentorOptions.test.tsx @@ -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(); @@ -101,9 +79,14 @@ describe('MentorOptions', () => { 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' })); From 406273c746f2a894d6c38fce780f33c439baf224 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:52:19 +0200 Subject: [PATCH 106/406] test: share student mentor modal setup --- .../components/StudentMentorModal.test.tsx | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/client/src/shared/components/StudentMentorModal.test.tsx b/client/src/shared/components/StudentMentorModal.test.tsx index ab1b91697..6b01a029f 100644 --- a/client/src/shared/components/StudentMentorModal.test.tsx +++ b/client/src/shared/components/StudentMentorModal.test.tsx @@ -39,16 +39,6 @@ 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(); render(); @@ -64,8 +54,16 @@ describe('StudentMentorModal', () => { const user = userEvent.setup(); 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 })); From 6ddf81ca1570aaf634a0d5ce606429f42ef16c5a Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:53:29 +0200 Subject: [PATCH 107/406] test: optimize user group table setup and queries --- .../components/UserGroupsTable.test.tsx | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/client/src/modules/UserGroupsAdmin/components/UserGroupsTable.test.tsx b/client/src/modules/UserGroupsAdmin/components/UserGroupsTable.test.tsx index ee4925eec..c9fcc5c2d 100644 --- a/client/src/modules/UserGroupsAdmin/components/UserGroupsTable.test.tsx +++ b/client/src/modules/UserGroupsAdmin/components/UserGroupsTable.test.tsx @@ -21,9 +21,19 @@ const data = [ }, ] as unknown as UserGroupDto[]; +function getGroupRow(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 group names, users and role tags', () => { - render(); + it('calls onEdit with the row record when Edit is clicked', async () => { + const user = userEvent.setup(); + const onEdit = vi.fn(); + render(); expect(screen.getByText('Admins')).toBeInTheDocument(); expect(screen.getByText('Mentors')).toBeInTheDocument(); @@ -31,14 +41,8 @@ describe('', () => { expect(screen.getByText('manager')).toBeInTheDocument(); expect(screen.getByText('supervisor')).toBeInTheDocument(); expect(screen.getByText('dementor')).toBeInTheDocument(); - }); - - it('calls onEdit with the row record when Edit is clicked', async () => { - const user = userEvent.setup(); - const onEdit = vi.fn(); - render(); - const row = screen.getByRole('row', { name: /Admins/ }); + const row = getGroupRow('Admins'); await user.click(within(row).getByText('Edit')); expect(onEdit).toHaveBeenCalledWith(data[0]); @@ -49,7 +53,7 @@ describe('', () => { const onDelete = vi.fn(); render(); - const row = screen.getByRole('row', { name: /Mentors/ }); + const row = getGroupRow('Mentors'); await user.click(within(row).getByText('Delete')); await user.click(await screen.findByRole('button', { name: /^ok$/i })); From 60f889ed1cc0ea5fbdf9d01c8b64ad93731587b2 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:54:45 +0200 Subject: [PATCH 108/406] test: share notification settings table setup --- .../NotificationSettingsTable.test.tsx | 43 ++++++------------- 1 file changed, 12 insertions(+), 31 deletions(-) diff --git a/client/src/modules/Notifications/components/NotificationSettingsTable.test.tsx b/client/src/modules/Notifications/components/NotificationSettingsTable.test.tsx index e1ae56c0d..9de6a24cd 100644 --- a/client/src/modules/Notifications/components/NotificationSettingsTable.test.tsx +++ b/client/src/modules/Notifications/components/NotificationSettingsTable.test.tsx @@ -21,28 +21,6 @@ const notifications: NotificationDto[] = [ ]; describe('NotificationSettingsTable', () => { - it('renders the column headers', () => { - render(); - - expect(screen.getByText('Notification')).toBeInTheDocument(); - expect(screen.getByText('Active')).toBeInTheDocument(); - expect(screen.getByText('Actions')).toBeInTheDocument(); - }); - - it('renders a row per notification with its name', () => { - render(); - - expect(screen.getByText('Enabled One')).toBeInTheDocument(); - expect(screen.getByText('Disabled One')).toBeInTheDocument(); - }); - - it('renders the active state with check / minus icons', () => { - render(); - - expect(screen.getByLabelText('check-circle')).toBeInTheDocument(); - expect(screen.getByLabelText('minus-circle')).toBeInTheDocument(); - }); - it('renders an empty table when there are no notifications', () => { render(); @@ -54,7 +32,18 @@ describe('NotificationSettingsTable', () => { const onEdit = vi.fn(); render(); - const editLinks = screen.getAllByText('Edit'); + expect(screen.getByText('Notification')).toBeInTheDocument(); + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.getByText('Actions')).toBeInTheDocument(); + expect(screen.getByText('Enabled One')).toBeInTheDocument(); + expect(screen.getByText('Disabled One')).toBeInTheDocument(); + expect(screen.getByLabelText('check-circle')).toBeInTheDocument(); + expect(screen.getByLabelText('minus-circle')).toBeInTheDocument(); + + const table = screen.getByRole('table'); + const editLinks = within(table).getAllByText('Edit'); + expect(editLinks).toHaveLength(notifications.length); + expect(within(table).getAllByText('Delete')).toHaveLength(notifications.length); fireEvent.click(editLinks[0]!); expect(onEdit).toHaveBeenCalledTimes(1); @@ -93,12 +82,4 @@ describe('NotificationSettingsTable', () => { expect(onDelete).not.toHaveBeenCalled(); }); - - it('renders one Edit / Delete action per row', () => { - render(); - - const table = screen.getByRole('table'); - expect(within(table).getAllByText('Edit')).toHaveLength(notifications.length); - expect(within(table).getAllByText('Delete')).toHaveLength(notifications.length); - }); }); From 3c0cb6e5cc175a2990762523e3020290fbfb0187 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:55:54 +0200 Subject: [PATCH 109/406] test: share teams page success-flow setup --- client/src/modules/Teams/Pages/Teams.test.tsx | 37 ++++--------------- 1 file changed, 7 insertions(+), 30 deletions(-) diff --git a/client/src/modules/Teams/Pages/Teams.test.tsx b/client/src/modules/Teams/Pages/Teams.test.tsx index 3cf420d2f..0c8ff7e78 100644 --- a/client/src/modules/Teams/Pages/Teams.test.tsx +++ b/client/src/modules/Teams/Pages/Teams.test.tsx @@ -176,15 +176,6 @@ beforeEach(() => { }); describe('', () => { - it('renders the page title and the header when a distribution is loaded', () => { - render(); - - expect(screen.getByRole('heading', { name: 'RS Teams' })).toBeInTheDocument(); - expect(screen.getByTestId('teams-header')).toBeInTheDocument(); - // Default tab renders the teams section. - expect(screen.getByTestId('teams-section')).toBeInTheDocument(); - }); - it('does not render the header or any section when there is no distribution', () => { distributionState.distribution = undefined; render(); @@ -197,21 +188,16 @@ describe('', () => { const user = userEvent.setup(); render(); + expect(screen.getByRole('heading', { name: 'RS Teams' })).toBeInTheDocument(); + expect(screen.getByTestId('teams-header')).toBeInTheDocument(); + expect(screen.getByTestId('teams-section')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'tab-students' })); expect(await screen.findByTestId('students-section')).toBeInTheDocument(); expect(screen.queryByTestId('teams-section')).not.toBeInTheDocument(); }); - it('switches the active tab to the my-team section', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByRole('button', { name: 'tab-myteam' })); - - expect(await screen.findByTestId('myteam-section')).toBeInTheDocument(); - }); - it('opens the team modal from the header create-team action', async () => { const user = userEvent.setup(); render(); @@ -280,17 +266,6 @@ describe('', () => { await waitFor(() => expect(screen.queryByTestId('join-modal')).not.toBeInTheDocument()); }); - it('creates a new team through the team modal when it is open', async () => { - modalForm.open = true; - const user = userEvent.setup(); - render(); - - await user.click(await screen.findByRole('button', { name: 'modal-create-submit' })); - - await waitFor(() => expect(createTeam).toHaveBeenCalledWith(42, 5, { name: 'New Team' })); - await waitFor(() => expect(distributionState.loadDistribution).toHaveBeenCalled()); - }); - it('updates an existing team through the team modal when an id is supplied', async () => { modalForm.open = true; const user = userEvent.setup(); @@ -321,7 +296,8 @@ describe('', () => { render(); await user.click(await screen.findByRole('button', { name: 'modal-create-submit' })); - await waitFor(() => expect(createTeam).toHaveBeenCalled()); + await waitFor(() => expect(createTeam).toHaveBeenCalledWith(42, 5, { name: 'New Team' })); + await waitFor(() => expect(distributionState.loadDistribution).toHaveBeenCalled()); // The success confirm dialog offers "Copy invitation password" -> copyPassword(team.id). const dialog = await screen.findByRole('dialog'); @@ -341,6 +317,7 @@ describe('', () => { render(); await user.click(screen.getByRole('button', { name: 'tab-myteam' })); + expect(await screen.findByTestId('myteam-section')).toBeInTheDocument(); await user.click(await screen.findByRole('button', { name: 'copy-password' })); await waitFor(() => expect(getTeamPassword).toHaveBeenCalledWith(42, 5, 8)); From cc482f736db23987c91350040399c93ae56183c0 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:57:24 +0200 Subject: [PATCH 110/406] test: optimize cross-check pairs page setup --- .../CrossCheckPairs/CrossCheckPairs.test.tsx | 30 +++++++------------ 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/client/src/modules/CrossCheckPairs/pages/CrossCheckPairs/CrossCheckPairs.test.tsx b/client/src/modules/CrossCheckPairs/pages/CrossCheckPairs/CrossCheckPairs.test.tsx index 54a26ef89..e29ec9a8f 100644 --- a/client/src/modules/CrossCheckPairs/pages/CrossCheckPairs/CrossCheckPairs.test.tsx +++ b/client/src/modules/CrossCheckPairs/pages/CrossCheckPairs/CrossCheckPairs.test.tsx @@ -85,29 +85,16 @@ describe('', () => { 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'); - }); - }); - - 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(); 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'); @@ -120,7 +107,10 @@ describe('', () => { 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. From 8c4598c591072328c388c49da187ef274d8c6ec6 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:58:33 +0200 Subject: [PATCH 111/406] test: optimize discord server table setup --- .../components/DiscordServersTable.test.tsx | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/client/src/modules/DiscordAdmin/components/DiscordServersTable.test.tsx b/client/src/modules/DiscordAdmin/components/DiscordServersTable.test.tsx index 868c382c2..e680b0435 100644 --- a/client/src/modules/DiscordAdmin/components/DiscordServersTable.test.tsx +++ b/client/src/modules/DiscordAdmin/components/DiscordServersTable.test.tsx @@ -8,22 +8,26 @@ 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 = userEvent.setup(); + 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]); @@ -34,7 +38,7 @@ describe('', () => { 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. From 4f254816aa3e95538f732419a1339b646a60b3a1 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 21:59:45 +0200 Subject: [PATCH 112/406] test: share student interview card fixtures --- .../Student/components/InterviewCard.test.tsx | 37 ++++--------------- 1 file changed, 8 insertions(+), 29 deletions(-) diff --git a/client/src/modules/Interview/Student/components/InterviewCard.test.tsx b/client/src/modules/Interview/Student/components/InterviewCard.test.tsx index 3a6820c06..7146ca5bb 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(); }); From 15f7fcfb7d893abdba4c7ab2fdfa277f3eb330b9 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 22:31:06 +0200 Subject: [PATCH 113/406] test: share user notification settings setup --- .../UserNotificationsSettingsPage.test.tsx | 20 ++++--------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/client/src/modules/Notifications/pages/UserNotificationsSettingsPage.test.tsx b/client/src/modules/Notifications/pages/UserNotificationsSettingsPage.test.tsx index 5e535b899..17a2eeb08 100644 --- a/client/src/modules/Notifications/pages/UserNotificationsSettingsPage.test.tsx +++ b/client/src/modules/Notifications/pages/UserNotificationsSettingsPage.test.tsx @@ -53,21 +53,6 @@ describe('UserNotificationsPage', () => { saveUserNotifications.mockResolvedValue(undefined); }); - it('loads and renders the user notification settings table', async () => { - render(); - - expect(await screen.findByText('First Notification')).toBeInTheDocument(); - expect(screen.getByText('Second Notification')).toBeInTheDocument(); - expect(getUserNotificationSettings).toHaveBeenCalledTimes(1); - }); - - it('enables the Save button when at least one channel is connected', async () => { - render(); - await screen.findByText('First Notification'); - - expect(screen.getByRole('button', { name: /save/i })).toBeEnabled(); - }); - it('disables the Save button when no channels are connected', async () => { getUserNotificationSettings.mockResolvedValue({ connections: { @@ -86,7 +71,10 @@ describe('UserNotificationsPage', () => { it('toggles a channel checkbox and persists all settings on Save', async () => { const user = userEvent.setup(); render(); - await screen.findByText('First Notification'); + expect(await screen.findByText('First Notification')).toBeInTheDocument(); + expect(screen.getByText('Second Notification')).toBeInTheDocument(); + expect(getUserNotificationSettings).toHaveBeenCalledTimes(1); + expect(screen.getByRole('button', { name: /save/i })).toBeEnabled(); // Turn on telegram for the first notification (was false). const firstRow = screen.getAllByRole('row')[1]!; From c5931ae1824e695be3b4cb0057df3fe12ff0c7ee Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 22:35:18 +0200 Subject: [PATCH 114/406] test: optimize certificate template picker checks --- .../CertificateTemplatePicker.test.tsx | 32 ++++--------------- 1 file changed, 7 insertions(+), 25 deletions(-) diff --git a/client/src/modules/CourseManagement/components/CertificateTemplatePicker/CertificateTemplatePicker.test.tsx b/client/src/modules/CourseManagement/components/CertificateTemplatePicker/CertificateTemplatePicker.test.tsx index 9795e75b3..0fad2a9ae 100644 --- a/client/src/modules/CourseManagement/components/CertificateTemplatePicker/CertificateTemplatePicker.test.tsx +++ b/client/src/modules/CourseManagement/components/CertificateTemplatePicker/CertificateTemplatePicker.test.tsx @@ -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 () => { @@ -167,16 +159,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'); From 77cd1386b3b3d77cec550ec8949ca2b41e219597 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 22:36:17 +0200 Subject: [PATCH 115/406] test: reuse team section interaction setup --- .../MyTeamSection/MyTeamSection.test.tsx | 24 ++++--------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/client/src/modules/Teams/components/MyTeamSection/MyTeamSection.test.tsx b/client/src/modules/Teams/components/MyTeamSection/MyTeamSection.test.tsx index cd1a1c8cf..7e732fa72 100644 --- a/client/src/modules/Teams/components/MyTeamSection/MyTeamSection.test.tsx +++ b/client/src/modules/Teams/components/MyTeamSection/MyTeamSection.test.tsx @@ -79,30 +79,14 @@ describe('', () => { expect(container).toBeEmptyDOMElement(); }); - it('renders the team name, description and members table', () => { - renderSection(); + it('calls copyPassword with the team id when "Invitation password" is clicked', async () => { + const user = userEvent.setup(); + const { copyPassword } = renderSection({ studentId: 1 }); expect(screen.getByText('My Awesome Team')).toBeInTheDocument(); expect(screen.getByText('A description of my team')).toBeInTheDocument(); expect(screen.getByText('Lead Person')).toBeInTheDocument(); - }); - - it('shows lead-only controls (password / change password / chat link) for the team lead', () => { - renderSection({ studentId: 1 }); - expect(screen.getByRole('button', { name: /invitation password/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /change password/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /chat link/i })).toBeInTheDocument(); - }); - - it('hides lead-only controls for a non-lead member', () => { - renderSection({ studentId: 999 }); - expect(screen.queryByRole('button', { name: /invitation password/i })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /change password/i })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: /leave team/i })).toBeInTheDocument(); - }); - - it('calls copyPassword with the team id when "Invitation password" is clicked', async () => { - const user = userEvent.setup(); - const { copyPassword } = renderSection({ studentId: 1 }); await user.click(screen.getByRole('button', { name: /invitation password/i })); expect(copyPassword).toHaveBeenCalledWith(7); }); @@ -132,6 +116,8 @@ describe('', () => { it('leaves the team: calls leaveTeam, switches tab and reloads', async () => { const user = userEvent.setup(); const { setActiveTab, reloadDistribution } = renderSection({ studentId: 999 }); + expect(screen.queryByRole('button', { name: /invitation password/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /change password/i })).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: /leave team/i })); From 1aab416e2a5867fcb68f39c972b78135454464f4 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 22:37:15 +0200 Subject: [PATCH 116/406] test: optimize dev tools user checks --- .../DevTools/DevToolsUsers.test.tsx | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/client/src/components/DevTools/DevToolsUsers.test.tsx b/client/src/components/DevTools/DevToolsUsers.test.tsx index 070065815..5f67eee60 100644 --- a/client/src/components/DevTools/DevToolsUsers.test.tsx +++ b/client/src/components/DevTools/DevToolsUsers.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; 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 = userEvent.setup(); 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'); @@ -58,7 +59,7 @@ describe('DevToolsUsers', () => { 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(); From 83f43e141262e559f954c4252b085b2fb31abe3e Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 22:39:03 +0200 Subject: [PATCH 117/406] test: reuse dashboard details setup --- .../components/Student/DashboardDetails.test.tsx | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/client/src/components/Student/DashboardDetails.test.tsx b/client/src/components/Student/DashboardDetails.test.tsx index 6b2c07944..ca0ddceed 100644 --- a/client/src/components/Student/DashboardDetails.test.tsx +++ b/client/src/components/Student/DashboardDetails.test.tsx @@ -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 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(); @@ -106,12 +104,6 @@ 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 onUpdateMentor = vi.fn(); From 3f87080d0d3b0a39be7983df37b62aadd1ce3ead Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 22:40:11 +0200 Subject: [PATCH 118/406] test: optimize cross-check criteria form setup --- .../CrossCheckCriteriaForm.test.tsx | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/client/src/modules/CrossCheck/components/CrossCheckCriteriaForm.test.tsx b/client/src/modules/CrossCheck/components/CrossCheckCriteriaForm.test.tsx index 12d2f04e1..b73ef2f0f 100644 --- a/client/src/modules/CrossCheck/components/CrossCheckCriteriaForm.test.tsx +++ b/client/src/modules/CrossCheck/components/CrossCheckCriteriaForm.test.tsx @@ -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; @@ -100,9 +90,15 @@ describe('', () => { const user = userEvent.setup(); 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'); @@ -116,7 +112,7 @@ describe('', () => { // 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'); From 308f97e60b05fe82386eac2784731720f33f40a5 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 22:40:56 +0200 Subject: [PATCH 119/406] test: reuse expelled students table setup --- .../components/ExpelledStudentsStats.test.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/client/src/modules/CourseManagement/components/ExpelledStudentsStats.test.tsx b/client/src/modules/CourseManagement/components/ExpelledStudentsStats.test.tsx index 589fe17a5..55cd586e1 100644 --- a/client/src/modules/CourseManagement/components/ExpelledStudentsStats.test.tsx +++ b/client/src/modules/CourseManagement/components/ExpelledStudentsStats.test.tsx @@ -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 = userEvent.setup(); 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 })); From c6f103ebc2aa366c00efee02f92af45f594a3f5d Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 22:42:05 +0200 Subject: [PATCH 120/406] test: reuse student feedback page setup --- .../pages/StudentFeedback/StudentFeedback.test.tsx | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/client/src/modules/Mentor/pages/StudentFeedback/StudentFeedback.test.tsx b/client/src/modules/Mentor/pages/StudentFeedback/StudentFeedback.test.tsx index 40fa69632..545da819d 100644 --- a/client/src/modules/Mentor/pages/StudentFeedback/StudentFeedback.test.tsx +++ b/client/src/modules/Mentor/pages/StudentFeedback/StudentFeedback.test.tsx @@ -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(), @@ -125,6 +113,8 @@ describe('StudentFeedback page', () => { it('should create feedback and reload on submit without an existing feedback id', async () => { const user = userEvent.setup(); renderPage(); + expect(screen.getByText('Recommendation Letter')).toBeInTheDocument(); + expect(screen.getByText('form for 7')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'create-feedback' })); From cbc5b829bc20a50c7071ef3aa1304a977caa560c Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 22:44:03 +0200 Subject: [PATCH 121/406] fix: remove old plan --- VITEST.md | 236 ------------------------------------------------------ 1 file changed, 236 deletions(-) delete mode 100644 VITEST.md diff --git a/VITEST.md b/VITEST.md deleted file mode 100644 index e8b60acb2..000000000 --- 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 From 470c66ca0c3c866fdf967291c18bc1676e459d5a Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 22:44:15 +0200 Subject: [PATCH 122/406] test: consolidate duplicate CV editor checks --- .../components/EditCv/index.test.tsx | 40 +------------------ 1 file changed, 2 insertions(+), 38 deletions(-) diff --git a/client/src/modules/Opportunities/components/EditCv/index.test.tsx b/client/src/modules/Opportunities/components/EditCv/index.test.tsx index 1cf564dc7..dec2ddb34 100644 --- a/client/src/modules/Opportunities/components/EditCv/index.test.tsx +++ b/client/src/modules/Opportunities/components/EditCv/index.test.tsx @@ -64,7 +64,7 @@ const mockSwitchView = vi.fn(); const mockOnUpdateResume = vi.fn(); describe('EditCV', () => { - test('should display forms and control buttons', () => { + test('should display forms and controls and switch view on Cancel', () => { render( { expect(visibleCoursesForm).toBeInTheDocument(); expect(saveButton).toBeInTheDocument(); expect(cancelButton).toBeInTheDocument(); - }); - - test('should switch view on Cancel button click', () => { - render( - , - ); - - const cancelButton = screen.getByRole('button', { name: /cancel/i }); fireEvent.click(cancelButton); - - expect(mockSwitchView).toHaveBeenCalled(); - }); - - test('should show notification view on Cancel button click', () => { - render( - , - ); - - const cancelButton = screen.getByRole('button', { name: /cancel/i }); - - fireEvent.click(cancelButton); - expect(mockSwitchView).toHaveBeenCalled(); }); @@ -155,6 +118,7 @@ describe('EditCV', () => { await waitFor(() => { expect(mockOnUpdateResume).toHaveBeenCalled(); + expect(mockSuccessNotification).toHaveBeenCalledWith({ message: 'CV successfully updated', duration: 2 }); }); }); From 88da0e560d257b44cb3567cbb9d45a766cba8326 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 22:45:39 +0200 Subject: [PATCH 123/406] test: advance student search debounce with controlled timers --- .../shared/components/StudentSearch.test.tsx | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/client/src/shared/components/StudentSearch.test.tsx b/client/src/shared/components/StudentSearch.test.tsx index 74c3e5a83..ae6c8240f 100644 --- a/client/src/shared/components/StudentSearch.test.tsx +++ b/client/src/shared/components/StudentSearch.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { StudentSearch } from './StudentSearch'; @@ -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 = userEvent.setup({ 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 = userEvent.setup({ 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); }); }); From af2c07ab109630e612f8b3fe286bca18c5a9d06a Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 22:47:26 +0200 Subject: [PATCH 124/406] test: share cross-check criteria control setup --- .../AddCriteriaForCrossCheck.test.tsx | 27 +++---------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/client/src/modules/CrossCheck/__tests__/AddCriteriaForCrossCheck.test.tsx b/client/src/modules/CrossCheck/__tests__/AddCriteriaForCrossCheck.test.tsx index f067d924e..6404874b5 100644 --- a/client/src/modules/CrossCheck/__tests__/AddCriteriaForCrossCheck.test.tsx +++ b/client/src/modules/CrossCheck/__tests__/AddCriteriaForCrossCheck.test.tsx @@ -8,12 +8,7 @@ describe('AddCriteriaForCrossCheck', () => { test('should match shapshot', () => { const view = render(); expect(view).toMatchSnapshot(); - }); - - test('should render "Add New Criteria" button', () => { - render(); - const element = screen.getByText(/Add New Criteria/i); - expect(element).toBeInTheDocument(); + expect(screen.getByText(/Add New Criteria/i)).toBeInTheDocument(); }); test('should call addCriteria when "Add new criteria" button was clicked', async () => { @@ -34,41 +29,27 @@ describe('AddCriteriaForCrossCheck', () => { }); }); - test('should render textarea', () => { - render(); - - const textarea = screen.getByPlaceholderText('Add description'); - expect(textarea).toBeInTheDocument(); - }); - test('should change textarea value on typing', async () => { const expectedString = 'test value'; render(); const textarea = screen.getByPlaceholderText('Add description'); + expect(textarea).toBeInTheDocument(); await userEvent.type(textarea, expectedString); expect(textarea.value).toEqual(expectedString); }); - test('should select criteria', async () => { - render(); - const selectCriteriaType = screen.getByRole('combobox'); - expect(selectCriteriaType).toBeInTheDocument(); - fireEvent.mouseDown(selectCriteriaType); - - const element = screen.getByRole('option', { name: 'Subtask' }); - expect(element).toBeInTheDocument(); - }); - test('input with adding max score renders only after user select criteria type Subtask', async () => { render(); const selectCriteriaType = screen.getByRole('combobox'); + expect(selectCriteriaType).toBeInTheDocument(); const inputMaxScore = screen.queryByLabelText('Add Max Score'); expect(inputMaxScore).not.toBeInTheDocument(); fireEvent.mouseDown(selectCriteriaType); + expect(screen.getByRole('option', { name: 'Subtask' })).toBeInTheDocument(); const optionSubtask = screen.getByTestId('Subtask'); fireEvent.click(optionSubtask); expect(screen.getByText('Add Max Score')).toBeInTheDocument(); From e6409087e15cc2f6f102253f5f6667be6c3ed5d4 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 22:48:54 +0200 Subject: [PATCH 125/406] test: reuse registry form button interaction setup --- .../FormButtons/FormButtons.test.tsx | 32 +++++-------------- 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/client/src/modules/Registry/components/FormButtons/FormButtons.test.tsx b/client/src/modules/Registry/components/FormButtons/FormButtons.test.tsx index 4b711915c..99a077503 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); From 2485a48cfab25210fce6a931ca0a53f920ac87f7 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 22:50:15 +0200 Subject: [PATCH 126/406] test: share submitted modal form setup --- .../components/Forms/ModalSubmitForm.test.tsx | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/client/src/shared/components/Forms/ModalSubmitForm.test.tsx b/client/src/shared/components/Forms/ModalSubmitForm.test.tsx index 3bddbd85c..b3a3497d8 100644 --- a/client/src/shared/components/Forms/ModalSubmitForm.test.tsx +++ b/client/src/shared/components/Forms/ModalSubmitForm.test.tsx @@ -21,24 +21,10 @@ describe('ModalSubmitForm', () => { }); describe('when form was submitted', () => { - it('should not render footer', () => { - render(); - - const footerBtn = screen.queryByText('Submit'); - - expect(footerBtn).not.toBeInTheDocument(); - }); - - it('should render success message', () => { - render(); - - const success = screen.getByText('Successfully submitted'); - - expect(success).toBeInTheDocument(); - }); - - it('should close on OK button click', () => { + it('should show success without a footer and close on OK', () => { render(); + expect(screen.queryByText('Submit')).not.toBeInTheDocument(); + expect(screen.getByText('Successfully submitted')).toBeInTheDocument(); const okButton = screen.getByText('Ok'); fireEvent.click(okButton); From 53d12d4829f2bbd2b4241dbf811c97d2b1cee2b7 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 22:51:29 +0200 Subject: [PATCH 127/406] test: share verification table display setup --- .../VerificationsTable.test.tsx | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/client/src/modules/AutoTest/components/VerificationsTable/VerificationsTable.test.tsx b/client/src/modules/AutoTest/components/VerificationsTable/VerificationsTable.test.tsx index feb3cfea4..db669d689 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', () => { From a4360151b858a8989fdb1f5e92474f5ec052ed89 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 22:53:20 +0200 Subject: [PATCH 128/406] test: share mentorship section render --- .../MentorshipSection/MentorshipSection.test.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) 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 7ce816ecb..8e63bd55a 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()); }); }); From 8e89b721f0152b528968b248eb0f86cb1b161adc Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 22:56:28 +0200 Subject: [PATCH 129/406] test: reuse header render scenarios --- client/src/shared/components/Header.test.tsx | 28 ++++++++------------ 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/client/src/shared/components/Header.test.tsx b/client/src/shared/components/Header.test.tsx index 2b7c8f8a2..649276043 100644 --- a/client/src/shared/components/Header.test.tsx +++ b/client/src/shared/components/Header.test.tsx @@ -1,4 +1,3 @@ -/* 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 { useRouter } from 'next/router'; @@ -38,24 +37,26 @@ describe('Header', () => { vi.mocked(useRouter).mockReturnValue({ asPath: '/' } as ReturnType); }); - it('renders the logo, theme switch and navigation links', () => { + it('renders the logo, theme switch and horizontal navigation links', () => { render(
); 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', () => { + const { rerender } = render(
); 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(); }); @@ -89,14 +90,6 @@ describe('Header', () => { 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', () => { // course.id === 0 -> courseNotEmpty is null (the `course.id ? course : null` and // `courseNotEmpty ?? null` falsy branches). @@ -117,6 +110,7 @@ describe('Header', () => { 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/); }); }); From 1db11e19bb21ad1397d71f95e6a2144e1e5cab3c Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 22:57:58 +0200 Subject: [PATCH 130/406] test: reuse action card confirmation setup --- .../Teams/components/TeamsHeader/ActionCard.test.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/client/src/modules/Teams/components/TeamsHeader/ActionCard.test.tsx b/client/src/modules/Teams/components/TeamsHeader/ActionCard.test.tsx index 4aa986d21..c47b0cd96 100644 --- a/client/src/modules/Teams/components/TeamsHeader/ActionCard.test.tsx +++ b/client/src/modules/Teams/components/TeamsHeader/ActionCard.test.tsx @@ -17,17 +17,12 @@ function renderCard(overrides: Partial[0]> = {}) { } describe('', () => { - it('renders the title, text and button caption', () => { - renderCard(); - expect(screen.getByText('Become a leader')).toBeInTheDocument(); - expect(screen.getByText('Create a team and lead it')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Create team' })).toBeInTheDocument(); - }); - - it('asks for confirmation and calls onClick only after confirming', async () => { + it('renders its content, asks for confirmation and calls onClick only after confirming', async () => { const user = userEvent.setup(); const { onClick } = renderCard(); + expect(screen.getByText('Become a leader')).toBeInTheDocument(); + expect(screen.getByText('Create a team and lead it')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Create team' })); // Popconfirm uses lowercased caption in its prompt. From 543cc4b9033ce0ae3a5d2bf506ce65e7de74bfa0 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 22:58:53 +0200 Subject: [PATCH 131/406] test: share registration form display setup --- .../RegistrationForm.test.tsx | 26 +++---------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/client/src/modules/Registry/components/RegistrationForm/RegistrationForm.test.tsx b/client/src/modules/Registry/components/RegistrationForm/RegistrationForm.test.tsx index c27135b72..df8d136b8 100644 --- a/client/src/modules/Registry/components/RegistrationForm/RegistrationForm.test.tsx +++ b/client/src/modules/Registry/components/RegistrationForm/RegistrationForm.test.tsx @@ -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; From 670c4629a656e6f2093688adfb086fd664e71c16 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 22:59:46 +0200 Subject: [PATCH 132/406] test: reuse question radio render --- .../AutoTest/components/Question/Question.test.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/client/src/modules/AutoTest/components/Question/Question.test.tsx b/client/src/modules/AutoTest/components/Question/Question.test.tsx index 89981f0ff..2b37ad942 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', () => { From d00839a8dc2d67d3169e493df67182561c214866 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:00:52 +0200 Subject: [PATCH 133/406] test: reuse team distribution card renders --- .../TeamDistributionCard.test.tsx | 42 ++++--------------- 1 file changed, 9 insertions(+), 33 deletions(-) diff --git a/client/src/modules/TeamDistribution/components/TeamDistributionCard/TeamDistributionCard.test.tsx b/client/src/modules/TeamDistribution/components/TeamDistributionCard/TeamDistributionCard.test.tsx index 7822338f1..14b32f0ae 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(); From 59136fae383a88858429c6b517c86883e6c21599 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:01:53 +0200 Subject: [PATCH 134/406] test: reuse score input interaction setup --- .../components/Forms/ScoreInput.test.tsx | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/client/src/shared/components/Forms/ScoreInput.test.tsx b/client/src/shared/components/Forms/ScoreInput.test.tsx index 61696a933..58e47c260 100644 --- a/client/src/shared/components/Forms/ScoreInput.test.tsx +++ b/client/src/shared/components/Forms/ScoreInput.test.tsx @@ -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 } }); @@ -50,9 +37,9 @@ describe('ScoreInput', () => { it('lets the user type a score and submits it', async () => { const user = userEvent.setup(); - const { onFinish } = renderScoreInput({ maxScore: 100 }); + 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 })); @@ -61,8 +48,10 @@ describe('ScoreInput', () => { it('shows a required-error and blocks submit when left empty', async () => { const user = userEvent.setup(); - const { onFinish } = renderScoreInput({ maxScore: 100 }); + 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(); From 0c502d673c04c0dcbc4ac2674e1ad0ccbe68d0d3 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:02:52 +0200 Subject: [PATCH 135/406] test: reuse heroes radar table render --- .../Heroes/HeroesRadarTable.test.tsx | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/client/src/components/Heroes/HeroesRadarTable.test.tsx b/client/src/components/Heroes/HeroesRadarTable.test.tsx index 7cba0b4a7..c6c2c1f7d 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 }); From d8847c2af49dd33bdba147c911045e9d36dd4bf4 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:03:51 +0200 Subject: [PATCH 136/406] test: reuse comment modal interaction setup --- .../shared/components/CommentModal.test.tsx | 25 ++++++------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/client/src/shared/components/CommentModal.test.tsx b/client/src/shared/components/CommentModal.test.tsx index 3fa10d5a4..0bd1b1346 100644 --- a/client/src/shared/components/CommentModal.test.tsx +++ b/client/src/shared/components/CommentModal.test.tsx @@ -15,23 +15,21 @@ describe('CommentModal', () => { baseProps.onOk.mockClear(); }); - it('renders the modal title and a comment textarea when open', () => { - render(); - - expect(screen.getByText('Leave a comment')).toBeInTheDocument(); - expect(screen.getByLabelText('Comment')).toBeInTheDocument(); - }); - - it('pre-fills the textarea with the initial value', () => { + it('pre-fills the textarea and calls onCancel', async () => { + const user = userEvent.setup(); render(); expect(screen.getByLabelText('Comment')).toHaveValue('prefilled'); + await user.click(screen.getByRole('button', { name: /cancel/i })); + expect(baseProps.onCancel).toHaveBeenCalledTimes(1); }); - it('calls onOk with the typed comment when submitted', async () => { + it('renders the modal and calls onOk with the typed comment when submitted', async () => { const user = userEvent.setup(); render(); + expect(screen.getByText('Leave a comment')).toBeInTheDocument(); + expect(screen.getByLabelText('Comment')).toBeInTheDocument(); await user.type(screen.getByLabelText('Comment'), 'Nice work'); await user.click(screen.getByRole('button', { name: /ok/i })); @@ -56,13 +54,4 @@ describe('CommentModal', () => { await waitFor(() => expect(baseProps.onOk).toHaveBeenCalledWith('')); }); - - it('calls onCancel when the cancel button is clicked', async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByRole('button', { name: /cancel/i })); - - expect(baseProps.onCancel).toHaveBeenCalledTimes(1); - }); }); From 58cf4da107b58f9f8216d8dbad60bc587a5f1429 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:18:42 +0200 Subject: [PATCH 137/406] test(client): add act-aware user setup --- client/src/__tests__/setupUser.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 client/src/__tests__/setupUser.ts diff --git a/client/src/__tests__/setupUser.ts b/client/src/__tests__/setupUser.ts new file mode 100644 index 000000000..f66fc6460 --- /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; + }; + }, + }); +} From 48a9ad55221d9bfed194e25ac3463e64435d7d3b Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:18:42 +0200 Subject: [PATCH 138/406] test(client): wrap FormItem interactions in act --- .../StageInterviewFeedback/FormItem.test.tsx | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/client/src/modules/Interviews/pages/StageInterviewFeedback/FormItem.test.tsx b/client/src/modules/Interviews/pages/StageInterviewFeedback/FormItem.test.tsx index 3326c421c..18bf15533 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 + @@ -44,7 +44,7 @@ function getRadio(label: string) { 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, @@ -62,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, @@ -80,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, @@ -98,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, @@ -117,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, @@ -143,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, @@ -161,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, @@ -239,7 +239,7 @@ 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 => } @@ -312,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(); @@ -323,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(); @@ -336,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(); @@ -348,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( @@ -365,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 ( From 22e5903579c71909c8999107e5f2777afe3591cb Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:20:05 +0200 Subject: [PATCH 139/406] test(client): wrap MarkdownInput interactions in act --- .../shared/components/Forms/MarkdownInput.test.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/client/src/shared/components/Forms/MarkdownInput.test.tsx b/client/src/shared/components/Forms/MarkdownInput.test.tsx index 252e47664..4af04412b 100644 --- a/client/src/shared/components/Forms/MarkdownInput.test.tsx +++ b/client/src/shared/components/Forms/MarkdownInput.test.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { Button, Form } from 'antd'; +import { setupUser } from '@client/__tests__/setupUser'; import MarkdownInput from './MarkdownInput'; // react-markdown is an ESM micromark pipeline that is heavy and brittle under jsdom. @@ -35,7 +35,7 @@ const LONG_COMMENT = 'This is a detailed markdown comment well over thirty chara describe('MarkdownInput', () => { it('renders write controls, previews the empty warning and toggles back', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderMarkdownInput(); expect(screen.getByLabelText(/Comment \(markdown syntax is supported\)/i)).toBeInTheDocument(); @@ -51,7 +51,7 @@ describe('MarkdownInput', () => { }); it('switches to preview mode and renders the typed text through react-markdown', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderMarkdownInput(); await user.type(screen.getByRole('textbox'), LONG_COMMENT); @@ -67,7 +67,7 @@ describe('MarkdownInput', () => { }); it('shows "Please leave a detailed comment" in preview when text is shorter than 30 chars', async () => { - const user = userEvent.setup(); + const user = setupUser(); const { container } = renderMarkdownInput(); await user.type(screen.getByRole('textbox'), 'short text'); @@ -81,7 +81,7 @@ describe('MarkdownInput', () => { }); it('clears the text and leaves preview mode when the form is reset', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); // Type, switch to preview, then reset the form (fires the Form.Item onReset -> resetText). @@ -99,7 +99,7 @@ describe('MarkdownInput', () => { }); it('seeds the preview text from a non-empty historicalCommentSelected prop', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderMarkdownInput('Historical pre-filled comment longer than thirty chars.'); await user.click(screen.getByRole('button', { name: /preview/i })); @@ -108,7 +108,7 @@ describe('MarkdownInput', () => { }); it('updates the previewed text when a new historical comment is selected', async () => { - const user = userEvent.setup(); + const user = setupUser(); const { rerender } = renderMarkdownInput(''); rerender( From 81656810425828866e50311351003de4f5b30b45 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:20:06 +0200 Subject: [PATCH 140/406] test(client): wrap TeamModal interactions in act --- .../components/TeamModal/TeamModal.test.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/client/src/modules/Teams/components/TeamModal/TeamModal.test.tsx b/client/src/modules/Teams/components/TeamModal/TeamModal.test.tsx index 6cbf0725b..787043666 100644 --- a/client/src/modules/Teams/components/TeamModal/TeamModal.test.tsx +++ b/client/src/modules/Teams/components/TeamModal/TeamModal.test.tsx @@ -1,6 +1,6 @@ import { screen, render, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { message } from 'antd'; +import { setupUser } from '@client/__tests__/setupUser'; import { TeamDto } from '@client/api'; import TeamModal from './TeamModal'; @@ -48,7 +48,7 @@ describe('', () => { }); it('renders the non-manager create modal and handles cancellation', async () => { - const user = userEvent.setup(); + const user = setupUser(); const { onCancel } = renderModal(); expect(screen.getByText('Create Team')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /^create$/i })).toBeInTheDocument(); @@ -58,7 +58,7 @@ describe('', () => { }); it('does not submit and shows validation errors when required fields are empty', async () => { - const user = userEvent.setup(); + const user = setupUser(); const { onSubmit } = renderModal(); await user.click(screen.getByRole('button', { name: /^create$/i })); @@ -70,7 +70,7 @@ describe('', () => { }); it('rejects an invalid Discord URL', async () => { - const user = userEvent.setup(); + const user = setupUser(); const { onSubmit } = renderModal(); await user.type(screen.getByLabelText('Name'), 'Dream Team'); @@ -83,7 +83,7 @@ describe('', () => { }); it('submits the create payload (without studentIds for non-managers)', async () => { - const user = userEvent.setup(); + const user = setupUser(); const { onSubmit } = renderModal({ isManager: false }); await user.type(screen.getByLabelText('Name'), 'Dream Team'); @@ -104,7 +104,7 @@ describe('', () => { }); it('passes the existing team id as the second submit argument in edit mode', async () => { - const user = userEvent.setup(); + const user = setupUser(); const data: Partial = { id: 42, name: 'Old Name', @@ -123,7 +123,7 @@ describe('', () => { }); it('pre-fills student ids from data in manager edit mode', async () => { - const user = userEvent.setup(); + const user = setupUser(); const data: Partial = { id: 5, name: 'Edited Team', @@ -146,7 +146,7 @@ describe('', () => { }); it('requires students for managers and submits after they are selected', async () => { - const user = userEvent.setup(); + const user = setupUser(); const { onSubmit } = renderModal({ isManager: true }); expect(screen.getByTestId('student-search')).toBeInTheDocument(); @@ -166,7 +166,7 @@ describe('', () => { }); it('warns and does not set the field when more than maxStudentsCount students are selected', async () => { - const user = userEvent.setup(); + const user = setupUser(); const warnSpy = vi.spyOn(message, 'warning').mockImplementation(() => ({}) as never); renderModal({ isManager: true, maxStudentsCount: 3 }); From 10552785f49c205d8bb3e01f696e54620f1bcd80 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:20:06 +0200 Subject: [PATCH 141/406] test(client): wrap Discord admin interactions in act --- .../pages/DiscordAdminPage/DiscordAdminPage.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/src/modules/DiscordAdmin/pages/DiscordAdminPage/DiscordAdminPage.test.tsx b/client/src/modules/DiscordAdmin/pages/DiscordAdminPage/DiscordAdminPage.test.tsx index dbe983c30..6a3b2b206 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'; @@ -68,7 +68,7 @@ describe('', () => { }); it('creates a server and reloads the list on submit', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('Alpha'); @@ -94,7 +94,7 @@ describe('', () => { }); it('opens the edit modal prefilled and updates by id', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('Alpha'); @@ -115,7 +115,7 @@ describe('', () => { }); it('deletes a server after confirming and reloads', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('Alpha'); @@ -128,7 +128,7 @@ 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(); @@ -145,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(); From 578740a7972d0d6020f12d1ca410a1028062acce Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:20:06 +0200 Subject: [PATCH 142/406] test(client): wrap ContactsCard interactions in act --- .../components/Profile/__test__/ContactsCard.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/src/components/Profile/__test__/ContactsCard.test.tsx b/client/src/components/Profile/__test__/ContactsCard.test.tsx index e0496b9b4..13b809649 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 @@ -98,7 +98,7 @@ describe('ContactsCard', () => { }); it('calls sendConfirmationEmail when the confirmation link is clicked', async () => { - const user = userEvent.setup(); + const user = setupUser(); const sendConfirmationEmail = vi.fn(); render( { }); 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(); @@ -134,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(); @@ -151,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(); From 0a4837208dcdea90f73ea182f0b85f3f75f501f4 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:21:15 +0200 Subject: [PATCH 143/406] test(client): wrap notification admin interactions in act --- .../AdminNotificationsSettingsPage.test.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/client/src/modules/Notifications/pages/AdminNotificationsPage/AdminNotificationsSettingsPage.test.tsx b/client/src/modules/Notifications/pages/AdminNotificationsPage/AdminNotificationsSettingsPage.test.tsx index c79fa9031..0e37437dc 100644 --- a/client/src/modules/Notifications/pages/AdminNotificationsPage/AdminNotificationsSettingsPage.test.tsx +++ b/client/src/modules/Notifications/pages/AdminNotificationsPage/AdminNotificationsSettingsPage.test.tsx @@ -1,6 +1,6 @@ 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 { AdminNotificationsPage } from './AdminNotificationsSettingsPage'; // --- Mocks ----------------------------------------------------------------- @@ -56,7 +56,7 @@ describe('AdminNotificationsPage', () => { }); it('creates a new notification and appends it to the table on submit', async () => { - const user = userEvent.setup(); + const user = setupUser(); const created = makeNotification({ id: 'fresh', name: 'Fresh One' }); createNotification.mockResolvedValue({ data: created }); @@ -81,7 +81,7 @@ describe('AdminNotificationsPage', () => { }); it('saves (updates) an existing notification on submit', async () => { - const user = userEvent.setup(); + const user = setupUser(); const updated = makeNotification({ id: 'existing', name: 'Renamed' }); saveNotification.mockResolvedValue({ data: updated }); @@ -106,7 +106,7 @@ describe('AdminNotificationsPage', () => { }); it('shows an error message when saving fails', async () => { - const user = userEvent.setup(); + const user = setupUser(); createNotification.mockRejectedValue(new Error('boom')); render(); @@ -127,7 +127,7 @@ describe('AdminNotificationsPage', () => { }); it('deletes a notification after confirmation and removes its row', async () => { - const user = userEvent.setup(); + const user = setupUser(); deleteNotification.mockResolvedValue(undefined); render(); @@ -143,7 +143,7 @@ describe('AdminNotificationsPage', () => { }); it('shows an error message when deletion fails', async () => { - const user = userEvent.setup(); + const user = setupUser(); deleteNotification.mockRejectedValue(new Error('nope')); render(); @@ -158,7 +158,7 @@ describe('AdminNotificationsPage', () => { }); it('opens the create modal and cancels without saving', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('Existing One'); From 6f5ac92ba1b94f11bf3c3f8135860f8589a04a41 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:21:15 +0200 Subject: [PATCH 144/406] test(client): wrap MainCard interactions in act --- .../Profile/__test__/MainCard.test.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/src/components/Profile/__test__/MainCard.test.tsx b/client/src/components/Profile/__test__/MainCard.test.tsx index 8c899e6fd..f6d186905 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(); @@ -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,7 +112,7 @@ 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(); @@ -129,7 +129,7 @@ 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' })); @@ -146,7 +146,7 @@ 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' })); @@ -162,7 +162,7 @@ describe('MainCard', () => { }); it('opens the obfuscate modal for admins', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await user.click(screen.getByRole('button', { name: 'Obfuscate' })); From 5118782b4bfa1f12c8a11e9faabb12bb9bb64533 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:21:15 +0200 Subject: [PATCH 145/406] test(client): wrap contributor modal interactions in act --- .../Contributor/components/ContributorModal.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/src/modules/Contributor/components/ContributorModal.test.tsx b/client/src/modules/Contributor/components/ContributorModal.test.tsx index 7c4dbc7cd..d1127c4a7 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 -------------------------------------------------------- @@ -45,7 +45,7 @@ describe('', () => { }); it('creates a contributor from the typed values', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onClose = vi.fn(); render(); @@ -61,7 +61,7 @@ describe('', () => { }); it('updates the existing contributor by id when editing', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onClose = vi.fn(); render(); @@ -80,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(); @@ -96,7 +96,7 @@ describe('', () => { }); it('calls onClose when Cancel is clicked', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onClose = vi.fn(); render(); From 0b3fc3918b8815c53d511f4f122b447700561e9e Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:21:15 +0200 Subject: [PATCH 146/406] test(client): wrap prompt modal interactions in act --- .../modules/Prompts/components/PromptModal.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/src/modules/Prompts/components/PromptModal.test.tsx b/client/src/modules/Prompts/components/PromptModal.test.tsx index 5f68acb29..2ae37f807 100644 --- a/client/src/modules/Prompts/components/PromptModal.test.tsx +++ b/client/src/modules/Prompts/components/PromptModal.test.tsx @@ -1,7 +1,7 @@ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { message } from 'antd'; import { PromptDto } from '@client/api'; +import { setupUser } from '@client/__tests__/setupUser'; import { PromptModal } from './PromptModal'; // --- Boundary mock --------------------------------------------------------- @@ -37,7 +37,7 @@ describe('', () => { }); 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(); @@ -48,7 +48,7 @@ describe('', () => { }); it('creates a prompt from the typed values, reloads and closes', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); @@ -67,7 +67,7 @@ describe('', () => { }); it('updates the existing prompt by id when editing', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps({ data: editPrompt }); render(); @@ -86,7 +86,7 @@ describe('', () => { }); it('shows an error message when the API rejects', async () => { - const user = userEvent.setup(); + const user = setupUser(); const errorSpy = vi.spyOn(message, 'error').mockImplementation(() => ({}) as never); createPrompt.mockRejectedValueOnce(new Error('boom')); const props = makeProps(); @@ -102,7 +102,7 @@ describe('', () => { }); it('renders create defaults and calls onCancel when Cancel is clicked', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); From bd8014280e58fb0e2612504771b07e620ae33807 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:22:06 +0200 Subject: [PATCH 147/406] test(client): wrap notification modal interactions in act --- .../components/NotificationSettingsModal.test.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/client/src/modules/Notifications/components/NotificationSettingsModal.test.tsx b/client/src/modules/Notifications/components/NotificationSettingsModal.test.tsx index c224c94ea..4a038549a 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 { @@ -51,7 +51,7 @@ describe('NotificationSettingsModal', () => { }); it('switches between Settings and channel template tabs', async () => { - const user = userEvent.setup(); + const user = setupUser(); render( { }); 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(); @@ -86,7 +86,7 @@ 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(); @@ -114,7 +114,7 @@ describe('NotificationSettingsModal', () => { }); it('prefills existing settings and channel fields, then submits their values', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onOk = vi.fn(); render( , @@ -142,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( , @@ -162,7 +162,7 @@ describe('NotificationSettingsModal', () => { }); it('renders the new notification fields and tabs, then cancels without changes', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onCancel = vi.fn(); render(); From f24df944729cb1cb912b4d9338f8efc912ae7d72 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:22:06 +0200 Subject: [PATCH 148/406] test(client): wrap user group interactions in act --- .../UserGroupsAdminPage/UserGroupsAdminPage.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/src/modules/UserGroupsAdmin/pages/UserGroupsAdminPage/UserGroupsAdminPage.test.tsx b/client/src/modules/UserGroupsAdmin/pages/UserGroupsAdminPage/UserGroupsAdminPage.test.tsx index e61fb189b..86764d2d0 100644 --- a/client/src/modules/UserGroupsAdmin/pages/UserGroupsAdminPage/UserGroupsAdminPage.test.tsx +++ b/client/src/modules/UserGroupsAdmin/pages/UserGroupsAdminPage/UserGroupsAdminPage.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 { UserGroupDto } from '@client/api'; import { UserGroupsAdminPage } from './UserGroupsAdminPage'; @@ -93,7 +93,7 @@ describe('', () => { }); it('creates a group with mapped user ids and a role', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('Admins'); @@ -117,7 +117,7 @@ describe('', () => { }); it('opens the edit modal prefilled and updates by id', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('Admins'); @@ -136,7 +136,7 @@ describe('', () => { }); it('deletes a group after confirming and reloads', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('Admins'); @@ -149,7 +149,7 @@ 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); deleteUserGroup.mockRejectedValueOnce(new Error('boom')); render(); @@ -164,7 +164,7 @@ 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); updateUserGroup.mockRejectedValueOnce(new Error('boom')); render(); From 0e1aee40f72440d4aeb26a13ecb42f97ec91c47e Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:22:06 +0200 Subject: [PATCH 149/406] test(client): wrap event admin interactions in act --- .../pages/EventsAdminPage/EventsAdminPage.test.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/client/src/modules/EventsAdmin/pages/EventsAdminPage/EventsAdminPage.test.tsx b/client/src/modules/EventsAdmin/pages/EventsAdminPage/EventsAdminPage.test.tsx index fb244475d..27d1ff0fd 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'; @@ -63,7 +63,7 @@ function getEventRow(name: string) { } async function selectOption( - user: ReturnType, + user: ReturnType, dialog: HTMLElement, label: string, text: string, @@ -93,7 +93,7 @@ describe('', () => { }); it('creates an event with the mapped CreateEventDto payload', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('Alpha'); @@ -119,7 +119,7 @@ describe('', () => { }); it('opens the edit modal prefilled and updates by id', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('Alpha'); @@ -138,7 +138,7 @@ describe('', () => { }); it('deletes an event after confirming and reloads', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('Alpha'); @@ -151,7 +151,7 @@ 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(); @@ -166,7 +166,7 @@ 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')); From 88d6c3978a74610bb2c51d0eaadc36fa5d57ba45 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:22:06 +0200 Subject: [PATCH 150/406] test(client): wrap prompt page interactions in act --- client/src/modules/Prompts/pages/PromptPage.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/src/modules/Prompts/pages/PromptPage.test.tsx b/client/src/modules/Prompts/pages/PromptPage.test.tsx index 30c717bc9..4dc2b0a08 100644 --- a/client/src/modules/Prompts/pages/PromptPage.test.tsx +++ b/client/src/modules/Prompts/pages/PromptPage.test.tsx @@ -1,6 +1,6 @@ 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 { PromptsPage } from './PromptPage'; // --- Boundary mocks -------------------------------------------------------- @@ -79,7 +79,7 @@ describe('', () => { }); it('creates a prompt and reloads the list on submit', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('summary'); @@ -96,7 +96,7 @@ describe('', () => { }); it('opens the edit modal prefilled and updates by id', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('summary'); @@ -117,7 +117,7 @@ describe('', () => { }); it('deletes a prompt and reloads the list', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await screen.findByText('gratitude'); From 3d65c300e7ad337ac86e686bb33bedc8bb0b49b2 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:22:06 +0200 Subject: [PATCH 151/406] test(client): wrap Discord modal interactions in act --- .../components/DiscordServersModal.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/src/modules/DiscordAdmin/components/DiscordServersModal.test.tsx b/client/src/modules/DiscordAdmin/components/DiscordServersModal.test.tsx index 1abe3196c..287835db8 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 --------------------------------------------------------------- @@ -34,7 +34,7 @@ describe('', () => { }); it('shows validation errors and does not submit when fields are empty', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); @@ -47,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(); @@ -66,7 +66,7 @@ describe('', () => { }); it('submits edited values keyed off the existing record', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps({ data: editServer }); render(); @@ -83,7 +83,7 @@ describe('', () => { }); it('renders empty create fields and cancels when untouched', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); From 3fa99667d17d90a3768b2728e4677bb51e5ecba7 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:23:55 +0200 Subject: [PATCH 152/406] test(client): avoid nested MarkdownInput updates --- .../shared/components/Forms/MarkdownInput.test.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/client/src/shared/components/Forms/MarkdownInput.test.tsx b/client/src/shared/components/Forms/MarkdownInput.test.tsx index 4af04412b..252e47664 100644 --- a/client/src/shared/components/Forms/MarkdownInput.test.tsx +++ b/client/src/shared/components/Forms/MarkdownInput.test.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { Button, Form } from 'antd'; -import { setupUser } from '@client/__tests__/setupUser'; import MarkdownInput from './MarkdownInput'; // react-markdown is an ESM micromark pipeline that is heavy and brittle under jsdom. @@ -35,7 +35,7 @@ const LONG_COMMENT = 'This is a detailed markdown comment well over thirty chara describe('MarkdownInput', () => { it('renders write controls, previews the empty warning and toggles back', async () => { - const user = setupUser(); + const user = userEvent.setup(); renderMarkdownInput(); expect(screen.getByLabelText(/Comment \(markdown syntax is supported\)/i)).toBeInTheDocument(); @@ -51,7 +51,7 @@ describe('MarkdownInput', () => { }); it('switches to preview mode and renders the typed text through react-markdown', async () => { - const user = setupUser(); + const user = userEvent.setup(); renderMarkdownInput(); await user.type(screen.getByRole('textbox'), LONG_COMMENT); @@ -67,7 +67,7 @@ describe('MarkdownInput', () => { }); it('shows "Please leave a detailed comment" in preview when text is shorter than 30 chars', async () => { - const user = setupUser(); + const user = userEvent.setup(); const { container } = renderMarkdownInput(); await user.type(screen.getByRole('textbox'), 'short text'); @@ -81,7 +81,7 @@ describe('MarkdownInput', () => { }); it('clears the text and leaves preview mode when the form is reset', async () => { - const user = setupUser(); + const user = userEvent.setup(); render(); // Type, switch to preview, then reset the form (fires the Form.Item onReset -> resetText). @@ -99,7 +99,7 @@ describe('MarkdownInput', () => { }); it('seeds the preview text from a non-empty historicalCommentSelected prop', async () => { - const user = setupUser(); + const user = userEvent.setup(); renderMarkdownInput('Historical pre-filled comment longer than thirty chars.'); await user.click(screen.getByRole('button', { name: /preview/i })); @@ -108,7 +108,7 @@ describe('MarkdownInput', () => { }); it('updates the previewed text when a new historical comment is selected', async () => { - const user = setupUser(); + const user = userEvent.setup(); const { rerender } = renderMarkdownInput(''); rerender( From 33fd332278c918bff422d1f7ed13d28ba7eb7ab9 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:25:53 +0200 Subject: [PATCH 153/406] test(client): wrap step context interactions in act --- .../StepContext.test.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/client/src/modules/Interviews/pages/StageInterviewFeedback/StepContext.test.tsx b/client/src/modules/Interviews/pages/StageInterviewFeedback/StepContext.test.tsx index 4a0acc5dd..f8e0ba481 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 { ReactNode, useContext } from 'react'; +import { setupUser } from '@client/__tests__/setupUser'; import { StepContextProvider, StepContext } from './StepContext'; import { StepsContent } from './StepsContent'; import { Steps } from './Steps'; @@ -117,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 })); } @@ -142,7 +142,7 @@ describe(' (multi-step feedback container)', () => { }); 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' })); @@ -154,7 +154,7 @@ describe(' (multi-step feedback container)', () => { }); it('saves Introduction, updates the Theory stepper, and navigates Back without saving', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderProvider(); await answerIntroductionAsConducted(user); @@ -202,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, @@ -228,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(); @@ -286,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); @@ -329,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); @@ -371,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 => { From 91165fcb7f6dac5453414a3ed899766826149caf Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:25:53 +0200 Subject: [PATCH 154/406] test(client): wrap feedback form interactions in act --- .../Feedback/components/FeedbackForm.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/src/modules/Feedback/components/FeedbackForm.test.tsx b/client/src/modules/Feedback/components/FeedbackForm.test.tsx index d7ae6db1c..00e465e80 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, @@ -86,7 +86,7 @@ describe('', () => { it('renders the form fields and blocks submission when required fields are empty', async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); - const user = userEvent.setup(); + const user = setupUser(); render(); // Recommendation radios. @@ -120,7 +120,7 @@ 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 @@ -158,7 +158,7 @@ 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(getRadio(/not hire/i)); @@ -175,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. @@ -244,7 +244,7 @@ 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(getRadio(/^hire$/i)); From fffe8497c5c95247685f33af66bc896b83993421 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:25:53 +0200 Subject: [PATCH 155/406] test(client): wrap contact form interactions in act --- .../components/Profile/__test__/ContactsCardForm.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/src/components/Profile/__test__/ContactsCardForm.test.tsx b/client/src/components/Profile/__test__/ContactsCardForm.test.tsx index e108e9810..e3ef22194 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,7 +31,7 @@ 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(); @@ -45,7 +45,7 @@ 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(); From d4749a0db5ef8d527cd87e189635b40448c5599e Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:25:53 +0200 Subject: [PATCH 156/406] test(client): wrap score import interactions in act --- .../SubmitScores/SubmitScorePage.csv.test.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/client/src/modules/SubmitScores/SubmitScorePage.csv.test.tsx b/client/src/modules/SubmitScores/SubmitScorePage.csv.test.tsx index ba7d459b1..4bf653412 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 -------------------------------------- @@ -139,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()); @@ -151,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 }, @@ -191,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' }, @@ -212,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()); @@ -234,7 +234,7 @@ describe(' CSV upload flow', () => { }); 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(); @@ -255,7 +255,7 @@ describe(' CSV upload flow', () => { }); it('handles a FileReader read error during parsing without uploading', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await waitFor(() => expect(getCourseTasks).toHaveBeenCalled()); @@ -275,7 +275,7 @@ describe(' CSV upload flow', () => { }); 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()); @@ -292,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()); From 2338941ed1de378b831a0e8b800423ebd203b42b Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:25:53 +0200 Subject: [PATCH 157/406] test(client): await course task modal updates --- .../components/CourseTaskModal/index.test.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/client/src/modules/CourseManagement/components/CourseTaskModal/index.test.tsx b/client/src/modules/CourseManagement/components/CourseTaskModal/index.test.tsx index b33b44ab2..78c1be754 100644 --- a/client/src/modules/CourseManagement/components/CourseTaskModal/index.test.tsx +++ b/client/src/modules/CourseManagement/components/CourseTaskModal/index.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 { message } from 'antd'; +import { setupUser } from '@client/__tests__/setupUser'; import { CourseTaskModal } from './index'; // Spy on antd's global message.error to assert the cross-check duration guard fires. @@ -71,10 +71,11 @@ describe('', () => { getTasks.mockResolvedValue({ data: tasks }); }); - it('renders nothing when data is null', () => { + it('renders nothing when data is null', async () => { render(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + await waitFor(() => expect(getTasks).toHaveBeenCalledOnce()); }); it('lists fetched tasks and auto-fills Task Type after selection', async () => { @@ -95,7 +96,7 @@ describe('', () => { }); it('shows a validation message and does not submit when Task is empty', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); @@ -129,7 +130,7 @@ describe('', () => { }); it('submits the built record (taskId, checker, scores) when the form is valid', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); @@ -153,7 +154,7 @@ describe('', () => { }); it('blocks cross-check submit when the cross-check duration is under 3 days', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); // Seed an editable record so the form already has dates close together; choosing // crossCheck makes the (range end → crossCheckEndDate) gap too small. @@ -177,7 +178,7 @@ describe('', () => { }); it('blocks submit and shows an error when the cross-check duration is under 3 days', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps({ // Edit an existing cross-check task whose cross-check window is only ~1 day after the // task end date — getInitialValues prefills range + crossCheckEndDate, so submit reaches @@ -209,7 +210,7 @@ describe('', () => { }); it('filters the task options by typed input via filterOption', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); const taskSelect = await screen.findByLabelText('Task'); @@ -224,7 +225,7 @@ describe('', () => { }); it('renders core fields and defaults, then cancels a pristine form', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); From e2532249950a9cbc761c4c06650ed19326a3ad2c Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:26:41 +0200 Subject: [PATCH 158/406] test(client): wrap team distribution interactions in act --- .../TeamDistributionModal.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/src/modules/TeamDistribution/components/TeamDistributionModal/TeamDistributionModal.test.tsx b/client/src/modules/TeamDistribution/components/TeamDistributionModal/TeamDistributionModal.test.tsx index 15b90089d..21e0374dd 100644 --- a/client/src/modules/TeamDistribution/components/TeamDistributionModal/TeamDistributionModal.test.tsx +++ b/client/src/modules/TeamDistribution/components/TeamDistributionModal/TeamDistributionModal.test.tsx @@ -1,6 +1,6 @@ import { screen, render, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import dayjs from 'dayjs'; +import { setupUser } from '@client/__tests__/setupUser'; import { TeamDistributionDto } from '@client/api'; import TeamDistributionModal from './TeamDistributionModal'; @@ -56,7 +56,7 @@ 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'); @@ -65,7 +65,7 @@ describe('', () => { }); 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 })); @@ -76,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'); @@ -87,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'); @@ -109,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', From 26d2d75d4f18cbfb08ac4be81e7a033c707597a3 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:26:41 +0200 Subject: [PATCH 159/406] test(client): wrap CV contact interactions in act --- .../components/EditCv/ContactsForm/index.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/src/modules/Opportunities/components/EditCv/ContactsForm/index.test.tsx b/client/src/modules/Opportunities/components/EditCv/ContactsForm/index.test.tsx index 45d4ed27e..d227fb494 100644 --- a/client/src/modules/Opportunities/components/EditCv/ContactsForm/index.test.tsx +++ b/client/src/modules/Opportunities/components/EditCv/ContactsForm/index.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 { ContactsForm } from './index'; const mockContactsList = { @@ -35,7 +35,7 @@ describe('ContactsForm', () => { }); test('shows a validation error for an invalid phone number', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); const phone = await screen.findByLabelText('Phone'); @@ -46,7 +46,7 @@ describe('ContactsForm', () => { }); test('accepts a valid phone number (no validation error)', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); const phone = await screen.findByLabelText('Phone'); @@ -56,7 +56,7 @@ describe('ContactsForm', () => { }); test('shows a validation error for an invalid github username', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); const github = await screen.findByLabelText('GitHub'); From aa5d8f76d474b8cc6b8d1cffd56cd6d0668460a0 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:26:41 +0200 Subject: [PATCH 160/406] test(client): wrap task submission interactions in act --- .../SubmitTaskSolution/SubmitTaskSolution.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/src/modules/StudentDashboard/components/SubmitTaskSolution/SubmitTaskSolution.test.tsx b/client/src/modules/StudentDashboard/components/SubmitTaskSolution/SubmitTaskSolution.test.tsx index 2fd34032d..8bc3d74f5 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'; @@ -65,7 +65,7 @@ describe('', () => { }); it('submits the selected task and solution url, then shows the success result', async () => { - const user = userEvent.setup(); + const user = setupUser(); getCourseTasksWithStudentSolution.mockResolvedValue({ data: tasks }); createTaskSolution.mockResolvedValue({}); render(); @@ -97,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(); From d0948a194f670f5471f668a964dafb8d7a0cdaa0 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:26:41 +0200 Subject: [PATCH 161/406] test(client): wrap event modal interactions in act --- .../EventsAdmin/components/EventsModal.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/src/modules/EventsAdmin/components/EventsModal.test.tsx b/client/src/modules/EventsAdmin/components/EventsModal.test.tsx index a1b592e6e..c6816ead9 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' }); @@ -47,7 +47,7 @@ describe('', () => { }); 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(); @@ -60,7 +60,7 @@ describe('', () => { }); it('submits name, selected type, discipline and optional fields', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); @@ -97,7 +97,7 @@ describe('', () => { }); it('renders empty create fields and cancels when untouched', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); From f14a922d18f6cb8c7dbcd73734db68d01934e546 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:26:41 +0200 Subject: [PATCH 162/406] test(client): wrap criteria interactions in act --- .../CrossCheck/AddCriteriaForCrossCheck.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/src/modules/CrossCheck/AddCriteriaForCrossCheck.test.tsx b/client/src/modules/CrossCheck/AddCriteriaForCrossCheck.test.tsx index a5e5b5b50..2d5f9a21d 100644 --- a/client/src/modules/CrossCheck/AddCriteriaForCrossCheck.test.tsx +++ b/client/src/modules/CrossCheck/AddCriteriaForCrossCheck.test.tsx @@ -1,17 +1,17 @@ // Complements `__tests__/AddCriteriaForCrossCheck.test.tsx` (basic render + save) // by covering the per-type payload branches and the canSave validation paths. import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import { AddCriteriaForCrossCheck } from './AddCriteriaForCrossCheck'; -async function selectType(user: ReturnType, optionName: string) { +async function selectType(user: ReturnType, optionName: string) { await user.click(screen.getByRole('combobox')); await user.click(await screen.findByText(optionName, { selector: '.ant-select-item-option-content' })); } describe(' payload branches', () => { it('keeps the save button disabled until a title text is entered, then clears on save', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onCreate = vi.fn(); render(); @@ -31,7 +31,7 @@ describe(' payload branches', () => { }); it('requires a non-zero max score for a subtask and emits it in the payload', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onCreate = vi.fn(); render(); @@ -53,7 +53,7 @@ describe(' payload branches', () => { }); it('stores a penalty max score as a negative value', async () => { - const user = userEvent.setup(); + const user = setupUser(); const onCreate = vi.fn(); render(); From 20eeb08f0695f431fddcde47a07616f3f906dd6b Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:30:06 +0200 Subject: [PATCH 163/406] test(client): wrap education card interactions in act --- .../Profile/__test__/EducationCard.test.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/client/src/components/Profile/__test__/EducationCard.test.tsx b/client/src/components/Profile/__test__/EducationCard.test.tsx index a65acb137..175860ca3 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,12 @@ describe('EducationCard', () => { }); }); - const openSettings = (user: ReturnType) => + 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 +56,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 +78,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 +94,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 +106,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); @@ -119,7 +119,7 @@ describe('EducationCard', () => { }); it('deletes a university and restores it on cancel', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await openSettings(user); @@ -137,7 +137,7 @@ describe('EducationCard', () => { }); it('renders the settings entry as "(Empty)" when a university is incomplete', async () => { - const user = userEvent.setup(); + const user = setupUser(); render( Date: Fri, 11 Sep 2026 23:30:06 +0200 Subject: [PATCH 164/406] test(client): wrap about card interactions in act --- .../src/components/Profile/__test__/AboutCard.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/src/components/Profile/__test__/AboutCard.test.tsx b/client/src/components/Profile/__test__/AboutCard.test.tsx index 0a6db89bf..e2ce333b8 100644 --- a/client/src/components/Profile/__test__/AboutCard.test.tsx +++ b/client/src/components/Profile/__test__/AboutCard.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 AboutCard from '../AboutCard'; describe('AboutCard', () => { @@ -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(); @@ -34,7 +34,7 @@ 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(); @@ -50,7 +50,7 @@ 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(); @@ -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' })); From 0bda43350326d98e5c40c97314e0c130e2d5fe47 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:30:06 +0200 Subject: [PATCH 165/406] test(client): wrap message panel interactions in act --- .../MessageSendingPanel.test.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 c68d38fce..c07af4213 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'; @@ -35,7 +35,7 @@ function renderPanel(props: Partial = {}) { describe('', () => { it('renders collapsed controls, opens on click and cancels', async () => { - const user = userEvent.setup(); + const user = setupUser(); renderPanel(); const collapsed = screen.getByPlaceholderText('Leave a message'); @@ -52,18 +52,18 @@ describe('', () => { }); 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('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')); @@ -77,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')); @@ -88,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')); @@ -101,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')); From fe037392cd183ca1209e452b013cc153dd56c169 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:30:06 +0200 Subject: [PATCH 166/406] test(client): wrap tasks page interactions in act --- .../Tasks/pages/TasksPage/TasksPage.test.tsx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/client/src/modules/Tasks/pages/TasksPage/TasksPage.test.tsx b/client/src/modules/Tasks/pages/TasksPage/TasksPage.test.tsx index 19605b3fa..83dc2c76a 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'; @@ -123,7 +123,7 @@ describe('TasksPage', () => { }); it('should close the modal when cancel is triggered', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); await waitFor(() => expect(getTasks).toHaveBeenCalled()); @@ -136,7 +136,7 @@ describe('TasksPage', () => { }); 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()); @@ -156,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 @@ -173,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 @@ -188,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(); @@ -201,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(); @@ -215,7 +215,7 @@ 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()); @@ -231,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(); @@ -245,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(); @@ -260,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', From e3e100b46ff929fb13f85f1f5c3a7a30444901f5 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:30:06 +0200 Subject: [PATCH 167/406] test(client): wrap editable criteria updates in act --- .../EditableTableForCrossCheck.test.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/src/modules/CrossCheck/EditableTableForCrossCheck.test.tsx b/client/src/modules/CrossCheck/EditableTableForCrossCheck.test.tsx index ce126bfdb..760d9232d 100644 --- a/client/src/modules/CrossCheck/EditableTableForCrossCheck.test.tsx +++ b/client/src/modules/CrossCheck/EditableTableForCrossCheck.test.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; -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 { CriteriaDto, CriteriaDtoTypeEnum } from '@client/api'; import { EditableTable } from './EditableTableForCrossCheck'; @@ -57,7 +57,7 @@ function getRow(text: string) { describe(' (CrossCheck editable criteria)', () => { it('renders rows, enters edit mode, disables other edits and saves changed text', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); expect(screen.getByText('First criteria')).toBeInTheDocument(); @@ -86,7 +86,7 @@ describe(' (CrossCheck editable criteria)', () => { }); it('cancels an edit and restores the original value', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); const row = getRow('First criteria'); @@ -102,7 +102,7 @@ describe(' (CrossCheck editable criteria)', () => { }); it('deletes a row through the Delete confirmation popconfirm', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); expect(screen.getByTestId('count').textContent).toBe('2'); @@ -119,7 +119,7 @@ describe(' (CrossCheck editable criteria)', () => { }); it('changes a row type via the type selector and clears Max when Title is chosen', async () => { - const user = userEvent.setup(); + const user = setupUser(); render(); const row = getRow('First criteria'); @@ -140,7 +140,7 @@ describe(' (CrossCheck editable criteria)', () => { }); it('keeps the existing max when a row type changes to a non-Title type', async () => { - const user = userEvent.setup(); + const user = setupUser(); // A subtask row keeps its max when re-typed to Penalty. render(); @@ -163,7 +163,7 @@ describe(' (CrossCheck editable criteria)', () => { expect(dragEndHandlers[0]).toBeTypeOf('function'); // Simulate dropping row k1 onto k2's position. - dragEndHandlers[0]({ active: { id: 'k1' }, over: { id: 'k2' } }); + act(() => dragEndHandlers[0]({ active: { id: 'k1' }, over: { id: 'k2' } })); await waitFor(() => { const dump = JSON.parse(screen.getByTestId('dump').textContent || '[]'); From 4732b9574ae20e970cc0d17b4c1279d48ac49ac7 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:31:01 +0200 Subject: [PATCH 168/406] test(client): wrap join team interactions in act --- .../components/JoinTeamModal/JoinTeamModal.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/src/modules/Teams/components/JoinTeamModal/JoinTeamModal.test.tsx b/client/src/modules/Teams/components/JoinTeamModal/JoinTeamModal.test.tsx index 59fbae53c..da1a873f3 100644 --- a/client/src/modules/Teams/components/JoinTeamModal/JoinTeamModal.test.tsx +++ b/client/src/modules/Teams/components/JoinTeamModal/JoinTeamModal.test.tsx @@ -1,5 +1,5 @@ import { screen, render, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { setupUser } from '@client/__tests__/setupUser'; import JoinTeamModal from './JoinTeamModal'; function renderModal() { @@ -13,7 +13,7 @@ describe('', () => { beforeEach(() => vi.clearAllMocks()); it('calls onCancel when the cancel button is clicked', async () => { - const user = userEvent.setup(); + const user = setupUser(); const { onCancel } = renderModal(); expect(screen.getByRole('dialog')).toBeInTheDocument(); @@ -26,7 +26,7 @@ describe('', () => { }); it('shows a required error and does not submit when the password is empty', async () => { - const user = userEvent.setup(); + const user = setupUser(); const { onSubmit } = renderModal(); await user.click(screen.getByRole('button', { name: /join/i })); @@ -36,7 +36,7 @@ describe('', () => { }); it('rejects a password that does not match the id_password pattern', async () => { - const user = userEvent.setup(); + const user = setupUser(); const { onSubmit } = renderModal(); await user.type(screen.getByLabelText('Team password'), 'no-underscore'); @@ -47,7 +47,7 @@ describe('', () => { }); it('parses "id_password" and calls onSubmit with the numeric id and password', async () => { - const user = userEvent.setup(); + const user = setupUser(); const { onSubmit } = renderModal(); await user.type(screen.getByLabelText('Team password'), '17_secretPass1'); From ad5161bad4a62b0ed91a8621a87cb9a93ddf9092 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:31:01 +0200 Subject: [PATCH 169/406] test(client): wrap discipline modal interactions in act --- .../Discipline/components/DisciplineModal.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/src/modules/Discipline/components/DisciplineModal.test.tsx b/client/src/modules/Discipline/components/DisciplineModal.test.tsx index a9a78628b..a260c784e 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'; @@ -46,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(); @@ -59,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(); @@ -73,7 +73,7 @@ describe('', () => { }); it('updates the existing discipline by id when editing', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps({ discipline: editDiscipline }); render(); @@ -91,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(); @@ -106,7 +106,7 @@ describe('', () => { }); it('calls onCancel when the Cancel button is clicked', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); From b7f2f722b2d67eee91faa2578950725440336812 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:31:01 +0200 Subject: [PATCH 170/406] test(client): wrap question list interactions in act --- .../StageInterviewFeedback/QuestionList.test.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/src/modules/Interviews/pages/StageInterviewFeedback/QuestionList.test.tsx b/client/src/modules/Interviews/pages/StageInterviewFeedback/QuestionList.test.tsx index c033a36d7..1ea0bea36 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'; @@ -91,7 +91,7 @@ describe(' question picker + custom + remove', () => { }); 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(); @@ -122,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 })); @@ -134,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 })); @@ -152,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 })); @@ -164,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 })); @@ -176,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. @@ -191,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 })); From 061fc428120d8649851b1cec3a928f3c6183c9dd Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:31:01 +0200 Subject: [PATCH 171/406] test(client): wrap obfuscation modal interactions in act --- .../__test__/ObfuscateConfirmationModal.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/src/components/Profile/__test__/ObfuscateConfirmationModal.test.tsx b/client/src/components/Profile/__test__/ObfuscateConfirmationModal.test.tsx index 42e73bc51..e0e2966ea 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(() => ({ @@ -43,7 +43,7 @@ function renderModal(overrides: Partial { 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'); @@ -55,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'); @@ -66,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'); @@ -82,7 +82,7 @@ describe('ObfuscationModal', () => { }); 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 }); From a3237be1d45c92d018486c1b625a6a4e818c9372 Mon Sep 17 00:00:00 2001 From: apalchys Date: Fri, 11 Sep 2026 23:31:01 +0200 Subject: [PATCH 172/406] test(client): wrap course event interactions in act --- .../components/CourseEventModal/index.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/src/modules/CourseManagement/components/CourseEventModal/index.test.tsx b/client/src/modules/CourseManagement/components/CourseEventModal/index.test.tsx index ad4515d75..844abd1cc 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 -------------------------------------------------------- @@ -157,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(); @@ -179,7 +179,7 @@ describe('', () => { }); 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'); @@ -194,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(); @@ -223,7 +223,7 @@ describe('', () => { }); it('renders the new-event fields and cancels a pristine form', async () => { - const user = userEvent.setup(); + const user = setupUser(); const props = makeProps(); render(); From c80fb23c569eb71a3a7d09a27132c7c0ced7d4c5 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:01:37 +0200 Subject: [PATCH 173/406] test(client): consolidate languages card scenarios --- .../Profile/__test__/LanguagesCard.test.tsx | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/client/src/components/Profile/__test__/LanguagesCard.test.tsx b/client/src/components/Profile/__test__/LanguagesCard.test.tsx index 3e8efeaae..93b9e9662 100644 --- a/client/src/components/Profile/__test__/LanguagesCard.test.tsx +++ b/client/src/components/Profile/__test__/LanguagesCard.test.tsx @@ -17,19 +17,10 @@ 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(); }); @@ -38,6 +29,8 @@ describe('LanguagesCard', () => { 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(); From a12b7768d02638e580633772528c5f168c82585f Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:01:38 +0200 Subject: [PATCH 174/406] test(client): consolidate interview summary scenarios --- .../components/InterviewsSummary.test.tsx | 50 ++++--------------- 1 file changed, 10 insertions(+), 40 deletions(-) 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 836610100..482a46f47 100644 --- a/client/src/modules/Mentor/pages/Interviews/components/InterviewsSummary.test.tsx +++ b/client/src/modules/Mentor/pages/Interviews/components/InterviewsSummary.test.tsx @@ -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 () => { + it('should render summary actions, toggle details and cancel a transfer', async () => { const user = userEvent.setup(); 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,27 +110,6 @@ 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 { reloadList } = renderSummary(); From 49b43a84fe2d4cdebdde40126229d6d82218f49c Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:01:38 +0200 Subject: [PATCH 175/406] test(client): consolidate student info scenarios --- .../components/StudentInfo/index.test.tsx | 62 +++++-------------- 1 file changed, 14 insertions(+), 48 deletions(-) diff --git a/client/src/modules/Students/components/StudentInfo/index.test.tsx b/client/src/modules/Students/components/StudentInfo/index.test.tsx index 785f0151e..228bbb091 100644 --- a/client/src/modules/Students/components/StudentInfo/index.test.tsx +++ b/client/src/modules/Students/components/StudentInfo/index.test.tsx @@ -1,5 +1,5 @@ /* eslint-disable testing-library/no-node-access -- the github link is resolved via .closest('a') */ -import { render, screen, within } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; 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,20 +45,26 @@ 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 () => { @@ -86,37 +92,6 @@ describe('', () => { 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(); - }); }); From 45e042898f81f6b40677a2a0ab0a091780ea3f42 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:01:38 +0200 Subject: [PATCH 176/406] test(client): consolidate mentors hall scenarios --- .../pages/MentorsHallOfFamePage.test.tsx | 67 ++++--------------- 1 file changed, 13 insertions(+), 54 deletions(-) diff --git a/client/src/modules/MentorsHallOfFame/pages/MentorsHallOfFamePage.test.tsx b/client/src/modules/MentorsHallOfFame/pages/MentorsHallOfFamePage.test.tsx index faaab7859..8ff88b973 100644 --- a/client/src/modules/MentorsHallOfFame/pages/MentorsHallOfFamePage.test.tsx +++ b/client/src/modules/MentorsHallOfFame/pages/MentorsHallOfFamePage.test.tsx @@ -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 () => { + it('switches period, updates the description and refetches all-time mentors', async () => { const user = userEvent.setup(); 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); - }); }); From 55d4af4b8b9375e4fab5170747dec4ca65011de2 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:01:39 +0200 Subject: [PATCH 177/406] test(client): consolidate profile settings modal scenarios --- .../CommonCardWithSettingsModal.test.tsx | 18 ++++-------------- .../CommonCardWithSettingsModal.test.tsx.snap | 2 +- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/client/src/components/Profile/__test__/CommonCardWithSettingsModal.test.tsx b/client/src/components/Profile/__test__/CommonCardWithSettingsModal.test.tsx index d787169c2..e9534a8b5 100644 --- a/client/src/components/Profile/__test__/CommonCardWithSettingsModal.test.tsx +++ b/client/src/components/Profile/__test__/CommonCardWithSettingsModal.test.tsx @@ -22,14 +22,11 @@ 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(); }); @@ -37,11 +34,12 @@ describe('CommonCardWithSettingsModal', () => { it('opens the settings modal and saves changes', async () => { const user = userEvent.setup(); 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); @@ -70,12 +68,4 @@ describe('CommonCardWithSettingsModal', () => { 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__/__snapshots__/CommonCardWithSettingsModal.test.tsx.snap b/client/src/components/Profile/__test__/__snapshots__/CommonCardWithSettingsModal.test.tsx.snap index 21f5db85d..2f8f9b325 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`] = `
Date: Sat, 12 Sep 2026 02:06:14 +0200 Subject: [PATCH 178/406] test(client): consolidate student stats modal scenarios --- .../__test__/StudentStatsModal.test.tsx | 53 ++++--------------- 1 file changed, 10 insertions(+), 43 deletions(-) diff --git a/client/src/components/Profile/__test__/StudentStatsModal.test.tsx b/client/src/components/Profile/__test__/StudentStatsModal.test.tsx index b5645139f..10ab6f7f0 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(); From b36204ca7aa1ec6499e026f18bf369be4db3e2ed Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:06:14 +0200 Subject: [PATCH 179/406] test(client): consolidate legacy feedback scenarios --- .../ui/LegacyScreeningFeedback.test.tsx | 39 +++++-------------- 1 file changed, 9 insertions(+), 30 deletions(-) diff --git a/client/src/components/Profile/ui/LegacyScreeningFeedback.test.tsx b/client/src/components/Profile/ui/LegacyScreeningFeedback.test.tsx index f0214a77b..6c850ba5a 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(); - }); }); From a26d8be22764578dad3e0d8afacd2197a85e2fef Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:06:14 +0200 Subject: [PATCH 180/406] test(client): consolidate additional action scenarios --- .../AdditionalActions.test.tsx | 28 +++---------------- 1 file changed, 4 insertions(+), 24 deletions(-) diff --git a/client/src/modules/Schedule/components/AdditionalActions/AdditionalActions.test.tsx b/client/src/modules/Schedule/components/AdditionalActions/AdditionalActions.test.tsx index 97479ca6d..aa82529df 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(); From a55b3364760f453efd12c4bc4545dac3daab7abc Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:06:14 +0200 Subject: [PATCH 181/406] test(client): consolidate cross-check history scenarios --- .../components/CrossCheckHistory.test.tsx | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/client/src/modules/CrossCheck/components/CrossCheckHistory.test.tsx b/client/src/modules/CrossCheck/components/CrossCheckHistory.test.tsx index 65955b844..ffbc37f7d 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(); }); From b2f23f6855ace6763a96de3deedc749e29bc9b82 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:06:15 +0200 Subject: [PATCH 182/406] test(client): consolidate score table tab scenarios --- .../ScoreTable/ScoreTableTabs.test.tsx | 22 +++++-------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/client/src/modules/Score/components/ScoreTable/ScoreTableTabs.test.tsx b/client/src/modules/Score/components/ScoreTable/ScoreTableTabs.test.tsx index 97326b56f..98c1f2437 100644 --- a/client/src/modules/Score/components/ScoreTable/ScoreTableTabs.test.tsx +++ b/client/src/modules/Score/components/ScoreTable/ScoreTableTabs.test.tsx @@ -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 = userEvent.setup(); 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,19 +81,6 @@ 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(); render(); From cd2620486ddd49ec6800c55cd4548a153c5f2b6b Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:09:59 +0200 Subject: [PATCH 183/406] test(client): consolidate stage feedback scenarios --- .../StageInterviewFeedback.test.tsx | 25 ++++++------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/client/src/modules/Interviews/pages/StageInterviewFeedback/StageInterviewFeedback.test.tsx b/client/src/modules/Interviews/pages/StageInterviewFeedback/StageInterviewFeedback.test.tsx index c24bab9b7..4b811817e 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(); - }); - }); }); From 76728931385d7b0f7ad6efe8c2bb46e4672b7a10 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:10:00 +0200 Subject: [PATCH 184/406] test(client): consolidate expired CV tooltip scenarios --- .../ExpirationTooltip/index.test.tsx | 27 +++---------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/client/src/modules/Opportunities/components/ExpirationTooltip/index.test.tsx b/client/src/modules/Opportunities/components/ExpirationTooltip/index.test.tsx index dca0bcd58..3be58bb28 100644 --- a/client/src/modules/Opportunities/components/ExpirationTooltip/index.test.tsx +++ b/client/src/modules/Opportunities/components/ExpirationTooltip/index.test.tsx @@ -87,34 +87,15 @@ describe('ExpirationTooltip', () => { expect(title).toBeInTheDocument(); expect(text).toBeInTheDocument(); - // Modal is rendered outside of the container, this is custom cleanup - modal.remove(); - }); - - test('should show expiration modal on click in case if CV is expired in no public mode', async () => { - const datestring1DayBefore = '2022-09-25'; - - render(); - - // Close initially opened modal fireEvent.click(await screen.findByText('Cancel')); - - const button = await screen.findByRole('button', { name: 'Archived' }); - fireEvent.click(button); - const modal = await screen.findByRole('dialog'); - - expect(modal).toBeInTheDocument(); - - const title = within(modal).getAllByText('Your CV is archived')[0]; - const text = within(modal).getByText(/You need to renew your resume/i); - - expect(title).toBeInTheDocument(); - expect(text).toBeInTheDocument(); + const reopenedModal = await screen.findByRole('dialog'); + expect(within(reopenedModal).getAllByText('Your CV is archived')[0]).toBeInTheDocument(); + expect(within(reopenedModal).getByText(/You need to renew your resume/i)).toBeInTheDocument(); // Modal is rendered outside of the container, this is custom cleanup - modal.remove(); + reopenedModal.remove(); }); test('should not show expiration modal in public mode', async () => { From bafdcd325788d326dd6d4a9cc0f265f45f28cf9b Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:10:00 +0200 Subject: [PATCH 185/406] test(client): consolidate timezone settings scenarios --- .../SettingsDrawer/TimeZone.test.tsx | 34 +++++-------------- 1 file changed, 8 insertions(+), 26 deletions(-) diff --git a/client/src/modules/Schedule/components/SettingsDrawer/TimeZone.test.tsx b/client/src/modules/Schedule/components/SettingsDrawer/TimeZone.test.tsx index 0078bc55f..133392153 100644 --- a/client/src/modules/Schedule/components/SettingsDrawer/TimeZone.test.tsx +++ b/client/src/modules/Schedule/components/SettingsDrawer/TimeZone.test.tsx @@ -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(); }); From 71e0421a73863bd3b590c8cb0ea8e8d6f5ea5130 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:10:00 +0200 Subject: [PATCH 186/406] test(client): consolidate welcome card assertions --- client/src/components/WelcomeCard.test.tsx | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/client/src/components/WelcomeCard.test.tsx b/client/src/components/WelcomeCard.test.tsx index 16a5152db..749e2fee2 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'); }); }); From a951bc40a4fde9601b98fe569399833ad71e510b Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:10:00 +0200 Subject: [PATCH 187/406] test(client): consolidate mentor preference scenarios --- .../components/MentorPreferencesModal.test.tsx | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) 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 df4410eed..e6d9f45a9 100644 --- a/client/src/modules/Mentor/pages/Interviews/components/MentorPreferencesModal.test.tsx +++ b/client/src/modules/Mentor/pages/Interviews/components/MentorPreferencesModal.test.tsx @@ -99,29 +99,16 @@ describe('MentorPreferencesModal', () => { createMentor.mockReset().mockResolvedValue({}); }); - it('should not render the modal until showMentorOptions is invoked', () => { - renderProvider(); - - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - - it('should open the modal and load mentor options when triggered', async () => { + it('should start closed, load mentor options when opened and close on cancel', async () => { const user = userEvent.setup(); renderProvider(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); 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/ })); From 1892ffbcb854d8415397cef4126ec2d7bcacb1d3 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:13:45 +0200 Subject: [PATCH 188/406] test(client): consolidate course task select scenarios --- .../Forms/__tests__/CourseTaskSelect.test.tsx | 27 +++++-------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/client/src/shared/components/Forms/__tests__/CourseTaskSelect.test.tsx b/client/src/shared/components/Forms/__tests__/CourseTaskSelect.test.tsx index 069bef353..43cfe0d2b 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(); - }); }); From 118e91cc6a262358452bd36632a1d0161585b1de Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:13:45 +0200 Subject: [PATCH 189/406] test(client): consolidate mentor endorsement scenarios --- .../MentorEndorsement.test.tsx | 32 ++++++------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/client/src/modules/Profile/components/MentorEndorsement/MentorEndorsement.test.tsx b/client/src/modules/Profile/components/MentorEndorsement/MentorEndorsement.test.tsx index a3bbcfd60..e824902d1 100644 --- a/client/src/modules/Profile/components/MentorEndorsement/MentorEndorsement.test.tsx +++ b/client/src/modules/Profile/components/MentorEndorsement/MentorEndorsement.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MentorEndorsement } from './MentorEndorsement'; @@ -39,21 +39,15 @@ describe('', () => { expect(getEndorsement).not.toHaveBeenCalled(); }); - it('fetches the endorsement for the github id when open', async () => { - render(); - await waitFor(() => expect(getEndorsement).toHaveBeenCalledWith('octocat')); - }); - - it('renders the generated summary text', async () => { - render(); + it('fetches and renders the cleaned endorsement, then closes on OK', async () => { + const user = userEvent.setup(); + const props = makeProps(); + render(); expect(await screen.findByText('Generated Text')).toBeInTheDocument(); + expect(getEndorsement).toHaveBeenCalledWith('octocat'); expect(screen.getByText(/Line one/)).toBeInTheDocument(); expect(screen.getByText(/Line two/)).toBeInTheDocument(); - }); - - it('renders the cleaned data model with nulls stripped', async () => { - render(); await screen.findByText('Data Model'); // The read-only JSON dump is the only textbox in the modal. @@ -61,6 +55,9 @@ describe('', () => { const json = JSON.parse(textarea.value); expect(json).toEqual({ name: 'Joe', nested: { keep: 'yes' } }); expect(json).not.toHaveProperty('empty'); + + await user.click(screen.getByRole('button', { name: /ok/i })); + expect(props.onClose).toHaveBeenCalled(); }); it('shows an error alert when the request fails', async () => { @@ -69,15 +66,4 @@ describe('', () => { expect(await screen.findByText('generation failed')).toBeInTheDocument(); }); - - it('calls onClose when the OK button is clicked', async () => { - const user = userEvent.setup(); - const props = makeProps(); - render(); - await screen.findByText('Generated Text'); - - await user.click(screen.getByRole('button', { name: /ok/i })); - - expect(props.onClose).toHaveBeenCalled(); - }); }); From 241665a49ab804b29ffc1b9701de26a7b16d9fb8 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:13:45 +0200 Subject: [PATCH 190/406] test(client): consolidate CV action scenarios --- .../ViewCv/ActionButtons/index.test.tsx | 42 +++---------------- 1 file changed, 6 insertions(+), 36 deletions(-) diff --git a/client/src/modules/Opportunities/components/ViewCv/ActionButtons/index.test.tsx b/client/src/modules/Opportunities/components/ViewCv/ActionButtons/index.test.tsx index f856d03ce..cf56d8c77 100644 --- a/client/src/modules/Opportunities/components/ViewCv/ActionButtons/index.test.tsx +++ b/client/src/modules/Opportunities/components/ViewCv/ActionButtons/index.test.tsx @@ -24,8 +24,8 @@ describe('ActionButtons', () => { vi.clearAllMocks(); }); - test('should have Edit, Share, Delete buttons', () => { - render(); + test('renders the actions and handles Edit and Share when a URL is provided', () => { + render(); const editButton = screen.getByRole('button', { name: /edit cv/i }); const shareButton = screen.getByRole('button', { name: /share/i }); @@ -34,25 +34,11 @@ describe('ActionButtons', () => { expect(editButton).toBeInTheDocument(); expect(shareButton).toBeInTheDocument(); expect(deleteButton).toBeInTheDocument(); - }); - - test('should switch view by click on Edit button', () => { - render(); - - const editButton = screen.getByRole('button', { name: /edit cv/i }); fireEvent.click(editButton); - expect(mockSwitchView).toHaveBeenCalled(); - }); - - test('should copy to clipboard by click on Share button if url is provided', async () => { - render(); - - const shareButton = screen.getByRole('button', { name: /share/i }); fireEvent.click(shareButton); - expect(mockCopyToClipboard).toHaveBeenCalledWith(mockUrl); }); @@ -67,15 +53,10 @@ describe('ActionButtons', () => { expect(mockSuccessNotification).not.toHaveBeenCalled(); }); - test('should disable Share button should if CV is expired', () => { - render(); - const shareButton = screen.getByRole('button', { name: /share/i }); - - expect(shareButton).toBeDisabled(); - }); + test('disables sharing and supports canceling and confirming CV deletion', async () => { + render(); - test('should show and hide delete confirmation modal correctly', async () => { - render(); + expect(screen.getByRole('button', { name: /share/i })).toBeDisabled(); const deleteButton = screen.getByRole('button', { name: /delete/i }); @@ -97,20 +78,9 @@ describe('ActionButtons', () => { await waitFor(() => expect(modalBodyFragment).not.toBeInTheDocument()); await waitFor(() => expect(modalConfirmButton).not.toBeInTheDocument()); await waitFor(() => expect(modalCancelButton).not.toBeInTheDocument()); - }); - - test('should delete CV after confirmation', async () => { - render(); - - const deleteButton = screen.getByRole('button', { name: /delete/i }); fireEvent.click(deleteButton); - - const modalConfirmButton = await screen.findByRole('button', { name: /delete cv/i }); - - expect(modalConfirmButton).toBeInTheDocument(); - - fireEvent.click(modalConfirmButton); + fireEvent.click(await screen.findByRole('button', { name: /delete cv/i })); expect(mockOnRemoveConsent).toHaveBeenCalled(); }); From 3a75a3275543b741adbc6f0ec2d7590a812174e6 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:13:46 +0200 Subject: [PATCH 191/406] test(client): consolidate table column scenarios --- .../SettingsDrawer/ShowTableColumns.test.tsx | 32 ++++++------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/client/src/modules/Schedule/components/SettingsDrawer/ShowTableColumns.test.tsx b/client/src/modules/Schedule/components/SettingsDrawer/ShowTableColumns.test.tsx index e8e3601b7..42a0d72e2 100644 --- a/client/src/modules/Schedule/components/SettingsDrawer/ShowTableColumns.test.tsx +++ b/client/src/modules/Schedule/components/SettingsDrawer/ShowTableColumns.test.tsx @@ -13,24 +13,23 @@ async function expandPanel(user: ReturnType) { } describe('', () => { - it('renders a checkbox for every configurable column', async () => { + it('renders every column and shows a hidden column when checked', async () => { const user = userEvent.setup(); - render(); + 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 () => { @@ -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]); - }); }); From 1ab7f97d1c738a063d75789a25cba7f497916e97 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:13:46 +0200 Subject: [PATCH 192/406] test(client): consolidate mentor deletion scenarios --- .../MentorRegistryDeleteModal.test.tsx | 27 ++++++------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/client/src/modules/MentorRegistry/components/MentorRegistryDeleteModal.test.tsx b/client/src/modules/MentorRegistry/components/MentorRegistryDeleteModal.test.tsx index 7a7e45cab..0d7648619 100644 --- a/client/src/modules/MentorRegistry/components/MentorRegistryDeleteModal.test.tsx +++ b/client/src/modules/MentorRegistry/components/MentorRegistryDeleteModal.test.tsx @@ -6,13 +6,19 @@ 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 = userEvent.setup(); + 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 () => { @@ -24,21 +30,4 @@ describe('', () => { 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(); - }); }); From 6e5758951d5058af06df0d57be43f7da8328ea4e Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:19:06 +0200 Subject: [PATCH 193/406] test(client): consolidate team distribution actions --- .../TeamDistributionCard/Actions.test.tsx | 41 +++---------------- 1 file changed, 5 insertions(+), 36 deletions(-) diff --git a/client/src/modules/TeamDistribution/components/TeamDistributionCard/Actions.test.tsx b/client/src/modules/TeamDistribution/components/TeamDistributionCard/Actions.test.tsx index 61c81e06f..8b4a8a16f 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(); - }); }); From 560ee2a0d36011b9087bc88434a2e8e5ac963ae3 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:19:06 +0200 Subject: [PATCH 194/406] test(client): consolidate editable criteria scenarios --- .../CrossCheck/EditableCriteriaInput.test.tsx | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/client/src/modules/CrossCheck/EditableCriteriaInput.test.tsx b/client/src/modules/CrossCheck/EditableCriteriaInput.test.tsx index a7ac60cb5..be82ccec2 100644 --- a/client/src/modules/CrossCheck/EditableCriteriaInput.test.tsx +++ b/client/src/modules/CrossCheck/EditableCriteriaInput.test.tsx @@ -15,21 +15,23 @@ function renderInput(props: React.ComponentProps) } describe('', () => { - it('renders an InputNumber for the Max column when type is not Title', () => { - renderInput({ + it('renders the Max input only when type is not Title', () => { + const { container, rerender } = renderInput({ dataIndex: EditableTableColumnsDataIndex.Max, onSelectChange: vi.fn(), type: CriteriaDtoTypeEnum.Subtask, }); expect(screen.getByRole('spinbutton')).toBeInTheDocument(); - }); - it('renders nothing for the Max column when type is Title', () => { - const { container } = renderInput({ - dataIndex: EditableTableColumnsDataIndex.Max, - onSelectChange: vi.fn(), - type: CriteriaDtoTypeEnum.Title, - }); + rerender( +
+ + , + ); expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument(); expect(container.querySelector('input')).toBeNull(); }); @@ -49,21 +51,23 @@ describe('', () => { expect(onSelectChange).toHaveBeenCalledWith('penalty', expect.anything()); }); - it('renders a TextArea for the Text column', () => { - renderInput({ + it('renders the Text input and nothing for an unknown column', () => { + const { container, rerender } = renderInput({ dataIndex: EditableTableColumnsDataIndex.Text, onSelectChange: vi.fn(), type: CriteriaDtoTypeEnum.Subtask, }); expect(screen.getByRole('textbox')).toBeInTheDocument(); - }); - it('renders nothing for an unknown column (default branch)', () => { - const { container } = renderInput({ - dataIndex: EditableTableColumnsDataIndex.Actions, - onSelectChange: vi.fn(), - type: CriteriaDtoTypeEnum.Subtask, - }); + rerender( +
+ + , + ); expect(container.querySelector('input, textarea, .ant-select')).toBeNull(); }); }); From 885655dc03eabe98de89891d006caaa8c6ab4c1b Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:19:06 +0200 Subject: [PATCH 195/406] test(client): consolidate column search scenarios --- .../shared/components/Table/columns.test.tsx | 119 +++++------------- 1 file changed, 30 insertions(+), 89 deletions(-) diff --git a/client/src/shared/components/Table/columns.test.tsx b/client/src/shared/components/Table/columns.test.tsx index e110de2cd..a78c03a2d 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(); }); }); From 76809b4ce019f42879afaec8fb62a15511f7e703 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:19:06 +0200 Subject: [PATCH 196/406] test(client): consolidate instruction rendering scenarios --- .../Instructions/Instructions.test.tsx | 22 ++----------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/client/src/modules/Mentor/components/Instructions/Instructions.test.tsx b/client/src/modules/Mentor/components/Instructions/Instructions.test.tsx index 67b96b4d6..be98732be 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' }])}; } From 2a278f1aa2e9907b3768b1dd37713e2d1684fcb6 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:19:07 +0200 Subject: [PATCH 197/406] test(client): consolidate auto test task assertions --- client/src/modules/AutoTest/pages/Task/Task.test.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/client/src/modules/AutoTest/pages/Task/Task.test.tsx b/client/src/modules/AutoTest/pages/Task/Task.test.tsx index 7591c16cb..142cb0793 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(); }); From 9a38bc95cec4ec6c0501b8bf6fc5a9dafc1a3fbc Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:23:54 +0200 Subject: [PATCH 198/406] test(client): consolidate footer assertions --- .../components/Footer/FooterLayout.test.tsx | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/client/src/components/Footer/FooterLayout.test.tsx b/client/src/components/Footer/FooterLayout.test.tsx index 679dbffd2..01378d359 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(); }); }); From fe361be556fbe3dc2f1404f03dfe70e620917277 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:23:55 +0200 Subject: [PATCH 199/406] test(client): consolidate cross-check pairs table states --- .../CrossCheckPairsTable.test.tsx | 26 ++++++------------- 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/client/src/modules/CrossCheckPairs/components/CrossCheckPairsTable/CrossCheckPairsTable.test.tsx b/client/src/modules/CrossCheckPairs/components/CrossCheckPairsTable/CrossCheckPairsTable.test.tsx index 78467bfde..44a7de793 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(); From 7e67b5ef55c2063790a554ee62a982916009313a Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:23:55 +0200 Subject: [PATCH 200/406] test(client): consolidate bad review table states --- .../BadReview/BadReviewTable.test.tsx | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/client/src/modules/CrossCheckPairs/components/BadReview/BadReviewTable.test.tsx b/client/src/modules/CrossCheckPairs/components/BadReview/BadReviewTable.test.tsx index 5b0ce0a95..34080681c 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(); - }); }); From 822cdd4fa599b3f57f85fd0d40231151c5c591e2 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:23:55 +0200 Subject: [PATCH 201/406] test(client): consolidate attempt answer assertions --- .../AttemptsAnswers/AttemptsAnswers.test.tsx | 32 ++++--------------- 1 file changed, 7 insertions(+), 25 deletions(-) diff --git a/client/src/modules/AutoTest/components/AttemptsAnswers/AttemptsAnswers.test.tsx b/client/src/modules/AutoTest/components/AttemptsAnswers/AttemptsAnswers.test.tsx index 896584fdb..0a5c19f24 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); }); }); From f3d4ba11d099591bc982e82a8ebe7d0ec34b2152 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:23:56 +0200 Subject: [PATCH 202/406] test(client): consolidate heroes badge scenarios --- .../Heroes/HeroesCountBadge.test.tsx | 38 ++++++------------- 1 file changed, 11 insertions(+), 27 deletions(-) diff --git a/client/src/components/Heroes/HeroesCountBadge.test.tsx b/client/src/components/Heroes/HeroesCountBadge.test.tsx index 91b38556c..289167422 100644 --- a/client/src/components/Heroes/HeroesCountBadge.test.tsx +++ b/client/src/components/Heroes/HeroesCountBadge.test.tsx @@ -3,46 +3,30 @@ import userEvent from '@testing-library/user-event'; 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 () => { + it('shows the badge name, comment, and formatted date in its tooltip', async () => { const user = userEvent.setup(); - render(); + 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'); }); From 0e407040d0d09a218dab167aa1be82a4a89a1583 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:28:13 +0200 Subject: [PATCH 203/406] test(client): consolidate task settings assertions --- .../TaskSettings/TaskSettings.test.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/src/modules/Tasks/components/TaskSettings/TaskSettings.test.tsx b/client/src/modules/Tasks/components/TaskSettings/TaskSettings.test.tsx index c7bbcbd5c..0e1c72e02 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(); + } }); }); From 490aa0f0fe974cbc7b5c57973b7468aa05f1f7af Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:28:13 +0200 Subject: [PATCH 204/406] test(client): consolidate certificate modal states --- .../IssueCertificateModal.test.tsx | 69 ++++--------------- 1 file changed, 14 insertions(+), 55 deletions(-) diff --git a/client/src/modules/CourseManagement/components/CertificateTemplatePicker/IssueCertificateModal.test.tsx b/client/src/modules/CourseManagement/components/CertificateTemplatePicker/IssueCertificateModal.test.tsx index afea32065..497744c5b 100644 --- a/client/src/modules/CourseManagement/components/CertificateTemplatePicker/IssueCertificateModal.test.tsx +++ b/client/src/modules/CourseManagement/components/CertificateTemplatePicker/IssueCertificateModal.test.tsx @@ -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 = userEvent.setup(); + 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(); }); }); From 1f37f05c9d418b9e646f253b14a99e4fbf87a60b Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:28:13 +0200 Subject: [PATCH 205/406] test(client): consolidate mentor dashboard states --- .../MentorDashboard/MentorDashboard.test.tsx | 32 +++---------------- 1 file changed, 5 insertions(+), 27 deletions(-) diff --git a/client/src/modules/Mentor/components/MentorDashboard/MentorDashboard.test.tsx b/client/src/modules/Mentor/components/MentorDashboard/MentorDashboard.test.tsx index d5422bede..6a214607f 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); From 1070e5c8cb6fec9a22a896d7676729943842145a Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:28:13 +0200 Subject: [PATCH 206/406] test(client): consolidate mentor search assertions --- client/src/shared/components/MentorSearch.test.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/client/src/shared/components/MentorSearch.test.tsx b/client/src/shared/components/MentorSearch.test.tsx index d913a7e6a..8099b18e3 100644 --- a/client/src/shared/components/MentorSearch.test.tsx +++ b/client/src/shared/components/MentorSearch.test.tsx @@ -21,17 +21,12 @@ 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(); render(); const combobox = screen.getByRole('combobox'); + expect(combobox).toBeInTheDocument(); combobox.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); await user.type(combobox, 'men'); From f970b35cac7f4d6d255a18de9f4875283b3d09cf Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:28:14 +0200 Subject: [PATCH 207/406] test(client): consolidate task stats interactions --- .../components/TasksStatsCard.test.tsx | 42 ++++++------------- 1 file changed, 13 insertions(+), 29 deletions(-) diff --git a/client/src/modules/StudentDashboard/components/TasksStatsCard.test.tsx b/client/src/modules/StudentDashboard/components/TasksStatsCard.test.tsx index e5f34d475..a09b059a7 100644 --- a/client/src/modules/StudentDashboard/components/TasksStatsCard.test.tsx +++ b/client/src/modules/StudentDashboard/components/TasksStatsCard.test.tsx @@ -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 = userEvent.setup(); 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 = userEvent.setup(); (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'); - }); - }); }); From 8365a40f6098e6e81cf8f38bf94443534a09a545 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:32:33 +0200 Subject: [PATCH 208/406] test(client): consolidate subtask criteria scenarios --- .../criteria/SubtaskCriteria.test.tsx | 63 ++++--------------- 1 file changed, 13 insertions(+), 50 deletions(-) diff --git a/client/src/modules/CrossCheck/components/criteria/SubtaskCriteria.test.tsx b/client/src/modules/CrossCheck/components/criteria/SubtaskCriteria.test.tsx index 55bd91f64..133c81e15 100644 --- a/client/src/modules/CrossCheck/components/criteria/SubtaskCriteria.test.tsx +++ b/client/src/modules/CrossCheck/components/criteria/SubtaskCriteria.test.tsx @@ -15,84 +15,47 @@ function makeSubtask(overrides: Partial = {}): CrossC } describe('', () => { - it('renders the criteria text and max points', () => { - 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 () => { + it('renders criteria values and handles reviewer input', async () => { const user = userEvent.setup(); const updateCriteriaData = vi.fn(); - render(); + render(); + expect(screen.getByText('Implements feature X')).toBeInTheDocument(); + expect(screen.getByText(/Max 10 points for criteria/)).toBeInTheDocument(); 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 })); - }); }); From 684893b87069e22c214728c1cc671b211107e7d8 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:32:34 +0200 Subject: [PATCH 209/406] test(client): consolidate mentor card variants --- .../components/MentorCard/MentorCard.test.tsx | 104 ++---------------- 1 file changed, 12 insertions(+), 92 deletions(-) diff --git a/client/src/modules/MentorsHallOfFame/components/MentorCard/MentorCard.test.tsx b/client/src/modules/MentorsHallOfFame/components/MentorCard/MentorCard.test.tsx index 65411ac2e..8a9fa5fa3 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(); }); }); From a654cb878a99b75dfed38932095b3d7f36e67d84 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:32:34 +0200 Subject: [PATCH 210/406] test(client): consolidate GDPR checkbox flow --- .../components/Forms/GdprCheckbox.test.tsx | 27 +++---------------- 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/client/src/shared/components/Forms/GdprCheckbox.test.tsx b/client/src/shared/components/Forms/GdprCheckbox.test.tsx index 6e0d113c1..ebaab5da2 100644 --- a/client/src/shared/components/Forms/GdprCheckbox.test.tsx +++ b/client/src/shared/components/Forms/GdprCheckbox.test.tsx @@ -17,44 +17,23 @@ function renderGdprCheckbox(onFinish = vi.fn()) { } describe('GdprCheckbox', () => { - it('renders both the English and Russian consent statements', () => { - renderGdprCheckbox(); + it('renders consent and submits checked and unchecked values', async () => { + const user = userEvent.setup(); + const { onFinish } = renderGdprCheckbox(); expect(screen.getByText(/I hereby agree to the processing of my personal data/i)).toBeInTheDocument(); expect(screen.getByText(/Я согласен на обработку моих персональных данных/i)).toBeInTheDocument(); - }); - - it('renders an unchecked checkbox by default', () => { - renderGdprCheckbox(); const checkbox = screen.getByRole('checkbox', { name: /I agree/i }); expect(checkbox).not.toBeChecked(); - }); - - it('checks the box when the user clicks it and submits checked: true', async () => { - const user = userEvent.setup(); - const { onFinish } = renderGdprCheckbox(); - - const checkbox = screen.getByRole('checkbox', { name: /I agree/i }); await user.click(checkbox); expect(checkbox).toBeChecked(); - await user.click(screen.getByRole('button', { name: /submit/i })); - await waitFor(() => expect(onFinish).toHaveBeenCalledWith({ gdpr: true })); - }); - - it('toggles back to unchecked on a second click', async () => { - const user = userEvent.setup(); - const { onFinish } = renderGdprCheckbox(); - const checkbox = screen.getByRole('checkbox', { name: /I agree/i }); - await user.click(checkbox); await user.click(checkbox); expect(checkbox).not.toBeChecked(); - await user.click(screen.getByRole('button', { name: /submit/i })); - await waitFor(() => expect(onFinish).toHaveBeenCalledWith({ gdpr: false })); }); }); From f648962ce6e86624df8b7eebd186d9bb24713a6e Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:32:34 +0200 Subject: [PATCH 211/406] test(client): consolidate public feedback modal states --- .../__test__/PublicFeedbackModal.test.tsx | 29 +++++-------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/client/src/components/Profile/__test__/PublicFeedbackModal.test.tsx b/client/src/components/Profile/__test__/PublicFeedbackModal.test.tsx index dde7fec52..e3602c2d0 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(); }); }); From c58c452ffb584ef1b110602a7160b4919edebbaf Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:32:35 +0200 Subject: [PATCH 212/406] test(client): consolidate language selector scenarios --- .../src/components/SelectLanguages.test.tsx | 42 +++++-------------- 1 file changed, 11 insertions(+), 31 deletions(-) diff --git a/client/src/components/SelectLanguages.test.tsx b/client/src/components/SelectLanguages.test.tsx index c938b2f6d..cfc3a7c6e 100644 --- a/client/src/components/SelectLanguages.test.tsx +++ b/client/src/components/SelectLanguages.test.tsx @@ -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,6 +37,9 @@ 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 () => { From eb638958c1b226f82f6a72b1c5b5fa283efd2bf9 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:32:52 +0200 Subject: [PATCH 213/406] test(client): update public feedback modal snapshot --- .../__test__/__snapshots__/PublicFeedbackModal.test.tsx.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/Profile/__test__/__snapshots__/PublicFeedbackModal.test.tsx.snap b/client/src/components/Profile/__test__/__snapshots__/PublicFeedbackModal.test.tsx.snap index e6d8867d0..518038c26 100644 --- a/client/src/components/Profile/__test__/__snapshots__/PublicFeedbackModal.test.tsx.snap +++ b/client/src/components/Profile/__test__/__snapshots__/PublicFeedbackModal.test.tsx.snap @@ -1,3 +1,3 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`PublicFeedbackModal > Should render correctly 1`] = `
`; +exports[`PublicFeedbackModal > renders and handles populated, empty, and hidden states 1`] = `
`; From 0c5e5eeaae7c97ba2ac59047877cfe2993cee227 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:37:02 +0200 Subject: [PATCH 214/406] test(client): consolidate theme switch scenarios --- .../shared/components/ThemeSwitch.test.tsx | 35 +++++-------------- 1 file changed, 8 insertions(+), 27 deletions(-) diff --git a/client/src/shared/components/ThemeSwitch.test.tsx b/client/src/shared/components/ThemeSwitch.test.tsx index 3812ecc1c..4ef48806c 100644 --- a/client/src/shared/components/ThemeSwitch.test.tsx +++ b/client/src/shared/components/ThemeSwitch.test.tsx @@ -27,24 +27,18 @@ 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(); }); @@ -56,33 +50,20 @@ describe('ThemeSwitch', () => { return screen.findAllByRole('menuitem'); } - it('switches to dark theme from the dropdown menu', async () => { + it('switches among dark, light, and automatic themes', async () => { const user = userEvent.setup(); 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); }); }); From e548fe58cebfff6ff3bfabc2402232b2f58fafe0 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:37:02 +0200 Subject: [PATCH 215/406] test(client): consolidate public feedback card scenarios --- .../__test__/PublicFeedbackCard.test.tsx | 25 +- .../PublicFeedbackCard.test.tsx.snap | 546 ++++++++++++++++++ 2 files changed, 553 insertions(+), 18 deletions(-) diff --git a/client/src/components/Profile/__test__/PublicFeedbackCard.test.tsx b/client/src/components/Profile/__test__/PublicFeedbackCard.test.tsx index 720fd4ffc..b155e016c 100644 --- a/client/src/components/Profile/__test__/PublicFeedbackCard.test.tsx +++ b/client/src/components/Profile/__test__/PublicFeedbackCard.test.tsx @@ -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 () => { + it('renders feedback details and opens and closes the modal', async () => { const user = userEvent.setup(); 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__/__snapshots__/PublicFeedbackCard.test.tsx.snap b/client/src/components/Profile/__test__/__snapshots__/PublicFeedbackCard.test.tsx.snap index 20df6d275..fd43657fb 100644 --- a/client/src/components/Profile/__test__/__snapshots__/PublicFeedbackCard.test.tsx.snap +++ b/client/src/components/Profile/__test__/__snapshots__/PublicFeedbackCard.test.tsx.snap @@ -1,5 +1,551 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`PublicFeedbackCard > matches the feedback card snapshot 1`] = ` +
+
+
+
+
+

+ + + + + + Public Feedback + +

+
+
+
+
+
+ + + Total badges: + + + + 6 +
+
+
+ + + Congratulations badge + + + + + + 1 + + + + + +
+
+ + + Great_speaker badge + + + + + + 3 + + + + + +
+
+ + + Thank_you badge + + + + + + 2 + + + + + +
+
+
+ + + Last feedback: + + +
+
+
+ + + +
+
+
+ + + Anton Petrov + + + + + a month ago + + +
+
+ + + Congratulations + + +
+ Test +
+
+
+
+
+
    +
  • + + + + + +
  • +
+
+
+`; + +exports[`PublicFeedbackCard > renders populated, mixed, and empty feedback data 1`] = ` +
+`; + exports[`PublicFeedbackCard > should render correctly 1`] = `
Date: Sat, 12 Sep 2026 02:37:02 +0200 Subject: [PATCH 216/406] test(client): consolidate notification settings table states --- .../NotificationsUserSettingsTable.test.tsx | 65 ++++--------------- 1 file changed, 12 insertions(+), 53 deletions(-) diff --git a/client/src/modules/Notifications/components/NotificationsUserSettingsTable.test.tsx b/client/src/modules/Notifications/components/NotificationsUserSettingsTable.test.tsx index b43ea215e..db9551e91 100644 --- a/client/src/modules/Notifications/components/NotificationsUserSettingsTable.test.tsx +++ b/client/src/modules/Notifications/components/NotificationsUserSettingsTable.test.tsx @@ -19,27 +19,19 @@ const notifications: UserNotificationSettings[] = [ ]; describe('NotificationsUserSettingsTable', () => { - it('renders the Notification column plus email & telegram channels (discord excluded)', () => { - render(); + it('renders settings, toggles channels, and handles table variants', async () => { + const user = userEvent.setup(); + const onCheck = vi.fn(); + const { container, rerender } = render(); expect(screen.getByText('Notification')).toBeInTheDocument(); expect(screen.getByText('email')).toBeInTheDocument(); expect(screen.getByText('telegram')).toBeInTheDocument(); expect(screen.queryByText('discord')).not.toBeInTheDocument(); - }); - - it('renders a row per notification with its name', () => { - render(); - expect(screen.getByText('First Notification')).toBeInTheDocument(); expect(screen.getByText('Second Notification')).toBeInTheDocument(); - }); - - it('reflects the per-channel checked state from settings', () => { - render(); const rows = screen.getAllByRole('row'); - // rows[0] is the header. const firstRow = rows[1]!; const secondRow = rows[2]!; @@ -50,63 +42,30 @@ describe('NotificationsUserSettingsTable', () => { const [secondEmail, secondTelegram] = within(secondRow).getAllByRole('checkbox'); expect(secondEmail).not.toBeChecked(); expect(secondTelegram).toBeChecked(); - }); - it('treats an undefined channel value as checked (undefinedAsTrue)', () => { - const data = [makeSettings({ id: 'x', name: 'No Email Setting', settings: { telegram: true } })]; - render(); + await user.click(firstTelegram!); + expect(onCheck).toHaveBeenCalledWith(['settings', 'telegram'], notifications[0], true); + await user.click(firstEmail!); + expect(onCheck).toHaveBeenCalledWith(['settings', 'email'], notifications[0], false); + const data = [makeSettings({ id: 'x', name: 'No Email Setting', settings: { telegram: true } })]; + rerender(); const dataRow = screen.getAllByRole('row')[1]!; const [email] = within(dataRow).getAllByRole('checkbox'); expect(email).toBeChecked(); - }); - - it('calls onCheck with dataIndex, record and the new value when toggling a channel on', async () => { - const user = userEvent.setup(); - const onCheck = vi.fn(); - render(); - const firstRow = screen.getAllByRole('row')[1]!; - const [, telegram] = within(firstRow).getAllByRole('checkbox'); - - await user.click(telegram!); - - expect(onCheck).toHaveBeenCalledTimes(1); - expect(onCheck).toHaveBeenCalledWith(['settings', 'telegram'], notifications[0], true); - }); - - it('calls onCheck with false when toggling a channel off', async () => { - const user = userEvent.setup(); - const onCheck = vi.fn(); - render(); - - const firstRow = screen.getAllByRole('row')[1]!; - const [email] = within(firstRow).getAllByRole('checkbox'); - - await user.click(email!); - - expect(onCheck).toHaveBeenCalledWith(['settings', 'email'], notifications[0], false); - }); - - it('marks disabled channel columns with the disabled class', () => { - const { container } = render( + rerender( , ); - - // The disabled column adds a CSS-module class; assert at least one cell carries a - // class containing "disabled". // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access const disabledCells = container.querySelectorAll('[class*="disabled"]'); expect(disabledCells.length).toBeGreaterThan(0); - }); - - it('renders an empty table with no rows when there are no notifications', () => { - render(); + rerender(); expect(screen.getAllByText(/no data/i).length).toBeGreaterThan(0); expect(screen.queryAllByRole('checkbox')).toHaveLength(0); }); From 1366f0c23e1e5b804e3a159967d5af18adf4ca2c Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:37:03 +0200 Subject: [PATCH 217/406] test(client): consolidate course task selector assertions --- .../SelectCourseTasks/SelectCourseTasks.test.tsx | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/client/src/modules/CourseManagement/components/SelectCourseTasks/SelectCourseTasks.test.tsx b/client/src/modules/CourseManagement/components/SelectCourseTasks/SelectCourseTasks.test.tsx index b35df4de2..86038cdee 100644 --- a/client/src/modules/CourseManagement/components/SelectCourseTasks/SelectCourseTasks.test.tsx +++ b/client/src/modules/CourseManagement/components/SelectCourseTasks/SelectCourseTasks.test.tsx @@ -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'); From dc71a0c98acbd0a0a006cf686ade4281f357b6d6 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:37:52 +0200 Subject: [PATCH 218/406] test(client): remove obsolete feedback card snapshots --- .../PublicFeedbackCard.test.tsx.snap | 546 ------------------ 1 file changed, 546 deletions(-) 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 fd43657fb..6c48d4008 100644 --- a/client/src/components/Profile/__test__/__snapshots__/PublicFeedbackCard.test.tsx.snap +++ b/client/src/components/Profile/__test__/__snapshots__/PublicFeedbackCard.test.tsx.snap @@ -272,549 +272,3 @@ exports[`PublicFeedbackCard > matches the feedback card snapshot 1`] = `
`; - -exports[`PublicFeedbackCard > renders populated, mixed, and empty feedback data 1`] = ` -
-`; - -exports[`PublicFeedbackCard > should render correctly 1`] = ` -
-
-
-
-
-

- - - - - - Public Feedback - -

-
-
-
-
-
- - - Total badges: - - - - 6 -
-
-
- - - Congratulations badge - - - - - - 1 - - - - - -
-
- - - Great_speaker badge - - - - - - 3 - - - - - -
-
- - - Thank_you badge - - - - - - 2 - - - - - -
-
-
- - - Last feedback: - - -
-
-
- - - -
-
-
- - - Anton Petrov - - - - - a month ago - - -
-
- - - Congratulations - - -
- Test -
-
-
-
-
-
    -
  • - - - - - -
  • -
-
-
-`; From fab7956cac68d3678e0fa4745edf6d9c658aecac Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:40:17 +0200 Subject: [PATCH 219/406] test(client): consolidate location selector scenarios --- .../components/Forms/LocationSelect.test.tsx | 91 ++++--------------- 1 file changed, 20 insertions(+), 71 deletions(-) diff --git a/client/src/shared/components/Forms/LocationSelect.test.tsx b/client/src/shared/components/Forms/LocationSelect.test.tsx index 0bdf4b8da..0e6ef7628 100644 --- a/client/src/shared/components/Forms/LocationSelect.test.tsx +++ b/client/src/shared/components/Forms/LocationSelect.test.tsx @@ -82,127 +82,76 @@ describe('LocationSelect', () => { expect(document.querySelector('.ant-alert-error')).toBeInTheDocument(); }); - it('renders a searchable combobox once initialized', () => { + it('renders initialized values and the suggestion loading state', () => { const triggerPoll = setupPolling(); (window as unknown as { google: unknown }).google = {}; - renderLocationSelect(); + const { rerender } = renderLocationSelect(); triggerPoll(); const combobox = screen.getByRole('combobox'); expect(combobox).toBeInTheDocument(); - // showSearch + filterOption=false means the combobox is the live search input. expect(combobox).toHaveValue(''); - }); - it('reflects the place value coming from the hook as the combobox text', () => { - const triggerPoll = setupPolling(); - (window as unknown as { google: unknown }).google = {}; mockPlaces({ value: 'Prague, Czechia' }); - renderLocationSelect(); - - triggerPoll(); - + rerender(); expect(screen.getByText('Prague, Czechia')).toBeInTheDocument(); - }); - it('shows a spinner inside the dropdown while suggestions are loading', () => { - const triggerPoll = setupPolling(); - (window as unknown as { google: unknown }).google = {}; - // loading=true drives the `notFoundContent={loading ? : null}` branch. mockPlaces({ value: 'Mi', suggestions: { data: [], loading: true } }); - renderLocationSelect(); - triggerPoll(); - + rerender(); fireEvent.mouseDown(screen.getByRole('combobox')); expect(document.querySelector('.ant-select-dropdown .ant-spin')).toBeInTheDocument(); }); - it('forwards typed search text to setValue', async () => { - const triggerPoll = setupPolling(); - (window as unknown as { google: unknown }).google = {}; - renderLocationSelect(); - triggerPoll(); - - fireEvent.change(screen.getByRole('combobox'), { target: { value: 'Min' } }); - - await waitFor(() => expect(mockSetValue).toHaveBeenCalledWith('Min')); - }); - - it('renders suggestion options from the places data', () => { + it('searches and selects place suggestions', async () => { const triggerPoll = setupPolling(); (window as unknown as { google: unknown }).google = {}; mockPlaces({ value: 'M', suggestions: { data: [{ description: 'Minsk, Belarus' }, { description: 'Munich, Germany' }], loading: false }, } as Partial>); - renderLocationSelect(); + const { onChange } = renderLocationSelect(); triggerPoll(); - fireEvent.mouseDown(screen.getByRole('combobox')); - - // Scope to the listbox options (antd also renders a hidden measure mirror of the label). + const combobox = screen.getByRole('combobox'); + fireEvent.change(combobox, { target: { value: 'Min' } }); + await waitFor(() => expect(mockSetValue).toHaveBeenCalledWith('Min')); + fireEvent.mouseDown(combobox); expect(screen.getByRole('option', { name: 'Minsk, Belarus' })).toBeInTheDocument(); expect(screen.getByRole('option', { name: 'Munich, Germany' })).toBeInTheDocument(); - }); - - it('parses a selected option into a Location and calls onChange', () => { - const triggerPoll = setupPolling(); - (window as unknown as { google: unknown }).google = {}; - mockPlaces({ - value: 'M', - suggestions: { data: [{ description: 'Minsk, Belarus' }], loading: false }, - } as Partial>); - const { onChange } = renderLocationSelect(); - triggerPoll(); - fireEvent.mouseDown(screen.getByRole('combobox')); - // antd wires its select handler on the `.ant-select-item-option` wrapper element, so we - // must click that node (the only rendered option here) rather than the inner content node. const option = document.querySelector('.ant-select-item-option'); expect(option).toHaveTextContent('Minsk, Belarus'); fireEvent.click(option as Element); - - // handleSelect: setValue(value, false) then onChange(toLocation(value)) expect(mockSetValue).toHaveBeenCalledWith('Minsk, Belarus', false); expect(onChange).toHaveBeenCalledWith({ cityName: 'Minsk', countryName: 'Belarus' } satisfies Location); }); - it('restores the location text on blur when the field was cleared', () => { + it('restores or retains the location value on blur', () => { const triggerPoll = setupPolling(); (window as unknown as { google: unknown }).google = {}; - // value is empty → handleBlur should reset it from the provided location prop. mockPlaces({ value: '' }); - renderLocationSelect({ location: { cityName: 'Berlin', countryName: 'Germany' } }); + const onChange = vi.fn(); + const { rerender } = renderLocationSelect({ + location: { cityName: 'Berlin', countryName: 'Germany' }, + onChange, + }); triggerPoll(); fireEvent.blur(screen.getByRole('combobox')); expect(mockSetValue).toHaveBeenCalledWith('Berlin, Germany', false); - }); - - it('resets to an empty string on blur when no location prop is given', () => { - const triggerPoll = setupPolling(); - (window as unknown as { google: unknown }).google = {}; - mockPlaces({ value: '' }); - renderLocationSelect({ location: null }); - triggerPoll(); + mockSetValue.mockClear(); + rerender(); fireEvent.blur(screen.getByRole('combobox')); - expect(mockSetValue).toHaveBeenCalledWith('', false); - }); - it('does not reset on blur when the field already has a value', () => { - const triggerPoll = setupPolling(); - (window as unknown as { google: unknown }).google = {}; mockPlaces({ value: 'Paris, France' }); - renderLocationSelect(); - triggerPoll(); - + rerender(); + mockSetValue.mockClear(); fireEvent.blur(screen.getByRole('combobox')); - expect(mockSetValue).not.toHaveBeenCalled(); }); }); From a3ceb281f3b492f55e80b617472878064868146a Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:43:41 +0200 Subject: [PATCH 220/406] test(client): consolidate notebook upload flow --- .../JupyterNotebook/JupyterNotebook.test.tsx | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/client/src/modules/AutoTest/components/JupyterNotebook/JupyterNotebook.test.tsx b/client/src/modules/AutoTest/components/JupyterNotebook/JupyterNotebook.test.tsx index c19d5102e..41f4ec77d 100644 --- a/client/src/modules/AutoTest/components/JupyterNotebook/JupyterNotebook.test.tsx +++ b/client/src/modules/AutoTest/components/JupyterNotebook/JupyterNotebook.test.tsx @@ -35,31 +35,19 @@ 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 () => { + it('renders, validates, and stores a selected notebook', async () => { const user = userEvent.setup(); 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]); - // 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'); }); From 8860d30439716bd4996a187a87d5ff0f86729d71 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:43:42 +0200 Subject: [PATCH 221/406] test(client): consolidate score widget values --- .../Profile/ui/__tests__/ScoreWidget.test.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/client/src/components/Profile/ui/__tests__/ScoreWidget.test.tsx b/client/src/components/Profile/ui/__tests__/ScoreWidget.test.tsx index 296713e45..28de10928 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(); }); }); From 0bab663403bf16189102f3b406aeda3cd71e74ae Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:43:42 +0200 Subject: [PATCH 222/406] test(client): consolidate dev tools container flow --- .../DevTools/DevToolsContainer.test.tsx | 23 ++----------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/client/src/components/DevTools/DevToolsContainer.test.tsx b/client/src/components/DevTools/DevToolsContainer.test.tsx index 332f96331..98122125d 100644 --- a/client/src/components/DevTools/DevToolsContainer.test.tsx +++ b/client/src/components/DevTools/DevToolsContainer.test.tsx @@ -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 = userEvent.setup(); 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); From f884ec8c5539f19cdaef7f5beb165bfc644b6605 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:43:42 +0200 Subject: [PATCH 223/406] test(client): consolidate review settings flow --- .../SolutionReviewSettingsPanel.test.tsx | 30 +++++-------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/client/src/modules/CrossCheck/components/SolutionReviewSettingsPanel/SolutionReviewSettingsPanel.test.tsx b/client/src/modules/CrossCheck/components/SolutionReviewSettingsPanel/SolutionReviewSettingsPanel.test.tsx index 1a21fd7d2..12493ec83 100644 --- a/client/src/modules/CrossCheck/components/SolutionReviewSettingsPanel/SolutionReviewSettingsPanel.test.tsx +++ b/client/src/modules/CrossCheck/components/SolutionReviewSettingsPanel/SolutionReviewSettingsPanel.test.tsx @@ -3,35 +3,21 @@ import userEvent from '@testing-library/user-event'; import SolutionReviewSettingsPanel from './SolutionReviewSettingsPanel'; describe('', () => { - it('renders the contacts label and an unchecked switch by default', () => { - 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 () => { + it('renders and toggles contact visibility with optional callbacks', async () => { const user = userEvent.setup(); const setAreContactsVisible = vi.fn(); - render(); + const { rerender } = render( + , + ); + expect(screen.getByText('Contacts')).toBeInTheDocument(); + expect(screen.getByRole('switch')).not.toBeChecked(); 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(); }); }); From 3d1c753c69562515b3149cbc4c8c557036b0362b Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:43:43 +0200 Subject: [PATCH 224/406] test(client): consolidate settings drawer flow --- .../SettingsDrawer/SettingsDrawer.test.tsx | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/client/src/modules/Schedule/components/SettingsDrawer/SettingsDrawer.test.tsx b/client/src/modules/Schedule/components/SettingsDrawer/SettingsDrawer.test.tsx index e4e9cf036..46d0d5375 100644 --- a/client/src/modules/Schedule/components/SettingsDrawer/SettingsDrawer.test.tsx +++ b/client/src/modules/Schedule/components/SettingsDrawer/SettingsDrawer.test.tsx @@ -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 = userEvent.setup(); 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()); }); }); From ab1bd8f090b1f5faafc6192f1ecf456f19495f6a Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:46:43 +0200 Subject: [PATCH 225/406] test(client): consolidate auto test task card states --- .../AutoTestTaskCard.test.tsx | 42 +++++++------------ 1 file changed, 16 insertions(+), 26 deletions(-) diff --git a/client/src/modules/AutoTest/components/AutoTestTaskCard/AutoTestTaskCard.test.tsx b/client/src/modules/AutoTest/components/AutoTestTaskCard/AutoTestTaskCard.test.tsx index 749ac34e6..eb5c302ac 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'); }); From 9c252a72d83b5d899355df1855ce9bb6fe138d92 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:46:43 +0200 Subject: [PATCH 226/406] test(client): consolidate data processing checkbox flow --- .../DataProcessingCheckbox.test.tsx | 22 ++++--------------- 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/client/src/modules/Registry/components/DataProcessingCheckbox/DataProcessingCheckbox.test.tsx b/client/src/modules/Registry/components/DataProcessingCheckbox/DataProcessingCheckbox.test.tsx index 8c86b70d8..ec3e07f35 100644 --- a/client/src/modules/Registry/components/DataProcessingCheckbox/DataProcessingCheckbox.test.tsx +++ b/client/src/modules/Registry/components/DataProcessingCheckbox/DataProcessingCheckbox.test.tsx @@ -19,31 +19,17 @@ const renderCheckbox = (checked = Checkbox.notChecked) => describe('DataProcessingCheckbox', () => { const user = userEvent.setup(); - 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(); }); }); From cd450b952f485f4285d972c46c2f9ccc2d4fd401 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:46:43 +0200 Subject: [PATCH 227/406] test(client): consolidate team welcome card states --- .../WelcomeCard/WelcomeCard.test.tsx | 37 ++++++------------- 1 file changed, 11 insertions(+), 26 deletions(-) diff --git a/client/src/modules/TeamDistribution/components/WelcomeCard/WelcomeCard.test.tsx b/client/src/modules/TeamDistribution/components/WelcomeCard/WelcomeCard.test.tsx index ad6f06230..c3c2722f6 100644 --- a/client/src/modules/TeamDistribution/components/WelcomeCard/WelcomeCard.test.tsx +++ b/client/src/modules/TeamDistribution/components/WelcomeCard/WelcomeCard.test.tsx @@ -1,35 +1,20 @@ -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import WelcomeCard from './WelcomeCard'; describe('WelcomeCard', () => { - it('should display correct title for managers', () => { - render(); - const title = screen.getByText('Create student teams to solve group tasks!'); - expect(title).toBeInTheDocument(); - }); - - it('should display correct title for non-managers', () => { - render(); - const title = screen.getByText('Become a member of the team!'); - expect(title).toBeInTheDocument(); - }); - - it('should display the create team distribution button for managers', () => { - render(); + it('renders and handles manager and non-manager states', () => { + const handleCreateTeamDistribution = vi.fn(); + const { rerender } = render( + , + ); + expect(screen.getByText('Create student teams to solve group tasks!')).toBeInTheDocument(); const button = screen.getByRole('button', { name: /add a new distribution/i }); expect(button).toBeInTheDocument(); - }); + fireEvent.click(button); + expect(handleCreateTeamDistribution).toHaveBeenCalled(); - it('should not display the create team distribution button for non-managers', () => { - render(); + rerender(); + expect(screen.getByText('Become a member of the team!')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /add a new distribution/i })).not.toBeInTheDocument(); }); - - it('should call the handleCreateTeamDistribution function when the create team distribution button is clicked', () => { - const handleCreateTeamDistribution = vi.fn(); - render(); - const button = screen.getByRole('button', { name: /add a new distribution/i }); - button.click(); - expect(handleCreateTeamDistribution).toHaveBeenCalled(); - }); }); From fb8ad221daa863debc03c25f60a684c83eed3a65 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:46:43 +0200 Subject: [PATCH 228/406] test(client): consolidate person selector scenarios --- .../shared/components/PersonSelect.test.tsx | 28 +++---------------- 1 file changed, 4 insertions(+), 24 deletions(-) diff --git a/client/src/shared/components/PersonSelect.test.tsx b/client/src/shared/components/PersonSelect.test.tsx index 65c48936a..61594104b 100644 --- a/client/src/shared/components/PersonSelect.test.tsx +++ b/client/src/shared/components/PersonSelect.test.tsx @@ -14,26 +14,13 @@ function openSelect() { } describe('PersonSelect', () => { - it('renders a searchable combobox with a placeholder', () => { - 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 () => { + it('renders, preselects, and selects people by id', async () => { const user = userEvent.setup(); const onChange = vi.fn(); - render(); + render(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + expect(screen.getByText(/Bob B/)).toBeInTheDocument(); openSelect(); await user.click(await screen.findByText(/Alice A/)); @@ -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(); - }); }); From c7dd2c2a562e19f8fc496f93698e2e8fb2a3df44 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:46:44 +0200 Subject: [PATCH 229/406] test(client): consolidate criteria deletion flow --- ...DeleteAllCrossCheckCriteriaButton.test.tsx | 30 +++++-------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/client/src/modules/CrossCheck/DeleteAllCrossCheckCriteriaButton.test.tsx b/client/src/modules/CrossCheck/DeleteAllCrossCheckCriteriaButton.test.tsx index f7e15e30f..56eb5deba 100644 --- a/client/src/modules/CrossCheck/DeleteAllCrossCheckCriteriaButton.test.tsx +++ b/client/src/modules/CrossCheck/DeleteAllCrossCheckCriteriaButton.test.tsx @@ -3,34 +3,20 @@ import userEvent from '@testing-library/user-event'; import { DeleteAllCrossCheckCriteriaButton } from './DeleteAllCrossCheckCriteriaButton'; describe('', () => { - it('renders the "Delete all" button', () => { - render(); - expect(screen.getByRole('button', { name: /delete all/i })).toBeInTheDocument(); - }); - - it('asks for confirmation and clears criteria when confirmed', async () => { + it('supports cancelling and confirming deletion', async () => { const user = userEvent.setup(); const setDataCriteria = vi.fn(); render(); - await user.click(screen.getByRole('button', { name: /delete all/i })); - - // Popconfirm asks before clearing. + const deleteButton = screen.getByRole('button', { name: /delete all/i }); + expect(deleteButton).toBeInTheDocument(); + await user.click(deleteButton); expect(await screen.findByText('Are you sure you want to delete all items?')).toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: /^ok$|^yes$/i })); - - expect(setDataCriteria).toHaveBeenCalledWith([]); - }); - - it('does not clear criteria when the confirmation is cancelled', async () => { - const user = userEvent.setup(); - const setDataCriteria = vi.fn(); - render(); - - await user.click(screen.getByRole('button', { name: /delete all/i })); await user.click(await screen.findByRole('button', { name: /cancel|no/i })); - expect(setDataCriteria).not.toHaveBeenCalled(); + + await user.click(deleteButton); + await user.click(await screen.findByRole('button', { name: /^ok$|^yes$/i })); + expect(setDataCriteria).toHaveBeenCalledWith([]); }); }); From b7c4318745b5b186f633251cf430a54d7f5cd9c6 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:51:33 +0200 Subject: [PATCH 230/406] test(client): consolidate student assignment modal flow --- .../Student/AssignStudentModal.test.tsx | 38 ++++--------------- 1 file changed, 7 insertions(+), 31 deletions(-) diff --git a/client/src/components/Student/AssignStudentModal.test.tsx b/client/src/components/Student/AssignStudentModal.test.tsx index 6f5bc516a..8d43fc8f5 100644 --- a/client/src/components/Student/AssignStudentModal.test.tsx +++ b/client/src/components/Student/AssignStudentModal.test.tsx @@ -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 = userEvent.setup(); + 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(); From d3320f7f01518749dc78267b6a4e2288d5340d17 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:51:33 +0200 Subject: [PATCH 231/406] test(client): consolidate CV course section states --- .../ViewCv/CoursesSection/index.test.tsx | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/client/src/modules/Opportunities/components/ViewCv/CoursesSection/index.test.tsx b/client/src/modules/Opportunities/components/ViewCv/CoursesSection/index.test.tsx index 0377ccc9f..ba6ecced7 100644 --- a/client/src/modules/Opportunities/components/ViewCv/CoursesSection/index.test.tsx +++ b/client/src/modules/Opportunities/components/ViewCv/CoursesSection/index.test.tsx @@ -38,13 +38,11 @@ const mockCourses = [ ] as [ResumeCourseDto, ResumeCourseDto, ResumeCourseDto, ResumeCourseDto]; describe('CoursesSection', () => { - test('should display nothing if courses are not provided', () => { - const { container } = render(); + test('renders empty, detailed, all-course, and filtered states', () => { + const { container, rerender } = render(); expect(container).toBeEmptyDOMElement(); - }); - test('should display all course data correctly', () => { - render(); + rerender(); const sectionHead = screen.getByRole('heading', { name: /rs school courses/i }); const fullName = screen.getByText(`${courseWithFullData.fullName} (${courseWithFullData.locationName})`); @@ -63,10 +61,8 @@ describe('CoursesSection', () => { expect(mentorLink).toHaveAttribute('href', `https://github.com/${courseWithFullData.mentor?.githubId}`); expect(position).toBeInTheDocument(); expect(score).toBeInTheDocument(); - }); - test('should display all courses if visible courses are empty', () => { - render(); + rerender(); mockCourses.forEach(({ fullName, rank }) => { const courseName = screen.getByText(fullName, { exact: false }); @@ -75,12 +71,9 @@ describe('CoursesSection', () => { expect(courseName).toBeInTheDocument(); expect(coursePosition).toBeInTheDocument(); }); - }); - test('should display only visible courses if provided', () => { const mockVisibleCourses = [mockCourses[0].id, mockCourses[2].id]; - - render(); + rerender(); mockVisibleCourses.forEach(courseId => { const course = mockCourses.find(({ id }) => id === courseId); From 0804e23253cf32d3c115424f4c8f931b8db73f56 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:51:33 +0200 Subject: [PATCH 232/406] test(client): consolidate GitHub panel validation --- .../GitHubPanel/GitHubPanel.test.tsx | 51 +++---------------- 1 file changed, 6 insertions(+), 45 deletions(-) diff --git a/client/src/modules/Tasks/components/GitHubPanel/GitHubPanel.test.tsx b/client/src/modules/Tasks/components/GitHubPanel/GitHubPanel.test.tsx index da1f78342..9f3c992b0 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()); }); }); From 9e5597206b2209d61be3c8accf4ddcfd75d4b0e3 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:51:33 +0200 Subject: [PATCH 233/406] test(client): consolidate no-course states --- .../Home/components/NoCourse/index.test.tsx | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/client/src/modules/Home/components/NoCourse/index.test.tsx b/client/src/modules/Home/components/NoCourse/index.test.tsx index c6f9e37c3..89e2886f0 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'); }); From 5d73da614e78bfe7cbe8f2b750db9ed33eeedbc3 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:51:34 +0200 Subject: [PATCH 234/406] test(client): consolidate mentor resend modal flow --- .../MentorRegistryResendModal.test.tsx | 45 ++++++++----------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/client/src/modules/MentorRegistry/components/MentorRegistryResendModal.test.tsx b/client/src/modules/MentorRegistry/components/MentorRegistryResendModal.test.tsx index 4dca54594..7fa97ee67 100644 --- a/client/src/modules/MentorRegistry/components/MentorRegistryResendModal.test.tsx +++ b/client/src/modules/MentorRegistry/components/MentorRegistryResendModal.test.tsx @@ -1,5 +1,5 @@ /* eslint-disable testing-library/no-node-access */ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MentorRegistryResendModal } from './MentorRegistryResendModal'; @@ -7,43 +7,36 @@ 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 = userEvent.setup(); + 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(); - }); }); From ca12bef9813be6dabea8c647784c0797b90e4368 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:55:18 +0200 Subject: [PATCH 235/406] test(client): consolidate tag color settings flow --- .../SettingsDrawer/ChangeTagColors.test.tsx | 47 +++++-------------- 1 file changed, 13 insertions(+), 34 deletions(-) diff --git a/client/src/modules/Schedule/components/SettingsDrawer/ChangeTagColors.test.tsx b/client/src/modules/Schedule/components/SettingsDrawer/ChangeTagColors.test.tsx index 048ae36a5..d8c1d1786 100644 --- a/client/src/modules/Schedule/components/SettingsDrawer/ChangeTagColors.test.tsx +++ b/client/src/modules/Schedule/components/SettingsDrawer/ChangeTagColors.test.tsx @@ -27,59 +27,38 @@ const tags = [TagEnum.Coding, TagEnum.Test]; // expand its header before reaching the tag chips / color pickers. async function renderExpanded(props: Parameters[0]) { const user = userEvent.setup(); - render(); + 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(); }); }); From 0d650cbd02dd0af8164244113bf32719794c146c Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:55:18 +0200 Subject: [PATCH 236/406] test(client): consolidate filtered tag scenarios --- .../FilteredTags/FilteredTags.test.tsx | 97 ++++--------------- 1 file changed, 21 insertions(+), 76 deletions(-) diff --git a/client/src/modules/Schedule/components/FilteredTags/FilteredTags.test.tsx b/client/src/modules/Schedule/components/FilteredTags/FilteredTags.test.tsx index a7477989e..5ecf329cb 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(); }); }); From 5c000c10895a06b8f18be8ba614b6a3fa68b9e36 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:55:19 +0200 Subject: [PATCH 237/406] test(client): consolidate interview student info variants --- .../StudentInfo.test.tsx | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/client/src/modules/Interviews/pages/StageInterviewFeedback/StudentInfo.test.tsx b/client/src/modules/Interviews/pages/StageInterviewFeedback/StudentInfo.test.tsx index 476537144..a335f769c 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(); From 74e6ceef33166c355c51b8982dd905b87dbfbbf5 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:55:19 +0200 Subject: [PATCH 238/406] test(client): consolidate footer social links --- .../components/Footer/SocialNetworks.test.tsx | 26 ++++--------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/client/src/components/Footer/SocialNetworks.test.tsx b/client/src/components/Footer/SocialNetworks.test.tsx index 5ae219711..380a5679a 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')); }); }); From d1419b2e10acc6ae14cc96d54909f01cd8c25721 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 02:55:19 +0200 Subject: [PATCH 239/406] test(client): consolidate interview card states --- .../components/InterviewCard.test.tsx | 45 +++++++------------ 1 file changed, 15 insertions(+), 30 deletions(-) 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 17b19db6a..ecf4d2ba6 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(); }); }); From 4ef928937c085a84bed29f4cba054d5d03d08665 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:01:22 +0200 Subject: [PATCH 240/406] test(client): consolidate home summary states --- .../HomeSummary/HomeSummary.test.tsx | 28 ++++--------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/client/src/modules/Home/components/HomeSummary/HomeSummary.test.tsx b/client/src/modules/Home/components/HomeSummary/HomeSummary.test.tsx index 0a45b3061..378bff0d0 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(); From 6dec9600e35a73ed8658da8baa330c07fa6f8630 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:01:22 +0200 Subject: [PATCH 241/406] test(client): consolidate page layout states --- .../src/shared/components/PageLayout.test.tsx | 45 +++++-------------- 1 file changed, 11 insertions(+), 34 deletions(-) diff --git a/client/src/shared/components/PageLayout.test.tsx b/client/src/shared/components/PageLayout.test.tsx index 1b9182a9b..1f9272c23 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(); From 0ce9f57958dd8ddd47c8515114f824e93a133450 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:01:22 +0200 Subject: [PATCH 242/406] test(client): consolidate task stats modal states --- .../components/TasksStatsModal.test.tsx | 41 ++++--------------- 1 file changed, 9 insertions(+), 32 deletions(-) diff --git a/client/src/modules/StudentDashboard/components/TasksStatsModal.test.tsx b/client/src/modules/StudentDashboard/components/TasksStatsModal.test.tsx index b34975fcb..0ca274dfb 100644 --- a/client/src/modules/StudentDashboard/components/TasksStatsModal.test.tsx +++ b/client/src/modules/StudentDashboard/components/TasksStatsModal.test.tsx @@ -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 = userEvent.setup(); + 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(); From 49c512962cceef8bae3cf3b0628aedc8a8995f11 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:01:22 +0200 Subject: [PATCH 243/406] test(client): consolidate table renderer variants --- .../components/Table/renderers.test.tsx | 75 +++++++------------ 1 file changed, 28 insertions(+), 47 deletions(-) diff --git a/client/src/shared/components/Table/renderers.test.tsx b/client/src/shared/components/Table/renderers.test.tsx index 5f53aa2f4..7de5731ed 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)} , From 394d1aafaa516b8b87b60055a1368c0aba7b187f Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:01:23 +0200 Subject: [PATCH 244/406] test(client): consolidate cross-check message states --- .../SolutionReview/Message/Message.test.tsx | 40 +++---------------- 1 file changed, 6 insertions(+), 34 deletions(-) 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 eda0d8e8e..73a596e1d 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(); }); }); From bd1bd1aa3907e77f6abad52556d91b8ce44e12e8 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:07:23 +0200 Subject: [PATCH 245/406] test(client): consolidate next event card assertions --- .../NextEventCard/NextEventCard.test.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/client/src/modules/StudentDashboard/components/NextEventCard/NextEventCard.test.tsx b/client/src/modules/StudentDashboard/components/NextEventCard/NextEventCard.test.tsx index d1beb4cb9..eb1b6d7ea 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(); + } }); }); From c70448ae8b685a97eacae683818c2a294bcffbd1 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:07:23 +0200 Subject: [PATCH 246/406] test(client): consolidate prescreening feedback assertions --- .../Profile/ui/PrescreeningFeedback.test.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/client/src/components/Profile/ui/PrescreeningFeedback.test.tsx b/client/src/components/Profile/ui/PrescreeningFeedback.test.tsx index 3394ba0cc..858a547c5 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(); }); From f35f9a6bb150c144620fa0c7b56ca3ade7c9b0d9 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:07:23 +0200 Subject: [PATCH 247/406] test(client): consolidate mentor data hook flows --- .../useMentorData/useMentorData.test.tsx | 24 ++----------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/client/src/modules/Registry/hooks/useMentorData/useMentorData.test.tsx b/client/src/modules/Registry/hooks/useMentorData/useMentorData.test.tsx index b23b977b7..c5b9ea7b0 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({ From 86e7c087f57c4b19480c3680a1abca09ddf7643e Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:07:24 +0200 Subject: [PATCH 248/406] test(client): consolidate course task verification flows --- .../useCourseTaskVerifications.test.ts | 26 +++---------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/client/src/modules/AutoTest/hooks/useCourseTaskVerifications/useCourseTaskVerifications.test.ts b/client/src/modules/AutoTest/hooks/useCourseTaskVerifications/useCourseTaskVerifications.test.ts index e92c5fcd1..c122de771 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)); From 7f908acd22a63b3f76a3270ab59d93569ab28950 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:13:31 +0200 Subject: [PATCH 249/406] test(client): consolidate mentor stats modal flow --- .../Profile/__test__/MentorStatsModal.test.tsx | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/client/src/components/Profile/__test__/MentorStatsModal.test.tsx b/client/src/components/Profile/__test__/MentorStatsModal.test.tsx index 658491ca2..7c700be91 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(); }); }); From e292f1ba238858458483dde1de3b0a65b1403bf8 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:13:31 +0200 Subject: [PATCH 250/406] test(client): streamline header banner carousel cases --- .../HeaderMiniBannerCarousel.test.tsx | 95 ++++++++----------- 1 file changed, 38 insertions(+), 57 deletions(-) diff --git a/client/src/components/HeaderMiniBannerCarousel.test.tsx b/client/src/components/HeaderMiniBannerCarousel.test.tsx index 1513eae03..3e116420e 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); }); From 1486fdf3621ce35f0484c080eeaa92cba63dbe56 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:13:32 +0200 Subject: [PATCH 251/406] test(client): consolidate mentoring language assertions --- .../LanguagesMentoring.test.tsx | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/client/src/modules/Registry/components/LanguagesMentoring/LanguagesMentoring.test.tsx b/client/src/modules/Registry/components/LanguagesMentoring/LanguagesMentoring.test.tsx index 54c3d2516..b18c2fd9d 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(); - }); }); From 034cda74cc27a28584903126c44d8c2db5f71ee9 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:13:32 +0200 Subject: [PATCH 252/406] test(client): consolidate preference assertions --- .../Cards/Preferences/Preferences.test.tsx | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) 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 446b83bfd..bdca9d9ff 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(); }); }); From 6d8e434d1138f5e87f94cbdf41514eae07b09361 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:13:33 +0200 Subject: [PATCH 253/406] test(client): consolidate JSON attributes flow --- .../JsonAttributesPanel/JsonAttributesPanel.test.tsx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/client/src/modules/Tasks/components/JsonAttributesPanel/JsonAttributesPanel.test.tsx b/client/src/modules/Tasks/components/JsonAttributesPanel/JsonAttributesPanel.test.tsx index b621aa0e3..58e086361 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(); From 708b29a5e56a457da3dc11312f93e472ceadb518 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:20:14 +0200 Subject: [PATCH 254/406] test(client): consolidate criteria action flows --- .../CrossCheck/CriteriaActions.test.tsx | 51 ++++++------------- 1 file changed, 16 insertions(+), 35 deletions(-) diff --git a/client/src/modules/CrossCheck/CriteriaActions.test.tsx b/client/src/modules/CrossCheck/CriteriaActions.test.tsx index f869d2b4f..87c7e8946 100644 --- a/client/src/modules/CrossCheck/CriteriaActions.test.tsx +++ b/client/src/modules/CrossCheck/CriteriaActions.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 { CriteriaDto, CriteriaDtoTypeEnum } from '@client/api'; import { CriteriaActions } from './CriteriaActions'; @@ -27,52 +26,34 @@ function setup(overrides: Partial> } describe('', () => { - it('renders Edit and Delete in read mode', () => { - setup(); - expect(screen.getByText('Edit')).toBeInTheDocument(); - expect(screen.getByText('Delete')).toBeInTheDocument(); - }); - - it('calls edit with the record when Edit is clicked', async () => { - const user = userEvent.setup(); + it('renders and invokes read-mode actions', () => { + vi.useFakeTimers(); const props = setup(); - await user.click(screen.getByText('Edit')); + expect(screen.getByText('Edit')).toBeInTheDocument(); + expect(screen.getByText('Delete')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Edit')); expect(props.edit).toHaveBeenCalledWith(record); - }); - - it('calls remove with the key after confirming the Delete popconfirm', async () => { - const user = userEvent.setup(); - const props = setup(); - - await user.click(screen.getByText('Delete')); - await user.click(await screen.findByRole('button', { name: 'Delete' })); + fireEvent.click(screen.getByText('Delete')); + act(() => vi.runOnlyPendingTimers()); + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + act(() => vi.runOnlyPendingTimers()); expect(props.remove).toHaveBeenCalledWith('k1'); + vi.useRealTimers(); }); - it('renders Save and Cancel in edit mode', () => { - setup({ editing: true }); - expect(screen.getByText('Save')).toBeInTheDocument(); - expect(screen.getByText('Cancel')).toBeInTheDocument(); - }); - - it('calls save with the key when Save is clicked', async () => { - const user = userEvent.setup(); + it('renders and invokes edit-mode actions', () => { const props = setup({ editing: true }); - await user.click(screen.getByText('Save')); + expect(screen.getByText('Save')).toBeInTheDocument(); + expect(screen.getByText('Cancel')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Save')); expect(props.save).toHaveBeenCalledWith('k1'); - }); - - it('calls cancel when Cancel is clicked', async () => { - const user = userEvent.setup(); - const props = setup({ editing: true }); - - await user.click(screen.getByText('Cancel')); + fireEvent.click(screen.getByText('Cancel')); expect(props.cancel).toHaveBeenCalled(); }); From aa329ecf1aa05f2688de37c1a7e5d575ae4f1b82 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:20:15 +0200 Subject: [PATCH 255/406] test(client): consolidate admin sider interactions --- .../components/Sider/AdminSider.test.tsx | 64 ++++++++----------- 1 file changed, 26 insertions(+), 38 deletions(-) diff --git a/client/src/shared/components/Sider/AdminSider.test.tsx b/client/src/shared/components/Sider/AdminSider.test.tsx index 7a769c8f0..5e212c650 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] }); From 2b2c278a659492203ce5ef3489e2c11ab233c40e Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:20:15 +0200 Subject: [PATCH 256/406] test(client): consolidate done section variants --- .../DoneSection/DoneSection.test.tsx | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) 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 85ad6c0db..937bda15b 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(); }); }); From 5106199771b6022bf7287f1c8a25e7c0e7c6528e Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:20:15 +0200 Subject: [PATCH 257/406] test(client): consolidate task description states --- .../TaskDescription/TaskDescription.test.tsx | 35 ++++++------------- 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/client/src/modules/AutoTest/components/TaskDescription/TaskDescription.test.tsx b/client/src/modules/AutoTest/components/TaskDescription/TaskDescription.test.tsx index c510e17db..7e6a5abf1 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(); }); }); From 1b21e57a51ed0db89ba2f4343799769dcc21c9ed Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:20:16 +0200 Subject: [PATCH 258/406] test(client): consolidate feedback section states --- .../ViewCv/FeedbackSection/index.test.tsx | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/client/src/modules/Opportunities/components/ViewCv/FeedbackSection/index.test.tsx b/client/src/modules/Opportunities/components/ViewCv/FeedbackSection/index.test.tsx index 81c6eb81d..e11a10272 100644 --- a/client/src/modules/Opportunities/components/ViewCv/FeedbackSection/index.test.tsx +++ b/client/src/modules/Opportunities/components/ViewCv/FeedbackSection/index.test.tsx @@ -33,13 +33,10 @@ const mockFeedback = { } as FeedbackDto; describe('FeedbackSection', () => { - test('should display nothing if feedback is not provided', () => { - const { container } = render(); + test('renders empty, known-skill, and unknown-skill feedback states', () => { + const { container, rerender } = render(); expect(container).toBeEmptyDOMElement(); - }); - - test('should display feedback if provided', () => { - render(); + rerender(); const sectionHeading = screen.getByRole('heading', { name: /mentor's feedback/i }); const mentorLink = screen.getByRole('link', { @@ -64,16 +61,12 @@ describe('FeedbackSection', () => { expect(communicationSkill).toBeInTheDocument(); expect(responsibilitySkill).toBeInTheDocument(); expect(teamPlayerSkill).toBeInTheDocument(); - }); - test('labels an unrecognized soft skill id as "Unknown"', () => { - // Forward-compat: a soft-skill id the frontend does not know maps to the default label. const withUnknownSkill = { ...mockFeedback, softSkills: [{ id: 'future-skill', value: FeedbackSoftSkillValueEnum.Great }], } as unknown as FeedbackDto; - - render(); + rerender(); expect(screen.getByText('Unknown: Great')).toBeInTheDocument(); }); From 7748b2c65f7299d492796b2e71ea356976af4a5d Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:23:31 +0200 Subject: [PATCH 259/406] test(client): consolidate filtered tag flows --- .../shared/components/FilteredTags.test.tsx | 61 ++++++++----------- 1 file changed, 24 insertions(+), 37 deletions(-) diff --git a/client/src/shared/components/FilteredTags.test.tsx b/client/src/shared/components/FilteredTags.test.tsx index 38c226de4..fe085ffb4 100644 --- a/client/src/shared/components/FilteredTags.test.tsx +++ b/client/src/shared/components/FilteredTags.test.tsx @@ -1,58 +1,45 @@ /* 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, fireEvent, render, screen } from '@testing-library/react'; import { FilteredTags } from './FilteredTags'; describe('FilteredTags', () => { - it('renders nothing when there are no active tag filters', () => { - const { container } = render(); + it('renders filter states and invokes tag actions', () => { + vi.useFakeTimers(); + const onTagClose = vi.fn(); + const onClearAllButtonClick = vi.fn(); + const { container, rerender } = render( + , + ); expect(container).toBeEmptyDOMElement(); - }); - - it('renders a tag for each active filter', () => { - render(); + rerender( + , + ); expect(screen.getByText('react')).toBeInTheDocument(); expect(screen.getByText('node')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /clear all/i })).toBeInTheDocument(); - }); + fireEvent.click(container.querySelector('.ant-tag-close-icon') as Element); + act(() => vi.runOnlyPendingTimers()); + expect(onTagClose).toHaveBeenCalledWith('react'); + fireEvent.click(screen.getByRole('button', { name: /clear all/i })); + expect(onClearAllButtonClick).toHaveBeenCalledTimes(1); - it('prefixes tags with filterName and maps display names via tagNameMap', () => { - render( + rerender( , ); expect(screen.getByText('Skill: JavaScript')).toBeInTheDocument(); - }); - - it('calls onTagClose with the tag when a tag is dismissed', async () => { - const user = userEvent.setup(); - const onTagClose = vi.fn(); - const { container } = render( - , - ); - - const closeIcon = container.querySelector('.ant-tag-close-icon'); - expect(closeIcon).toBeInTheDocument(); - await user.click(closeIcon as Element); - - expect(onTagClose).toHaveBeenCalledWith('react'); - }); - - it('calls onClearAllButtonClick when the clear all button is clicked', async () => { - const user = userEvent.setup(); - const onClearAllButtonClick = vi.fn(); - render(); - - await user.click(screen.getByRole('button', { name: /clear all/i })); - - expect(onClearAllButtonClick).toHaveBeenCalledTimes(1); + vi.useRealTimers(); }); }); From fb26985e6d1c53d3e202fc84280ec9acf5c073fc Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:23:31 +0200 Subject: [PATCH 260/406] test(client): consolidate mentor info states --- .../components/MentorInfo/MentorInfo.test.tsx | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/client/src/modules/StudentDashboard/components/MentorInfo/MentorInfo.test.tsx b/client/src/modules/StudentDashboard/components/MentorInfo/MentorInfo.test.tsx index efbecb79a..e8bcff10c 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(); }); }); From b876e7f03b9353b25df07d3e7eac868c8577a6ce Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:23:31 +0200 Subject: [PATCH 261/406] test(client): consolidate criteria type selection --- .../CrossCheck/CriteriaTypeSelect.test.tsx | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/client/src/modules/CrossCheck/CriteriaTypeSelect.test.tsx b/client/src/modules/CrossCheck/CriteriaTypeSelect.test.tsx index a25546fea..755ee3e6e 100644 --- a/client/src/modules/CrossCheck/CriteriaTypeSelect.test.tsx +++ b/client/src/modules/CrossCheck/CriteriaTypeSelect.test.tsx @@ -2,28 +2,19 @@ import { fireEvent, render, screen, within } from '@testing-library/react'; import { CriteriaTypeSelect } from './CriteriaTypeSelect'; describe('', () => { - it('renders the placeholder and a combobox', () => { - render(); + it('renders options and reports the selected value', () => { + const onChange = vi.fn(); + render(); + expect(screen.getByText('Select type')).toBeInTheDocument(); expect(screen.getByRole('combobox')).toBeInTheDocument(); - }); - - it('lists Title, Subtask and Penalty options when opened', () => { - render(); fireEvent.mouseDown(screen.getByRole('combobox')); const body = within(document.body); expect(body.getByText('Title')).toBeInTheDocument(); expect(body.getByText('Subtask')).toBeInTheDocument(); expect(body.getByText('Penalty')).toBeInTheDocument(); - }); - - it('calls onChange with the selected option value', () => { - const onChange = vi.fn(); - render(); - - fireEvent.mouseDown(screen.getByRole('combobox')); - fireEvent.click(within(document.body).getByTestId('Subtask')); + fireEvent.click(body.getByTestId('Subtask')); expect(onChange).toHaveBeenCalledWith('subtask', expect.anything()); }); From ca5eacc5415a23259cf71a87a8ef40bc5bf47092 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:23:32 +0200 Subject: [PATCH 262/406] test(client): consolidate mobile schedule item states --- .../MobileItemCard/MobileItemCard.test.tsx | 41 ++++--------------- 1 file changed, 7 insertions(+), 34 deletions(-) diff --git a/client/src/modules/Schedule/components/MobileItemCard/MobileItemCard.test.tsx b/client/src/modules/Schedule/components/MobileItemCard/MobileItemCard.test.tsx index 596448bd1..21775d0ef 100644 --- a/client/src/modules/Schedule/components/MobileItemCard/MobileItemCard.test.tsx +++ b/client/src/modules/Schedule/components/MobileItemCard/MobileItemCard.test.tsx @@ -28,48 +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 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. - const link = heading.closest('a'); - 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(); }); }); From 10d4e4999577cfa12266f7f0b3e6d49dc5edf4d4 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:23:32 +0200 Subject: [PATCH 263/406] test(client): consolidate task status tab states --- .../TaskStatusTabs/TaskStatusTabs.test.tsx | 58 +++++-------------- 1 file changed, 14 insertions(+), 44 deletions(-) diff --git a/client/src/modules/Mentor/components/TaskStatusTabs/TaskStatusTabs.test.tsx b/client/src/modules/Mentor/components/TaskStatusTabs/TaskStatusTabs.test.tsx index ba98c9946..fe35111d8 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[] { From a4776251326135d7fbe104928f2c62c1395e5476 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:27:22 +0200 Subject: [PATCH 264/406] test(client): consolidate tooltiped button states --- .../components/TooltipedButton.test.tsx | 41 ++++++------------- 1 file changed, 13 insertions(+), 28 deletions(-) diff --git a/client/src/shared/components/TooltipedButton.test.tsx b/client/src/shared/components/TooltipedButton.test.tsx index f804ab9e6..6904f9321 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(); }); }); From 110d561eef0fd9f1b2ba5aae0740750552f7093d Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:27:23 +0200 Subject: [PATCH 265/406] test(client): consolidate distribution card title states --- .../TeamDistributionCard/CardTitle.test.tsx | 51 +++++-------------- 1 file changed, 14 insertions(+), 37 deletions(-) diff --git a/client/src/modules/TeamDistribution/components/TeamDistributionCard/CardTitle.test.tsx b/client/src/modules/TeamDistribution/components/TeamDistributionCard/CardTitle.test.tsx index bc3fbcb14..0bb5d81b3 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(); }); }); From 66d9346c5c2c91bdf7dd925ee92a1c1edfe5d3fd Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:27:23 +0200 Subject: [PATCH 266/406] test(client): consolidate course detail assertions --- .../CourseDetails/CourseDetails.test.tsx | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) 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 fb2edbd07..aa7b6fcc5 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(); }); }); From 2ddf911fe67c54594db5bfd9ff31514786bee685 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:27:23 +0200 Subject: [PATCH 267/406] test(client): consolidate settings item flows --- client/src/components/SettingsItem.test.tsx | 53 +++++++-------------- 1 file changed, 17 insertions(+), 36 deletions(-) diff --git a/client/src/components/SettingsItem.test.tsx b/client/src/components/SettingsItem.test.tsx index a7ec71581..eb19e955d 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(); }); }); From 20d54d33166b2f9f3c66db3b77423fb902e7740c Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:27:24 +0200 Subject: [PATCH 268/406] test(client): consolidate Discord card states --- .../Profile/__test__/DiscordCard.test.tsx | 58 ++++++++----------- 1 file changed, 23 insertions(+), 35 deletions(-) diff --git a/client/src/components/Profile/__test__/DiscordCard.test.tsx b/client/src/components/Profile/__test__/DiscordCard.test.tsx index f8df72f31..a0a2bcad4 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(); }); }); From aff7b2284868af627dc3a347cc3d4bc839051b0a Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:32:07 +0200 Subject: [PATCH 269/406] test(client): consolidate gratitude list states --- .../GratitudeList/index.test.tsx | 77 +++++-------------- 1 file changed, 20 insertions(+), 57 deletions(-) diff --git a/client/src/modules/Opportunities/components/ViewCv/GratitudeSection/GratitudeList/index.test.tsx b/client/src/modules/Opportunities/components/ViewCv/GratitudeSection/GratitudeList/index.test.tsx index 420893cf8..77113d306 100644 --- a/client/src/modules/Opportunities/components/ViewCv/GratitudeSection/GratitudeList/index.test.tsx +++ b/client/src/modules/Opportunities/components/ViewCv/GratitudeSection/GratitudeList/index.test.tsx @@ -1,4 +1,3 @@ -import assert from 'node:assert'; import { fireEvent, render, screen } from '@testing-library/react'; import { GratitudeDto } from '@client/api'; import { GratitudeList } from './index'; @@ -26,66 +25,30 @@ describe('GratitudeList', () => { afterAll(() => { vi.useRealTimers(); }); - test('should display nothing if gratitude list is empty', () => { - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); - - test('should display gratitudes list if provided', () => { - render(); - - assert.ok(mockGratitudes.length === 3); - - const gratitudeComment1 = screen.getByText(mockGratitudes[0]!.comment); - const timeAgo1 = screen.getByText('11 hours ago'); - const gratitudeComment2 = screen.getByText(mockGratitudes[1]!.comment); - const timeAgo2 = screen.getByText('a day ago'); - const gratitudeComment3 = screen.getByText(mockGratitudes[2]!.comment); - const timeAgo3 = screen.getByText('3 months ago'); - - expect(gratitudeComment1).toBeInTheDocument(); - expect(timeAgo1).toBeInTheDocument(); - expect(gratitudeComment2).toBeInTheDocument(); - expect(timeAgo2).toBeInTheDocument(); - expect(gratitudeComment3).toBeInTheDocument(); - expect(timeAgo3).toBeInTheDocument(); - }); - - test('should display number of feedbacks equal to showCount and Show All button if number of feedbacks is greater than showCount', () => { - const mockShowCount = 1; - - render(); - - const feedbacksCount = screen.getAllByRole('listitem'); - const showAllButton = screen.getByRole('button', { name: 'Show all' }); - - expect(feedbacksCount.length).toBe(mockShowCount); - expect(showAllButton).toBeInTheDocument(); - }); - - test('should display number of feedbacks equal to showCount and not show Show All button if number of feedbacks is not greater than showCount', () => { - render(); - - const feedbacksCount = screen.getAllByRole('listitem'); - const showAllButton = screen.queryByRole('button', { name: 'Show all' }); - - expect(feedbacksCount).toHaveLength(mockGratitudes.length); - expect(showAllButton).not.toBeInTheDocument(); - }); - - test('should collapse and expand the list of feedbacks correctly', () => { - const mockShowCount = 1; - - render(); - - expect(screen.getAllByRole('listitem').length).toBe(mockShowCount); + test('renders empty, full, partial, expanded, and collapsed list states', () => { + const { container, rerender } = render( + , + ); + + expect(screen.getByText(mockGratitudes[0]!.comment)).toBeInTheDocument(); + expect(screen.getByText('11 hours ago')).toBeInTheDocument(); + expect(screen.getByText(mockGratitudes[1]!.comment)).toBeInTheDocument(); + expect(screen.getByText('a day ago')).toBeInTheDocument(); + expect(screen.getByText(mockGratitudes[2]!.comment)).toBeInTheDocument(); + expect(screen.getByText('3 months ago')).toBeInTheDocument(); + expect(screen.getAllByRole('listitem')).toHaveLength(mockGratitudes.length); + expect(screen.queryByRole('button', { name: 'Show all' })).not.toBeInTheDocument(); + + rerender(); + expect(screen.getAllByRole('listitem')).toHaveLength(1); fireEvent.click(screen.getByRole('button', { name: 'Show all' })); - - expect(screen.getAllByRole('listitem').length).toBe(mockGratitudes.length); + expect(screen.getAllByRole('listitem')).toHaveLength(mockGratitudes.length); fireEvent.click(screen.getByRole('button', { name: 'Show partially' })); + expect(screen.getAllByRole('listitem')).toHaveLength(1); - expect(screen.getAllByRole('listitem').length).toBe(mockShowCount); + rerender(); + expect(container).toBeEmptyDOMElement(); }); }); From 9753d69f1a0244399527d9d319cba3c3b21e16eb Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:32:08 +0200 Subject: [PATCH 270/406] test(client): consolidate criteria modal flows --- .../criteria/CrossCheckCriteriaModal.test.tsx | 35 ++++++------------- 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/client/src/modules/CrossCheck/components/criteria/CrossCheckCriteriaModal.test.tsx b/client/src/modules/CrossCheck/components/criteria/CrossCheckCriteriaModal.test.tsx index 41e7fd129..8ab7ce9ed 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); }); }); From 2c65366597c361083eb15bc62afc1111a005e5b5 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:32:08 +0200 Subject: [PATCH 271/406] test(client): consolidate session access flows --- .../Course/contexts/SessionContext.test.tsx | 78 +++++++------------ 1 file changed, 29 insertions(+), 49 deletions(-) diff --git a/client/src/modules/Course/contexts/SessionContext.test.tsx b/client/src/modules/Course/contexts/SessionContext.test.tsx index b9c4eeebd..67c9eb9f9 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(); }); }); From cb2cec3c9e19da0cceda03246688c2528ea49ab9 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:32:09 +0200 Subject: [PATCH 272/406] test(client): consolidate status tab flows --- .../components/StatusTabs/StatusTabs.test.tsx | 54 ++++++------------- 1 file changed, 15 insertions(+), 39 deletions(-) diff --git a/client/src/modules/AutoTest/components/StatusTabs/StatusTabs.test.tsx b/client/src/modules/AutoTest/components/StatusTabs/StatusTabs.test.tsx index 50527bbe5..1550f8124 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[] { From b86ebee9e72e4ec7466e18e587a1d47353bf4230 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:34:34 +0200 Subject: [PATCH 273/406] test(client): consolidate mentor countries card assertions --- .../MentorsCountriesCard/MentorsCountriesCard.test.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/client/src/modules/CourseStatistics/components/MentorsCountriesCard/MentorsCountriesCard.test.tsx b/client/src/modules/CourseStatistics/components/MentorsCountriesCard/MentorsCountriesCard.test.tsx index 00622a349..9e52af63a 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'); From 96a1612463f31abb60c8e8ce98b78e745d141cfd Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:34:34 +0200 Subject: [PATCH 274/406] test(client): consolidate certificate countries assertions --- .../StudentsCertificatesCountriesCard.test.tsx | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/client/src/modules/CourseStatistics/components/StudentsCertificatesCountriesCard/StudentsCertificatesCountriesCard.test.tsx b/client/src/modules/CourseStatistics/components/StudentsCertificatesCountriesCard/StudentsCertificatesCountriesCard.test.tsx index 0a66b03f3..c4ba1da0e 100644 --- a/client/src/modules/CourseStatistics/components/StudentsCertificatesCountriesCard/StudentsCertificatesCountriesCard.test.tsx +++ b/client/src/modules/CourseStatistics/components/StudentsCertificatesCountriesCard/StudentsCertificatesCountriesCard.test.tsx @@ -48,7 +48,7 @@ const studentsCertificatesCountriesStats: CountriesStatsDto = { }; describe('', () => { - it('renders the card title', () => { + it('renders the title and forwards chart data', async () => { render( ', () => { ); expect(screen.getByText('Certificates Countries')).toBeInTheDocument(); - }); - - it('forwards countries, certificate count, certificates axis title and Lime color', async () => { - render( - , - ); const chart = await screen.findByTestId('countries-chart'); expect(chart).toHaveAttribute('data-length', '2'); From 17eb700cd466e6d42b89b69bc2481b54e46b6911 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:34:34 +0200 Subject: [PATCH 275/406] test(client): consolidate visible course states --- .../EditCv/VisibleCoursesForm/index.test.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/client/src/modules/Opportunities/components/EditCv/VisibleCoursesForm/index.test.tsx b/client/src/modules/Opportunities/components/EditCv/VisibleCoursesForm/index.test.tsx index 83514f83a..a5e5215b0 100644 --- a/client/src/modules/Opportunities/components/EditCv/VisibleCoursesForm/index.test.tsx +++ b/client/src/modules/Opportunities/components/EditCv/VisibleCoursesForm/index.test.tsx @@ -26,8 +26,8 @@ const mockCourses = [ ] as ResumeCourseDto[]; describe('VisibleCoursesForm', () => { - test('should display all courses with positions', () => { - render(); + test('renders courses and both empty-list variants', () => { + const { rerender } = render(); mockCourses.forEach(({ fullName, rank }) => { const courseName = screen.getByText(fullName); @@ -36,17 +36,11 @@ describe('VisibleCoursesForm', () => { expect(courseName).toBeInTheDocument(); expect(coursePosition).toBeInTheDocument(); }); - }); - - test('shows a fallback message when there are no courses', () => { - render(); + rerender(); expect(screen.getByText('No courses to show')).toBeInTheDocument(); - }); - - test('shows the fallback message when courses is null', () => { - render(); + rerender(); expect(screen.getByText('No courses to show')).toBeInTheDocument(); }); }); From aaa925a0d7b79576510f9a48ad657cb91865726e Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:34:35 +0200 Subject: [PATCH 276/406] test(client): consolidate interview subheader states --- .../StageInterviewFeedback/SubHeader.test.tsx | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/client/src/modules/Interviews/pages/StageInterviewFeedback/SubHeader.test.tsx b/client/src/modules/Interviews/pages/StageInterviewFeedback/SubHeader.test.tsx index c10f2b978..c680df09d 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(); From 18876df5ff2f9fa19b8d1c859bd57ea66c35ec6e Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:34:35 +0200 Subject: [PATCH 277/406] test(client): consolidate penalty criteria flows --- .../criteria/PenaltyCriteria.test.tsx | 32 +++++-------------- 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/client/src/modules/CrossCheck/components/criteria/PenaltyCriteria.test.tsx b/client/src/modules/CrossCheck/components/criteria/PenaltyCriteria.test.tsx index 6596558fe..cdfdd1df5 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' })); From 86635ea4690d87de3fce0062434d72bc6e3b2d19 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:39:16 +0200 Subject: [PATCH 278/406] test(client): consolidate course access assertions --- .../src/modules/Course/components/CourseNoAccess.test.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/client/src/modules/Course/components/CourseNoAccess.test.tsx b/client/src/modules/Course/components/CourseNoAccess.test.tsx index 6b8a5a7c2..93e675fe0 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', '/'); }); From 07a4657a26419c772ef196deece1586df1de7138 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:39:17 +0200 Subject: [PATCH 279/406] test(client): consolidate available review states --- .../AvailableReviewCard.test.tsx | 32 +++++++------------ 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/client/src/modules/StudentDashboard/components/AvailableReviewCard/AvailableReviewCard.test.tsx b/client/src/modules/StudentDashboard/components/AvailableReviewCard/AvailableReviewCard.test.tsx index b20797e38..04bfc2dae 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(); }); From a16e1c52df6165f4aff8ec349955cc23afc64049 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:39:17 +0200 Subject: [PATCH 280/406] test(client): consolidate contact list flows --- .../ContactsList/index.test.tsx | 43 ++++++------------- 1 file changed, 13 insertions(+), 30 deletions(-) diff --git a/client/src/modules/Opportunities/components/ViewCv/ContactsSection/ContactsList/index.test.tsx b/client/src/modules/Opportunities/components/ViewCv/ContactsSection/ContactsList/index.test.tsx index d3aa226bc..929b633fb 100644 --- a/client/src/modules/Opportunities/components/ViewCv/ContactsSection/ContactsList/index.test.tsx +++ b/client/src/modules/Opportunities/components/ViewCv/ContactsSection/ContactsList/index.test.tsx @@ -26,8 +26,9 @@ const mockContacts = { }; describe('ContactsList', () => { - test('should display proper values', () => { - render(); + test('renders contacts and copies populated and missing values', async () => { + const user = userEvent.setup(); + const { rerender } = render(); const email = screen.getByText(mockContacts.email); const githubUsername = screen.getByText(mockContacts.githubUsername); @@ -44,10 +45,6 @@ describe('ContactsList', () => { expect(skype).toBeInTheDocument(); expect(telegram).toBeInTheDocument(); expect(website).toBeInTheDocument(); - }); - - test('should display corresponding icons', () => { - render(); const emailIcon = screen.getByRole('img', { name: 'mail' }); const githubIcon = screen.getByRole('img', { name: 'github' }); @@ -64,41 +61,27 @@ describe('ContactsList', () => { expect(skypeIcon).toBeInTheDocument(); expect(telegramIcon).toBeInTheDocument(); expect(websiteIcon).toBeInTheDocument(); - }); - - test('should have corresponding links', () => { - render(); const links = screen.getAllByRole('link'); expect(links).toHaveLength(7); - const [emailIcon, githubIcon, linkedinIcon, phoneIcon, skypeIcon, telegramIcon, websiteIcon] = links; + const [emailLink, githubLink, linkedinLink, phoneLink, skypeLink, telegramLink, websiteLink] = links; - expect(emailIcon).toHaveAttribute('title', 'E-mail'); - expect(githubIcon).toHaveAttribute('title', 'GitHub'); - expect(linkedinIcon).toHaveAttribute('title', 'LinkedIn'); - expect(phoneIcon).toHaveAttribute('title', 'Phone'); - expect(skypeIcon).toHaveAttribute('title', 'Skype'); - expect(telegramIcon).toHaveAttribute('title', 'Telegram'); - expect(websiteIcon).toHaveAttribute('title', 'Website'); - }); - - test('copies a contact value to the clipboard and shows a notification', async () => { - const user = userEvent.setup(); - render(); + expect(emailLink).toHaveAttribute('title', 'E-mail'); + expect(githubLink).toHaveAttribute('title', 'GitHub'); + expect(linkedinLink).toHaveAttribute('title', 'LinkedIn'); + expect(phoneLink).toHaveAttribute('title', 'Phone'); + expect(skypeLink).toHaveAttribute('title', 'Skype'); + expect(telegramLink).toHaveAttribute('title', 'Telegram'); + expect(websiteLink).toHaveAttribute('title', 'Website'); + rerender(); await user.click(screen.getByRole('button')); expect(copyToClipboard).toHaveBeenCalledWith('copy@me.com'); expect(notificationSuccess).toHaveBeenCalledWith({ message: 'Copied to clipboard' }); - }); - - test('copies an empty string when a kept contact value is undefined', async () => { - const user = userEvent.setup(); - // getContactsToRender drops only null values; an `undefined` value is kept and - // exercises the `value ?? ''` fallback on copy. - render(); + rerender(); await user.click(screen.getByRole('button')); expect(copyToClipboard).toHaveBeenCalledWith(''); From f6abe95e1090e17b6255a2aa9e16e18dd9a1f1bc Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:39:18 +0200 Subject: [PATCH 281/406] test(client): consolidate consent states --- .../components/Consents.test.tsx | 35 +++++-------------- 1 file changed, 9 insertions(+), 26 deletions(-) diff --git a/client/src/modules/Notifications/components/Consents.test.tsx b/client/src/modules/Notifications/components/Consents.test.tsx index 072539caf..fe5382702 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(); - }); }); From 74d881662d6760e5b97ddee92c3e04848c3bb2f0 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:42:35 +0200 Subject: [PATCH 282/406] test(client): consolidate discipline states --- .../Cards/Disciplines/Disciplines.test.tsx | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) 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 20c585d3c..947ba9e09 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(
, From 1a40052486908fdd6983d50f36095756359b6869 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:43:46 +0200 Subject: [PATCH 283/406] test(client): consolidate course selector flow --- .../components/CourseSelector/index.test.tsx | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/client/src/modules/Home/components/CourseSelector/index.test.tsx b/client/src/modules/Home/components/CourseSelector/index.test.tsx index f783d7d48..ee5e57c97 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()); }); }); From fa9511fe632e1c1c2aa58f1266313e359fa3d4a0 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:43:47 +0200 Subject: [PATCH 284/406] test(client): consolidate custom popconfirm flows --- .../common/CustomPopconfirm.test.tsx | 47 +++++++------------ 1 file changed, 18 insertions(+), 29 deletions(-) diff --git a/client/src/components/common/CustomPopconfirm.test.tsx b/client/src/components/common/CustomPopconfirm.test.tsx index 92aaf3e41..977d7b941 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', () => { + vi.useFakeTimers(); const onConfirm = vi.fn(); render( @@ -23,28 +12,28 @@ 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); + act(() => vi.runOnlyPendingTimers()); + expect(screen.getByText('Remove item?')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /yes|ok/i })); + act(() => vi.runOnlyPendingTimers()); + expect(onConfirm).toHaveBeenCalled(); + vi.useRealTimers(); }); - it('honors an explicitly provided placement', async () => { - const user = userEvent.setup(); + it('honors an explicitly provided placement', () => { + 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' })); + act(() => vi.runOnlyPendingTimers()); + expect(document.querySelector('.ant-popover-placement-bottomLeft')).not.toBeNull(); + vi.useRealTimers(); }); }); From be828d3824afcc049beef777cc2525a8c7d502b2 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:46:09 +0200 Subject: [PATCH 285/406] test(client): consolidate theme provider flows --- client/src/providers/ThemeProvider.test.tsx | 52 ++------------------- 1 file changed, 3 insertions(+), 49 deletions(-) diff --git a/client/src/providers/ThemeProvider.test.tsx b/client/src/providers/ThemeProvider.test.tsx index f37d4ce0f..0935da855 100644 --- a/client/src/providers/ThemeProvider.test.tsx +++ b/client/src/providers/ThemeProvider.test.tsx @@ -57,17 +57,6 @@ describe('ThemeProvider', () => { expect(() => ctx?.changeAutoTheme()).not.toThrow(); }); - it('defaults to auto mode following the system (light) when nothing is stored', () => { - setMatchMedia(false); - render( - - - , - ); - expect(screen.getByTestId('auto')).toHaveTextContent('true'); - expect(screen.getByTestId('theme')).toHaveTextContent('light'); - }); - it('defaults to auto mode following the system (dark) when nothing is stored', () => { setMatchMedia(true); render( @@ -79,7 +68,7 @@ describe('ThemeProvider', () => { expect(screen.getByTestId('theme')).toHaveTextContent('dark'); }); - it('themeChange to dark applies the dark theme, body class, and persists it', () => { + it('themeChange applies and persists dark and light themes', () => { render( @@ -87,22 +76,12 @@ describe('ThemeProvider', () => { ); fireEvent.click(screen.getByText('set-dark')); - expect(screen.getByTestId('theme')).toHaveTextContent('dark'); expect(document.body).toHaveClass(AppTheme.Dark); expect(document.body).not.toHaveClass(AppTheme.Light); expect(localStorage.getItem('app-theme')).toBe('dark'); expect(screen.getByTestId('auto')).toHaveTextContent('false'); - }); - - it('themeChange to light replaces dark body class with light', () => { - render( - - - , - ); - fireEvent.click(screen.getByText('set-dark')); fireEvent.click(screen.getByText('set-light')); expect(screen.getByTestId('theme')).toHaveTextContent('light'); @@ -111,17 +90,6 @@ describe('ThemeProvider', () => { expect(localStorage.getItem('app-theme')).toBe('light'); }); - it('restores a valid stored theme on mount', () => { - localStorage.setItem('app-theme', AppTheme.Dark); - render( - - - , - ); - expect(screen.getByTestId('theme')).toHaveTextContent('dark'); - expect(screen.getByTestId('auto')).toHaveTextContent('false'); - }); - it('enables auto mode for a legacy stored "auto" value and follows system (dark)', () => { // "auto" is no longer written to storage, but users upgraded from the old build may still // have it. It is not a valid AppTheme, so it falls through to the auto (follow-system) branch. @@ -138,8 +106,7 @@ describe('ThemeProvider', () => { expect(localStorage.getItem('app-theme')).toBeNull(); }); - it('toggling auto on clears the stored theme and applies the system (light) theme', () => { - // Start from an explicit manual theme so auto is off, then toggle it on. + it('restores a stored theme and toggles auto mode on and off', () => { localStorage.setItem('app-theme', AppTheme.Dark); setMatchMedia(false); render( @@ -148,26 +115,13 @@ describe('ThemeProvider', () => { , ); expect(screen.getByTestId('auto')).toHaveTextContent('false'); + expect(screen.getByTestId('theme')).toHaveTextContent('dark'); fireEvent.click(screen.getByText('toggle-auto')); expect(screen.getByTestId('auto')).toHaveTextContent('true'); expect(localStorage.getItem('app-theme')).toBeNull(); expect(screen.getByTestId('theme')).toHaveTextContent('light'); - }); - - it('toggling auto off then on flips the auto flag', () => { - // Start from an explicit manual theme so auto is off initially. - localStorage.setItem('app-theme', AppTheme.Light); - render( - - - , - ); - expect(screen.getByTestId('auto')).toHaveTextContent('false'); - - fireEvent.click(screen.getByText('toggle-auto')); - expect(screen.getByTestId('auto')).toHaveTextContent('true'); fireEvent.click(screen.getByText('toggle-auto')); expect(screen.getByTestId('auto')).toHaveTextContent('false'); From fc4a7fc46d048228c5e2fe9100cfcc563203f716 Mon Sep 17 00:00:00 2001 From: apalchys Date: Sat, 12 Sep 2026 03:46:10 +0200 Subject: [PATCH 286/406] test(client): consolidate registry footer assertions --- .../src/modules/Registry/components/Footer/Footer.test.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/client/src/modules/Registry/components/Footer/Footer.test.tsx b/client/src/modules/Registry/components/Footer/Footer.test.tsx index a51d90180..6d75c64fb 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(