- Add safety checks for window.openTTSModal and window.openTranslateModal availability - Implement fallback dynamic imports if window functions not available - Prevents "window.openTTSModal is not a function" and "window.openTranslateModal is not a function" errors - Provides graceful error handling with user feedback if modal imports fail - Resolves loading order dependency issues between ui.js and embed modal This ensures modal buttons work regardless of script loading order.
573 lines
No EOL
20 KiB
JavaScript
573 lines
No EOL
20 KiB
JavaScript
// Jest tests for Translate Modal functionality
|
|
import { jest, describe, test, expect, beforeEach } from '@jest/globals';
|
|
|
|
// Mock the dependencies before importing the module
|
|
jest.mock('../../src/config.js', () => ({
|
|
API_KEY: 'mock-api-key'
|
|
}));
|
|
|
|
jest.mock('../../src/ui-themes.js', () => ({
|
|
detectCurrentTheme: jest.fn(() => 'light'),
|
|
getThemeColors: jest.fn(() => ({
|
|
bg: '#ffffff',
|
|
text: '#000000',
|
|
accent: '#007bff',
|
|
border: '#dddddd'
|
|
}))
|
|
}));
|
|
|
|
jest.mock('../../src/ui-translations.js', () => ({
|
|
getUIText: jest.fn((key) => `mock-${key}`),
|
|
getUserLanguagePreference: jest.fn(() => 'en')
|
|
}));
|
|
|
|
jest.mock('../../src/language-detection.js', () => ({
|
|
detectPageLanguage: jest.fn(() => Promise.resolve('en'))
|
|
}));
|
|
|
|
jest.mock('../../src/translation.js', () => ({
|
|
NATIVE_LANGUAGE_NAMES: {
|
|
'en': 'English',
|
|
'es': 'Español',
|
|
'fr': 'Français',
|
|
'de': 'Deutsch',
|
|
'zh': '中文',
|
|
'ja': '日本語',
|
|
'ko': '한국어',
|
|
'ar': 'العربية',
|
|
'hi': 'हिन्दी',
|
|
'pt': 'Português'
|
|
}
|
|
}));
|
|
|
|
// Import the module under test
|
|
let openTranslateModal;
|
|
|
|
describe('Translate Modal', () => {
|
|
beforeEach(async () => {
|
|
// Reset all mocks
|
|
global.resetMocks();
|
|
|
|
// Clear any existing modals
|
|
const existingModal = document.getElementById('translate-modal');
|
|
if (existingModal) {
|
|
existingModal.remove();
|
|
}
|
|
|
|
// Mock fetch for translation API calls
|
|
global.fetch.mockImplementation((url, options) => {
|
|
if (url.includes('chat/completions') || url.includes('translate')) {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
body: {
|
|
getReader: () => ({
|
|
read: jest.fn()
|
|
.mockResolvedValueOnce({
|
|
done: false,
|
|
value: new TextEncoder().encode('data: {"choices":[{"delta":{"content":"Translated text here"}}]}\n\n')
|
|
})
|
|
.mockResolvedValueOnce({
|
|
done: true
|
|
})
|
|
})
|
|
}
|
|
});
|
|
}
|
|
return Promise.reject(new Error('Unexpected URL'));
|
|
});
|
|
|
|
// Dynamically import the module to ensure fresh state
|
|
const module = await import('../../src/translate-modal.js');
|
|
openTranslateModal = module.openTranslateModal;
|
|
});
|
|
|
|
describe('Modal Creation and Display', () => {
|
|
test('should create translate modal with correct structure', async () => {
|
|
await openTranslateModal('Hello world', 'en');
|
|
|
|
const modal = document.getElementById('translate-modal');
|
|
expect(modal).toBeTruthy();
|
|
expect(modal.tagName).toBe('DIALOG');
|
|
|
|
// Check modal structure
|
|
const article = modal.querySelector('article');
|
|
expect(article).toBeTruthy();
|
|
|
|
const header = modal.querySelector('header');
|
|
expect(header).toBeTruthy();
|
|
expect(header.textContent).toContain('mock-'); // Should contain mocked UI text
|
|
|
|
const closeButton = modal.querySelector('button[data-close]');
|
|
expect(closeButton).toBeTruthy();
|
|
});
|
|
|
|
test('should open modal with showModal method', async () => {
|
|
const showModalSpy = jest.spyOn(HTMLDialogElement.prototype, 'showModal');
|
|
|
|
await openTranslateModal('Test content', 'en');
|
|
|
|
expect(showModalSpy).toHaveBeenCalled();
|
|
|
|
const modal = document.getElementById('translate-modal');
|
|
expect(modal.open).toBe(true);
|
|
});
|
|
|
|
test('should prevent opening multiple translate modals', async () => {
|
|
await openTranslateModal('First content', 'en');
|
|
const firstModal = document.getElementById('translate-modal');
|
|
|
|
await openTranslateModal('Second content', 'fr');
|
|
const modals = document.querySelectorAll('#translate-modal');
|
|
|
|
expect(modals.length).toBe(1);
|
|
expect(firstModal).toBe(document.getElementById('translate-modal'));
|
|
});
|
|
|
|
test('should handle empty or null content gracefully', async () => {
|
|
await openTranslateModal('', 'en');
|
|
let modal = document.getElementById('translate-modal');
|
|
expect(modal).toBeTruthy();
|
|
|
|
await openTranslateModal(null, 'en');
|
|
modal = document.getElementById('translate-modal');
|
|
expect(modal).toBeTruthy();
|
|
|
|
await openTranslateModal(undefined, 'en');
|
|
modal = document.getElementById('translate-modal');
|
|
expect(modal).toBeTruthy();
|
|
});
|
|
|
|
test('should display original text in textarea', async () => {
|
|
const originalText = 'Hello, this is a test message for translation.';
|
|
await openTranslateModal(originalText, 'en');
|
|
|
|
const originalTextArea = document.querySelector('#original-text, textarea[data-original]');
|
|
expect(originalTextArea).toBeTruthy();
|
|
expect(originalTextArea.value).toBe(originalText);
|
|
});
|
|
});
|
|
|
|
describe('Language Selection', () => {
|
|
test('should create source language dropdown', async () => {
|
|
await openTranslateModal('Test content', 'en');
|
|
|
|
const sourceSelect = document.querySelector('#source-language, select[data-source]');
|
|
expect(sourceSelect).toBeTruthy();
|
|
expect(sourceSelect.tagName).toBe('SELECT');
|
|
});
|
|
|
|
test('should create target language dropdown', async () => {
|
|
await openTranslateModal('Test content', 'en');
|
|
|
|
const targetSelect = document.querySelector('#target-language, select[data-target]');
|
|
expect(targetSelect).toBeTruthy();
|
|
expect(targetSelect.tagName).toBe('SELECT');
|
|
});
|
|
|
|
test('should populate language options correctly', async () => {
|
|
await openTranslateModal('Test content', 'en');
|
|
|
|
const sourceSelect = document.querySelector('#source-language, select[data-source]');
|
|
const targetSelect = document.querySelector('#target-language, select[data-target]');
|
|
|
|
const sourceOptions = sourceSelect.querySelectorAll('option');
|
|
const targetOptions = targetSelect.querySelectorAll('option');
|
|
|
|
expect(sourceOptions.length).toBeGreaterThan(5);
|
|
expect(targetOptions.length).toBeGreaterThan(5);
|
|
|
|
// Check for common languages
|
|
const sourceValues = Array.from(sourceOptions).map(opt => opt.value);
|
|
const targetValues = Array.from(targetOptions).map(opt => opt.value);
|
|
|
|
expect(sourceValues).toContain('en');
|
|
expect(sourceValues).toContain('es');
|
|
expect(targetValues).toContain('fr');
|
|
expect(targetValues).toContain('de');
|
|
});
|
|
|
|
test('should set detected source language automatically', async () => {
|
|
await openTranslateModal('Test content', 'es');
|
|
|
|
const sourceSelect = document.querySelector('#source-language, select[data-source]');
|
|
expect(sourceSelect.value).toBe('es');
|
|
});
|
|
|
|
test('should update language selection on change', async () => {
|
|
await openTranslateModal('Test content', 'en');
|
|
|
|
const targetSelect = document.querySelector('#target-language, select[data-target]');
|
|
targetSelect.value = 'fr';
|
|
targetSelect.dispatchEvent(new Event('change'));
|
|
|
|
expect(targetSelect.value).toBe('fr');
|
|
});
|
|
|
|
test('should have language swap functionality', async () => {
|
|
await openTranslateModal('Test content', 'en');
|
|
|
|
const sourceSelect = document.querySelector('#source-language, select[data-source]');
|
|
const targetSelect = document.querySelector('#target-language, select[data-target]');
|
|
const swapButton = document.querySelector('#swap-languages, button[data-swap]');
|
|
|
|
sourceSelect.value = 'en';
|
|
targetSelect.value = 'fr';
|
|
|
|
if (swapButton) {
|
|
swapButton.click();
|
|
|
|
expect(sourceSelect.value).toBe('fr');
|
|
expect(targetSelect.value).toBe('en');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Translation Process', () => {
|
|
test('should initiate translation when translate button is clicked', async () => {
|
|
await openTranslateModal('Hello world', 'en');
|
|
|
|
const translateButton = document.querySelector('#translate-btn, button[data-translate]');
|
|
expect(translateButton).toBeTruthy();
|
|
|
|
translateButton.click();
|
|
|
|
// Wait for async operation
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: expect.objectContaining({
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer mock-api-key'
|
|
}),
|
|
body: expect.stringContaining('Hello world')
|
|
})
|
|
);
|
|
});
|
|
|
|
test('should disable translate button during translation', async () => {
|
|
await openTranslateModal('Test content', 'en');
|
|
|
|
const translateButton = document.querySelector('#translate-btn, button[data-translate]');
|
|
const originalText = translateButton.textContent;
|
|
|
|
translateButton.click();
|
|
|
|
expect(translateButton.disabled).toBe(true);
|
|
expect(translateButton.textContent).not.toBe(originalText);
|
|
});
|
|
|
|
test('should display translation results', async () => {
|
|
await openTranslateModal('Hello world', 'en');
|
|
|
|
const translateButton = document.querySelector('#translate-btn, button[data-translate]');
|
|
translateButton.click();
|
|
|
|
// Wait for translation to complete
|
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
|
|
const resultArea = document.querySelector('#translation-result, textarea[data-result]');
|
|
expect(resultArea).toBeTruthy();
|
|
expect(resultArea.value).toContain('Translated text');
|
|
});
|
|
|
|
test('should show error message on translation failure', async () => {
|
|
global.fetch.mockRejectedValueOnce(new Error('Translation API Error'));
|
|
|
|
await openTranslateModal('Test content', 'en');
|
|
|
|
const translateButton = document.querySelector('#translate-btn, button[data-translate]');
|
|
translateButton.click();
|
|
|
|
// Wait for error handling
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
// Check for error message
|
|
const errorElement = document.querySelector('.error-message, [data-error]');
|
|
const hasError = errorElement || translateButton.textContent.includes('Error') || translateButton.textContent.includes('Failed');
|
|
expect(hasError).toBeTruthy();
|
|
});
|
|
|
|
test('should handle streaming translation response', async () => {
|
|
await openTranslateModal('Test content', 'en');
|
|
|
|
const translateButton = document.querySelector('#translate-btn, button[data-translate]');
|
|
translateButton.click();
|
|
|
|
// Wait for streaming to complete
|
|
await new Promise(resolve => setTimeout(resolve, 150));
|
|
|
|
const resultArea = document.querySelector('#translation-result, textarea[data-result]');
|
|
expect(resultArea.value.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('Copy Functionality', () => {
|
|
test('should create copy button for translation result', async () => {
|
|
await openTranslateModal('Hello world', 'en');
|
|
|
|
const translateButton = document.querySelector('#translate-btn, button[data-translate]');
|
|
translateButton.click();
|
|
|
|
// Wait for translation
|
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
|
|
const copyButton = document.querySelector('#copy-translation, button[data-copy]');
|
|
expect(copyButton).toBeTruthy();
|
|
});
|
|
|
|
test('should copy translation to clipboard when copy button is clicked', async () => {
|
|
// Mock clipboard API
|
|
Object.assign(navigator, {
|
|
clipboard: {
|
|
writeText: jest.fn(() => Promise.resolve())
|
|
}
|
|
});
|
|
|
|
await openTranslateModal('Hello world', 'en');
|
|
|
|
const translateButton = document.querySelector('#translate-btn, button[data-translate]');
|
|
translateButton.click();
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
|
|
const copyButton = document.querySelector('#copy-translation, button[data-copy]');
|
|
if (copyButton) {
|
|
copyButton.click();
|
|
|
|
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
|
expect.stringContaining('Translated text')
|
|
);
|
|
}
|
|
});
|
|
|
|
test('should show copy success feedback', async () => {
|
|
Object.assign(navigator, {
|
|
clipboard: {
|
|
writeText: jest.fn(() => Promise.resolve())
|
|
}
|
|
});
|
|
|
|
await openTranslateModal('Hello world', 'en');
|
|
|
|
const translateButton = document.querySelector('#translate-btn, button[data-translate]');
|
|
translateButton.click();
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
|
|
const copyButton = document.querySelector('#copy-translation, button[data-copy]');
|
|
if (copyButton) {
|
|
const originalText = copyButton.textContent;
|
|
copyButton.click();
|
|
|
|
// Should show feedback briefly
|
|
expect(copyButton.textContent).not.toBe(originalText);
|
|
|
|
// Should restore original text after timeout
|
|
setTimeout(() => {
|
|
expect(copyButton.textContent).toBe(originalText);
|
|
}, 2000);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Text Editing', () => {
|
|
test('should allow editing original text', async () => {
|
|
await openTranslateModal('Original text', 'en');
|
|
|
|
const originalTextArea = document.querySelector('#original-text, textarea[data-original]');
|
|
originalTextArea.value = 'Modified text';
|
|
originalTextArea.dispatchEvent(new Event('input'));
|
|
|
|
expect(originalTextArea.value).toBe('Modified text');
|
|
});
|
|
|
|
test('should retranslate when original text is modified and translate button is clicked', async () => {
|
|
await openTranslateModal('Original text', 'en');
|
|
|
|
const originalTextArea = document.querySelector('#original-text, textarea[data-original]');
|
|
const translateButton = document.querySelector('#translate-btn, button[data-translate]');
|
|
|
|
originalTextArea.value = 'Modified text for translation';
|
|
originalTextArea.dispatchEvent(new Event('input'));
|
|
|
|
translateButton.click();
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
expect.objectContaining({
|
|
body: expect.stringContaining('Modified text for translation')
|
|
})
|
|
);
|
|
});
|
|
|
|
test('should handle very long text input', async () => {
|
|
const longText = 'A'.repeat(5000);
|
|
await openTranslateModal(longText, 'en');
|
|
|
|
const originalTextArea = document.querySelector('#original-text, textarea[data-original]');
|
|
expect(originalTextArea.value).toBe(longText);
|
|
|
|
const translateButton = document.querySelector('#translate-btn, button[data-translate]');
|
|
expect(() => translateButton.click()).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('Modal Closing', () => {
|
|
test('should close modal when close button is clicked', async () => {
|
|
await openTranslateModal('Test content', 'en');
|
|
|
|
const modal = document.getElementById('translate-modal');
|
|
const closeButton = modal.querySelector('button[data-close], .close-btn');
|
|
|
|
const closeSpy = jest.spyOn(modal, 'close');
|
|
closeButton.click();
|
|
|
|
expect(closeSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
test('should remove modal from DOM when closed', async () => {
|
|
await openTranslateModal('Test content', 'en');
|
|
|
|
const modal = document.getElementById('translate-modal');
|
|
const closeButton = modal.querySelector('button[data-close], .close-btn');
|
|
|
|
closeButton.click();
|
|
|
|
// Wait for cleanup
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
|
|
expect(document.getElementById('translate-modal')).toBeFalsy();
|
|
});
|
|
|
|
test('should handle escape key to close modal', async () => {
|
|
await openTranslateModal('Test content', 'en');
|
|
|
|
const modal = document.getElementById('translate-modal');
|
|
const closeSpy = jest.spyOn(modal, 'close');
|
|
|
|
const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape' });
|
|
document.dispatchEvent(escapeEvent);
|
|
|
|
expect(closeSpy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Accessibility', () => {
|
|
test('should have proper ARIA attributes', async () => {
|
|
await openTranslateModal('Test content', 'en');
|
|
|
|
const modal = document.getElementById('translate-modal');
|
|
|
|
// Check for accessibility attributes
|
|
expect(modal.getAttribute('role') || modal.tagName === 'DIALOG').toBeTruthy();
|
|
expect(modal.getAttribute('aria-modal')).toBe('true');
|
|
|
|
const textareas = modal.querySelectorAll('textarea');
|
|
textareas.forEach(textarea => {
|
|
expect(textarea.getAttribute('aria-label')).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
test('should have proper keyboard navigation', async () => {
|
|
await openTranslateModal('Test content', 'en');
|
|
|
|
const modal = document.getElementById('translate-modal');
|
|
const focusableElements = modal.querySelectorAll(
|
|
'button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
);
|
|
|
|
expect(focusableElements.length).toBeGreaterThan(0);
|
|
|
|
// First focusable element should receive focus
|
|
expect(document.activeElement).toBe(focusableElements[0]);
|
|
});
|
|
|
|
test('should have proper labels for form controls', async () => {
|
|
await openTranslateModal('Test content', 'en');
|
|
|
|
const selects = document.querySelectorAll('select');
|
|
const textareas = document.querySelectorAll('textarea');
|
|
|
|
selects.forEach(select => {
|
|
const hasLabel = select.getAttribute('aria-label') ||
|
|
document.querySelector(`label[for="${select.id}"]`) ||
|
|
select.closest('label');
|
|
expect(hasLabel).toBeTruthy();
|
|
});
|
|
|
|
textareas.forEach(textarea => {
|
|
const hasLabel = textarea.getAttribute('aria-label') ||
|
|
document.querySelector(`label[for="${textarea.id}"]`) ||
|
|
textarea.closest('label');
|
|
expect(hasLabel).toBeTruthy();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Error Handling', () => {
|
|
test('should handle network errors gracefully', async () => {
|
|
global.fetch.mockRejectedValueOnce(new Error('Network error'));
|
|
|
|
expect(async () => {
|
|
await openTranslateModal('Test content', 'en');
|
|
const translateButton = document.querySelector('#translate-btn, button[data-translate]');
|
|
translateButton.click();
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
}).not.toThrow();
|
|
});
|
|
|
|
test('should handle invalid language codes', async () => {
|
|
await openTranslateModal('Test content', 'invalid-lang');
|
|
|
|
const sourceSelect = document.querySelector('#source-language, select[data-source]');
|
|
|
|
// Should fallback to a valid language or show error
|
|
expect(sourceSelect.value).not.toBe('invalid-lang');
|
|
});
|
|
|
|
test('should handle API rate limiting', async () => {
|
|
global.fetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 429,
|
|
statusText: 'Too Many Requests'
|
|
});
|
|
|
|
await openTranslateModal('Test content', 'en');
|
|
|
|
const translateButton = document.querySelector('#translate-btn, button[data-translate]');
|
|
|
|
expect(async () => {
|
|
translateButton.click();
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
}).not.toThrow();
|
|
});
|
|
|
|
test('should handle malformed API response', async () => {
|
|
global.fetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
body: {
|
|
getReader: () => ({
|
|
read: jest.fn().mockResolvedValue({
|
|
done: false,
|
|
value: new TextEncoder().encode('invalid json response')
|
|
})
|
|
})
|
|
}
|
|
});
|
|
|
|
await openTranslateModal('Test content', 'en');
|
|
|
|
const translateButton = document.querySelector('#translate-btn, button[data-translate]');
|
|
|
|
expect(async () => {
|
|
translateButton.click();
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
}).not.toThrow();
|
|
});
|
|
});
|
|
}); |