Files
homeassistant_espscreen/tests/test_alert.py
T
Max Gramser 08197c4ae1 Free memory on the CYD and drop the old manual profile (0.2.50)
The CYD restarted under load: a slider fill rounded less than its track made
LVGL draw the fill into a buffer of its own size on every redraw (15-20 KB), and
when that allocation failed the refresh retried until the task watchdog fired.
Track and fill now share one radius, and tests/test_layer_free.py keeps every
layer-making style out of the profiles, the packages and the firmware headers.

A tile keeps only the fields every card uses; climate modes, select options,
weather, sun and timer times, the media title and the vacuum rows moved to an
Extra block that exists only while a state needs one, and the state copy became
a fingerprint. runtime_tiles::model went from 24,296 to 8,216 bytes.

The manual profile that bound tiles to fixed entities in the YAML is gone:
331 TILE* substitutions, DYNAMIC_TILES/TILE_COUNT/DIRECT_ACTIONS, the 80 Home
Assistant subscriptions, its tap handlers and scripts, its own vacuum card, its
guides and helper scripts, and the five stale header copies in the repository
root that two C++ tests were compiling instead of the components. esphome config
of both Easy profiles is unchanged by that cleanup.

tile_scroll and the wake overlay no longer light up on a tap, and the debug
sensor min_free reports the lowest free heap since boot. On the bench CYD static
RAM went from 32.0% to 22.8% and free heap from 64-77 KB to about 101 KB, with
78 KB as the lowest point over thirteen hours without a restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:06:41 +02:00

111 lines
6.8 KiB
Python

"""The alert a Home Assistant action puts over the whole screen: both board profiles and both remote packages."""
import re
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
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)
class AlertTests(unittest.TestCase):
def sources(self):
for name in list(PROFILES.values()) + list(PACKAGES.values()):
yield name, (ROOT / name).read_text()
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_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', ' - id: show_tile_page')
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, (ROOT / name).read_text(), name)
self.assertIn(header, (ROOT / PACKAGES[board]).read_text(), board)
if __name__ == '__main__':
unittest.main()