3e959b3df9
Everything a user sees or reads is now English: both boards' labels, status replies and logs, the ESP Screens editor and Alerts cheatsheet, and the README and guides (docs/TILES.md, CALIBRATING.md, ACCEPTANCE.md and TROUBLESHOOTING.md replace the Dutch file names). Labels follow Home Assistant's own English names (Heat/Cool modes, Heating/Cooling actions, Away, Returning to dock, weather conditions), dates use English abbreviations and the top bar writes numbers the English way. Docs use HA 2026.9's menu names (Settings -> Apps, App store, Settings -> Tools). Unused legacy files (home-like.yaml, buttons.yaml, a.out) are removed. Firmware 0.2.34 names its entities in English. Home Assistant registers a renamed ESPHome entity under a new entity ID, so the inbox text.<screen>_tegelinstellingen becomes text.<screen>_tile_settings and the layout stored under the old ID would be lost. The manager now recognises the screen by its device, during a run or after a restart, and moves its layout and update history along; the Update button and the nightly round follow the screen through the rename. Screens that still run 0.2.33 or older keep working: both languages are matched. Main is the only release branch from now on. Tested on a Home Assistant Yellow (HA 2026.9.1) with both bench boards: see docs/TEST_RESULTS_0240.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
99 lines
6.2 KiB
Python
99 lines
6.2 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 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()
|