test: Add tests for localization (#2421)

This commit is contained in:
Dermot Duffy
2026-06-30 17:45:12 -07:00
committed by dermotduffy
parent 62fe65556e
commit 050c2f7c1a
3 changed files with 263 additions and 3 deletions
+1 -3
View File
@@ -70,9 +70,7 @@ export const loadLanguages = async (hass: HomeAssistant): Promise<boolean> => {
languages[lang] = await import('./languages/pt-BR.json');
}
if (lang) {
advancedCameraCardLanguage = lang;
}
advancedCameraCardLanguage = lang;
return true;
};
+261
View File
@@ -0,0 +1,261 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { type MockProxy, mock } from 'vitest-mock-extended';
import type { HomeAssistant } from '../../src/ha/types';
// localize.ts stores the active language and loaded translations in module-level
// variables. resetModules() clears Vitest's module cache so each test gets a
// fresh import with those variables re-initialized.
const importFresh = async () => {
vi.resetModules();
return await import('../../src/localize/localize');
};
const stubGlobals = (): MockProxy<Storage> => {
const storage = mock<Storage>();
storage.getItem.mockReturnValue(null);
vi.stubGlobal('localStorage', storage);
vi.stubGlobal('navigator', { languages: [] });
return storage;
};
describe('getLanguage', () => {
let storage: MockProxy<Storage>;
beforeEach(() => {
storage = stubGlobals();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('should return hass.language when available', async () => {
const { getLanguage } = await importFresh();
const hass = mock<HomeAssistant>();
hass.language = 'fr';
hass.selectedLanguage = null;
expect(getLanguage(hass)).toBe('fr');
});
it('should return hass.selectedLanguage when language is not set', async () => {
const { getLanguage } = await importFresh();
const hass = mock<HomeAssistant>();
// language must be nullish (not empty string) for selectedLanguage fallback.
hass.language = undefined as unknown as string;
hass.selectedLanguage = 'de';
expect(getLanguage(hass)).toBe('de');
});
it('should canonicalize hyphens to underscores', async () => {
const { getLanguage } = await importFresh();
const hass = mock<HomeAssistant>();
hass.language = 'pt-BR';
expect(getLanguage(hass)).toBe('pt_BR');
});
it('should fall back to localStorage selectedLanguage', async () => {
const { getLanguage } = await importFresh();
storage.getItem.mockReturnValue(JSON.stringify('it'));
expect(getLanguage()).toBe('it');
});
it('should canonicalize localStorage language', async () => {
const { getLanguage } = await importFresh();
storage.getItem.mockReturnValue(JSON.stringify('pt-BR'));
expect(getLanguage()).toBe('pt_BR');
});
it('should skip null localStorage value', async () => {
const { getLanguage } = await importFresh();
storage.getItem.mockReturnValue(JSON.stringify(null));
expect(getLanguage()).toBe('en');
});
it('should fall back to navigator.languages when matching a loaded language', async () => {
vi.stubGlobal('navigator', { languages: ['en'] });
const { getLanguage } = await importFresh();
expect(getLanguage()).toBe('en');
});
it('should skip non-matching navigator languages', async () => {
vi.stubGlobal('navigator', { languages: ['xx', 'yy'] });
const { getLanguage } = await importFresh();
expect(getLanguage()).toBe('en');
});
it('should return default language when no sources available', async () => {
const { getLanguage } = await importFresh();
expect(getLanguage()).toBe('en');
});
});
describe('loadLanguages', () => {
beforeEach(() => {
stubGlobals();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('should load and use Catalan translations', async () => {
const { loadLanguages, localize } = await importFresh();
const hass = mock<HomeAssistant>();
hass.language = 'ca';
await loadLanguages(hass);
expect(localize('common.live')).toBe('En directe');
});
it('should load and use German translations', async () => {
const { loadLanguages, localize } = await importFresh();
const hass = mock<HomeAssistant>();
hass.language = 'de';
await loadLanguages(hass);
expect(localize('actions.abort')).toBe('Aktion abbrechen');
});
it('should load and use French translations', async () => {
const { loadLanguages, localize } = await importFresh();
const hass = mock<HomeAssistant>();
hass.language = 'fr';
await loadLanguages(hass);
expect(localize('common.live')).toBe('En direct');
});
it('should load and use Italian translations', async () => {
const { loadLanguages, localize } = await importFresh();
const hass = mock<HomeAssistant>();
hass.language = 'it';
await loadLanguages(hass);
expect(localize('common.no_media')).toBe(
'Nessun contenuto multimediale da visualizzare',
);
});
it('should load and use Polish translations', async () => {
const { loadLanguages, localize } = await importFresh();
const hass = mock<HomeAssistant>();
hass.language = 'pl';
await loadLanguages(hass);
expect(localize('actions.abort')).toBe('Przerwano akcję');
});
it('should load and use Portuguese translations', async () => {
const { loadLanguages, localize } = await importFresh();
const hass = mock<HomeAssistant>();
hass.language = 'pt';
await loadLanguages(hass);
expect(localize('common.live')).toBe('Ao Vivo');
});
it('should load and use Brazilian Portuguese translations', async () => {
const { loadLanguages, localize } = await importFresh();
const hass = mock<HomeAssistant>();
hass.language = 'pt_BR';
hass.selectedLanguage = null;
await loadLanguages(hass);
expect(localize('common.live')).toBe('Ao Vivo');
});
it('should fall back to English for unsupported language', async () => {
const { loadLanguages, localize } = await importFresh();
const hass = mock<HomeAssistant>();
hass.language = 'xx';
await loadLanguages(hass);
expect(localize('common.advanced_camera_card')).toBe('Advanced Camera Card');
});
});
describe('localize', () => {
beforeEach(() => {
stubGlobals();
vi.stubGlobal('navigator', { languages: [] });
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('should resolve a dotted key from English', async () => {
const { localize } = await importFresh();
expect(localize('common.advanced_camera_card')).toBe('Advanced Camera Card');
});
it('should use loaded language when available', async () => {
const { loadLanguages, localize } = await importFresh();
const hass = mock<HomeAssistant>();
hass.language = 'de';
await loadLanguages(hass);
// German translation should be used (and it exists).
const result = localize('common.advanced_camera_card');
expect(result).toBeTruthy();
});
it('should fall back to English when primary language key is missing', async () => {
const { loadLanguages, localize } = await importFresh();
const hass = mock<HomeAssistant>();
hass.language = 'de';
await loadLanguages(hass);
// 'common.dismiss' exists only in English, so the German lookup throws and
// falls back to English.
expect(localize('common.dismiss')).toBe('Dismiss');
});
it('should perform search and replace', async () => {
const { localize } = await importFresh();
const result = localize('common.advanced_camera_card', 'Advanced', 'Basic');
expect(result).toBe('Basic Camera Card');
});
it('should not replace when search is empty', async () => {
const { localize } = await importFresh();
expect(localize('common.advanced_camera_card', '', '')).toBe('Advanced Camera Card');
});
it('should throw for completely missing key', async () => {
const { localize } = await importFresh();
// The first reduce throws (caught), the English fallback also throws
// (uncaught), so this will throw a TypeError.
expect(() => localize('nonexistent.deeply.nested.key')).toThrow();
});
it('should return translated value for single-segment key', async () => {
const { localize } = await importFresh();
// 'actions' is a top-level key that resolves to an object, not a string,
// but should not throw.
expect(localize('actions')).toBeTruthy();
});
});
+1
View File
@@ -9,6 +9,7 @@ const FULL_COVERAGE = [
'src/config/**/*.ts',
'src/const.ts',
'src/ha/**/*.ts',
'src/localize/**/*.ts',
'src/types.ts',
'src/utils/**/*.ts',
'src/view/*.ts',