95cdfa30cf
Tiles get an absolute grid slot instead of a packed order: a tile lands exactly where it is dropped, dropping on another tile swaps the two, everything else stays put, and empty cells and empty pages persist. The mockup previews the result while dragging, a new page waits below the last one, "Pagina toevoegen" keeps an empty page, empty cells accept the next tile from the picker, and the mouse no longer selects text while dragging. Storage and protocol stay version 1: `tiles[].slot` (0-47) and `pages` (1-8) are additive, the layout message carries `slots` and `pages`. Firmware 0.2.26 places tiles on those slots (`place()` next to `pack()`); older firmware packs in order as before and the editor says so. Co-Authored-By: Claude Fable 5.1 <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': 'Thuis', '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': 'Thuis', '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': 'Thuis', 'tiles': bad})
|
|
# Forecast forces a wide card, so the right column is refused there too.
|
|
with self.assertRaises(ValueError):
|
|
validate_layout({'title': 'Thuis', 'tiles': [{'entity': 'weather.w', 'slot': 1, 'options': {'display': 'forecast'}}]})
|
|
|
|
def test_pages_kept_on_purpose(self):
|
|
layout = validate_layout({'title': 'Thuis', 'tiles': tiles(('light.a', 0)), 'pages': 3})
|
|
self.assertEqual(layout['pages'], 3)
|
|
self.assertNotIn('pages', validate_layout({'title': 'Thuis', 'tiles': []}))
|
|
for bad in [0, 9, '2', 2.0, True]:
|
|
with self.assertRaises(ValueError, msg=bad): validate_layout({'title': 'Thuis', 'tiles': [], 'pages': bad})
|
|
|
|
def test_last_cell_of_page_eight_and_wide_on_last_row(self):
|
|
layout = validate_layout({'title': 'Thuis', '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': 'Thuis', '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': 'Thuis', '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': 'Thuis', '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': 'Thuis', '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': 'Thuis', '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': 'Thuis', '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()
|