Files
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

101 lines
6.3 KiB
Python

"""Grid positions (app 0.2.31 / firmware 0.2.26): explicit slots, empty cells, in-order fallback."""
import json
import tempfile
import unittest
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
import test_portal
from core import MAX_SLOTS, has_gaps, pack_slots, validate_layout
def tiles(*specs):
out = []
for spec in specs:
entity, slot, wide = (spec + (None, False))[:3]
tile = {'entity': entity}
if slot is not None: tile['slot'] = slot
if wide: tile['options'] = {'size': 'wide'}
out.append(tile)
return out
class Positions(unittest.TestCase):
def test_layout_without_positions_packs_in_order(self):
layout = validate_layout({'title': 'Home', 'tiles': tiles(('light.a',), ('weather.w', None, True), ('light.b',), ('light.c',))})
self.assertEqual([t['slot'] for t in layout['tiles']], [0, 2, 4, 5])
self.assertEqual(pack_slots(layout['tiles']), [0, 2, 4, 5])
self.assertFalse(has_gaps(layout['tiles']))
def test_explicit_positions_keep_gaps_and_sort_by_slot(self):
layout = validate_layout({'title': 'Home', 'tiles': tiles(('light.b', 7), ('light.a', 1), ('weather.w', 4, True), ('light.c', 46))})
self.assertEqual([(t['entity'], t['slot']) for t in layout['tiles']], [('light.a', 1), ('weather.w', 4), ('light.b', 7), ('light.c', 46)])
self.assertTrue(has_gaps(layout['tiles']))
# A second pass (reload from disk) is stable.
self.assertEqual(validate_layout(layout), layout)
def test_invalid_positions_are_refused(self):
for bad in [tiles(('light.a', 0), ('light.b', 0)), # same cell
tiles(('light.a', 1), ('weather.w', 0, True)), # wide covers 0 and 1
tiles(('weather.w', 3, True)), # wide in the right column
tiles(('light.a', MAX_SLOTS)), # beyond page eight
tiles(('light.a', -1)), tiles(('light.a', '2')), tiles(('light.a', 2.0)),
tiles(('light.a', 0), ('light.b',))]: # some tiles without a position
with self.assertRaises(ValueError, msg=bad): validate_layout({'title': 'Home', 'tiles': bad})
# Forecast forces a wide card, so the right column is refused there too.
with self.assertRaises(ValueError):
validate_layout({'title': 'Home', 'tiles': [{'entity': 'weather.w', 'slot': 1, 'options': {'display': 'forecast'}}]})
def test_pages_kept_on_purpose(self):
layout = validate_layout({'title': 'Home', 'tiles': tiles(('light.a', 0)), 'pages': 3})
self.assertEqual(layout['pages'], 3)
self.assertNotIn('pages', validate_layout({'title': 'Home', 'tiles': []}))
for bad in [0, 9, '2', 2.0, True]:
with self.assertRaises(ValueError, msg=bad): validate_layout({'title': 'Home', 'tiles': [], 'pages': bad})
def test_last_cell_of_page_eight_and_wide_on_last_row(self):
layout = validate_layout({'title': 'Home', 'tiles': tiles(('light.a', MAX_SLOTS - 1), ('weather.w', MAX_SLOTS - 4, True))})
self.assertEqual([t['slot'] for t in layout['tiles']], [MAX_SLOTS - 4, MAX_SLOTS - 1])
class PositionsOnTheWire(unittest.IsolatedAsyncioTestCase):
async def test_layout_message_carries_slots(self):
with tempfile.TemporaryDirectory() as tmp:
m = test_portal.ManagerTests().setup_manager(Path(tmp) / 'screens.json')
m.ha.states['light.b'] = {'state': 'off', 'attributes': {}}
m.save('text.screen', {'title': 'Home', 'tiles': tiles(('light.b', 7), ('light.a', 2))})
await m.sync_one('text.screen', m.layouts['text.screen'])
wire = m.ha.messages[0][1]
self.assertEqual(wire['entities'], ['light.a', 'light.b'])
self.assertEqual(wire['slots'], [2, 7])
# State messages index the entity list, in slot order.
self.assertEqual([(msg['i'], msg['entity']) for _, msg in m.ha.messages[1:]], [(0, 'light.a'), (1, 'light.b')])
# Moving a tile changes the layout message, so the whole layout goes out again.
m.ha.messages.clear()
m.save('text.screen', {'title': 'Home', 'tiles': tiles(('light.b', 6), ('light.a', 2))})
await m.sync_one('text.screen', m.layouts['text.screen'])
self.assertEqual(m.ha.messages[0][1]['slots'], [2, 6])
self.assertEqual(len(m.ha.messages), 3)
self.assertNotIn('pages', m.ha.messages[0][1])
m.ha.messages.clear()
m.save('text.screen', {'title': 'Home', 'tiles': tiles(('light.b', 6), ('light.a', 2)), 'pages': 4})
await m.sync_one('text.screen', m.layouts['text.screen'])
self.assertEqual(m.ha.messages[0][1]['pages'], 4)
async def test_old_storage_gets_positions_and_old_editor_keeps_order(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'screens.json'
legacy = {'title': 'Home', 'tiles': [{'entity': 'light.a', 'name': ''}, {'entity': 'light.b', 'name': '', 'options': {'size': 'wide'}}]}
path.write_text(json.dumps({'version': 1, 'screens': {'text.screen': legacy}}))
m = test_portal.ManagerTests().setup_manager(path)
self.assertEqual([t['slot'] for t in m.layouts['text.screen']['tiles']], [0, 2])
m.ha.states['light.b'] = {'state': 'off', 'attributes': {}}
# The editor moves light.b to page two; an older editor that saves without positions packs again.
m.save('text.screen', {'title': 'Home', 'tiles': [{'entity': 'light.a', 'slot': 1}, {'entity': 'light.b', 'slot': 6, 'options': {'size': 'wide'}}]})
self.assertEqual([t['slot'] for t in m.layouts['text.screen']['tiles']], [1, 6])
self.assertEqual(json.loads(path.read_text())['screens']['text.screen']['tiles'][1]['slot'], 6)
m.save('text.screen', {'title': 'Home', 'tiles': [{'entity': 'light.b'}, {'entity': 'light.a'}]})
saved = m.layouts['text.screen']['tiles']
self.assertEqual([(t['entity'], t['slot']) for t in saved], [('light.b', 0), ('light.a', 2)])
self.assertEqual(saved[0]['options'], {'size': 'wide'}, 'options survive an old editor, as before')
if __name__ == '__main__':
unittest.main()