Files
homeassistant_espscreen/tests/test_addon_translations.py
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

333 lines
21 KiB
Python

"""The app's own texts in the translations (app 0.2.90): its messages, statuses and labels in the editor's language, the
words it sends to the screens in theirs.
Every key the code asks for is in en.json with the placeholders the code fills in, and the English tables the Claude skill
writes from (backgrounds, direct controls, the alert reference, the icons) say what en.json says. A made-up language, xx,
shows that another language reaches the editor through the request's X-ESP-Screens-Language header and the screens
through Settings -> Language & region, while what the app hands Home Assistant as data stays English.
"""
import ast
import asyncio
import importlib.util
import json
from datetime import datetime, timezone
from pathlib import Path
import re
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
APP = ROOT / 'screen_manager/app'
sys.path.insert(0, str(APP))
sys.path.insert(0, str(ROOT / 'tests'))
import core # noqa: E402
import header_bar # noqa: E402
import history_card # noqa: E402
import i18n # noqa: E402
from i18n import REQUEST_LANGUAGE, TRANSLATIONS, english, shown, t # noqa: E402
import tile_icons # noqa: E402
import updates # noqa: E402
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
if HAS_AIOHTTP:
import test_scaling
from aiohttp.test_utils import TestClient, TestServer
from server import Manager, create_app
ENGLISH = TRANSLATIONS.flat['en']
PLACEHOLDER = re.compile(r'\{([a-z_]+)\}')
KEY = re.compile(r'(addon|screen)(\.[a-z0-9_]+){2,}')
def lookups():
"""(where, key, params) of every t(), english() and screen_t() with a written-out key in the app's code."""
for path in sorted(APP.glob('*.py')):
for node in ast.walk(ast.parse(path.read_text(encoding='utf-8'))):
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in ('t', 'english', 'screen_t')
and node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str)):
yield f'{path.name}:{node.lineno}', node.args[0].value, {keyword.arg for keyword in node.keywords}
def written_keys():
"""(where, key) of every other translation key the code names in full, such as the tables of words by key."""
for path in sorted(APP.glob('*.py')):
for node in ast.walk(ast.parse(path.read_text(encoding='utf-8'))):
if isinstance(node, ast.Constant) and isinstance(node.value, str) and KEY.fullmatch(node.value):
yield f'{path.name}:{node.lineno}', node.value
class Language:
"""xx for as long as a test runs: the texts under `prefixes` marked "[xx] ", every other one in English."""
def __init__(self, *prefixes):
self.prefixes = prefixes
def __enter__(self):
TRANSLATIONS.data['xx'] = {'_meta': {'name': 'Xx', 'english': 'Xx', 'plural': 'one_other', 'clock': '24'}}
TRANSLATIONS.flat['xx'] = {key: ' | '.join(f'[xx] {form.strip()}' for form in text.split('|'))
for key, text in ENGLISH.items() if key.startswith(self.prefixes)}
return self
def __exit__(self, *_):
del TRANSLATIONS.data['xx'], TRANSLATIONS.flat['xx']
i18n.set_screens('en', 'point')
class Keys(unittest.TestCase):
def test_every_key_the_app_asks_for_is_in_english_with_its_placeholders(self):
found = list(lookups())
self.assertGreater(len(found), 150)
for where, key, params in found:
self.assertIn(key, ENGLISH, where)
# `n` picks a plural form; every other placeholder is filled in by name.
self.assertEqual(set(PLACEHOLDER.findall(ENGLISH[key])), params, f'{where}: {key}')
for where, key in written_keys():
self.assertIn(key, ENGLISH, where)
def test_the_app_s_texts_are_whole_and_keep_the_editor_s_characters_out(self):
for key, text in ENGLISH.items():
if key.startswith('addon.'):
self.assertTrue(text.strip(), key)
self.assertNotIn('@', text, key)
self.assertFalse(re.search(r'[{}]', PLACEHOLDER.sub('', text)), key)
# A plural has English's two forms, each with its own words.
self.assertIn(len(text.split('|')), (1, 2), key)
def test_the_english_tables_the_claude_skill_writes_from_say_what_english_says(self):
self.assertEqual({name: item['label'] for name, item in core.backgrounds().items()},
{name: item['label'] for name, item in core.TILE_BACKGROUNDS.items()})
catalogue = core.controls_catalogue()
for domain, choices in core.CONTROLS.items():
self.assertEqual([(c['key'], c['label']) for c in catalogue[domain]['choices']], [*choices, ('none', 'None')], domain)
reference = core.alert_reference()
self.assertEqual([(f['name'], f['label'], f['help'], f['example']) for f in reference['fields']],
[(name, label, help_, example) for name, _, label, help_, example in core.ALERT_FIELDS])
self.assertEqual(tuple(reference['camera'].values()), core.ALERT_CAMERA_FIELD)
self.assertEqual([(e['action'], e['label']) for e in reference['endings']], list(core.ALERT_ENDINGS))
picker = tile_icons.editor()['groups']
self.assertEqual([(group['label'], [(i['name'], i['cp'], i['label']) for i in group['icons']]) for group in picker],
[(group, list(icons)) for group, icons in tile_icons.GROUPS])
for entity, name in core.BUILTIN.items():
self.assertEqual(core.builtin_name(entity, english), name)
self.assertEqual(core.builtin_name(entity, t), name)
def test_the_binary_words_are_home_assistant_s_from_the_translations(self):
for device_class, (on, off) in header_bar.BINARY_STATES.items():
self.assertEqual((on, off), (ENGLISH[f'screen.ha.binary.{device_class}_on'], ENGLISH[f'screen.ha.binary.{device_class}_off']))
self.assertEqual(header_bar.binary_words(device_class), (on, off))
self.assertEqual(header_bar.binary_words('future_class'), ('On', 'Off'))
class Texts(unittest.TestCase):
def test_a_text_keeps_its_key_and_can_be_written_again(self):
made = english('addon.errors.alerts.cannot_show', name='Hall', reason=english('addon.errors.alerts.offline'))
self.assertEqual(made, "Hall can't show an alert: offline.")
self.assertEqual((made.key, set(made.params)), ('addon.errors.alerts.cannot_show', {'name', 'reason'}))
self.assertEqual(json.dumps({'error': made}), json.dumps({'error': "Hall can't show an alert: offline."}))
with Language('addon.'):
self.assertEqual(made.into('xx'), "[xx] Hall can't show an alert: [xx] offline.")
token = REQUEST_LANGUAGE.set('xx')
try:
self.assertEqual(shown(made), "[xx] Hall can't show an alert: [xx] offline.")
self.assertEqual(t('addon.errors.unknown_screen'), '[xx] Unknown screen.')
self.assertEqual(shown('Synced'), 'Synced', 'a word the screen reported stays as it came')
finally:
REQUEST_LANGUAGE.reset(token)
# Outside a request, as for a tile event's answer to Home Assistant: English.
self.assertEqual(t('addon.errors.unknown_screen'), 'Unknown screen.')
def test_a_count_picks_its_plural_form(self):
self.assertEqual(t('addon.errors.layout.tiles_max', n=48), 'Choose at most 48 tiles.')
self.assertEqual(t('addon.errors.layout.tiles_max', n=1), 'Choose at most 1 tile.')
def test_the_reasons_a_screen_can_t_show_an_alert_are_english_for_the_log(self):
_, skipped = core.alert_targets([{'name': 'A', 'online': True}, {'name': 'B', 'node': 'b', 'online': False},
{'name': 'C', 'node': 'c', 'online': True, 'firmware': 'unknown'}])
self.assertEqual([reason for _, reason in skipped], ['device name unknown', 'offline', 'firmware unknown'])
with Language('addon.'):
token = REQUEST_LANGUAGE.set('xx')
try:
self.assertEqual([shown(reason) for _, reason in skipped],
['[xx] device name unknown', '[xx] offline', '[xx] firmware [xx] unknown'])
finally:
REQUEST_LANGUAGE.reset(token)
class ScreenWords(unittest.TestCase):
"""What the app sends to the screens, in the language of Settings -> Language & region."""
def test_the_screens_get_the_app_s_words_in_their_language(self):
with Language('addon.screen.', 'screen.ha.', 'screen.date.', 'screen.settings.', 'screen.vacuum.', 'screen.script.'):
i18n.set_screens('xx', 'comma')
self.assertEqual(core.state_message(0, {'entity': 'screen.page_2', 'name': ''}, {})['name'], '[xx] Go to page 2')
self.assertEqual(core.state_message(0, {'entity': 'screen.settings', 'name': ''}, {})['name'], '[xx] Settings')
self.assertEqual(core.state_message(0, {'entity': 'screen.clock', 'name': 'Kitchen'}, {})['name'], 'Kitchen')
self.assertEqual([core.vacuum_label(v) for v in ('vac_and_mop', 'quiet', 'off', 'some_thing')],
['[xx] Vac & mop', '[xx] Quiet', '[xx] Off', 'Some thing'])
day = {'datetime': '2026-09-14T12:00:00+00:00', 'condition': 'sunny'}
self.assertEqual(core.extras({'entity': 'weather.home'}, {}, forecast=[day])['days'][0]['d'], '[xx] Mo')
value = lambda entity, raw, **attributes: header_bar.value(entity, {'state': raw, 'attributes': attributes})[0]
self.assertEqual(value('sensor.power', '1234.5', unit_of_measurement='W'), '1.234,5 W')
self.assertEqual(value('lock.front', 'jammed'), '[xx] Jammed')
self.assertEqual(value('climate.hall', 'heat_cool'), '[xx] Auto')
self.assertEqual(value('binary_sensor.door', 'on', device_class='door'), '[xx] Open')
self.assertEqual(value('binary_sensor.x', 'off'), '[xx] Off')
self.assertEqual(value('script.night', 'off'), '[xx] Never run')
self.assertEqual(value('input_datetime.alarm', '2026-09-15', has_date=True), '[xx] 15 [xx] Sep')
self.assertEqual(history_card.state_label('person', 'not_home', {}), '[xx] Away')
self.assertEqual(history_card.state_label('sensor', 'unknown', {}), '[xx] Unknown')
self.assertEqual(history_card.tick_text(2500.0, 500, 'W'), '2.500')
# Back in English, as before this app version.
self.assertEqual(core.state_message(0, {'entity': 'screen.page_2', 'name': ''}, {})['name'], 'Go to page 2')
self.assertEqual(header_bar.number_text(1234.5, 1), '1,234.5')
self.assertEqual(header_bar.short_date(datetime(2026, 9, 15)), '15 Sep')
def test_a_timeline_s_other_states_take_the_screens_word(self):
start, end = 0, 24 * 3600
changes = [(i * 600, f'state_{i % 8}') for i in range(144)]
with Language('addon.screen.'):
i18n.set_screens('xx', 'point')
message = history_card.timeline('sensor.status', 24, changes, start, end, timezone.utc, {})
# Folded to the letters the screen draws, like every label of the card: the brackets go.
self.assertEqual(message['states'][-1][0], 'xx Other')
class Updates(unittest.IsolatedAsyncioTestCase):
def updater(self, tmp, start):
class HA:
def __init__(self):
self.calls = []
async def request(self, kind, **data):
self.calls.append(data)
class Firmware:
def profile_names(self):
return {'hall.yaml': {'node': 'hall', 'friendly': 'Hall'}}
class Owner:
def __init__(self):
self.ha, self.firmware = HA(), Firmware()
self.firmware.start = start
def screen(self, inbox):
return {'id': inbox, 'name': 'Hall', 'node': 'hall', 'online': True, 'ip': '10.0.0.5', 'firmware': '0.2.1'}
def screens(self):
return [self.screen('text.hall')]
return updates.Updater(Owner(), Path(tmp) / 'updates.json')
async def test_a_result_keeps_its_key_and_shows_in_the_editor_s_language(self):
def refuse(data):
raise ValueError(t('addon.errors.firmware.no_esphome'))
with tempfile.TemporaryDirectory() as tmp, Language('addon.'):
updater = self.updater(tmp, refuse)
# A round started from an editor in xx: the refusal is made in xx, and kept in English with its key.
token = REQUEST_LANGUAGE.set('xx')
try:
self.assertEqual(await updater.update_one('text.hall'), 'failed')
finally:
REQUEST_LANGUAGE.reset(token)
kept = json.loads((Path(tmp) / 'updates.json').read_text())['results']['text.hall']
self.assertEqual((kept['message'], kept['key'], kept['params']),
('The ESPHome CLI is missing from this installation.', 'addon.errors.firmware.no_esphome', {}))
self.assertEqual(updater.state_for(updater.screen('text.hall'))['result']['message'],
'The ESPHome CLI is missing from this installation.')
token = REQUEST_LANGUAGE.set('xx')
try:
self.assertEqual(updater.state_for(updater.screen('text.hall'))['result']['message'],
'[xx] The ESPHome CLI is missing from this installation.')
updater.record('text.hall', 'success', english('addon.updates.updated', version='0.2.99'))
self.assertEqual(updater.results['text.hall']['message'], 'Updated to firmware 0.2.99.')
self.assertEqual(updater.state_for(updater.screen('text.hall'))['result']['message'], '[xx] Updated to firmware 0.2.99.')
# A result an app before 0.2.90 kept has no key: it shows as it was kept.
updater.results['text.hall'] = {'time': 0, 'state': 'failed', 'message': 'Build or install failed.', 'version': '0.2.70'}
self.assertEqual(updater.state_for(updater.screen('text.hall'))['result']['message'], 'Build or install failed.')
finally:
REQUEST_LANGUAGE.reset(token)
async def test_the_notification_of_a_stopped_round_speaks_the_screens_language(self):
def refuse(data):
raise ValueError(t('addon.errors.firmware.no_esphome'))
with tempfile.TemporaryDirectory() as tmp, Language('addon.'):
updater = self.updater(tmp, refuse)
i18n.set_screens('xx', 'point')
await updater.run_round(['text.hall'], automatic=True)
message = updater.manager.ha.calls[0]['service_data']['message']
self.assertEqual(message, '[xx] Automatic update stopped at Hall: [xx] The ESPHome CLI is missing from this installation. '
'The remaining screens were not touched.')
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class Editor(unittest.IsolatedAsyncioTestCase):
async def test_the_editor_gets_the_app_s_texts_in_its_language(self):
with tempfile.TemporaryDirectory() as tmp, Language('addon.'):
manager = Manager(test_scaling.fake_ha(firmware=core.FIRMWARE_VERSION), Path(tmp) / 'screens.json')
xx = {'X-ESP-Screens-Language': 'xx'}
async with TestClient(TestServer(create_app(manager, True))) as client:
full = await (await client.get('/api/inventory', headers=xx)).json()
self.assertEqual(full['backgrounds']['red']['label'], '[xx] Red')
self.assertEqual(full['controls']['light']['choices'][0]['label'], '[xx] On/off switch')
self.assertEqual(full['alerts']['fields'][0]['label'], '[xx] Title')
self.assertEqual(full['alerts']['fields'][0]['example'], '[xx] Someone is at the door')
self.assertEqual(full['alerts']['fields'][2]['example'], 'doorbell', 'an icon name is typed as it is')
self.assertEqual(full['icons']['groups'][0]['label'], '[xx] Lighting')
self.assertEqual(full['icons']['groups'][0]['icons'][0]['label'], '[xx] Light bulb')
self.assertEqual(full['header']['builtin'][0]['label'], '[xx] Time')
self.assertEqual({item['id']: (item['name'], item['device']) for item in full['builtin']}['screen.page_3'],
('[xx] Go to page 3', '[xx] Built into the screen'))
self.assertEqual(full['screens'][0]['delivery'], '[xx] Choose your first tiles')
csrf = full['csrf']
# The delivery is kept in English and shown in the language of whoever looks.
manager.save('text.screen', {'title': 'Office', 'tiles': [{'entity': 'light.a', 'name': ''}]})
self.assertEqual(manager.status['text.screen'], 'Saved; waiting for sync')
light = await (await client.get('/api/inventory?light=1', headers=xx)).json()
self.assertEqual(light['screens'][0]['delivery'], '[xx] Saved; waiting for sync')
light = await (await client.get('/api/inventory?light=1')).json()
self.assertEqual(light['screens'][0]['delivery'], 'Saved; waiting for sync')
# The live updates: an EventSource sends no headers, so the language comes in the address.
async with client.get('/api/events?language=xx') as stream:
line = await asyncio.wait_for(stream.content.readline(), 1)
self.assertEqual(json.loads(line.decode().removeprefix('data: '))['screens'][0]['delivery'], '[xx] Saved; waiting for sync')
refused =await client.put('/api/screens/text.gone', json={'title': 'T', 'tiles': []}, headers={**xx, 'X-Screen-CSRF': csrf})
self.assertEqual((refused.status, (await refused.json())['error']),
(400, "[xx] This isn't a paired ESP screen. Refresh the overview."))
refused = await client.put('/api/screens/text.gone', json={'title': 'T', 'tiles': []}, headers={'X-Screen-CSRF': csrf})
self.assertEqual((await refused.json())['error'], "This isn't a paired ESP screen. Refresh the overview.")
forbidden = await client.put('/api/screens/text.screen', json={}, headers=xx)
self.assertEqual((forbidden.status, await forbidden.text()), (403, '[xx] Refresh this page and try again.'))
async def test_identify_speaks_the_screens_language(self):
with tempfile.TemporaryDirectory() as tmp, Language('addon.screen.', 'screen.alert.'):
ha = test_scaling.fake_ha(firmware=core.FIRMWARE_VERSION)
ha.calls = []
async def call(action, data):
ha.calls.append((action, data))
ha.call = call
manager = Manager(ha, Path(tmp) / 'screens.json')
i18n.set_screens('xx', 'point')
async with TestClient(TestServer(create_app(manager, True))) as client:
csrf = (await (await client.get('/api/inventory?light=1')).json())['csrf']
# Firmware from before the languages speaks English, whatever the screens' language is.
answer = await client.post('/api/screens/text.screen/identify', headers={'X-Screen-CSRF': csrf})
self.assertEqual(answer.status, 200)
# Firmware that says it speaks the screens' language gets the card in it.
ha.registry = ha.registry + [{'entity_id': 'sensor.language', 'platform': 'esphome', 'original_name': 'Screen language',
'device_id': 'd1'}]
ha.states['sensor.language'] = {'state': 'xx'}
answer = await client.post('/api/screens/text.screen/identify', headers={'X-Screen-CSRF': csrf})
self.assertEqual(answer.status, 200)
texts = [(action, data['title'], data['subtitle'], data['button_text']) for action, data in ha.calls]
self.assertEqual(texts, [('esphome.office_1_show_alert', 'This is Office 1', 'Identify, from ESP Screens', 'OK'),
('esphome.office_1_show_alert', '[xx] This is Office 1', '[xx] Identify, from ESP Screens', '[xx] OK')])
i18n.set_screens('en', 'point')
async def test_a_tile_event_answers_home_assistant_in_english(self):
with tempfile.TemporaryDirectory() as tmp, Language('addon.'):
ha = test_scaling.fake_ha(firmware=core.FIRMWARE_VERSION)
manager = Manager(ha, Path(tmp) / 'screens.json')
i18n.set_screens('xx', 'point')
with self.assertRaises(ValueError) as caught:
await manager.tile_event('esp_screens_add_tile', {'entity': 'foo.bar'})
self.assertEqual(str(caught.exception), 'foo.bar cannot go on a screen.')
if __name__ == '__main__':
unittest.main()