Files
homeassistant_espscreen/tests/test_alert.py
T
Max Gramser d76461d774 Build every screen from one shared core plus a small file per board, the same firmware as before (0.2.84)
The two board profiles (5,000 lines each, 87 % the same text) become packages/core.yaml and one file per
board under packages/boards/: the hardware, a table of 84 sizes and font sizes, 18 hooks with the lines of C++
only that board adds inside a shared lambda, and the board's own sections. packages/cyd.yaml and
packages/guition.yaml keep their names and places (a screen installed from ESP Screens builds as before);
home-like-2432s028.yaml and guition-4848s040.yaml become the same entries for a checkout.

The firmware stays 0.2.70: the C++ ESPHome generates for each board, through the checkout profile and
through the GitHub package route, was compared line by line with the old profiles' (docs/TEST_RESULTS_0284.md).
tools/check_packages.py replaces tools/generate_packages.py; tools/profiles.py gives the tests and tools the
three files as one; docs/PROFILES.md says how the files fit together and how a board is added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 22:57:14 +02:00

142 lines
8.9 KiB
Python

"""The alert a Home Assistant action puts over the whole screen: both board profiles and both remote packages."""
import re
import struct
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
import profiles # noqa: E402
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
import tile_icons # noqa: E402
PROFILES = {'cyd': 'home-like-2432s028.yaml', 'guition': 'guition-4848s040.yaml'}
PACKAGES = {'cyd': 'packages/cyd.yaml', 'guition': 'packages/guition.yaml'}
FIELDS = [('title', 'string'), ('subtitle', 'string'), ('icon', 'string'), ('color', 'string'), ('button_text', 'string'), ('timeout', 'int'), ('flash', 'bool')]
def section(text, start, end):
return text.split(start, 1)[1].split(end, 1)[0]
def script(text, name):
"""One script block without its trailing comment lines, which introduce the next script."""
return re.sub(r'^\s*#.*\n?', '', section(text, f' - id: {name}\n', '\n - id: '), flags=re.M)
def line_height(font, size):
"""LVGL's line height of an ESPHome font: FreeType's size height ('hhea' ascender - descender + line gap, scaled
to the pixel size and rounded to whole pixels), which ESPHome hands to lv_font_t.line_height."""
data = font.read_bytes()
tables = {}
for i in range(struct.unpack('>H', data[4:6])[0]):
tag, _, offset, _ = struct.unpack('>4sIII', data[12 + 16 * i:28 + 16 * i])
tables[tag] = offset
units_per_em = struct.unpack('>H', data[tables[b'head'] + 18:tables[b'head'] + 20])[0]
ascender, descender, line_gap = struct.unpack('>hhh', data[tables[b'hhea'] + 4:tables[b'hhea'] + 10])
scale = ((size * 64) << 16) // units_per_em # 16.16
height = ((ascender - descender + line_gap) * scale + 0x8000) >> 16 # 26.6 pixels
return (height + 32) // 64
class AlertTests(unittest.TestCase):
def sources(self):
for name in list(PROFILES.values()) + list(PACKAGES.values()):
yield name, profiles.text(name)
def test_action_takes_the_six_fields_and_hands_them_to_one_script(self):
for name, text in self.sources():
block = section(text, ' - action: show_alert\n', ' - action: dismiss_alert\n')
self.assertEqual(re.findall(r'^ (\w+): (\w+)$', block, re.M), FIELDS, name)
self.assertIn('id: alert_show', block, name)
for field, _ in FIELDS:
self.assertIn(f"{field}: !lambda 'return {field};'", block, name)
remote = text.split(' - action: dismiss_alert\n', 1)[1][:200]
self.assertIn('id: alert_dismiss', remote, name)
self.assertIn('reason: "remote"', remote, name)
def test_overlay_lives_on_the_top_layer_above_every_page(self):
for name, text in self.sources():
lvgl = section(text, '\nlvgl:\n', '\nscript:\n')
self.assertLess(lvgl.index(' top_layer:\n'), lvgl.index(' pages:\n'), name)
top = section(lvgl, ' top_layer:\n', ' pages:\n')
for widget in ('alert_overlay', 'alert_card', 'alert_icon', 'alert_title', 'alert_subtitle', 'alert_ok', 'alert_ok_label'):
self.assertIn(f'id: {widget}\n', top, (name, widget))
self.assertIn('hidden: true', section(top, 'id: alert_overlay\n', 'widgets:'), name)
self.assertIn(f'text: "\\U000{tile_icons.GLYPHS["alert-outline"]}"', top, name)
self.assertIn('long_mode: DOT', section(top, 'id: alert_title\n', 'id: alert_subtitle'), name)
self.assertIn('long_mode: WRAP', section(top, 'id: alert_subtitle\n', 'id: alert_ok'), name)
ok = section(top, 'id: alert_ok\n', 'widgets:')
self.assertIn('lv_label_set_text(id(alert_ok_label), alert.button.c_str());', script(text, 'alert_show'), name)
self.assertIn('cyd::touch_guard.accept(millis(), 13)', ok, name)
self.assertLess(ok.index('touch_guard.accept'), ok.index('id: alert_dismiss'), name)
self.assertIn('reason: "ok"', ok, name)
def test_title_is_one_line_high_so_a_long_title_ends_in_an_ellipsis(self):
# LVGL 9.5 only puts the ellipsis on a DOT label whose wrapped text is taller than the label. Without a height
# the label grows with its lines instead, and a long title ran into the subtitle.
for name, text in self.sources():
top = section(section(text, '\nlvgl:\n', '\nscript:\n'), ' top_layer:\n', ' pages:\n')
title = section(top, 'id: alert_title\n', 'id: alert_subtitle')
self.assertIn('height: ${ALERT_TITLE_H}\n', title, name)
self.assertIn('text_font: headline\n', title, name)
# The core names the font as ${FONT_DIR}/... at the board's ${FONT_HEADLINE_SIZE} (app 0.2.84+).
font, size = re.search(r'(?m)^ - file: "(?:[^"\n]*/)?(fonts/[^"\n]+)"\n id: headline\n size: (\d+)$', profiles.resolved(name)).groups()
v = lambda key: int(re.search(rf'^ {key}: "(-?\d+)"', text, re.M)[1])
self.assertEqual(v('ALERT_TITLE_H'), line_height(ROOT / font, int(size)), name)
self.assertLessEqual(v('ALERT_TITLE_Y') + v('ALERT_TITLE_H'), v('ALERT_SUBTITLE_Y'), name)
def test_standby_waits_and_no_other_path_closes_the_card(self):
for name, text in self.sources():
guard = re.search(r'if \(id\(display_dimmed\) \|\| id\(touch_down\) \|\| id\(calibration_active\)([^\n]*)\) return false;', text)
self.assertIsNotNone(guard, name)
self.assertIn('|| id(alert_active)', guard[1], name)
for other in ('close_cards', 'dim_display', 'wake_display', 'apply_screen_settings'):
self.assertNotIn('alert', script(text, other), (name, other))
dismiss = script(text, 'alert_dismiss')
self.assertIn('id(alert_active) = false;', dismiss, name)
self.assertIn('id(last_touch_ms) = millis();', dismiss, name)
for stopped in ('alert_timeout', 'alert_flash'):
self.assertIn(f'script.stop: {stopped}', dismiss, name)
self.assertIn('screen_settings::current.brightness', dismiss, name)
def test_every_ending_reports_to_home_assistant_but_the_self_test_does_not(self):
for name, text in self.sources():
report = script(text, 'alert_report')
self.assertIn('event: esphome.screen_alert', report, name)
for key in ('action', 'title', 'screen'):
self.assertRegex(report, rf'(?m)^\s+{key}: !lambda', name)
self.assertIn('id: alert_report', script(text, 'alert_dismiss'), name)
self.assertIn('reason: "timeout"', script(text, 'alert_timeout'), name)
show = script(text, 'alert_show')
self.assertIn('id(alert_report).execute("replaced")', show, name)
self.assertIn('screen_alert::make(title, subtitle, icon, color, button_text, timeout, flash, ${ALERT_TITLE_MAX}, ${ALERT_SUBTITLE_MAX}, ${ALERT_BUTTON_MAX})', show, name)
self.assertLess(show.index('script.execute: wake_display'), show.index('lvgl.widget.show: alert_overlay'), name)
self.assertLess(show.index('lvgl.widget.show: alert_overlay'), show.index('script.execute: alert_flash'), name)
self_test = section(text, ' - id: ui_self_test\n', '\n - id: ')
self.assertIn('lvgl.widget.show: alert_overlay', self_test, name)
self.assertIn('lvgl.widget.hide: alert_overlay', self_test, name)
for forbidden in ('alert_show', 'alert_dismiss', 'homeassistant.event'):
self.assertNotIn(forbidden, self_test, name)
def test_flash_blinks_four_times_and_the_card_fits_the_screen(self):
for name, text in self.sources():
flash = script(text, 'alert_flash')
self.assertIn('count: 4', flash, name)
self.assertEqual(flash.count('delay:'), 2, name)
v = lambda key: int(re.search(rf'^ {key}: "(-?\d+)"', text, re.M)[1])
self.assertLessEqual(v('ALERT_CARD_W'), v('DISPLAY_W') - 16, name)
self.assertLessEqual(v('ALERT_CARD_H'), v('DISPLAY_H') - 16, name)
self.assertLessEqual(v('ALERT_TEXT_X') + v('ALERT_TEXT_W'), v('ALERT_CARD_W') - v('ALERT_ICON_X'), name)
self.assertLessEqual(v('ALERT_SUBTITLE_Y') + v('ALERT_SUBTITLE_H'),
v('ALERT_CARD_H') - v('ALERT_BUTTON_INSET') - v('ALERT_BUTTON_H'), name)
self.assertGreater(v('ALERT_TITLE_MAX'), 0, name)
self.assertGreater(v('ALERT_BUTTON_MAX'), 0, name)
self.assertGreater(v('ALERT_SUBTITLE_MAX'), v('ALERT_TITLE_MAX'), name)
def test_helper_header_is_included_locally_and_in_the_remote_package(self):
header = ' - <esphome/components/smart_display/alert_overlay.h>\n'
for board, name in PROFILES.items():
self.assertIn(header, profiles.text(name), name)
self.assertIn(header, profiles.text(PACKAGES[board]), board)
if __name__ == '__main__':
unittest.main()