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

103 lines
7.4 KiB
Python

"""Direct controls on wide cards: editor choices, validation and what goes on the wire."""
import importlib.util
from pathlib import Path
import re
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
sys.path.insert(0, str(ROOT / 'tests'))
from core import CONTROLS, controls_catalogue, min_firmware, packets, resolve_controls, screen_options, state_message, validate_layout
import tile_icons
import test_portal
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
class ControlChoices(unittest.TestCase):
def test_catalogue_offers_each_domain_its_sets_plus_none(self):
catalogue = controls_catalogue()
self.assertEqual(catalogue['media_player']['default'], 'volume')
self.assertEqual([c['key'] for c in catalogue['media_player']['choices']], ['volume', 'playback', 'none'])
self.assertEqual([c['key'] for c in catalogue['climate']['choices']], ['setpoint', 'mode', 'none'])
for domain in ('climate', 'switch', 'vacuum', 'cover', 'media_player'):
self.assertIn(domain, catalogue)
self.assertNotIn('sensor', catalogue)
def test_validation_accepts_own_sets_and_rejects_others(self):
layout = validate_layout({'title': 'T', 'tiles': [{'entity': 'cover.curtain', 'options': {'size': 'wide', 'controls': 'position'}}]})
self.assertEqual(layout['tiles'][0]['options']['controls'], 'position')
validate_layout({'title': 'T', 'tiles': [{'entity': 'light.a', 'options': {'controls': 'none'}}]})
for entity, choice in (('cover.a', 'volume'), ('sensor.a', 'toggle'), ('switch.a', 'brightness'), ('climate.a', 42), ('light.a', 'run')):
with self.assertRaises(ValueError):
validate_layout({'title': 'T', 'tiles': [{'entity': entity, 'options': {'size': 'wide', 'controls': choice}}]})
def test_wide_cards_get_the_default_set_only_in_the_standard_layout(self):
self.assertEqual(resolve_controls({'entity': 'climate.a', 'options': {'size': 'wide'}}), 'setpoint')
self.assertEqual(resolve_controls({'entity': 'media_player.a', 'options': {'size': 'wide', 'controls': 'playback'}}), 'playback')
self.assertIsNone(resolve_controls({'entity': 'media_player.a', 'options': {'size': 'wide', 'controls': 'none'}}))
self.assertIsNone(resolve_controls({'entity': 'climate.a'}), 'single cards stay as they are')
self.assertIsNone(resolve_controls({'entity': 'climate.a', 'options': {'size': 'wide', 'display': 'watch'}}))
self.assertIsNone(resolve_controls({'entity': 'light.a', 'options': {'size': 'wide', 'inline': 'slider'}}))
self.assertIsNone(resolve_controls({'entity': 'sensor.a', 'options': {'size': 'wide'}}))
def test_wire_carries_only_the_shown_set_and_the_attributes_the_panels_need(self):
states = {'media_player.sonos': {'state': 'playing', 'attributes': {'volume_level': 0.17, 'is_volume_muted': False, 'media_title': 'TV', 'supported_features': 8321599, 'assumed_state': True}},
'cover.curtain': {'state': 'open', 'attributes': {'current_position': 80, 'device_class': 'curtain', 'supported_features': 15}},
'climate.ac': {'state': 'cool', 'attributes': {'hvac_action': 'cooling', 'hvac_modes': ['off', 'cool', 'heat'], 'temperature': 20, 'target_temp_step': 1.0}}}
msg = state_message(0, {'entity': 'media_player.sonos', 'name': '', 'options': {'size': 'wide'}}, states)
self.assertEqual(msg['o'], {'size': 'wide', 'controls': 'volume'})
self.assertIs(msg['a']['is_volume_muted'], False)
self.assertNotIn('assumed_state', msg['a'])
msg = state_message(1, {'entity': 'cover.curtain', 'name': '', 'options': {'size': 'wide', 'controls': 'none', 'background': 'blue'}}, states)
self.assertEqual(msg['o'], {'size': 'wide', 'background': 'blue'}, 'an explicit none is not sent; the stored choice stays')
self.assertEqual(msg['a']['device_class'], 'curtain')
msg = state_message(2, {'entity': 'climate.ac', 'name': '', 'options': {'size': 'wide', 'controls': 'mode'}}, states)
self.assertEqual(msg['o'], {'size': 'wide', 'controls': 'mode'})
self.assertEqual(msg['a']['hvac_action'], 'cooling')
self.assertTrue(all(len(p) <= 255 for p in packets(msg)))
msg = state_message(3, {'entity': 'climate.ac', 'name': '', 'options': {'controls': 'mode'}}, states)
self.assertEqual(msg['o'], {}, 'a single card never shows a panel')
self.assertIsNone(min_firmware({'tiles': [{'entity': 'climate.ac', 'options': {'size': 'wide', 'controls': 'mode'}}]}), 'older firmware ignores the field')
def test_screen_options_keeps_the_choice_out_of_the_wire_but_in_storage(self):
tile = {'entity': 'switch.desk', 'name': '', 'options': {'size': 'wide', 'controls': 'toggle', 'icon': 'auto'}}
self.assertEqual(screen_options(tile, {}), {'size': 'wide', 'controls': 'toggle'})
self.assertEqual(validate_layout({'title': 'T', 'tiles': [tile]})['tiles'][0]['options'], tile['options'])
def test_control_glyphs_exist_in_the_icon_fonts_and_the_editor_font(self):
header = (ROOT / 'components/smart_display/tile_controls.h').read_text()
used = {code.upper() for code in re.findall(r'\\U000(F[0-9A-F]{4})', header)}
carried = {code for code in tile_icons.GLYPHS.values()}
self.assertTrue(used <= carried, used - carried)
self.assertTrue(set(tile_icons.CONTROL_GLYPHS) <= set(tile_icons.GLYPHS))
self.assertEqual(set(tile_icons.editor()['controls']), set(tile_icons.CONTROL_GLYPHS))
for name in ('guition-4848s040.yaml', 'home-like-2432s028.yaml', 'packages/guition.yaml', 'packages/cyd.yaml'):
text = (ROOT / name).read_text()
for code in used:
self.assertIn(f'"\\U000{code}"', text, (name, code))
self.assertIn('runtime_tiles::control_font = id(sublabel_big)->get_lv_font();', text, name)
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class ControlInventory(unittest.IsolatedAsyncioTestCase):
async def test_inventory_lists_the_catalogue_and_a_saved_choice_survives_an_old_editor(self):
with tempfile.TemporaryDirectory() as tmp:
m = test_portal.ManagerTests().setup_manager(Path(tmp) / 'screens.json')
self.assertEqual(m.inventory()[0][0]['id'], 'text.screen')
m.save('text.screen', {'title': 'Home', 'tiles': [{'entity': 'light.a', 'options': {'size': 'wide', 'controls': 'brightness'}}]})
m.save('text.screen', {'title': 'Home', 'tiles': [{'entity': 'light.a', 'options': {'size': 'wide'}}]})
self.assertEqual(m.layouts['text.screen']['tiles'][0]['options'], {'size': 'wide', 'controls': 'brightness'})
await m.sync_one('text.screen', m.layouts['text.screen'])
self.assertEqual(m.ha.messages[1][1]['o'], {'size': 'wide', 'controls': 'brightness'})
from aiohttp.test_utils import TestClient, TestServer
from server import create_app
async with TestClient(TestServer(create_app(m, development=True))) as client:
data = await (await client.get('/api/inventory')).json()
self.assertEqual(data['controls']['cover']['default'], 'buttons')
self.assertIn('controls', data['icons'])
light = await (await client.get('/api/inventory?light=1')).json()
self.assertNotIn('controls', light)