diff --git a/frontend/src/__mocks__/render.jsx b/frontend/src/__mocks__/render.jsx new file mode 100644 index 0000000..e3d6b0b --- /dev/null +++ b/frontend/src/__mocks__/render.jsx @@ -0,0 +1,7 @@ +// Manual mock for src/render.jsx +// Re-exports useAppContext backed by the test AppContext so components +// rendered via renderWithProviders(…) receive the correct context values. +import { vi } from 'vitest'; + +export { useAppContext } from '../test/test-utils.jsx'; +export default vi.fn(); diff --git a/frontend/src/test/Base.test.jsx b/frontend/src/test/Base.test.jsx new file mode 100644 index 0000000..316765d --- /dev/null +++ b/frontend/src/test/Base.test.jsx @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { renderWithProviders } from './test-utils'; +import Base from '../components/Base'; + +vi.mock('../render.jsx'); + +// MenuBar fetches job status and organizations on mount. +// Provide benign responses so state updates don't throw. +function setupFetch() { + global.fetch.mockImplementation((url) => { + if (url.includes('/status/jobs/')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ jobs: { deliver_contents: null } }), + }); + } + if (url.includes('/organizations/')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ organizations: [] }), + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + }); +} + +describe('Base', () => { + beforeEach(() => { + setupFetch(); + }); + + it('renders children', () => { + renderWithProviders( + +

Page content

+ + ); + expect(screen.getByText('Page content')).toBeInTheDocument(); + }); + + it('renders a single breadcrumb as the active (non-linked) crumb', () => { + renderWithProviders( + +
+ + ); + // The last crumb is rendered as Typography, not a link + expect(screen.getByText('Dashboard')).toBeInTheDocument(); + }); + + it('renders intermediate breadcrumbs as links', () => { + renderWithProviders( + +
+ + ); + expect(screen.getByRole('link', { name: 'Home' })).toHaveAttribute('href', '/'); + expect(screen.getByRole('link', { name: 'Courses' })).toHaveAttribute('href', '/courses'); + // Last crumb is plain text, not a link + expect(screen.getByText('Module 1')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Module 1' })).not.toBeInTheDocument(); + }); + + it('renders the AvaCode Solutions footer link', () => { + renderWithProviders( + +
+ + ); + expect(screen.getByRole('link', { name: /avacode solutions/i })).toBeInTheDocument(); + }); + + it('calls organizationIdRefreshCallback when organization changes', async () => { + const onOrgRefresh = vi.fn(); + renderWithProviders( + +
+ + ); + // Called immediately on mount with null (initial state) + await waitFor(() => expect(onOrgRefresh).toHaveBeenCalledWith(null)); + }); + + it('renders without BottomDrawer when bottomDrawerParams is omitted', () => { + renderWithProviders( + +
+ + ); + // FAB for BottomDrawer must not be present + expect(screen.queryByRole('button', { name: /filter list/i })).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/BottomDrawer.test.jsx b/frontend/src/test/BottomDrawer.test.jsx new file mode 100644 index 0000000..dd36b93 --- /dev/null +++ b/frontend/src/test/BottomDrawer.test.jsx @@ -0,0 +1,59 @@ +import { describe, it, expect, vi } from 'vitest'; +import { screen, fireEvent } from '@testing-library/react'; +import { renderWithProviders } from './test-utils'; +import BottomDrawer from '../components/BottomDrawer'; + +// vite/modulepreload-polyfill is mocked globally in setup.js + +describe('BottomDrawer', () => { + it('renders children inside the drawer when it is open', () => { + renderWithProviders( + icon}> +

Filter options

+
+ ); + // Drawer children are only mounted after the FAB is clicked + fireEvent.click(screen.getByRole('button', { name: /filter list/i })); + expect(screen.getByText('Filter options')).toBeInTheDocument(); + }); + + it('renders the FAB trigger button', () => { + renderWithProviders( + icon}> +

Content

+
+ ); + expect(screen.getByRole('button', { name: /filter list/i })).toBeInTheDocument(); + }); + + it('clicking the FAB opens the drawer (content accessible)', () => { + renderWithProviders( + icon}> +

Drawer content

+
+ ); + fireEvent.click(screen.getByRole('button', { name: /filter list/i })); + expect(screen.getByText('Drawer content')).toBeInTheDocument(); + }); + + it('accepts any icon element', () => { + const { container } = renderWithProviders( + ★}> +

Content

+
+ ); + expect(container.querySelector('[data-testid="custom-icon"]')).toBeInTheDocument(); + }); + + it('renders multiple children inside the drawer when open', () => { + renderWithProviders( + icon}> +

Child One

+

Child Two

+
+ ); + fireEvent.click(screen.getByRole('button', { name: /filter list/i })); + expect(screen.getByText('Child One')).toBeInTheDocument(); + expect(screen.getByText('Child Two')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/ContentEditor.test.jsx b/frontend/src/test/ContentEditor.test.jsx new file mode 100644 index 0000000..96074ff --- /dev/null +++ b/frontend/src/test/ContentEditor.test.jsx @@ -0,0 +1,86 @@ +import { describe, it, expect, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { renderWithProviders } from './test-utils'; +import ContentEditor from '../components/ContentEditor'; + +vi.mock('../render.jsx'); + +// ContentEditor returns null until Tiptap's useEditor initialises the +// ProseMirror instance. All assertions are wrapped in waitFor so they +// only run once the toolbar has mounted. + +describe('ContentEditor', () => { + it('renders the formatting toolbar', async () => { + renderWithProviders( + + ); + + await waitFor(() => expect(screen.getByText('H1')).toBeInTheDocument()); + expect(screen.getByText('H2')).toBeInTheDocument(); + expect(screen.getByText('H3')).toBeInTheDocument(); + }); + + it('renders Undo and Redo buttons in the toolbar', async () => { + renderWithProviders( + + ); + + // Wait for the editor to initialise (returns null until ready) + await waitFor(() => expect(screen.getByText('H1')).toBeInTheDocument()); + + // The toolbar contains many icon-only buttons: H1, H2, H3, Undo, Redo, + // Bold, Italic, Code-block, link, alignment, image, etc. + const toolbarButtons = screen.getAllByRole('button'); + expect(toolbarButtons.length).toBeGreaterThan(8); + }); + + it('hides the toolbar when disabled=true', async () => { + renderWithProviders( + + ); + + // The editor itself is still mounted (content is readable), but the + // toolbar must not be rendered. + await waitFor(() => + expect(screen.queryByText('H1')).not.toBeInTheDocument() + ); + }); + + it('renders initial HTML content inside the editor area', async () => { + renderWithProviders( + + ); + + await waitFor(() => + expect(screen.getByText('Welcome to the editor')).toBeInTheDocument() + ); + }); + + it('calls editorInstanceCallback with the editor instance', async () => { + const onEditor = vi.fn(); + renderWithProviders( + + ); + + await waitFor(() => expect(onEditor).toHaveBeenCalledWith(expect.objectContaining({ + chain: expect.any(Function), + }))); + }); +}); diff --git a/frontend/src/test/FileUpload.test.jsx b/frontend/src/test/FileUpload.test.jsx new file mode 100644 index 0000000..d0e4b6a --- /dev/null +++ b/frontend/src/test/FileUpload.test.jsx @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { renderWithProviders } from './test-utils'; +import FileUpload from '../components/FileUpload'; + +const defaultProps = { + uploadApiEndpoint: '/api/upload/', + token: 'test-token', + csrfToken: 'csrf-token', + onUploadSuccess: vi.fn(), + onUploadError: vi.fn(), +}; + +describe('FileUpload', () => { + beforeEach(() => { + defaultProps.onUploadSuccess.mockClear(); + defaultProps.onUploadError.mockClear(); + }); + + it('renders the upload button with the default label', () => { + renderWithProviders(); + expect(screen.getByText('Upload File')).toBeInTheDocument(); + }); + + it('renders a custom upload label', () => { + renderWithProviders(); + expect(screen.getByText('Attach Document')).toBeInTheDocument(); + }); + + it('renders helper text when provided', () => { + renderWithProviders(); + expect(screen.getByText('Max size 5 MB')).toBeInTheDocument(); + }); + + it('shows "Uploading…" while the request is in-flight', async () => { + let resolveUpload; + global.fetch.mockReturnValue( + new Promise((resolve) => { + resolveUpload = resolve; + }) + ); + const { container } = renderWithProviders(); + const input = container.querySelector('input[type="file"]'); + fireEvent.change(input, { target: { files: [new File(['x'], 'doc.pdf', { type: 'application/pdf' })] } }); + + await waitFor(() => expect(screen.getByText('Uploading...')).toBeInTheDocument()); + + // Settle the promise so act() can clean up + resolveUpload({ ok: true, json: () => Promise.resolve({ file_name: 'doc.pdf' }) }); + }); + + it('shows the filename and remove button after a successful upload', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ file_name: 'report.pdf' }), + }); + const onUploadSuccess = vi.fn(); + const { container } = renderWithProviders( + + ); + fireEvent.change(container.querySelector('input[type="file"]'), { + target: { files: [new File(['x'], 'report.pdf', { type: 'application/pdf' })] }, + }); + + await waitFor(() => expect(screen.getByText('report.pdf')).toBeInTheDocument()); + expect(screen.getByText('Remove File')).toBeInTheDocument(); + expect(onUploadSuccess).toHaveBeenCalledWith({ file_name: 'report.pdf' }); + }); + + it('uses a custom remove label', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ file_name: 'doc.pdf' }), + }); + const { container } = renderWithProviders( + + ); + fireEvent.change(container.querySelector('input[type="file"]'), { + target: { files: [new File(['x'], 'doc.pdf', { type: 'application/pdf' })] }, + }); + + await waitFor(() => expect(screen.getByText('Delete attachment')).toBeInTheDocument()); + }); + + it('shows an error alert when the upload fails', async () => { + global.fetch.mockResolvedValue({ + ok: false, + json: () => Promise.resolve({ error: 'File too large' }), + }); + const { container } = renderWithProviders(); + fireEvent.change(container.querySelector('input[type="file"]'), { + target: { files: [new File(['x'], 'big.pdf', { type: 'application/pdf' })] }, + }); + + await waitFor(() => expect(screen.getByText('File too large')).toBeInTheDocument()); + }); + + it('clears the uploaded file when remove is clicked', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ file_name: 'doc.pdf' }), + }); + const onUploadSuccess = vi.fn(); + const { container } = renderWithProviders( + + ); + fireEvent.change(container.querySelector('input[type="file"]'), { + target: { files: [new File(['x'], 'doc.pdf', { type: 'application/pdf' })] }, + }); + + await waitFor(() => expect(screen.getByText('doc.pdf')).toBeInTheDocument()); + + fireEvent.click(screen.getByText('Remove File')); + + expect(screen.queryByText('doc.pdf')).not.toBeInTheDocument(); + expect(onUploadSuccess).toHaveBeenLastCalledWith({ file_path: null, file_name: null }); + }); + + it('sends the CSRF token and bearer token in the request', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ file_name: 'f.pdf' }), + }); + const { container } = renderWithProviders(); + fireEvent.change(container.querySelector('input[type="file"]'), { + target: { files: [new File(['x'], 'f.pdf', { type: 'application/pdf' })] }, + }); + + await waitFor(() => expect(global.fetch).toHaveBeenCalledOnce()); + const [url, options] = global.fetch.mock.calls[0]; + expect(url).toBe('/api/upload/'); + expect(options.headers['X-CSRFToken']).toBe('csrf-token'); + expect(options.method).toBe('POST'); + }); +}); diff --git a/frontend/src/test/ImageUpload.test.jsx b/frontend/src/test/ImageUpload.test.jsx new file mode 100644 index 0000000..f2feaba --- /dev/null +++ b/frontend/src/test/ImageUpload.test.jsx @@ -0,0 +1,145 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { renderWithProviders } from './test-utils'; +import ImageUpload from '../components/ImageUpload'; + +vi.mock('../render.jsx'); + +describe('ImageUpload', () => { + beforeEach(() => { + global.fetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ file_url: '/media/img.png', file_path: 'img.png' }), + }); + }); + + it('shows the upload button when no initialUrl is given', () => { + renderWithProviders( + + ); + expect(screen.getByText('Upload')).toBeInTheDocument(); + }); + + it('shows the uploaded image when initialUrl is provided', () => { + renderWithProviders( + + ); + expect(screen.getByAltText('Uploaded image')).toHaveAttribute( + 'src', + 'https://example.com/photo.png' + ); + }); + + it('shows the remove button when an image is displayed', () => { + renderWithProviders( + + ); + expect(screen.getByText('Remove')).toBeInTheDocument(); + }); + + it('removes the image when the remove button is clicked', () => { + const onUploadSuccess = vi.fn(); + renderWithProviders( + + ); + fireEvent.click(screen.getByText('Remove')); + expect(screen.queryByAltText('Uploaded image')).not.toBeInTheDocument(); + expect(onUploadSuccess).toHaveBeenCalledWith({ file_url: null, file_path: null }); + }); + + it('rejects WebP files and calls onUploadError', () => { + const onUploadError = vi.fn(); + const { container } = renderWithProviders( + + ); + fireEvent.change(container.querySelector('input[type="file"]'), { + target: { files: [new File(['data'], 'photo.webp', { type: 'image/webp' })] }, + }); + expect(onUploadError).toHaveBeenCalledWith(expect.any(Error)); + expect(onUploadError.mock.calls[0][0].message).toMatch(/webp/i); + }); + + it('rejects non-image files and calls onUploadError', () => { + const onUploadError = vi.fn(); + const { container } = renderWithProviders( + + ); + fireEvent.change(container.querySelector('input[type="file"]'), { + target: { files: [new File(['data'], 'document.pdf', { type: 'application/pdf' })] }, + }); + expect(onUploadError).toHaveBeenCalledWith(expect.any(Error)); + }); + + it('uploads a valid image and shows the result', async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ file_url: '/media/img.png', file_path: 'img.png' }), + }); + const onUploadSuccess = vi.fn(); + const { container } = renderWithProviders( + + ); + fireEvent.change(container.querySelector('input[type="file"]'), { + target: { files: [new File(['data'], 'photo.png', { type: 'image/png' })] }, + }); + + await waitFor(() => + expect(onUploadSuccess).toHaveBeenCalledWith({ + file_url: '/media/img.png', + file_path: 'img.png', + }) + ); + expect(screen.getByAltText('Uploaded image')).toHaveAttribute('src', '/media/img.png'); + }); + + it('calls onUploadError when the server returns an error response', async () => { + global.fetch.mockResolvedValue({ ok: false, json: () => Promise.resolve({}) }); + const onUploadError = vi.fn(); + const { container } = renderWithProviders( + + ); + fireEvent.change(container.querySelector('input[type="file"]'), { + target: { files: [new File(['data'], 'photo.png', { type: 'image/png' })] }, + }); + + await waitFor(() => expect(onUploadError).toHaveBeenCalledWith(expect.any(Error))); + }); + + it('updates the preview when initialUrl prop changes', async () => { + const { rerender } = renderWithProviders( + + ); + expect(screen.getByAltText('Uploaded image')).toHaveAttribute('src', 'https://example.com/old.png'); + + rerender( + + ); + // Rerender without providers — need to re-wrap; verify new URL is reflected + await waitFor(() => + expect(screen.getByAltText('Uploaded image')).toHaveAttribute( + 'src', + 'https://example.com/new.png' + ) + ); + }); +}); diff --git a/frontend/src/test/MenuBar.test.jsx b/frontend/src/test/MenuBar.test.jsx new file mode 100644 index 0000000..c3f6cc6 --- /dev/null +++ b/frontend/src/test/MenuBar.test.jsx @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from './test-utils'; +import MenuBar from '../components/MenuBar'; + +vi.mock('../render.jsx'); + +// In jsdom, window.matchMedia always reports no matches, which makes MUI's +// useMediaQuery return false. That causes the nav Drawer to use the +// "temporary" variant (closed by default), so nav links are unmounted. +// Mocking useMediaQuery to return true simulates an md+ screen, so the +// Drawer is rendered as "permanent" and links are always in the DOM. +vi.mock('@mui/material', async () => ({ + ...(await vi.importActual('@mui/material')), + useMediaQuery: vi.fn(() => true), +})); + +// --------------------------------------------------------------------------- +// Default fetch mock — returns empty organizations and healthy job status. +// Individual tests override this with vi.fn().mockImplementation when needed. +// --------------------------------------------------------------------------- +function setupDefaultFetch() { + global.fetch.mockImplementation((url) => { + if (url.includes('/status/jobs/')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ jobs: { deliver_contents: null } }), + }); + } + if (url.includes('/organizations/')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ organizations: [] }), + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + }); +} + +const defaultProps = { + activeOrganizationId: null, + changeOrganizationCallback: vi.fn(), + showOrganizationSwitcher: true, + drawerWidth: 250, +}; + +describe('MenuBar', () => { + beforeEach(() => { + setupDefaultFetch(); + defaultProps.changeOrganizationCallback.mockClear(); + }); + + it('renders the logo image', () => { + renderWithProviders(); + const logos = screen.getAllByAltText('Logo'); + expect(logos.length).toBeGreaterThan(0); + }); + + it('always shows the Courses navigation link', () => { + renderWithProviders(); + expect(screen.getByText('Courses')).toBeInTheDocument(); + }); + + it('does not show Organizations link for a regular user', () => { + renderWithProviders(); + expect(screen.queryByText('Organizations')).not.toBeInTheDocument(); + }); + + it('does not show Learners link for a regular user', () => { + renderWithProviders(); + expect(screen.queryByText('Learners')).not.toBeInTheDocument(); + }); + + it('shows Organizations link for organization admin', () => { + renderWithProviders(, { + appContext: { isOrganizationAdmin: true }, + }); + expect(screen.getByText('Organizations')).toBeInTheDocument(); + }); + + it('shows Learners link for organization admin', () => { + renderWithProviders(, { + appContext: { isOrganizationAdmin: true }, + }); + expect(screen.getByText('Learners')).toBeInTheDocument(); + }); + + it('shows Learners link for instructor', () => { + renderWithProviders(, { + appContext: { isInstructor: true }, + }); + expect(screen.getByText('Learners')).toBeInTheDocument(); + }); + + it('shows Learners link for platform admin', () => { + renderWithProviders(, { + appContext: { isPlatformAdmin: true }, + }); + expect(screen.getByText('Learners')).toBeInTheDocument(); + }); + + it('shows Settings menu item with API Keys for platform admin', () => { + renderWithProviders(, { + appContext: { isPlatformAdmin: true }, + }); + expect(screen.getByText('Settings')).toBeInTheDocument(); + }); + + it('populates the organization selector after fetch', async () => { + global.fetch.mockImplementation((url) => { + if (url.includes('/organizations/')) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ organizations: [{ id: '1', name: 'Acme Corp' }] }), + }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ jobs: { deliver_contents: null } }), + }); + }); + + renderWithProviders(); + + await waitFor(() => expect(screen.getByText('Acme Corp')).toBeInTheDocument()); + }); + + it('hides the organization selector when showOrganizationSwitcher is false', () => { + renderWithProviders(); + // No combobox / select for org switching + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + }); + + it('shows content delivery chip for platform admin when job status is present', async () => { + global.fetch.mockImplementation((url) => { + if (url.includes('/status/jobs/')) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + jobs: { + deliver_contents: { + job_health_status: 'healthy', + last_execution_started_at: null, + }, + }, + }), + }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ organizations: [] }), + }); + }); + + renderWithProviders(, { + appContext: { isPlatformAdmin: true }, + }); + + await waitFor(() => + expect(screen.getByText('Content delivery')).toBeInTheDocument() + ); + }); + + it('expands Settings sub-menu on click to reveal API Keys', async () => { + const user = userEvent.setup(); + renderWithProviders(, { + appContext: { isPlatformAdmin: true }, + }); + + await user.click(screen.getByText('Settings')); + expect(screen.getByText('API Keys')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/RequiredTextField.test.jsx b/frontend/src/test/RequiredTextField.test.jsx new file mode 100644 index 0000000..dd89f00 --- /dev/null +++ b/frontend/src/test/RequiredTextField.test.jsx @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, fireEvent } from '@testing-library/react'; +import { renderWithProviders } from './test-utils'; +import RequiredTextField from '../components/RequiredTextField'; + +vi.mock('../render.jsx'); + +describe('RequiredTextField', () => { + it('renders the label text', () => { + renderWithProviders(); + // getByLabelText finds the input whose label matches — confirms the label is rendered + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + }); + + it('marks the input as required', () => { + renderWithProviders(); + expect(screen.getByRole('textbox')).toBeRequired(); + }); + + it('renders helper text', () => { + renderWithProviders( + + ); + expect(screen.getByText('Enter full name')).toBeInTheDocument(); + }); + + it('renders in error state with aria-invalid and helper text', () => { + renderWithProviders( + + ); + expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'true'); + expect(screen.getByText('This field is required')).toBeInTheDocument(); + }); + + it('fires onChange when user types', () => { + const onChange = vi.fn(); + renderWithProviders(); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Jane' } }); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('applies dir="rtl" when context direction is rtl', () => { + const { container } = renderWithProviders( + , + { appContext: { direction: 'rtl' } } + ); + expect(container.querySelector('[dir="rtl"]')).toBeInTheDocument(); + }); + + it('passes extra sx and props through to TextField', () => { + renderWithProviders( + + ); + expect(screen.getByTestId('user-input')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/ThemeSwitcher.test.jsx b/frontend/src/test/ThemeSwitcher.test.jsx new file mode 100644 index 0000000..86c539b --- /dev/null +++ b/frontend/src/test/ThemeSwitcher.test.jsx @@ -0,0 +1,44 @@ +import { describe, it, expect, vi } from 'vitest'; +import { screen, fireEvent } from '@testing-library/react'; +import { renderWithProviders } from './test-utils'; +import { lightTheme, darkTheme } from '../theme/themes'; +import ThemeSwitcher from '../components/ThemeSwitcher'; + +vi.mock('../render.jsx'); + +describe('ThemeSwitcher', () => { + it('shows "Dark" button when rendered with the light theme', () => { + renderWithProviders(, { theme: lightTheme }); + expect(screen.getByText('Dark')).toBeInTheDocument(); + }); + + it('shows "Light" button when rendered with the dark theme', () => { + renderWithProviders(, { theme: darkTheme }); + expect(screen.getByText('Light')).toBeInTheDocument(); + }); + + it('stores "dark" in localStorage when switching from light theme', () => { + renderWithProviders(, { theme: lightTheme }); + fireEvent.click(screen.getByRole('button')); + expect(localStorage.setItem).toHaveBeenCalledWith('theme', 'dark'); + }); + + it('stores "light" in localStorage when switching from dark theme', () => { + renderWithProviders(, { theme: darkTheme }); + fireEvent.click(screen.getByRole('button')); + expect(localStorage.setItem).toHaveBeenCalledWith('theme', 'light'); + }); + + it('toggles the button label after click', () => { + renderWithProviders(, { theme: lightTheme }); + expect(screen.getByText('Dark')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button')); + expect(screen.getByText('Light')).toBeInTheDocument(); + }); + + it('shows the Dark mode icon in light theme', () => { + const { container } = renderWithProviders(, { theme: lightTheme }); + // DarkModeRoundedIcon is rendered as an SVG inside the button + expect(container.querySelector('button svg')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/setup.js b/frontend/src/test/setup.js index 25633c7..2a8e2fd 100644 --- a/frontend/src/test/setup.js +++ b/frontend/src/test/setup.js @@ -47,9 +47,31 @@ vi.mock('../assets/logo-h-dark.png', () => ({ default: 'logo-h-dark.png' })); vi.mock('../assets/logo-v-light.png', () => ({ default: 'logo-v-light.png' })); vi.mock('../assets/logo-v-dark.png', () => ({ default: 'logo-v-dark.png' })); +// Mock Vite-specific virtual module (not available in jsdom environment) +vi.mock('vite/modulepreload-polyfill', () => ({})); + +// Mock ldrs animation library (uses custom elements / browser APIs unavailable in jsdom) +vi.mock('ldrs/react', () => ({ ChaoticOrbit: () => null })); +vi.mock('ldrs/react/ChaoticOrbit.css', () => ({})); + // Reset mocks between tests beforeEach(() => { vi.clearAllMocks(); localStorageMock.clear(); global.fetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({}) }); }); + +// ProseMirror's scroll-to-selection requires getClientRects / getBoundingClientRect +// on Text nodes and Range objects — neither is implemented by jsdom. +// Use plain functions (not vi.fn) so vi.clearAllMocks() in beforeEach does not +// reset the implementation and leave them returning undefined. +const _emptyRects = Object.assign([], { item: () => null }); +if (typeof Text !== 'undefined') { + Text.prototype.getClientRects = () => _emptyRects; + Text.prototype.getBoundingClientRect = () => new DOMRect(0, 0, 0, 0); +} +if (typeof Range !== 'undefined' && !Range.prototype.getClientRects) { + Range.prototype.getClientRects = () => _emptyRects; +} +// Also patch Element in case jsdom's own stub is absent in some test envs +Element.prototype.getClientRects = () => _emptyRects;