Files
homeassistant_espscreen/tests/test_language_region.py
T
Max Gramser 365ed158b9 The editor's mockup writes like the screens, and speaks its own language around them
- Numbers in the mockup follow what the app sends the screens (style, grouping from five digits, the space before
  %), a state without a unit stays as it is, and built-in cards carry the screens' names (Instellingen, not the
  editor's Einstellungen).
- Automatic in Language & region is labelled with what it means now (Home Assistant's language decides).
- A screen's status (Synced, Loading tiles, ...) shows in the editor's language; the screens keep reporting it in
  English, which the app reads.
- The doorbell example uses the alert examples of the editor's language; control labels keep their capitals.
- Editor bundle rebuilt with every language's texts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 16:57:35 +02:00

267 lines
15 KiB
Python

"""Settings -> Language & region (app 0.2.90): how the screens write the clock and numbers, the language each screen's
messages go in, the language in the screens' YAML and the 24 hours an app from before it kept."""
import asyncio
import importlib.util
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
sys.path.insert(0, str(ROOT / 'tests'))
import i18n # noqa: E402
import yaml # noqa: E402
from firmware import Firmware, LenientLoader # noqa: E402
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
PROFILE_TAIL = 'packages:\n display: !include /r/packages/core.yaml\n'
def region(tmp, ha='en', **stored):
path = Path(tmp) / 'language.json'
if stored:
path.write_text(json.dumps({'version': 1, **stored}))
return i18n.Region(path, ha_language=lambda: ha)
class NotationTests(unittest.TestCase):
def test_every_home_assistant_language_has_a_notation(self):
tool = importlib.util.spec_from_file_location('i18n_tool', ROOT / 'tools' / 'i18n.py')
module = importlib.util.module_from_spec(tool)
tool.loader.exec_module(module)
self.assertEqual(set(module.HA_LANGUAGES), set(i18n.REGIONS) - {'_about'})
for code, written in i18n.REGIONS.items():
if code == '_about':
continue
self.assertIn(written['clock'], ('12', '24'), code)
self.assertIn(written['numbers'], ('point', 'comma', 'space'), code)
self.assertIn(written['group_min'], (1, 2), code)
def test_automatic_follows_home_assistants_own_language(self):
with tempfile.TemporaryDirectory() as tmp:
# Swedish: English texts (no file yet), Swedish clock and numbers.
sv = region(tmp, 'sv')
self.assertEqual((sv.language(), sv.clock_24h(), sv.number_style(), sv.group_min(), sv.percent_space()),
('en', True, 'space', 1, True))
# Brazilian Portuguese: the Portuguese texts, Brazil's numbers.
br = region(tmp, 'pt-BR')
self.assertEqual((br.language(), br.number_style(), br.group_min()), ('pt', 'comma', 1))
pt = region(tmp, 'pt')
self.assertEqual((pt.number_style(), pt.group_min(), pt.percent_space()), ('space', 2, False))
us, uk = region(tmp, 'en'), region(tmp, 'en-GB')
self.assertEqual((us.clock_24h(), uk.clock_24h(), uk.language()), (False, True, 'en-GB'))
de = region(tmp, 'de')
self.assertEqual((de.number_style(), de.percent_space()), ('comma', True))
def test_a_chosen_language_and_format_win(self):
with tempfile.TemporaryDirectory() as tmp:
chosen = region(tmp, 'nl', language='es', clock='12', numbers='point')
# A chosen format is Home Assistant's for it, which groups from four digits; the space before % stays the
# language's.
self.assertEqual((chosen.language(), chosen.clock_24h(), chosen.number_style(), chosen.group_min()),
('es', False, 'point', 1))
self.assertEqual(region(tmp, 'nl', language='es', clock='auto', numbers='auto').group_min(), 2)
with tempfile.TemporaryDirectory() as tmp:
view = region(tmp, 'de').view()
self.assertEqual({key: view[key] for key in ('group_min', 'percent_space', 'numbers_effective')},
{'group_min': 1, 'percent_space': True, 'numbers_effective': 'comma'})
def test_numbers_and_units_as_home_assistant_writes_them(self):
self.assertEqual(i18n.format_number('1234.5', 'comma', 2), '1234,5')
self.assertEqual(i18n.format_number('12345.5', 'comma', 2), '12.345,5')
self.assertEqual(i18n.format_number('1234', 'space'), '1 234')
self.assertEqual(i18n.format_number('-1234567', 'point'), '-1,234,567')
self.assertEqual(i18n.format_number('on', 'comma'), 'on')
try:
i18n.set_screens('de', 'comma', True, 1, True)
self.assertEqual([i18n.unit_suffix(unit) for unit in ('%', '°', '°C', '', None)], [' %', '°', ' °C', '', ''])
i18n.set_screens('nl', 'comma', True, 1, False)
self.assertEqual(i18n.unit_suffix('%'), '%')
finally:
i18n.set_screens('en', 'point')
def test_plural_rules_match_the_firmware(self):
"""Python's rules against the C++ ones a screen's build writes (screen_text_gen.PLURAL_RULES), n from -30."""
compiler = shutil.which(os.environ.get('CXX', 'clang++')) or shutil.which('g++')
if not compiler:
self.skipTest('no C++ compiler')
spec = importlib.util.spec_from_file_location('gen', ROOT / 'components/smart_display/screen_text_gen.py')
gen = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gen)
numbers = list(range(-30, 131)) + [1001, 1011, 1021, 1112]
with tempfile.TemporaryDirectory() as tmp:
source = Path(tmp) / 'rules.cpp'
functions = '\n'.join(f'int rule_{name}(int n) {{ {body} }}' for name, body in gen.PLURAL_RULES.items())
calls = '\n'.join(f' for (int n : ns) std::printf("{name} %d %d\\n", n, rule_{name}(n));' for name in gen.PLURAL_RULES)
source.write_text('#include <cstdio>\n' + functions + '\nint main() {\n int ns[] = {' + ','.join(map(str, numbers)) +
'};\n' + calls + '\n}\n')
subprocess.run([compiler, '-std=c++17', str(source), '-o', str(Path(tmp) / 'rules')], check=True)
out = subprocess.run([str(Path(tmp) / 'rules')], check=True, capture_output=True, text=True).stdout.split('\n')
for line in filter(None, out):
name, n, index = line.split()
self.assertEqual(i18n.PLURAL[name](int(n)), int(index), f'{name}({n})')
class ScreenContextTests(unittest.TestCase):
def tearDown(self):
i18n.set_screens('en', 'point')
def test_each_screen_gets_the_words_its_firmware_speaks(self):
import header_bar
i18n.set_screens('nl', 'comma', True, 1, False)
legacy = i18n.screen_context({'language': None, 'language_sensor': False})
dutch = i18n.screen_context({'language': 'nl', 'language_sensor': True})
restarting = i18n.screen_context({'language': None, 'language_sensor': True})
self.assertEqual(i18n.screen_t('screen.ha.on'), 'Aan')
for context, word, number, letters in ((legacy, 'On', '1,234.5', 'Waz'), (dutch, 'Aan', '1.234,5', 'Wąż'),
(restarting, 'Aan', '1.234,5', 'Wąż')):
token = i18n.SCREEN.set(context)
try:
self.assertEqual((i18n.screen_t('screen.ha.on'), i18n.screen_number('1234.5')), (word, number))
# Firmware from before the languages lacks most accented letters: they fold to the base letter.
self.assertEqual(header_bar.clean_text('Wąż'), letters)
finally:
i18n.SCREEN.reset(token)
self.assertIsNone(i18n.screen_context(None))
class ProfileLanguageTests(unittest.TestCase):
CASES = {
'plain': ('substitutions:\n DEVICE_NAME: "a"\n\n' + PROFILE_TAIL, True),
'four spaces': ('substitutions:\n DEVICE_NAME: "a"\n FONT: "x"\n' + PROFILE_TAIL, True),
'a comment inside': ('substitutions:\n DEVICE_NAME: "a"\n# a note\n LANGUAGE: "en"\n' + PROFILE_TAIL, True),
'quoted key': ('substitutions:\n "LANGUAGE": "en"\n DEVICE_NAME: a\n' + PROFILE_TAIL, True),
'no final newline': (PROFILE_TAIL + 'substitutions:\n DEVICE_NAME: "a"\n LANGUAGE: "de"', True),
'document start': ('# mine\n---\n' + PROFILE_TAIL, True),
'comments after the key': ('substitutions: # mine\n DEVICE_NAME: "a" # name\n\n\n# esphome\nesphome:\n name: a\n' + PROFILE_TAIL, True),
'windows line ends': (('substitutions:\n DEVICE_NAME: "a"\n' + PROFILE_TAIL).replace('\n', '\r\n'), True),
'include': ('substitutions: !include subs.yaml\n' + PROFILE_TAIL, False),
'flow mapping': ('substitutions: {DEVICE_NAME: a}\n' + PROFILE_TAIL, False),
'not ours': ('substitutions:\n A: b\nesphome:\n name: z\n', False),
}
def test_the_language_line_changes_nothing_else(self):
with tempfile.TemporaryDirectory() as tmp:
firmware = Firmware(tmp, tmp)
for index, (name, (text, writes)) in enumerate(self.CASES.items()):
with self.subTest(name):
path = Path(tmp) / f'p{index}.yaml'
path.write_bytes(text.encode())
before = yaml.load(text.replace('\r\n', '\n'), Loader=LenientLoader)
with self.assertLogs('screen_manager', 'WARNING') if name in ('include', 'flow mapping') else _quiet():
self.assertEqual(firmware.set_language(path.name, 'nl'), writes)
out = path.read_bytes().decode()
self.assertEqual('\r\n' in out, '\r\n' in text, 'line ends stay as they were')
after = yaml.load(out.replace('\r\n', '\n'), Loader=LenientLoader)
if writes:
self.assertEqual(after['substitutions']['LANGUAGE'], 'nl')
strip = lambda data: ({k: v for k, v in data.items() if k != 'substitutions'},
{k: v for k, v in (data.get('substitutions') or {}).items() if k != 'LANGUAGE'})
self.assertEqual(strip(after), strip(before))
self.assertFalse(firmware.set_language(path.name, 'nl'), 'nothing to do the second time')
else:
self.assertEqual(out, text)
english = Path(tmp) / 'english.yaml'
english.write_text(PROFILE_TAIL)
self.assertFalse(firmware.set_language(english.name, 'en'), 'English is the default: no line needed')
def test_every_build_writes_the_language_first(self):
with tempfile.TemporaryDirectory() as tmp:
firmware = Firmware(tmp, tmp)
path = Path(tmp) / 'screen.yaml'
path.write_text('substitutions:\n DEVICE_NAME: "screen"\n' + PROFILE_TAIL)
firmware.language = lambda: 'de'
firmware.run = lambda *args: asyncio.sleep(0)
with unittest.mock.patch('shutil.which', return_value='/usr/bin/esphome'):
async def start():
firmware.start({'file': 'screen.yaml', 'action': 'validate'})
self.assertNotIn('LANGUAGE', path.read_text(), 'validating changes nothing')
await firmware.task
firmware.start({'file': 'screen.yaml', 'action': 'build'})
await firmware.task
asyncio.run(start())
self.assertIn('LANGUAGE: "de"', path.read_text())
class _quiet:
def __enter__(self):
return self
def __exit__(self, *args):
return False
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class ClockPinTests(unittest.IsolatedAsyncioTestCase):
def manager(self, path, switches=None):
import test_scaling
from server import Manager
ha = test_scaling.fake_ha(firmware='0.2.75')
for index, state in enumerate(switches or []):
ha.registry.append({'entity_id': f'switch.clock_{index}', 'platform': 'esphome', 'original_name': '24-hour clock',
'device_id': 'd1'})
if state is not None:
ha.states[f'switch.clock_{index}'] = {'state': state}
ha.ha_language = 'en'
ha.services_changed = asyncio.Event()
return Manager(ha, path)
async def test_a_new_install_follows_the_language_from_the_start(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'screens.json'
first = self.manager(path)
self.assertTrue((Path(tmp) / 'language.json').exists(), 'stored at once')
first.save('text.screen', {'title': 'Home', 'tiles': [{'entity': 'light.a'}]})
again = self.manager(path, ['on'])
self.assertEqual((again.region.clock, again.region.pinned, again.region.clock_24h()), ('auto', False, False))
async def test_an_older_app_keeps_its_24_hours_until_the_switches_tell(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'screens.json'
path.write_text(json.dumps({'version': 1, 'screens': {'text.screen': {'title': 'Home', 'tiles': [{'entity': 'light.a'}]}}}))
waiting = self.manager(path, ['off', None])
self.assertEqual((waiting.region.clock, waiting.region.pinned), ('24', True))
self.assertFalse(waiting.resolve_clock_pin(), 'a switch without a state: ask again later')
self.assertTrue(json.loads((Path(tmp) / 'language.json').read_text())['pinned'], 'the question survives a restart')
twelve = self.manager(path, ['off', 'off'])
self.assertTrue(twelve.resolve_clock_pin())
self.assertEqual((twelve.region.clock, twelve.region.pinned), ('12', False))
(Path(tmp) / 'language.json').unlink()
mixed = self.manager(path, ['off', 'on'])
self.assertFalse(mixed.resolve_clock_pin())
self.assertEqual((mixed.region.clock, mixed.region.pinned), ('24', False))
(Path(tmp) / 'language.json').unlink()
chosen = self.manager(path, ['off', None])
chosen.region.change({'clock': 'auto'})
self.assertFalse(chosen.region.pinned, 'the owner chose a clock')
async def test_language_and_region_over_http(self):
from server import create_app
from aiohttp.test_utils import TestClient, TestServer
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(Path(tmp) / 'screens.json')
async with TestClient(TestServer(create_app(m, True))) as client:
csrf = (await (await client.get('/api/inventory?light=1')).json())['csrf']
answer = await client.put('/api/language', json={'setting': 'de', 'numbers': 'point'}, headers={'X-Screen-CSRF': csrf})
view = (await answer.json())['language']
self.assertEqual((view['effective'], view['numbers_effective'], view['group_min'], view['percent_space']),
('de', 'point', 1, True))
# What the screen reports stays English (the app reads it); the editor shows it in its own language.
dutch = await (await client.get('/api/inventory?light=1', headers={'X-ESP-Screens-Language': 'nl'})).json()
self.assertEqual((m.screens()[0]['status'], dutch['screens'][0]['status']), ('Synced', 'Gesynchroniseerd'))
refused = await client.put('/api/language', json={'clock': '13'}, headers={'X-Screen-CSRF': csrf, 'X-ESP-Screens-Language': 'nl'})
self.assertEqual(refused.status, 400)
self.assertEqual((await refused.json())['error'], i18n.TRANSLATIONS.text('addon.errors.region_choice', 'nl'))
self.assertEqual(json.loads((Path(tmp) / 'language.json').read_text())['language'], 'de')
import unittest.mock # noqa: E402
if __name__ == '__main__':
unittest.main()