- test_anime_endpoints.py: Minor updates - test_download_retry.py: Refinements - test_i18n.js: Updates - test_tmdb_client.py: Improvements - test_tmdb_rate_limiting.py: Test enhancements - test_user_preferences.js: Updates
497 lines
18 KiB
JavaScript
497 lines
18 KiB
JavaScript
/**
|
|
* Unit tests for internationalization (i18n) functionality
|
|
* Tests translation loading, language switching, and text updates
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, 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');
|
|
});
|
|
});
|
|
});
|