Files
homeassistant_espscreen/tests/test_portal.py
T
Max Gramser 95cdfa30cf 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>
2026-09-14 13:00:39 +02:00

181 lines
11 KiB
Python

import asyncio
import base64
import importlib.util
import json
from pathlib import Path
import sys
import tempfile
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'screen_manager/app'))
from core import discover, installation_yaml, packets, state_message, validate_layout, validate_settings
class ProtocolTests(unittest.TestCase):
def test_unicode_chunks_and_limits(self):
message = {'v':1,'op':'layout','title':'Woonkamer 🌈', 'entities':['light.lamp']*10}
parts = packets(message)
self.assertTrue(all(len(part)<=255 for part in parts))
self.assertEqual(json.loads(base64.b64decode(''.join(p.split('|')[3] for p in parts))),message)
self.assertTrue(parts[-1].split('|')[2]=='1')
with self.assertRaises(ValueError): packets({'huge':'x'*4096})
def test_layout_rejects_duplicates_unsupported_and_overflow(self):
for tiles in [[{'entity':'lock.front'}],[{'entity':'light.a'}]*2,[{'entity':f'light.a{i}'} for i in range(21)],[{'entity':'light.a;restart'}]]:
with self.assertRaises(ValueError): validate_layout({'title':'Thuis','tiles':tiles})
self.assertEqual(validate_layout({'title':'Thuis','tiles':[]})['tiles'],[])
def test_settings_defaults_ranges_and_old_layout(self):
self.assertNotIn('settings', validate_layout({'title':'Thuis','tiles':[]}))
defaults = validate_settings({})
self.assertEqual(defaults['standby_seconds'], 600)
self.assertEqual(defaults['night_start'], 22*60)
for patch in [{'standby_seconds':True}, {'brightness':0}, {'night_start':1440},
{'show_clock':1}, {'standby_seconds':600.5}, {'new_unknown':1},
{'brightness':10,'standby_brightness':20}]:
with self.assertRaises(ValueError): validate_settings(patch)
for bad in [None, [], 'wrong']:
with self.assertRaises(ValueError): validate_settings(bad)
settings = validate_settings({'brightness':40,'standby_brightness':0,'night_brightness':0})
self.assertEqual(settings['brightness'],40)
message={'v':1,'op':'layout','title':'Thuis','entities':[], 'settings':settings}
self.assertTrue(all(len(p)<=255 for p in packets(message)))
def test_message_has_only_bounded_display_attributes(self):
state={'state':'on','attributes':{'friendly_name':'é'*90,'access_token':'private','hs_color':[float('nan'),300], 'brightness':float('inf')}}
msg=state_message(0,{'entity':'light.a','name':''},{'light.a':state})
self.assertNotIn('access_token',msg['a']); self.assertNotIn('brightness',msg['a'])
self.assertLessEqual(len(msg['name'].encode()),80)
packets(msg)
def test_discovery_only_enabled_esphome_inboxes(self):
registry=[{'entity_id':'text.screen','platform':'esphome','original_name':'Tegelinstellingen'},
{'entity_id':'text.wrong','platform':'template','original_name':'Tegelinstellingen'},
{'entity_id':'text.disabled','platform':'esphome','original_name':'Tegelinstellingen','disabled_by':'user'}]
screens,_=discover(registry,{'text.screen':{'state':'Gesynchroniseerd'}},[],[])
self.assertEqual([s['id'] for s in screens],['text.screen'])
def test_unique_credentials_and_remote_profiles(self):
data={'board':'cyd','name':'woonkamer','friendly_name':'Woonkamer'}
first,second=installation_yaml(data),installation_yaml(data)
self.assertNotEqual(first,second)
self.assertIn('DEVICE_NAME: "woonkamer"',first)
self.assertIn('packages/cyd.yaml',first)
self.assertIn('!secret wifi_password',first)
import re
self.assertEqual(len(base64.b64decode(re.search(r'key: "([^"]+)"',first)[1])),32)
for name in ['../bad','Bad Name','a\napi:']:
with self.assertRaises(ValueError): installation_yaml({**data,'name':name})
HAS_AIOHTTP=importlib.util.find_spec('aiohttp') is not None
if HAS_AIOHTTP:
from server import Manager, create_app
from aiohttp.test_utils import TestClient, TestServer
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
class ManagerTests(unittest.IsolatedAsyncioTestCase):
def setup_manager(self,path):
class HA:
online=True
registry=[{'entity_id':'text.screen','platform':'esphome','original_name':'Tegelinstellingen'}]
devices=[];areas=[]
states={'text.screen':{'state':'Ready'},'light.a':{'state':'on','attributes':{'friendly_name':'Lamp'}}}
changed=asyncio.Event()
def __init__(self): self.messages=[]
async def send(self,inbox,message): self.messages.append((inbox,message))
return Manager(HA(),path)
async def test_update_restart_preserves_layout(self):
with tempfile.TemporaryDirectory() as temp:
path=Path(temp)/'screens.json';m=self.setup_manager(path)
layout={'title':'Mijn huis','tiles':[{'entity':'light.a','name':'Eigen naam'}]}
m.save('text.screen',layout)
fresh=self.setup_manager(path)
# 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)
self.assertEqual(len(fresh.ha.messages),2,'unchanged states should not resend')
fresh.ha.states['light.a']['state']='off'
await fresh.sync_one('text.screen',layout)
self.assertEqual(fresh.ha.messages[-1][1]['state'],'off')
self.assertEqual(len(fresh.ha.messages),3)
original=path.read_text();path.write_text('{"version":99,"screens":{}}')
with self.assertRaises(ValueError): self.setup_manager(path)
self.assertIn('99',path.read_text(),'unknown schema must never be overwritten')
async def test_settings_survive_update_and_old_client_save(self):
with tempfile.TemporaryDirectory() as temp:
path=Path(temp)/'screens.json'; m=self.setup_manager(path)
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)
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)
self.assertEqual(fresh.layouts['text.screen']['settings'],settings)
fresh.save('text.screen',{**legacy,'title':'Andere titel'})
saved=fresh.layouts['text.screen']
self.assertEqual(saved['settings'],settings)
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)
await fresh.sync_one('text.screen',saved)
self.assertEqual(fresh.ha.messages[0][1]['settings'],{k:v for k,v in settings.items() if k not in ('swipe_pages','rotation')})
await fresh.sync_one('text.screen',saved)
self.assertEqual(len(fresh.ha.messages),2)
await fresh.sync_one('text.screen',saved,True)
self.assertEqual(fresh.ha.messages[2][1]['settings'],{k:v for k,v in settings.items() if k not in ('swipe_pages','rotation')})
async def test_reorder_aborts_old_batch(self):
with tempfile.TemporaryDirectory() as temp:
m=self.setup_manager(Path(temp)/'screens.json');layout={'title':'Thuis','tiles':[{'entity':'light.a','name':''}]}
m.save('text.screen',layout)
original=m.ha.send
async def interrupt(inbox,message):
await original(inbox,message)
m.save(inbox,{'title':'Leeg','tiles':[]})
m.ha.send=interrupt
await m.sync_one('text.screen',m.layouts['text.screen'])
self.assertEqual(len(m.ha.messages),1)
self.assertNotIn('text.screen',m.sent)
async def test_http_csrf_validation_download_and_no_token(self):
with tempfile.TemporaryDirectory() as temp:
m=self.setup_manager(Path(temp)/'screens.json')
async with TestClient(TestServer(create_app(m,True))) as client:
response=await client.get('/api/inventory');inventory=await response.json()
self.assertNotIn('token',inventory)
response=await client.put('/api/screens/text.screen',json={'title':'Test','tiles':[]})
self.assertEqual(response.status,403)
headers={'X-Screen-CSRF':inventory['csrf']}
response=await client.put('/api/screens/text.other',headers=headers,json={'title':'Test','tiles':[]})
self.assertEqual(response.status,400)
response=await client.put('/api/screens/text.screen',headers=headers,json={'title':'Test','tiles':[]})
self.assertEqual(response.status,200)
from firmware import Firmware
m.firmware=Firmware(Path(temp)/'esphome',Path(temp))
profile={'board':'guition','name':'screen-new','friendly_name':'Nieuw','wifi_ssid':'net','wifi_password':'pw'}
# A refused USB target leaves no profile behind.
response=await client.post('/api/firmware/profiles',headers=headers,json={**profile,'target':'/dev/ttyUSB9'})
self.assertEqual(response.status,400)
self.assertFalse((Path(temp)/'esphome'/'screen-new.yaml').exists())
response=await client.post('/api/firmware/profiles',headers=headers,json=profile)
self.assertEqual(response.status,200)
self.assertEqual(response.headers['Cache-Control'],'no-store')
result=await response.json()
self.assertEqual(result['file'],'screen-new.yaml')
self.assertEqual(len(base64.b64decode(result['api_key'])),32)
self.assertNotIn('job',result)
self.assertIn('packages/guition.yaml',(Path(temp)/'esphome'/'screen-new.yaml').read_text())
self.assertNotIn('pw',json.dumps((await (await client.get('/api/firmware')).json())['wifi']))
# Until Home Assistant lists the screen, the page shows the profile as "not yet in HA" with its key.
pending=(await (await client.get('/api/inventory?light=1')).json())['pending']
self.assertEqual([(p['file'],p['friendly'],p['installed'],p['api_key']==result['api_key']) for p in pending],[('screen-new.yaml','Nieuw',False,True)])
if __name__=='__main__':unittest.main()