Files
homeassistant_espscreen/tests/test_language_region.py
T
Max Gramser 125346d94e Notation like Home Assistant, each screen in its own language, and the language in every build
From the end-to-end test on the bench boards and an independent review:
- Units spaced as Home Assistant does (54% / 54 % in German and French, 18°, 21.5 °C), in the firmware and the app.
- Grouping from five digits where the language does (1234 but 12.345 in Spanish, Polish, Portuguese): the layout
  message carries group_min and percent_space; an unknown number style no longer refuses a layout.
- Automatic follows Home Assistant's own language for the clock and numbers, also without a translation (Swedish:
  24 hours, 1 234,5; pt-BR: 1.234,5): regions.json from the CLDR for all 63 languages Home Assistant offers.
- 12-hour clock: the top bar says 3:48 PM, the clock card 3:48 (not 03:48), Last runs follow the clock.
- The climate setpoint font has the decimal comma; the value fonts have ², ³ and µ (m³ was a box).
- Weather days: at most three letters without the dot (mié, sáb, czw); the screen took three bytes.
- Texts fall back per key through the base language (pt-BR -> pt -> en) in the firmware and the app.
- Each screen gets the app's words in the language its firmware speaks; firmware from before the languages gets
  English and its own letters, not boxes.
- set_language re-reads what it writes (indentation, comments, quoted keys, CRLF, !include) and every build writes
  the language first; the 24-hour pin no longer hits a fresh install and waits for the old clock switches.
- Tests: region and notation, plural parity with the C++ rules, set_language edge cases, the pin, PUT /api/language.

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

264 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))
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()