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:
@@ -90,7 +90,9 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
|
||||
layout={'title':'Mijn huis','tiles':[{'entity':'light.a','name':'Eigen naam'}]}
|
||||
m.save('text.screen',layout)
|
||||
fresh=self.setup_manager(path)
|
||||
self.assertEqual(fresh.layouts['text.screen'],layout)
|
||||
# Stored layouts always carry a grid position; a save without one packs in order.
|
||||
self.assertEqual(fresh.layouts['text.screen'],{**layout,'tiles':[{**layout['tiles'][0],'slot':0}]})
|
||||
layout=fresh.layouts['text.screen']
|
||||
await fresh.sync_one('text.screen',layout)
|
||||
self.assertEqual(len(fresh.ha.messages),2)
|
||||
await fresh.sync_one('text.screen',layout)
|
||||
@@ -109,7 +111,8 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
|
||||
legacy={'title':'Thuis','tiles':[{'entity':'light.a','name':'Mijn lamp'}]}
|
||||
path.write_text(json.dumps({'version':1,'screens':{'text.screen':legacy}}))
|
||||
m=self.setup_manager(path)
|
||||
self.assertEqual(m.layouts['text.screen'],legacy)
|
||||
stored={**legacy,'tiles':[{**legacy['tiles'][0],'slot':0}]}
|
||||
self.assertEqual(m.layouts['text.screen'],stored)
|
||||
settings=validate_settings({'brightness':55,'standby_seconds':1200})
|
||||
m.save('text.screen',{**legacy,'settings':settings})
|
||||
fresh=self.setup_manager(path)
|
||||
@@ -117,7 +120,7 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
|
||||
fresh.save('text.screen',{**legacy,'title':'Andere titel'})
|
||||
saved=fresh.layouts['text.screen']
|
||||
self.assertEqual(saved['settings'],settings)
|
||||
self.assertEqual(saved['tiles'],legacy['tiles'])
|
||||
self.assertEqual(saved['tiles'],stored['tiles'])
|
||||
before=path.read_bytes()
|
||||
with self.assertRaises(ValueError): fresh.save('text.screen',{**saved,'settings':{'brightness':0}})
|
||||
self.assertEqual(path.read_bytes(),before)
|
||||
@@ -137,7 +140,7 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
|
||||
await original(inbox,message)
|
||||
m.save(inbox,{'title':'Leeg','tiles':[]})
|
||||
m.ha.send=interrupt
|
||||
await m.sync_one('text.screen',layout)
|
||||
await m.sync_one('text.screen',m.layouts['text.screen'])
|
||||
self.assertEqual(len(m.ha.messages),1)
|
||||
self.assertNotIn('text.screen',m.sent)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -57,3 +57,47 @@ static void test_domains_and_packing() {
|
||||
assert(pack(m.tiles, 4, p) == 2 && p[3].page == 1 && p[3].slot == 0);
|
||||
}
|
||||
struct RunExtra { RunExtra() { test_domains_and_packing(); } } run_extra;
|
||||
// Explicit grid positions (0.2.26+): gaps stay empty, a wide card starts in the left column.
|
||||
static void test_explicit_slots() {
|
||||
using namespace runtime_tiles;
|
||||
Model m; bool changed = false, moved = false;
|
||||
std::array<Placement, MAX_TILES> p;
|
||||
// Validation: one slot per entity, inside eight pages, no duplicates.
|
||||
assert(!m.set_layout({"light.a", "light.b"}, "Thuis", changed, {0}, moved));
|
||||
assert(!m.set_layout({"light.a", "light.b"}, "Thuis", changed, {0, 48}, moved));
|
||||
assert(!m.set_layout({"light.a", "light.b"}, "Thuis", changed, {3, 3}, moved));
|
||||
assert(!m.configured);
|
||||
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {0, 5, 7}, moved));
|
||||
assert(changed && !moved && m.explicit_slots);
|
||||
assert(place(m, p) == 2 && p[0].page == 0 && p[0].slot == 0 && p[1].slot == 5 && p[2].page == 1 && p[2].slot == 1);
|
||||
// Same tiles elsewhere: states survive, only the pages re-place.
|
||||
for (size_t i = 0; i < m.count; ++i) { m.tiles[i].received = true; m.tiles[i].state = "on"; }
|
||||
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {2, 5, 7}, moved));
|
||||
assert(!changed && moved && m.ready() && m.tiles[0].state == "on");
|
||||
assert(place(m, p) == 2 && p[0].slot == 2);
|
||||
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {2, 5, 7}, moved));
|
||||
assert(!changed && !moved);
|
||||
// A wide card on an odd slot snaps to its row start; the page count follows its footprint.
|
||||
m.tiles[2].wide = true;
|
||||
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {2, 5, 11}, moved));
|
||||
assert(!changed && moved && place(m, p) == 2 && p[2].page == 1 && p[2].slot == 4);
|
||||
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {2, 5, 12}, moved));
|
||||
assert(place(m, p) == 3 && p[2].page == 2 && p[2].slot == 0);
|
||||
// An empty page between two used pages stays a page; the last slot of page eight is allowed.
|
||||
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {0, 1, 46}, moved));
|
||||
assert(place(m, p) == 8 && p[2].page == 7 && p[2].slot == 4);
|
||||
// Pages kept on purpose extend the count, never shrink it.
|
||||
m.pages = 3; assert(place(m, p) == 8);
|
||||
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {0, 1, 2}, moved));
|
||||
assert(place(m, p) == 3); m.pages = 1; assert(place(m, p) == 1); m.pages = 200; assert(place(m, p) == MAX_PAGES); m.pages = 1;
|
||||
// Without slots (older manager) the in-order packing returns.
|
||||
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {}, moved));
|
||||
assert(!changed && moved && !m.explicit_slots);
|
||||
assert(place(m, p) == 1 && p[0].slot == 0 && p[1].slot == 1 && p[2].slot == 2);
|
||||
assert(m.set_layout({"light.a", "light.b", "light.c"}, "Thuis", changed, {}, moved));
|
||||
assert(!changed && !moved);
|
||||
// Changing the tiles resets everything, including the positions.
|
||||
assert(m.set_layout({"light.a", "light.z"}, "Thuis", changed, {1, 6}, moved));
|
||||
assert(changed && !moved && !m.ready() && place(m, p) == 2 && p[0].slot == 1 && p[1].page == 1);
|
||||
}
|
||||
struct RunSlots { RunSlots() { test_explicit_slots(); } } run_slots;
|
||||
|
||||
Reference in New Issue
Block a user