Free drag & drop with fixed positions, empty cells and kept pages (0.2.31)

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>
This commit is contained in:
Max Gramser
2026-09-14 13:00:39 +02:00
parent ad0385d60f
commit 95cdfa30cf
19 changed files with 676 additions and 140 deletions
+98
View File
@@ -0,0 +1,98 @@
"""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()