Files
homeassistant_espscreen/tests/test_settings_page.py
T
Max Gramser 1a6308aba7 Fix what a full test round of today's releases found: a ticking single clock, a switch that works in standby, no taps from a swipe (0.2.78)
A test and code-analysis round of 0.2.72 to 0.2.76 (suites at every tag, sanitizers, ESP32 and GitHub builds, host
harnesses on both boards, the editor in a browser, best practice checked against ESPHome, LVGL, Home Assistant and
Vue) found these, fixed here with Max's choices for the product questions:

- Firmware 0.2.65: the second hand of a single analog clock moves again; on a page that is one full on/off tile the
  waking push also switches it; on the Guition a flick is never a tap and a finger that slides off a tile doesn't tap
  it; Previous/Next react to a firm press; the same Go to page tile may sit on several pages; a layout the screen has
  no memory for is refused instead of crashing; 64-bit redraw mask for 48 tiles; Montserrat 14 no longer built.
- App: Save refuses a layout too long for one message or too big for eight pages; an offline screen keeps its
  firmware version from the device registry; HA refusals reach the editor; 128 KB requests; changelog out of the
  live stream; cached hashed assets; tini as init and a clean stop; changelog parsing without asterisks or crashes.
- Editor: clicking the open screen keeps unsaved edits, edits during a save stay unsaved, a Page row and phone
  dragging to other pages, repeated page tiles, limits from the server, HA's error messages, a type-checked build.
- tools/check.sh + GitHub Actions + a CYD flash budget; docs match 48 tiles and the new editor.

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

135 lines
7.9 KiB
Python

"""The settings page on the screen itself (app 0.2.52, firmware 0.2.44).
Two halves have to agree without ever seeing each other: the row table in settings_screen.h writes keys
that only mean something if ESP Screens knows them, and the eleven-key `settings` block older firmware
insists on may never grow. Both are checked here, against the real files.
"""
import importlib.util
import json
import re
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
sys.path.insert(0, str(ROOT / 'tests'))
import test_portal
from core import (BUILTIN, SETTING_RULES, SETTINGS_BESIDE_BLOCK, entity_id, min_firmware, validate_layout,
validate_settings)
SCREEN = (ROOT / 'components/smart_display/settings_screen.h').read_text()
RUNTIME = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
# The keys the firmware's own block still carries; everything newer travels as its own key.
FROZEN = {'standby_enabled', 'standby_seconds', 'brightness', 'standby_brightness', 'night_enabled',
'night_start', 'night_end', 'night_brightness', 'show_clock', 'clock_24h', 'home_on_standby'}
assert FROZEN == set(SETTING_RULES) - set(SETTINGS_BESIDE_BLOCK)
class SettingsPage(unittest.TestCase):
def test_every_key_the_screen_reports_is_a_setting_the_app_knows(self):
# Rows write through set(key, value) (firmware 0.2.49+), which reports the key it was given.
rows = set(re.findall(r'\bset\("(\w+)"', SCREEN))
self.assertTrue(rows, 'no rows found in settings_screen.h')
self.assertLessEqual(rows, set(SETTING_RULES), 'the screen writes a key ESP Screens would drop')
handled = set(re.findall(r'key == "(\w+)"', SCREEN))
self.assertLessEqual(rows, handled, 'a row writes a key set() does not handle')
self.assertEqual(handled, set(SETTING_RULES) - {'show_clock'}, 'set() handles every setting the app knows')
def test_the_frozen_settings_block_keeps_exactly_eleven_keys(self):
# Firmware before 0.2.44 refuses a `settings` object of any other size; that is why the newer
# settings travel as their own keys.
self.assertEqual(re.search(r'if \(obj\.size\(\) != (\d+)\) return false;', RUNTIME).group(1), '11')
self.assertEqual(FROZEN, set(SETTING_RULES) & FROZEN)
def test_new_settings_default_and_validate(self):
clean = validate_settings({})
self.assertEqual(clean['auto_home'], True)
self.assertEqual(clean['auto_home_seconds'], 120)
self.assertEqual(validate_settings({'auto_home_seconds': 30})['auto_home_seconds'], 30)
for bad in ({'auto_home_seconds': 29}, {'auto_home_seconds': 3601}, {'auto_home': 1},
{'auto_home_seconds': True}):
with self.assertRaises(ValueError):
validate_settings(bad)
def test_the_settings_tile_is_a_builtin_that_needs_new_firmware(self):
self.assertIn('screen.settings', BUILTIN)
self.assertTrue(entity_id('screen.settings'))
self.assertEqual(min_firmware({'tiles': [{'entity': 'screen.settings'}]}), (0, 2, 44))
self.assertLess(min_firmware({'tiles': [{'entity': 'screen.clock'}]}), (0, 2, 44))
layout = validate_layout({'title': 'Home', 'tiles': [{'entity': 'screen.settings', 'name': 'Settings'}]})
self.assertEqual(layout['tiles'][0]['entity'], 'screen.settings')
@unittest.skipUnless(importlib.util.find_spec('aiohttp'), 'Run using .venv-portal/bin/python for server tests')
class LayoutMessage(unittest.IsolatedAsyncioTestCase):
async def test_the_new_keys_ride_beside_the_frozen_block(self):
with tempfile.TemporaryDirectory() as temp:
manager = test_portal.ManagerTests().setup_manager(Path(temp) / 'screens.json')
layout = {'title': 'Home', 'tiles': [{'entity': 'light.a'}],
'settings': validate_settings({'auto_home': False, 'auto_home_seconds': 600})}
manager.save('text.screen', layout)
message = manager.layout_message('text.screen', manager.layouts['text.screen'], {})
self.assertEqual(set(message['settings']), FROZEN, 'older firmware refuses any other size')
self.assertEqual(message['auto_home'], False)
self.assertEqual(message['auto_home_seconds'], 600)
# A screen that never stored settings still gets the defaults, not a missing key.
plain = manager.layout_message('text.screen', {**manager.layouts['text.screen'],
'settings': validate_settings({})}, {})
self.assertEqual((plain['auto_home'], plain['auto_home_seconds']), (True, 120))
self.assertLess(len(json.dumps(message)), 4096, 'the screen refuses a message over 4 KB')
class Firmware(unittest.TestCase):
"""The board profiles have to carry the page, its way in, and the timer that closes it."""
def setUp(self):
self.profiles = {name: (ROOT / name).read_text()
for name in ('home-like-2432s028.yaml', 'guition-4848s040.yaml')}
def test_both_boards_wire_the_page_and_its_gesture(self):
for name, text in self.profiles.items():
self.assertIn('settings_screen::attach_hold', text, name)
self.assertIn('settings_screen::report', text, name)
self.assertIn('settings_screen::may_open', text, name)
self.assertIn('- action: open_settings', text, name)
self.assertIn('- id: go_home', text, name)
self.assertIn('runtime_tiles::auto_home', text, name)
version = re.search(r'SCREEN_FIRMWARE_VERSION: "([0-9.]+)"', text)[1]
self.assertGreaterEqual(tuple(map(int, version.split('.'))), (0, 2, 44), name)
def test_the_hold_strip_covers_the_top_bar_and_no_more(self):
for name, text in self.profiles.items():
values = dict(re.findall(r'^ (\w+): "([^"]*)"', text, re.M))
width, scroll = int(values['DISPLAY_W']), int(values['SCROLL_Y'])
x = int(values['SETTINGS_HOLD_X'].replace('${EDGE_SWIPE_BAND_PX}', values.get('EDGE_SWIPE_BAND_PX', '0')))
hold = values['SETTINGS_HOLD_W'].replace('${DISPLAY_W}', values['DISPLAY_W'])
self.assertLessEqual(x + int(hold), width, name)
self.assertGreater(scroll, 20, name)
if 'EDGE_SWIPE_BAND_PX' in values:
# The page swipe owns both edge bands; the hold may not start there.
band = int(values['EDGE_SWIPE_BAND_PX'])
self.assertGreaterEqual(x, band, name)
self.assertLessEqual(x + int(hold), width - band, name)
def test_the_hold_strip_lies_under_every_card(self):
# Firmware 0.2.44-0.2.47 created the strip after the cards, so it lay on top of them: a card's
# back button and its action at the top right lost their taps to it. (The wake overlay of a dimmed
# screen was not affected: apply_screen_settings moves it to the foreground.)
self.assertIn('lv_obj_move_to_index(hold_area, lv_obj_get_index(below));', SCREEN)
cards = ('brightness_overlay', 'color_detail_overlay', 'climate_detail_overlay', 'climate_mode_overlay',
'dim_wake_overlay')
for name, text in self.profiles.items():
call = re.search(r'settings_screen::attach_hold\(id\(home_page\)->obj, [^;]*, id\((\w+)\)\);', text)
self.assertTrue(call, f'{name}: attach_hold gets no card to stay under')
self.assertEqual(call[1], cards[0], name)
page = text[text.index('- id: home_page'):]
positions = [page.index(f'id: {card}\n') for card in cards]
self.assertEqual(positions, sorted(positions), f'{name}: {cards[0]} must be the first card on the page')
self.assertLess(page.index('id: page_next\n'), positions[0], f'{name}: the page controls stay under the strip')
if __name__ == '__main__':
unittest.main()