/* * Copyright (C) 2025 - present Instructure, Inc. * * This file is part of Canvas. * * Canvas is free software: you can redistribute it and/or modify it under * the terms of the GNU Affero General Public License as published by the Free * Software Foundation, version 3 of the License. * * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more * details. * * You should have received a copy of the GNU Affero General Public License along * with this program. If not, see . */ import {fireEvent, render, screen, waitFor} from '@testing-library/react' import {vi} from 'vitest' import {ManualCaptionCreator} from '../ManualCaptionCreator' const LIVE_REGION_ID = 'flash_screenreader_holder' const mockLanguages = [ {id: 'en', label: 'English'}, {id: 'es', label: 'Spanish'}, {id: 'fr', label: 'French'}, ] function createValidFile(name = 'captions.vtt', size = 1000): File { const content = new Array(size).fill('a').join('') return new File([content], name, {type: 'text/vtt'}) } function createLargeFile(): File { // Create file larger than 295000 bytes const content = new Array(300000).fill('a').join('') return new File([content], 'large-captions.vtt', {type: 'text/vtt'}) } function renderComponent(overrideProps = {}) { const defaultProps = { languages: mockLanguages, onPrimary: vi.fn(), onCancel: vi.fn(), liveRegion: () => document.getElementById(LIVE_REGION_ID), } return { ...render(), props: {...defaultProps, ...overrideProps}, } } describe('', () => { beforeEach(() => { const liveRegion = document.createElement('div') liveRegion.id = LIVE_REGION_ID liveRegion.setAttribute('role', 'alert') document.body.appendChild(liveRegion) }) afterEach(() => { const liveRegion = document.getElementById(LIVE_REGION_ID) if (liveRegion) { document.body.removeChild(liveRegion) } }) it('shows language required error when no language is selected', () => { const onPrimary = vi.fn() renderComponent({onPrimary}) // Click Upload without selecting language const uploadButton = screen.getByText('Upload') fireEvent.click(uploadButton) // Should show language error expect(screen.getAllByText('Please select a language').length).toBeGreaterThan(0) expect(onPrimary).not.toHaveBeenCalled() }) it('shows file required error when no file is selected', () => { const onPrimary = vi.fn() renderComponent({onPrimary}) // Select a language first - click on placeholder text to open, then click option const selectPlaceholder = screen.getByPlaceholderText('Select Language') fireEvent.click(selectPlaceholder) fireEvent.click(screen.getByText('English')) // Click Upload without selecting file const uploadButton = screen.getByText('Upload') fireEvent.click(uploadButton) // Should show file error expect(screen.getAllByText('Please select a file before uploading.').length).toBeGreaterThan(0) expect(onPrimary).not.toHaveBeenCalled() }) it('shows file validation error when too large file is selected', async () => { renderComponent() // Get the hidden file input and simulate file selection const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement const largeFile = createLargeFile() fireEvent.change(fileInput, {target: {files: [largeFile]}}) // Should show size limit error expect((await screen.findAllByText(/exceeds the 295000 Byte limit/i)).length).toBeGreaterThan(0) }) it('calls onPrimary with correct args when both language and valid file are selected', () => { const onPrimary = vi.fn() renderComponent({onPrimary}) // Select a language - click on placeholder text to open, then click option const selectPlaceholder = screen.getByPlaceholderText('Select Language') fireEvent.click(selectPlaceholder) fireEvent.click(screen.getByText('Spanish')) // Select a valid file const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement const validFile = createValidFile() fireEvent.change(fileInput, {target: {files: [validFile]}}) // Click Upload const uploadButton = screen.getByText('Upload') fireEvent.click(uploadButton) // onPrimary should be called with languageId and file expect(onPrimary).toHaveBeenCalledWith('es', validFile) }) it('cancel button calls onCancel', async () => { const onCancel = vi.fn() renderComponent({onCancel}) const cancelButton = screen.getByText('Cancel') fireEvent.click(cancelButton) await waitFor(() => { expect(onCancel).toHaveBeenCalledTimes(1) }) }) it('language error clears when language selected', async () => { renderComponent() // Click Upload to show error const uploadButton = screen.getByText('Upload') fireEvent.click(uploadButton) // Error should be visible expect(screen.getAllByText('Please select a language').length).toBeGreaterThan(0) // Select a language - click on placeholder text to open, then click option const selectPlaceholder = screen.getByPlaceholderText('Select Language') fireEvent.click(selectPlaceholder) fireEvent.click(screen.getByText('English')) // Error should be cleared expect(screen.queryByText('Please select a language')).not.toBeInTheDocument() }) it('shows file validation error for invalid file extension', () => { renderComponent() const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement const invalidFile = new File(['content'], 'captions.txt', {type: 'text/plain'}) fireEvent.change(fileInput, {target: {files: [invalidFile]}}) expect(screen.getAllByText(/Please select a .vtt or .srt file/i).length).toBeGreaterThan(0) }) it('shows both language and file errors when neither is provided', () => { const onPrimary = vi.fn() renderComponent({onPrimary}) fireEvent.click(screen.getByText('Upload')) expect(screen.getAllByText('Please select a language').length).toBeGreaterThan(0) expect(screen.getAllByText('Please select a file before uploading.').length).toBeGreaterThan(0) expect(onPrimary).not.toHaveBeenCalled() }) it('file validation error clears when valid file selected', async () => { renderComponent() // Select a large file to trigger error const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement const largeFile = createLargeFile() fireEvent.change(fileInput, {target: {files: [largeFile]}}) // Error should be visible expect((await screen.findAllByText(/exceeds the 295000 Byte limit/i)).length).toBeGreaterThan(0) // Select a valid file const validFile = createValidFile() fireEvent.change(fileInput, {target: {files: [validFile]}}) // Error should be cleared await waitFor(() => { expect(screen.queryByText(/exceeds the 295000 Byte limit/i)).not.toBeInTheDocument() }) }) describe('a11y', () => { it('a11y: alerts error messages when they appear', async () => { renderComponent() // Click Upload without selections to trigger errors const uploadButton = screen.getByText('Upload') fireEvent.click(uploadButton) // Check that Alert components are rendered (screen reader announcements) const alerts = document.querySelectorAll('[role="alert"]') await waitFor(() => { expect(alerts.length).toBeGreaterThan(0) }) }) it('a11y: button has aria-describedby linking to format hint and file status', async () => { renderComponent() const chooseFileButton = screen.getByText(/choose file/i).closest('button') // Before file selection: includes both hint and status expect(chooseFileButton).toHaveAttribute('aria-describedby', 'cc-file-hint cc-file-status') expect(document.getElementById('cc-file-hint')).toHaveTextContent(/SRT or WebVTT/i) expect(document.getElementById('cc-file-status')).toHaveTextContent(/no file chosen/i) // Select a file const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement const validFile = createValidFile('my-captions.vtt') fireEvent.change(fileInput, {target: {files: [validFile]}}) // After file selection: hint is dropped, only status remains expect(await screen.findByText('my-captions.vtt')).toBeInTheDocument() expect(chooseFileButton).toHaveAttribute('aria-describedby', 'cc-file-status') expect(document.getElementById('cc-file-status')).toHaveTextContent('my-captions.vtt') }) it('a11y: focuses language select when Upload clicked without language', () => { renderComponent() fireEvent.click(screen.getByText('Upload')) const languageInput = screen.getByPlaceholderText('Select Language') expect(document.activeElement).toBe(languageInput) }) it('a11y: focuses file button when Upload clicked with language but no file', () => { renderComponent() fireEvent.click(screen.getByPlaceholderText('Select Language')) fireEvent.click(screen.getByText('English')) fireEvent.click(screen.getByText('Upload')) const chooseFileButton = screen.getByText(/choose file/i).closest('button') expect(document.activeElement).toBe(chooseFileButton) }) it('a11y: sets aria-invalid on language select when error shown', () => { renderComponent() fireEvent.click(screen.getByText('Upload')) const languageInput = screen.getByPlaceholderText('Select Language') expect(languageInput).toHaveAttribute('aria-invalid', 'true') }) it('a11y: sets aria-invalid on file button when file validation error', () => { renderComponent() fireEvent.click(screen.getByPlaceholderText('Select Language')) fireEvent.click(screen.getByText('English')) fireEvent.click(screen.getByText('Upload')) const chooseFileButton = screen.getByText(/choose file/i).closest('button') expect(chooseFileButton).toHaveAttribute('aria-invalid', 'true') }) it('a11y: aria-describedby switches to error ID on file validation error', () => { renderComponent() fireEvent.click(screen.getByPlaceholderText('Select Language')) fireEvent.click(screen.getByText('English')) fireEvent.click(screen.getByText('Upload')) const chooseFileButton = screen.getByText(/choose file/i).closest('button') expect(chooseFileButton).toHaveAttribute('aria-describedby', 'cc-file-error') }) it('a11y: file button aria-label reflects selection state', () => { renderComponent() const chooseFileButton = screen.getByText(/choose file/i).closest('button') expect(chooseFileButton).toHaveAttribute('aria-label', 'Choose File (required)') const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement const validFile = createValidFile('my-captions.vtt') fireEvent.change(fileInput, {target: {files: [validFile]}}) expect(chooseFileButton).toHaveAttribute('aria-label', 'Selected file: my-captions.vtt') }) it('a11y: announces language error to screen readers via live region', () => { renderComponent() fireEvent.click(screen.getByText('Upload')) const liveRegion = document.getElementById(LIVE_REGION_ID) expect(liveRegion).toHaveTextContent('Please select a language') }) it('a11y: announces file error to screen readers via live region', () => { renderComponent() fireEvent.click(screen.getByPlaceholderText('Select Language')) fireEvent.click(screen.getByText('English')) fireEvent.click(screen.getByText('Upload')) const liveRegion = document.getElementById(LIVE_REGION_ID) expect(liveRegion).toHaveTextContent('Please select a file before uploading.') }) }) describe('Pendo tracking', () => { const mockTrack = vi.fn() beforeEach(() => { ;(window as any).canvasUsageMetrics = {track: mockTrack} }) afterEach(() => { delete (window as any).canvasUsageMetrics }) it('fires canvas_caption_validation_error missing_language when no language is selected', async () => { renderComponent() fireEvent.click(screen.getByText('Upload')) await waitFor(() => { expect(mockTrack).toHaveBeenCalledWith('canvas_caption_validation_error', { type: 'track', flow_type: 'upload_file', error_type: 'missing_language', }) }) }) it('fires canvas_caption_validation_error missing_file when no file is selected', async () => { renderComponent() fireEvent.click(screen.getByPlaceholderText('Select Language')) fireEvent.click(screen.getByText('English')) fireEvent.click(screen.getByText('Upload')) await waitFor(() => { expect(mockTrack).toHaveBeenCalledWith('canvas_caption_validation_error', { type: 'track', flow_type: 'upload_file', error_type: 'missing_file', }) }) }) it('fires canvas_caption_validation_error file_too_large when large file selected', async () => { renderComponent() const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement fireEvent.change(fileInput, {target: {files: [createLargeFile()]}}) await waitFor(() => { expect(mockTrack).toHaveBeenCalledWith('canvas_caption_validation_error', { type: 'track', flow_type: 'upload_file', error_type: 'file_too_large', }) }) }) }) describe('.onDirtyStateChanged', () => { describe('called with true', () => { it('when language is selected', async () => { const onDirtyStateChanged = vi.fn() renderComponent({onDirtyStateChanged}) // Select a language - click on placeholder text to open, then click option const selectPlaceholder = screen.getByPlaceholderText('Select Language') fireEvent.click(selectPlaceholder) fireEvent.click(screen.getByText('English')) await waitFor(() => { expect(onDirtyStateChanged).toHaveBeenCalledWith(true) }) }) it('when file is selected', async () => { const onDirtyStateChanged = vi.fn() renderComponent({onDirtyStateChanged}) const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement const validFile = createValidFile('my-captions.vtt') fireEvent.change(fileInput, {target: {files: [validFile]}}) await waitFor(() => { expect(onDirtyStateChanged).toHaveBeenCalledWith(true) }) }) }) }) })