1a6308aba7
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>
196 lines
12 KiB
Python
196 lines
12 KiB
Python
"""The same navigation tile on several pages (app 0.2.78, firmware 0.2.65): a "Back to page 1" on every page.
|
|
|
|
Every other entity is on a screen once. A layout with copies needs firmware 0.2.65, so an older screen gets the usual
|
|
"Install screen firmware" refusal. A tile event from Claude in Home Assistant names the copy it means by its spot or its
|
|
page (`from_slot`/`from_page` for a move) and acts on the first copy, in spot order, when it doesn't; the answer says
|
|
which tile it was, and the layout sensor lists every copy.
|
|
"""
|
|
import asyncio
|
|
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 (PAGE_TILE_REPEAT_MIN_FIRMWARE, TILE_RESULT_EVENT, apply_tile_event, layout_snapshot, min_firmware, # noqa: E402
|
|
run_tile_event, validate_layout)
|
|
|
|
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
|
if HAS_AIOHTTP:
|
|
import test_tile_events
|
|
from server import Manager
|
|
|
|
BACK = 'screen.page_1'
|
|
|
|
|
|
def tile(entity, slot, **options):
|
|
item = {'entity': entity, 'name': '', 'slot': slot}
|
|
if options:
|
|
item['options'] = options
|
|
return item
|
|
|
|
|
|
def layout(*tiles):
|
|
return {'title': 'Hall', 'tiles': [dict(t) for t in tiles]}
|
|
|
|
|
|
def places(result):
|
|
return [(t['entity'], t['slot']) for t in sorted(result['tiles'], key=lambda t: t['slot'])]
|
|
|
|
|
|
# A light on page 1, and the way back on pages 2 and 3.
|
|
MENU = layout(tile('light.a', 0), tile(BACK, 6, icon='home'), tile('light.b', 7), tile(BACK, 12))
|
|
|
|
|
|
class Layouts(unittest.TestCase):
|
|
def test_copies_of_a_navigation_tile_need_firmware_0_2_65(self):
|
|
result = validate_layout(MENU)
|
|
self.assertEqual(places(result), [('light.a', 0), (BACK, 6), ('light.b', 7), (BACK, 12)])
|
|
self.assertEqual(PAGE_TILE_REPEAT_MIN_FIRMWARE, (0, 2, 65))
|
|
self.assertEqual(min_firmware(result), (0, 2, 65))
|
|
self.assertEqual(min_firmware(validate_layout(layout(tile('light.a', 0), tile(BACK, 6)))), (0, 2, 62), 'one copy: as before')
|
|
# Two on one page is odd but harmless, and what the editor allows too.
|
|
validate_layout(layout(tile(BACK, 0), tile(BACK, 1)))
|
|
for entity in ('light.a', 'screen.clock', 'screen.settings'):
|
|
with self.assertRaisesRegex(ValueError, 'only appear once'):
|
|
validate_layout(layout(tile(entity, 0), tile(entity, 6)))
|
|
|
|
def test_the_sensor_lists_every_copy(self):
|
|
snapshot = layout_snapshot({'name': 'Hall', 'node': 'hall'}, validate_layout(MENU))
|
|
self.assertEqual([(t['entity'], t['page'], t['slot'], t.get('to_page')) for t in snapshot['tiles']],
|
|
[('light.a', 1, 0, None), (BACK, 2, 6, 1), ('light.b', 2, 7, None), (BACK, 3, 12, 1)])
|
|
|
|
|
|
class Events(unittest.TestCase):
|
|
def test_add_puts_a_copy_on_a_page_without_one_and_changes_the_one_that_is_there(self):
|
|
result, placed = run_tile_event(MENU, 'add', {'entity': BACK, 'page': 4, 'name': 'Back'}, repeat_pages=True)
|
|
self.assertEqual(places(result)[-1], (BACK, 18))
|
|
self.assertEqual((placed['slot'], placed['name']), (18, 'Back'))
|
|
# Named by the page it is on: that copy changes and stays where it is.
|
|
result, changed = run_tile_event(MENU, 'add', {'entity': BACK, 'page': 3, 'color': 'blue'}, repeat_pages=True)
|
|
self.assertEqual(places(result), places(MENU))
|
|
self.assertEqual((changed['slot'], changed['options']), (12, {'background': 'blue'}))
|
|
self.assertEqual(next(t for t in result['tiles'] if t['slot'] == 6)['options'], {'icon': 'home'}, 'the other copy is untouched')
|
|
# Named by nothing: the first copy.
|
|
result, changed = run_tile_event(MENU, 'add', {'entity': BACK, 'name': 'Home'}, repeat_pages=True)
|
|
self.assertEqual((changed['slot'], len(result['tiles'])), (6, 4))
|
|
|
|
def test_older_firmware_keeps_one_and_adding_it_again_moves_it(self):
|
|
start = layout(tile('light.a', 0), tile(BACK, 6))
|
|
result, moved = run_tile_event(start, 'add', {'entity': BACK, 'page': 3})
|
|
self.assertEqual(places(result), [('light.a', 0), (BACK, 12)])
|
|
self.assertEqual(moved['slot'], 12)
|
|
self.assertEqual(places(apply_tile_event(start, 'add', {'entity': BACK, 'page': 3})), places(result), 'the old call is the same')
|
|
|
|
def test_remove_takes_the_copy_named_or_the_first(self):
|
|
self.assertEqual(places(apply_tile_event(MENU, 'remove', {'entity': BACK})), [('light.a', 0), ('light.b', 7), (BACK, 12)])
|
|
self.assertEqual(places(apply_tile_event(MENU, 'remove', {'entity': BACK, 'page': 3})), [('light.a', 0), (BACK, 6), ('light.b', 7)])
|
|
result, removed = run_tile_event(MENU, 'remove', {'entity': BACK, 'slot': 12})
|
|
self.assertEqual((removed['slot'], len(result['tiles'])), (12, 3))
|
|
self.assertEqual(places(apply_tile_event(MENU, 'remove', {'entity': BACK, 'from_slot': 12})), places(result))
|
|
with self.assertRaisesRegex(ValueError, 'screen.page_1 is not on page 4'):
|
|
apply_tile_event(MENU, 'remove', {'entity': BACK, 'page': 4})
|
|
with self.assertRaisesRegex(ValueError, 'screen.page_1 is not on spot 7'):
|
|
apply_tile_event(MENU, 'remove', {'entity': BACK, 'slot': 7})
|
|
|
|
def test_move_takes_the_copy_named_by_from_page_or_from_slot_or_the_first(self):
|
|
self.assertEqual(places(apply_tile_event(MENU, 'move', {'entity': BACK, 'page': 5})),
|
|
[('light.a', 0), ('light.b', 7), (BACK, 12), (BACK, 24)])
|
|
self.assertEqual(places(apply_tile_event(MENU, 'move', {'entity': BACK, 'from_page': 3, 'page': 5})),
|
|
[('light.a', 0), (BACK, 6), ('light.b', 7), (BACK, 24)])
|
|
result, moved = run_tile_event(MENU, 'move', {'entity': BACK, 'from_slot': 12, 'slot': 13})
|
|
self.assertEqual((moved['slot'], places(result)[-1]), (13, (BACK, 13)))
|
|
with self.assertRaisesRegex(ValueError, 'is not on page 1'):
|
|
apply_tile_event(MENU, 'move', {'entity': BACK, 'from_page': 1, 'page': 5})
|
|
with self.assertRaisesRegex(ValueError, 'page between 1 and 8'):
|
|
apply_tile_event(MENU, 'move', {'entity': BACK, 'from_page': 9, 'page': 5})
|
|
|
|
def test_order_takes_the_next_copy_each_time_it_is_named(self):
|
|
# The whole screen: named once, the first copy leads and the other keeps its place in the order.
|
|
self.assertEqual(places(apply_tile_event(MENU, 'order', {'entities': [BACK]})),
|
|
[(BACK, 0), ('light.a', 1), ('light.b', 2), (BACK, 3)])
|
|
self.assertEqual(places(apply_tile_event(MENU, 'order', {'entities': [BACK, 'light.b', BACK]})),
|
|
[(BACK, 0), ('light.b', 1), (BACK, 2), ('light.a', 3)])
|
|
with self.assertRaisesRegex(ValueError, 'Named more often than it is on this screen: screen.page_1'):
|
|
apply_tile_event(MENU, 'order', {'entities': [BACK, BACK, BACK]})
|
|
# One page: only the copy on that page.
|
|
self.assertEqual(places(apply_tile_event(MENU, 'order', {'page': 2, 'entities': ['light.b', BACK]})),
|
|
[('light.a', 0), ('light.b', 6), (BACK, 7), (BACK, 12)])
|
|
with self.assertRaisesRegex(ValueError, 'Named more often than it is on page 2'):
|
|
apply_tile_event(MENU, 'order', {'page': 2, 'entities': [BACK, BACK]})
|
|
self.assertIsNone(run_tile_event(MENU, 'order', {'entities': [BACK]})[1], 'an order acts on no single tile')
|
|
|
|
|
|
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
|
class Through(unittest.IsolatedAsyncioTestCase):
|
|
def manager(self, tmp, firmware):
|
|
ha = test_tile_events.fake_ha()
|
|
ha.states['sensor.d1_fw']['state'] = firmware
|
|
ha.registry.append({'entity_id': 'light.a', 'platform': 'demo', 'original_name': 'A', 'device_id': 'd9'})
|
|
ha.states['light.a'] = {'state': 'on', 'attributes': {'friendly_name': 'A'}}
|
|
return Manager(ha, Path(tmp) / 'screens.json')
|
|
|
|
async def events(self, m, *events):
|
|
for data in events:
|
|
m.ha.tile_events.put_nowait(('esp_screens_add_tile', data))
|
|
worker = asyncio.create_task(m.tile_loop())
|
|
for _ in range(300):
|
|
await asyncio.sleep(0.01)
|
|
if len(m.ha.fired) == len(events):
|
|
break
|
|
worker.cancel()
|
|
return [answer for _, answer in m.ha.fired]
|
|
|
|
async def test_firmware_0_2_65_takes_a_copy_on_every_page_and_the_answer_says_where(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
m = self.manager(tmp, '0.2.65')
|
|
m.save('text.d1_tiles', {'title': 'Hall', 'tiles': [tile('light.a', 0), tile('light.reading', 6)]})
|
|
answers = await self.events(m, {'entity': BACK, 'page': 2, 'name': 'Back'}, {'entity': BACK, 'page': 3, 'name': 'Back'})
|
|
self.assertEqual([(a['ok'], a['page'], a['slot']) for a in answers], [(True, 2, 7), (True, 3, 12)], answers)
|
|
self.assertEqual(answers[0]['event'], 'esp_screens_add_tile')
|
|
saved = json.loads(m.path.read_text())['screens']['text.d1_tiles']['tiles']
|
|
self.assertEqual([(t['entity'], t['slot']) for t in saved], [('light.a', 0), ('light.reading', 6), (BACK, 7), (BACK, 12)])
|
|
_, attributes = m.ha.published['sensor.esp_screens_living_room']
|
|
self.assertEqual([t['slot'] for t in attributes['tiles'] if t['entity'] == BACK], [7, 12])
|
|
self.assertTrue(all(fired == TILE_RESULT_EVENT for fired, _ in m.ha.fired))
|
|
|
|
async def test_older_firmware_moves_the_one_it_has_and_refuses_copies_from_the_editor(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
m = self.manager(tmp, '0.2.63')
|
|
m.save('text.d1_tiles', {'title': 'Hall', 'tiles': [tile('light.a', 0), tile(BACK, 6)]})
|
|
answers = await self.events(m, {'entity': BACK, 'page': 3})
|
|
self.assertEqual((answers[0]['ok'], answers[0]['page'], answers[0]['slot']), (True, 3, 12), answers)
|
|
self.assertEqual([t['slot'] for t in m.layouts['text.d1_tiles']['tiles'] if t['entity'] == BACK], [12])
|
|
with self.assertRaisesRegex(ValueError, 'Install screen firmware 0.2.65 or newer first'):
|
|
m.save('text.d1_tiles', MENU)
|
|
|
|
async def test_each_copy_keeps_its_own_settings_when_saved(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
m = self.manager(tmp, '0.2.65')
|
|
m.ha.registry.append({'entity_id': 'light.b', 'platform': 'demo', 'original_name': 'B', 'device_id': 'd9'})
|
|
m.ha.states['light.b'] = {'state': 'on', 'attributes': {'friendly_name': 'B'}}
|
|
m.save('text.d1_tiles', layout(tile('light.a', 0), tile(BACK, 6), tile('light.b', 7), tile(BACK, 12, icon='home')))
|
|
# Both copies sent without options keep none: a page from before an option existed gets its old options back,
|
|
# but such a page never sends copies, and one copy must never take another's icon.
|
|
m.save('text.d1_tiles', layout(tile('light.a', 0), tile(BACK, 6), tile('light.b', 7), tile(BACK, 12)))
|
|
self.assertEqual([t.get('options') for t in m.layouts['text.d1_tiles']['tiles'] if t['entity'] == BACK], [None, None])
|
|
m.save('text.d1_tiles', MENU)
|
|
self.assertEqual([t.get('options') for t in m.layouts['text.d1_tiles']['tiles'] if t['entity'] == BACK], [{'icon': 'home'}, None])
|
|
m.ha.messages = []
|
|
|
|
async def send(inbox, message, action=None, respond=False):
|
|
m.ha.messages.append(message)
|
|
m.ha.send = send
|
|
await m.sync_one('text.d1_tiles', m.layouts['text.d1_tiles'])
|
|
self.assertEqual(m.ha.messages[0]['entities'], ['light.a', BACK, 'light.b', BACK])
|
|
self.assertEqual(m.ha.messages[0]['slots'], [0, 6, 7, 12])
|
|
self.assertEqual([msg['i'] for msg in m.ha.messages if msg['op'] == 'state' and msg['entity'] == BACK], [1, 3])
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|