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

151 lines
8.5 KiB
Python

"""What a layout of forty-eight tiles runs into, refused when it is saved instead of later (app 0.2.78).
- The screen takes the layout in one message of at most 4096 bytes that lists every entity id: 48 tiles with long ids
passed Save and then never reached the screen.
- Tiles without positions are packed in order: 48 with one double-width, or 43 with a full-page one that isn't first,
ran past the eighth page and failed later with "Invalid tile position".
- The request limit was 16 KB from the twenty-tile days, and a valid 48-tile layout with tap actions is larger.
- The layout sensor keeps under the 16 KB Home Assistant's recorder stores of a state's attributes.
"""
import importlib.util
import json
from pathlib import Path
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 (ACTION_MAX_BYTES, MAX_TILES, apply_tile_event, encode, layout_snapshot, pack_slots, packed_slots, # noqa: E402
validate_layout, validate_tap_action, action_for_screen)
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
if HAS_AIOHTTP:
import test_scaling
from aiohttp.test_utils import TestClient, TestServer
from server import Manager, create_app
TOO_LONG = "These tiles' entity IDs are too long together to fit in one message to the screen; remove a few tiles."
NO_ROOM = "These tiles don't fit on eight pages; remove a tile or make one smaller."
# Home Assistant's recorder keeps a state's attributes up to this many bytes of JSON.
RECORDER_ATTRIBUTES = 16384
def entity(index, length, domain='light'):
stem = f'{domain}.tile_{index:02d}_'
return stem + 'x' * (length - len(stem))
def tiles(count, length=12, **options):
return [{'entity': entity(i, length), 'name': '', **({'options': dict(options)} if options else {})} for i in range(count)]
class Packing(unittest.TestCase):
def test_tiles_that_run_past_the_last_page_are_refused_at_once(self):
wide = tiles(MAX_TILES)
wide[5]['options'] = {'size': 'wide'}
self.assertGreaterEqual(max(pack_slots(wide)), 48, 'what used to be stored')
with self.assertRaisesRegex(ValueError, NO_ROOM):
validate_layout({'title': 'Hall', 'tiles': wide})
full = tiles(43)
full[1]['options'] = {'size': 'full'}
with self.assertRaisesRegex(ValueError, NO_ROOM):
packed_slots(full)
with self.assertRaisesRegex(ValueError, NO_ROOM):
validate_layout({'title': 'Hall', 'tiles': full})
# The same tiles fit with the full page first, and 48 single tiles fill every spot.
first = [full[1]] + full[:1] + full[2:]
self.assertEqual(validate_layout({'title': 'Hall', 'tiles': first})['tiles'][-1]['slot'], 47)
self.assertEqual(packed_slots(tiles(MAX_TILES)), list(range(48)))
def test_ordering_the_whole_screen_says_so_too(self):
placed = [{**tile, 'slot': slot} for tile, slot in zip(tiles(43), [0] + list(range(6, 48)))]
placed[0]['options'] = {'size': 'full'}
layout = validate_layout({'title': 'Hall', 'tiles': placed})
with self.assertRaisesRegex(ValueError, NO_ROOM):
apply_tile_event(layout, 'order', {'entities': [entity(1, 12)]})
class Snapshot(unittest.TestCase):
def test_the_layout_sensor_stays_within_what_the_recorder_keeps(self):
action = {'action': 'light.turn_on', 'data': {'brightness_pct': 40, 'transition': 2, 'color_name': 'warm' + 'x' * 40}}
layout = validate_layout({'title': 'T' * 96, 'tiles': [
{'entity': entity(i, 60), 'name': 'N' * 30, 'slot': i,
'options': {'size': 'single', 'controls': 'none', 'display': 'standard', 'tap': 'action' if i % 12 == 0 else 'detail',
**({'action': action} if i % 12 == 0 else {})}} for i in range(MAX_TILES)]})
snapshot = layout_snapshot({'name': 'Living room screen', 'node': 'living-room-screen'}, layout)
# What publish_layouts hands Home Assistant.
attributes = {'friendly_name': 'Living room screen tiles', 'icon': 'mdi:view-dashboard-outline', **snapshot}
size = len(json.dumps(attributes, ensure_ascii=False, separators=(',', ':')).encode())
self.assertLess(size, RECORDER_ATTRIBUTES, size)
self.assertEqual(set(snapshot), {'screen', 'node', 'title', 'pages', 'tiles'})
documented = {'entity', 'name', 'page', 'row', 'column', 'slot', 'size', 'controls', 'display', 'tap', 'action', 'to_page'}
for item in snapshot['tiles']:
self.assertLessEqual(set(item), documented)
self.assertEqual(sum('action' in item for item in snapshot['tiles']), 4)
def largest_tap_action():
"""A tap action as large as validate_tap_action allows: eight fields, ACTION_MAX_BYTES on the wire."""
data = {f'field_{n}': '' for n in range(8)}
while True:
for key in data:
trial = {**data, key: data[key] + 'v'}
act = action_for_screen({'action': 'light.turn_on', 'data': trial})
if len(json.dumps(act, separators=(',', ':')).encode()) > ACTION_MAX_BYTES:
return validate_tap_action({'action': 'light.turn_on', 'data': data})
data = trial
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class Saving(unittest.IsolatedAsyncioTestCase):
def manager(self, tmp, layout_tiles):
ha = test_scaling.fake_ha(firmware='0.2.63')
for tile in layout_tiles:
ha.registry.append({'entity_id': tile['entity'], 'platform': 'hue'})
ha.states[tile['entity']] = {'state': 'on', 'attributes': {}}
return Manager(ha, Path(tmp) / 'screens.json')
async def test_long_entity_ids_are_refused_when_saved(self):
long_ids, short_ids = tiles(MAX_TILES, 80), tiles(MAX_TILES)
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp, long_ids + short_ids)
with self.assertRaisesRegex(ValueError, TOO_LONG):
m.save('text.screen', {'title': 'Hall', 'tiles': long_ids})
self.assertNotIn('text.screen', m.layouts)
m.save('text.screen', {'title': 'Hall', 'tiles': short_ids})
self.assertEqual(len(m.layouts['text.screen']['tiles']), 48)
self.assertTrue(await m.sync_one('text.screen', m.layouts['text.screen']))
# A layout an older app stored anyway gets a status, not a retry every pass.
stored = validate_layout({'title': 'Hall', 'tiles': long_ids})
m.layouts = {'text.screen': stored}
m.sent.clear()
self.assertFalse(await m.sync_one('text.screen', stored))
self.assertIn('too large for one message', m.status['text.screen'])
async def test_the_largest_valid_layout_is_accepted_and_a_huge_request_is_refused_plainly(self):
# 70-character ids are about the longest 48 tiles can have in one message.
action = largest_tap_action()
layout_tiles = [{'entity': entity(i, 70), 'name': 'é' * 40, 'slot': i,
'options': {'tap': 'action', 'action': action, 'background': 'purple', 'icon': 'lightbulb', 'size': 'single',
'inline': 'none', 'display': 'standard'}} for i in range(MAX_TILES)]
body = {'title': 'T' * 96, 'tiles': layout_tiles}
with tempfile.TemporaryDirectory() as tmp:
m = self.manager(tmp, layout_tiles)
async with TestClient(TestServer(create_app(m, True))) as client:
headers = {'X-Screen-CSRF': (await (await client.get('/api/inventory?light=1')).json())['csrf']}
raw = json.dumps(body).encode()
self.assertGreater(len(raw), 48 * 1024, 'far past the old 16 KB')
response = await client.put('/api/screens/text.screen', data=raw, headers={**headers, 'Content-Type': 'application/json'})
self.assertEqual((response.status, await response.json()), (200, {'saved': True}))
self.assertEqual(len(m.layouts['text.screen']['tiles']), 48)
encode(m.layout_message('text.screen', m.layouts['text.screen'], m.screen('text.screen')))
huge = json.dumps({'title': 'Hall', 'tiles': [], 'filler': 'x' * (128 * 1024)}).encode()
response = await client.put('/api/screens/text.screen', data=huge, headers={**headers, 'Content-Type': 'application/json'})
self.assertEqual((response.status, await response.json()), (413, {'error': 'That request is too large.'}))
if __name__ == '__main__':
unittest.main()