/** * Unit tests for internationalization (i18n) functionality * Tests translation loading, language switching, and text updates */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; describe('Localization', () => { let localization; let originalLocalStorage; let originalNavigator; beforeEach(() => { // Mock localStorage originalLocalStorage = global.localStorage; global.localStorage = { data: {}, getItem(key) { return this.data[key] || null; }, setItem(key, value) { this.data[key] = value; }, removeItem(key) { delete this.data[key]; }, clear() { this.data = {}; } }; // Mock navigator originalNavigator = global.navigator; global.navigator = { language: 'en-US', userLanguage: 'en-US' }; // Mock document global.document = { querySelectorAll: vi.fn(() => []), getElementById: vi.fn(() => null) }; // Load the Localization class const LocalizationClass = class Localization { constructor() { this.currentLanguage = 'en'; this.fallbackLanguage = 'en'; this.translations = {}; this.loadTranslations(); } loadTranslations() { this.translations.en = { 'config-title': 'Configuration', 'toggle-theme': 'Toggle theme', 'search-placeholder': 'Search for anime...', 'series-collection': 'Series Collection', 'download-queue': 'Download Queue', 'loading': 'Loading...', 'close': 'Close', 'ok': 'OK' }; this.translations.de = { 'config-title': 'Konfiguration', 'toggle-theme': 'Design wechseln', 'search-placeholder': 'Nach Anime suchen...', 'series-collection': 'Serien-Sammlung', 'download-queue': 'Download-Warteschlange', 'loading': 'Wird geladen...', 'close': 'Schließen', 'ok': 'OK' }; const savedLanguage = localStorage.getItem('language') || this.detectLanguage(); this.setLanguage(savedLanguage); } detectLanguage() { const browserLang = navigator.language || navigator.userLanguage; const langCode = browserLang.split('-')[0]; return this.translations[langCode] ? langCode : this.fallbackLanguage; } setLanguage(langCode) { if (this.translations[langCode]) { this.currentLanguage = langCode; localStorage.setItem('language', langCode); this.updatePageTexts(); } } getText(key, fallback = key) { const translation = this.translations[this.currentLanguage]; if (translation && translation[key]) { return translation[key]; } const fallbackTranslation = this.translations[this.fallbackLanguage]; if (fallbackTranslation && fallbackTranslation[key]) { return fallbackTranslation[key]; } return fallback; } updatePageTexts() { document.querySelectorAll('[data-text]').forEach(element => { const key = element.getAttribute('data-text'); const text = this.getText(key); if (element.tagName === 'INPUT' && element.type === 'text') { element.placeholder = text; } else { element.textContent = text; } }); } getAvailableLanguages() { return Object.keys(this.translations).map(code => ({ code: code, name: this.getLanguageName(code) })); } getLanguageName(code) { const names = { 'en': 'English', 'de': 'Deutsch' }; return names[code] || code.toUpperCase(); } formatMessage(key, ...args) { let message = this.getText(key); args.forEach((arg, index) => { message = message.replace(`{${index}}`, arg); }); return message; } }; localization = new LocalizationClass(); }); afterEach(() => { global.localStorage = originalLocalStorage; global.navigator = originalNavigator; }); describe('Initialization', () => { it('should initialize with default English language', () => { expect(localization.currentLanguage).toBe('en'); expect(localization.fallbackLanguage).toBe('en'); }); it('should load English translations', () => { expect(localization.translations.en).toBeDefined(); expect(localization.translations.en['config-title']).toBe('Configuration'); }); it('should load German translations', () => { expect(localization.translations.de).toBeDefined(); expect(localization.translations.de['config-title']).toBe('Konfiguration'); }); it('should detect browser language', () => { global.navigator.language = 'de-DE'; const langCode = localization.detectLanguage(); expect(langCode).toBe('de'); }); it('should fallback to English for unsupported languages', () => { global.navigator.language = 'fr-FR'; const langCode = localization.detectLanguage(); expect(langCode).toBe('en'); }); it('should load saved language from localStorage', () => { localStorage.setItem('language', 'de'); const LocalizationClass = localization.constructor; const newInstance = new LocalizationClass(); expect(newInstance.currentLanguage).toBe('de'); }); }); describe('Language Switching', () => { it('should switch to German', () => { localization.setLanguage('de'); expect(localization.currentLanguage).toBe('de'); }); it('should switch to English', () => { localization.setLanguage('en'); expect(localization.currentLanguage).toBe('en'); }); it('should save language preference to localStorage', () => { localization.setLanguage('de'); expect(localStorage.getItem('language')).toBe('de'); }); it('should not switch to unsupported language', () => { const originalLang = localization.currentLanguage; localization.setLanguage('fr'); expect(localization.currentLanguage).toBe(originalLang); }); it('should update page texts when switching language', () => { const mockElements = [ { getAttribute: () => 'config-title', textContent: '', tagName: 'H1' } ]; document.querySelectorAll = vi.fn(() => mockElements); localization.setLanguage('de'); expect(document.querySelectorAll).toHaveBeenCalledWith('[data-text]'); }); }); describe('Text Retrieval', () => { it('should get English text for current language', () => { localization.setLanguage('en'); const text = localization.getText('config-title'); expect(text).toBe('Configuration'); }); it('should get German text for current language', () => { localization.setLanguage('de'); const text = localization.getText('config-title'); expect(text).toBe('Konfiguration'); }); it('should return fallback for missing key in current language', () => { localization.setLanguage('de'); const text = localization.getText('nonexistent-key', 'Fallback'); expect(text).toBe('Fallback'); }); it('should return key as fallback when no translation exists', () => { const text = localization.getText('missing-key'); expect(text).toBe('missing-key'); }); it('should fallback to English for missing German translations', () => { localization.setLanguage('de'); delete localization.translations.de['loading']; const text = localization.getText('loading'); expect(text).toBe('Loading...'); }); }); describe('Page Text Updates', () => { it('should update element text content', () => { const mockElement = { getAttribute: () => 'config-title', textContent: '', tagName: 'H1' }; document.querySelectorAll = vi.fn(() => [mockElement]); localization.setLanguage('en'); localization.updatePageTexts(); expect(mockElement.textContent).toBe('Configuration'); }); it('should update input placeholder', () => { const mockInput = { getAttribute: () => 'search-placeholder', placeholder: '', tagName: 'INPUT', type: 'text' }; document.querySelectorAll = vi.fn(() => [mockInput]); localization.setLanguage('en'); localization.updatePageTexts(); expect(mockInput.placeholder).toBe('Search for anime...'); }); it('should update multiple elements', () => { const mockElements = [ { getAttribute: () => 'config-title', textContent: '', tagName: 'H1' }, { getAttribute: () => 'loading', textContent: '', tagName: 'SPAN' }, { getAttribute: () => 'close', textContent: '', tagName: 'BUTTON' } ]; document.querySelectorAll = vi.fn(() => mockElements); localization.updatePageTexts(); expect(mockElements[0].textContent).toBe('Configuration'); expect(mockElements[1].textContent).toBe('Loading...'); expect(mockElements[2].textContent).toBe('Close'); }); it('should handle missing elements gracefully', () => { document.querySelectorAll = vi.fn(() => []); expect(() => localization.updatePageTexts()).not.toThrow(); }); }); describe('Available Languages', () => { it('should return list of available languages', () => { const languages = localization.getAvailableLanguages(); expect(languages).toHaveLength(2); expect(languages).toContainEqual({ code: 'en', name: 'English' }); expect(languages).toContainEqual({ code: 'de', name: 'Deutsch' }); }); it('should get English language name', () => { const name = localization.getLanguageName('en'); expect(name).toBe('English'); }); it('should get German language name', () => { const name = localization.getLanguageName('de'); expect(name).toBe('Deutsch'); }); it('should return uppercase code for unknown language', () => { const name = localization.getLanguageName('fr'); expect(name).toBe('FR'); }); }); describe('Message Formatting', () => { it('should format message with single argument', () => { localization.translations.en['welcome-message'] = 'Welcome, {0}!'; const formatted = localization.formatMessage('welcome-message', 'John'); expect(formatted).toBe('Welcome, John!'); }); it('should format message with multiple arguments', () => { localization.translations.en['greeting'] = 'Hello {0}, you have {1} new messages'; const formatted = localization.formatMessage('greeting', 'Alice', '5'); expect(formatted).toBe('Hello Alice, you have 5 new messages'); }); it('should handle missing placeholders', () => { localization.translations.en['simple'] = 'No placeholders here'; const formatted = localization.formatMessage('simple', 'unused'); expect(formatted).toBe('No placeholders here'); }); it('should format message in German', () => { localization.setLanguage('de'); localization.translations.de['welcome-message'] = 'Willkommen, {0}!'; const formatted = localization.formatMessage('welcome-message', 'Johann'); expect(formatted).toBe('Willkommen, Johann!'); }); }); describe('Translation Completeness', () => { it('should have same keys in English and German', () => { const enKeys = Object.keys(localization.translations.en); const deKeys = Object.keys(localization.translations.de); expect(enKeys.sort()).toEqual(deKeys.sort()); }); it('should not have empty translations', () => { const checkTranslations = (lang) => { const translations = localization.translations[lang]; Object.entries(translations).forEach(([key, value]) => { expect(value).toBeTruthy(); expect(value.trim()).not.toBe(''); }); }; checkTranslations('en'); checkTranslations('de'); }); it('should have non-identical translations for different languages', () => { const enKeys = Object.keys(localization.translations.en); let identicalCount = 0; enKeys.forEach(key => { if (localization.translations.en[key] === localization.translations.de[key]) { identicalCount++; } }); // Some translations might be identical (e.g., "OK"), but not all expect(identicalCount).toBeLessThan(enKeys.length); }); }); describe('Edge Cases', () => { it('should handle null key', () => { const text = localization.getText(null); expect(text).toBe(null); }); it('should handle undefined key', () => { const text = localization.getText(undefined); expect(text).toBe(undefined); }); it('should handle empty string key', () => { const text = localization.getText(''); expect(text).toBe(''); }); it('should handle rapid language switching', () => { localization.setLanguage('de'); localization.setLanguage('en'); localization.setLanguage('de'); localization.setLanguage('en'); expect(localization.currentLanguage).toBe('en'); }); it('should handle localStorage errors gracefully', () => { const originalSetItem = localStorage.setItem; localStorage.setItem = () => { throw new Error('localStorage full'); }; expect(() => localization.setLanguage('de')).not.toThrow(); localStorage.setItem = originalSetItem; }); it('should handle missing navigator.language', () => { delete global.navigator.language; global.navigator.userLanguage = 'de-DE'; const langCode = localization.detectLanguage(); expect(langCode).toBe('de'); }); it('should handle missing both language properties', () => { delete global.navigator.language; delete global.navigator.userLanguage; const langCode = localization.detectLanguage(); expect(langCode).toBe('en'); }); }); describe('Integration with Document', () => { it('should query for elements with data-text attribute', () => { const spy = vi.spyOn(document, 'querySelectorAll'); localization.updatePageTexts(); expect(spy).toHaveBeenCalledWith('[data-text]'); }); it('should handle elements without getAttribute method', () => { const mockElements = [ { getAttribute: undefined, textContent: '', tagName: 'DIV' } ]; document.querySelectorAll = vi.fn(() => mockElements); expect(() => localization.updatePageTexts()).not.toThrow(); }); it('should handle elements with null attributes', () => { const mockElements = [ { getAttribute: () => null, textContent: '', tagName: 'DIV' } ]; document.querySelectorAll = vi.fn(() => mockElements); expect(() => localization.updatePageTexts()).not.toThrow(); }); }); describe('Persistence', () => { it('should persist language across page reloads', () => { localization.setLanguage('de'); const savedLang = localStorage.getItem('language'); expect(savedLang).toBe('de'); // Simulate page reload const LocalizationClass = localization.constructor; const newInstance = new LocalizationClass(); expect(newInstance.currentLanguage).toBe('de'); }); it('should clear previous language when switching', () => { localization.setLanguage('de'); expect(localStorage.getItem('language')).toBe('de'); localization.setLanguage('en'); expect(localStorage.getItem('language')).toBe('en'); }); }); });