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

165 lines
10 KiB
Python

"""Colours live in one place, and Dark mode reaches every screen (app 0.2.63 / firmware 0.2.54).
components/smart_display/theme.h holds every colour the firmware chooses: roles with a light and a dark value, the named
card colours and Home Assistant's state colours. The board profiles name paints (shared LVGL styles theme.h fills) and
the firmware asks theme.h for roles, so a new look is a refill of a few styles plus one redraw. These tests keep it that
way: no colour written anywhere else, every paint defined and filled, and the look applied at boot and after
every settings change. tests/test_theme.cpp checks the values themselves.
"""
import re
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'))
from core import DARK_MODE_MIN_FIRMWARE, FIRMWARE_VERSION, SETTING_ENTITIES, SETTING_RULES, SETTINGS_BESIDE_BLOCK # noqa: E402
COMPONENT = ROOT / 'components/smart_display'
THEME = (COMPONENT / 'theme.h').read_text()
PROFILES = list(profiles.NAMES)
HEX = re.compile(r'0x[0-9A-Fa-f]{6}\b')
# What may still carry a number that looks like a colour: the light's colour picker shows the colours a light can take,
# a mask and a codepoint check are no colours, and the value card's colour key is a wheel of fixed hues.
ALLOWED_IN_FIRMWARE = {
'light_controls.h': ('constexpr uint32_t WARM = 0xFF9C32, COOL = 0xC6E6FF;',
'inline constexpr uint32_t RAINBOW[] = {0xFF0000, 0xFFFF00, 0x00FF00, 0x00FFFF, 0x0000FF, 0xFF00FF, 0xFF0000};'),
'tile_icon.h': ('if (cp < 0x10000 || cp > 0x10FFFF) return {};',),
'runtime_tiles.h': ('state_color=lv_color_to_u32(lv_color_hsv_to_rgb(t.hue%360,t.saturation,100))&0xFFFFFF;',),
}
COLOUR_WHEEL = {'0xFF3B30', '0xFF9500', '0xFFD60A', '0x30D158', '0x32D6FF', '0x0A84FF', '0x5856D6', '0xBF5AF2'}
def paint_names():
block = re.search(r'enum class Paint : uint8_t \{(.*?)\};', THEME, re.S)[1]
return [name.strip() for name in block.replace('\n', ' ').split(',') if name.strip() and name.strip() != 'COUNT']
class OnePlace(unittest.TestCase):
def test_no_header_writes_a_colour_of_its_own(self):
for path in sorted(COMPONENT.glob('*.h')):
if path.name == 'theme.h':
continue
for number, line in enumerate(path.read_text().splitlines(), 1):
if HEX.search(line):
self.assertIn(line.strip(), ALLOWED_IN_FIRMWARE.get(path.name, ()), f'{path.name}:{number}: a colour outside theme.h')
def test_no_profile_writes_a_colour_of_its_own(self):
for path in PROFILES:
text = profiles.text(path)
wheel = re.findall(r'^\s+arc_color: (0x[0-9A-F]{6})$', text, re.M)
self.assertEqual(set(wheel), COLOUR_WHEEL, path)
everything = HEX.findall(text)
self.assertEqual(sorted(everything), sorted(wheel), f'{path}: only the colour wheel keeps fixed hues')
self.assertNotIn('BG_TOP_COLOR', text)
self.assertNotIn('ACCENT_AUTO', text)
self.assertNotRegex(text, r'^\s+(bg|text|border|bg_grad|line|outline|shadow)_color:', path)
def test_every_role_has_a_light_and_a_dark_value(self):
roles = re.search(r'enum Role : uint8_t \{(.*?)ROLE_COUNT', THEME, re.S)[1]
names = re.findall(r'^\s+([A-Z_]+),', roles, re.M)
values = re.findall(r'/\* (\w+) \*/\s+\{0x([0-9A-F]{6}), 0x([0-9A-F]{6})\}', THEME)
self.assertEqual(names, [name for name, _, _ in values], 'the table follows the enum, one row per role')
self.assertGreater(len(names), 40)
class Paints(unittest.TestCase):
def test_every_paint_is_defined_adopted_and_used_in_every_profile(self):
paints = paint_names()
for path in PROFILES:
text = profiles.merged(path)
defined = re.findall(r'^ - id: (paint_\w+)$', text, re.M)
used = set(re.findall(r'styles: (paint_\w+)$', text, re.M))
filling = text.split('theme::paints = []() {', 1)[1].split('\n };', 1)[0]
explicit = re.findall(r'theme::fill\(id\((\w+)\), Paint::(\w+)\);', filling)
listed = re.search(r'for \(auto \*style : \{([^}]*)\}\) theme::fill\(style, Paint::(\w+)\);', filling)
looped = [(name, listed[2]) for name in re.findall(r'id\((\w+)\)', listed[1])]
adopted = dict(explicit + looped)
self.assertEqual(len(adopted), len(explicit) + len(looped), f'{path}: every style is filled once')
self.assertEqual(text.count('theme::fill('), len(explicit) + 1, f'{path}: styles are filled in theme::paints only')
self.assertEqual(set(defined), used, f'{path}: every paint style is used, every used one defined')
for style in defined + ['style_page', 'style_tile', 'style_icon_circle', 'style_room', 'style_time', 'style_title',
'style_value', 'style_value_big']:
self.assertIn(style, adopted, f'{path}: {style} takes its colours from theme.h')
self.assertIn(adopted[style], paints, f'{path}: {style}')
for style, paint in adopted.items():
if style.startswith('paint_'):
self.assertEqual(style, f'paint_{paint}', f'{path}: a paint style is named after its paint')
# The styles that carry colours have none written down.
styles = re.search(r'^ style_definitions:\n(.*?)^ # ---------- ALERT', text, re.M | re.S)[1]
self.assertNotRegex(styles, r'_color:', path)
def test_the_look_is_applied_at_boot_after_a_change_and_drawn_again(self):
for path in PROFILES:
text = profiles.text(path)
boot = text.split('runtime_tiles::load_settings();', 1)[1].split('runtime_tiles::settings_changed', 1)[0]
self.assertIn('theme::paints();\n theme::set_dark(settings_screen::dark_mode != 0);', boot,
f'{path}: the paints filled, then the saved look, before the first frame')
apply = text.split(' - id: apply_screen_settings\n', 1)[1].split('\n - id: ', 1)[0]
self.assertIn('theme::set_dark(settings_screen::dark_mode != 0);', apply, f'{path}: a change of the setting')
redraw = text.split('theme::redraw = []() {', 1)[1].split('};', 1)[0]
for needle in ('runtime_tiles::restyle();', 'light_controls::restyle();', 'settings_screen::restyle();',
'theme::surface(id(alert_card_color))', 'id(climate_card_refresh).execute();',
'id(open_climate_mode_picker).execute();'):
self.assertIn(needle, redraw, f'{path}: {needle}')
self.assertIn('id(alert_card_color) = alert.color;', text)
self.assertIn('lv_color_hex(theme::surface(alert.color))', text)
def test_the_firmware_draws_long_lived_parts_with_paints(self):
tiles = (COMPONENT / 'runtime_tiles.h').read_text()
for needle in ('lv_obj_add_style(w.slider,theme::style(theme::Paint::knob),LV_PART_KNOB);',
'lv_obj_add_style(w.busy,theme::style(theme::Paint::veil),0);',
'lv_obj_add_style(w.spinner,theme::style(theme::Paint::spinner),LV_PART_MAIN);',
'lv_obj_add_style(part, theme::style(theme::Paint::slate), 0);',
'inline void restyle() {'):
self.assertIn(needle, tiles)
# Dark mode is kept in a preference of its own, next to the others outside the frozen settings block.
self.assertIn('make_preference<uint32_t>(0x44524B31)', tiles)
self.assertIn('inline void restyle() {', (COMPONENT / 'settings_screen.h').read_text())
self.assertIn('inline void restyle() {', (COMPONENT / 'light_controls.h').read_text())
class RedrawnTheSame(unittest.TestCase):
"""A change of look draws again what was drawn, and the renders compare a screen that switched with one that
booted in that look. Two differences they found were older than Dark mode: what a page showed depended on the
order it was drawn in."""
def test_a_switch_knob_follows_the_size_it_was_given(self):
page = (COMPONENT / 'settings_screen.h').read_text()
move = page.split('inline void move_knob(', 1)[1].split('\n}\n', 1)[0]
# draw() ends in refresh() before LVGL has laid the new rows out, when coordinates still read 0.
self.assertNotIn('lv_obj_get_width', move)
self.assertNotIn('lv_obj_get_height', move)
self.assertIn('knob_x(lv_obj_get_style_width(track, LV_PART_MAIN), lv_obj_get_style_height(track, LV_PART_MAIN), on)', move)
pill = page.split('inline lv_obj_t *pill(', 1)[1].split('\n}\n', 1)[0]
self.assertIn('plain(track, knob_x(w, h, on), inset, size, size)', pill)
def test_the_sun_path_keeps_its_own_fill_after_a_palette_change(self):
tiles = (COMPONENT / 'runtime_tiles.h').read_text()
self.assertIn('w.fill_color=lv_color_hex(theme::ha::SUNNY);', tiles)
self.assertIn('if(w.extra_mode!="sunpath")w.fill_color=color;', tiles)
self.assertEqual(len(re.findall(r'\bw\.fill_color=', tiles)), 2, 'the sun path and the palette are the only two')
class Setting(unittest.TestCase):
def test_dark_mode_is_a_setting_the_screen_owns(self):
self.assertEqual(SETTING_RULES['dark_mode'], (False, None, None))
self.assertIn('dark_mode', SETTINGS_BESIDE_BLOCK, 'never inside the eleven-key block older firmware insists on')
self.assertEqual(SETTING_ENTITIES['dark_mode'], ('switch', 'Dark mode'))
self.assertEqual(DARK_MODE_MIN_FIRMWARE, '0.2.54')
self.assertLessEqual(tuple(map(int, DARK_MODE_MIN_FIRMWARE.split('.'))), tuple(map(int, FIRMWARE_VERSION.split('.'))))
page = (COMPONENT / 'settings_screen.h').read_text()
self.assertIn('toggle("Dark mode", []() -> int32_t { return dark_mode; },', page)
self.assertIn('else if (key == "dark_mode") reported = dark_mode = flag(value);', page)
editor = (ROOT / 'web/src/store.ts').read_text()
self.assertIn('{ key: "dark_mode", label: "Dark mode", kind: "toggle" },', editor)
brightness = editor.split('{ title: "Brightness"', 1)[1].split('] },', 1)[0]
self.assertLess(brightness.index('"brightness"'), brightness.index('"dark_mode"'), 'right under Brightness, as on the screen')
self.assertLess(brightness.index('"dark_mode"'), brightness.index('"standby_enabled"'))
if __name__ == '__main__':
unittest.main()