Files
homeassistant_espscreen/tests/test_alert.py
T
Max Gramser e68162bd86 Keep a long alert title on one line, ending in an ellipsis (0.2.64)
The title label had long_mode: DOT and a width but no height. LVGL 9.5 only
puts the dots on a label whose wrapped text is taller than the label, so the
label grew with its lines and a long title ran into the subtitle. It now has
one line of the headline font as its height (32 px Guition, 21 px CYD).

The Claude skill and the editor's alert tips said the CYD shows about forty
characters of a title; both screens show about twenty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:17:24 +02:00

139 lines
8.7 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 / '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, (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_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)
font, size = re.search(r'(?m)^ - file: "(?:[^"\n]*/)?(fonts/[^"\n]+)"\n id: headline\n size: (\d+)$', text).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', ' - 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()