"""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 = ' - \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()