Fix what a full test round of today's releases found: a ticking single clock, a switch that works in standby, no taps from a swipe (0.2.78)
A test and code-analysis round of 0.2.72 to 0.2.76 (suites at every tag, sanitizers, ESP32 and GitHub builds, host harnesses on both boards, the editor in a browser, best practice checked against ESPHome, LVGL, Home Assistant and Vue) found these, fixed here with Max's choices for the product questions: - Firmware 0.2.65: the second hand of a single analog clock moves again; on a page that is one full on/off tile the waking push also switches it; on the Guition a flick is never a tap and a finger that slides off a tile doesn't tap it; Previous/Next react to a firm press; the same Go to page tile may sit on several pages; a layout the screen has no memory for is refused instead of crashing; 64-bit redraw mask for 48 tiles; Montserrat 14 no longer built. - App: Save refuses a layout too long for one message or too big for eight pages; an offline screen keeps its firmware version from the device registry; HA refusals reach the editor; 128 KB requests; changelog out of the live stream; cached hashed assets; tini as init and a clean stop; changelog parsing without asterisks or crashes. - Editor: clicking the open screen keeps unsaved edits, edits during a save stay unsaved, a Page row and phone dragging to other pages, repeated page tiles, limits from the server, HA's error messages, a type-checked build. - tools/check.sh + GitHub Actions + a CYD flash budget; docs match 48 tiles and the new editor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
from core import (ALERT_FIELDS, ALERT_MAX_TIMEOUT, BROADCAST_DISMISS, BROADCAST_EVENTS, BROADCAST_SHOW, # noqa: E402
|
||||
alert_data, alert_reference, alert_targets)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
import tile_icons # noqa: E402
|
||||
from core import (ALERT_LIMITS, ALERT_MIN_FIRMWARE, FIRMWARE_VERSION, TILE_BACKGROUNDS, alert_reference, # noqa: E402
|
||||
alert_service)
|
||||
|
||||
+20
-4
@@ -129,18 +129,23 @@ class Clock:
|
||||
@unittest.skipUnless(HAS_PIL, 'Pillow comes with ESPHome in the add-on image')
|
||||
class Feed(unittest.IsolatedAsyncioTestCase):
|
||||
def feed(self, answers):
|
||||
self.fetched = []
|
||||
self.fetched, self.gate = [], None
|
||||
clock = Clock()
|
||||
|
||||
async def fetch(entity):
|
||||
self.fetched.append(entity)
|
||||
answer = answers[min(len(self.fetched), len(answers)) - 1]
|
||||
if self.gate is not None:
|
||||
# A camera that takes its time: the fetch stays on its way until the test opens the gate.
|
||||
await self.gate.wait()
|
||||
if isinstance(answer, Exception):
|
||||
raise answer
|
||||
return answer
|
||||
return camera_feed.CameraFeed(fetch, clock=clock), clock
|
||||
|
||||
async def settle(self):
|
||||
"""Let the fetch a load started run before counting fetches: whether it already ran when serve() returns
|
||||
depends on whether serve() had to wait for anything itself."""
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
@@ -163,18 +168,23 @@ class Feed(unittest.IsolatedAsyncioTestCase):
|
||||
feed, clock = self.feed([first, second, third])
|
||||
token = feed.link('camera.max', (480, 480))
|
||||
_, one, tag_one = await feed.serve(token)
|
||||
self.assertEqual(self.fetched, ['camera.max'] * 2, 'the first picture, and the next one started at once')
|
||||
await self.settle()
|
||||
self.assertEqual(self.fetched, ['camera.max'] * 2, 'the first picture, and the next one started at once')
|
||||
await asyncio.sleep(0.05)
|
||||
self.assertEqual(len(self.fetched), 2, 'nobody loads, nothing is fetched')
|
||||
clock.now += 4
|
||||
_, two, tag_two = await feed.serve(token, tag_one)
|
||||
await self.settle()
|
||||
self.assertNotEqual(tag_two, tag_one, 'the picture fetched after the last load')
|
||||
self.assertEqual(len(self.fetched), 3)
|
||||
# Two screens loading at once share one fetch.
|
||||
# Two screens loading while the next picture is on its way share that one fetch.
|
||||
self.gate = asyncio.Event()
|
||||
await feed.serve(token, tag_two)
|
||||
await feed.serve(token, tag_two)
|
||||
self.assertLessEqual(len(self.fetched), 4)
|
||||
await self.settle()
|
||||
self.assertEqual(len(self.fetched), 4)
|
||||
self.gate.set()
|
||||
await self.settle()
|
||||
|
||||
async def test_a_camera_that_fails_is_asked_again_after_a_pause(self):
|
||||
feed, clock = self.feed([ConnectionError('500'), ConnectionError('500'), picture('JPEG', (640, 360))])
|
||||
@@ -182,22 +192,27 @@ class Feed(unittest.IsolatedAsyncioTestCase):
|
||||
with self.assertLogs(camera_feed.LOG, 'INFO'):
|
||||
self.assertEqual((await feed.serve(token))[0], 503)
|
||||
self.assertEqual((await feed.serve(token))[0], 503)
|
||||
await self.settle()
|
||||
self.assertEqual(len(self.fetched), 1, 'no second try within the pause')
|
||||
clock.now += 5
|
||||
self.assertEqual((await feed.serve(token))[0], 503)
|
||||
await self.settle()
|
||||
self.assertEqual(len(self.fetched), 2)
|
||||
clock.now += 10
|
||||
self.assertEqual((await feed.serve(token))[0], 200)
|
||||
await self.settle()
|
||||
self.assertEqual(len(self.fetched), 4, 'the good picture, then the next one')
|
||||
|
||||
async def test_an_alert_gets_a_snapshot_of_its_own_moment(self):
|
||||
first, second = picture('JPEG', (640, 360)), picture('PNG', (640, 360))
|
||||
feed, clock = self.feed([first, second])
|
||||
tag_first, _ = await feed.frame('camera.front_door', (392, 220), fresh=False)
|
||||
await self.settle()
|
||||
self.assertEqual(len(self.fetched), 1)
|
||||
clock.now += 20
|
||||
# The bell rings 20 s later: the kept snapshot is not the moment, a new fetch is.
|
||||
tag_alert, _ = await feed.frame('camera.front_door', (392, 220), fresh=False, now=True)
|
||||
await self.settle()
|
||||
self.assertEqual(len(self.fetched), 2)
|
||||
self.assertNotEqual(tag_alert, tag_first)
|
||||
|
||||
@@ -236,6 +251,7 @@ class Feed(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIn(tokens[-1], feed.links)
|
||||
self.assertTrue(all(re.fullmatch(r'[A-Za-z0-9_-]{24}', token) for token in tokens))
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
async def test_the_http_port(self):
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
feed, _ = self.feed([picture('JPEG', (1920, 1080))])
|
||||
|
||||
@@ -27,8 +27,9 @@ int main() {
|
||||
pager.begin(2000); assert(pager.accept_repeat(2080, 12));
|
||||
pager.begin(2250); assert(pager.accept_repeat(2330, 12)); // 250 ms after the last
|
||||
pager.begin(2500); assert(pager.accept_repeat(2580, 12)); // and again
|
||||
pager.begin(2600); assert(!pager.accept(2680, 12)); // the old rule: bounce window
|
||||
pager.begin(2700); assert(pager.accept_repeat(2780, 11)); // Previous right after Next
|
||||
pager.begin(2800); assert(!pager.accept(2880, 12)); // 300 ms after the last Next: the old
|
||||
assert(pager.accept_repeat(2880, 12)); // rule's 600 ms window drops it, this takes it
|
||||
pager.begin(2950); assert(pager.accept_repeat(3030, 11)); // Previous right after Next
|
||||
cyd::TouchGuard g;
|
||||
g.begin(100);
|
||||
assert(!g.accept(120, 1)); // resistive noise pulse
|
||||
@@ -84,7 +85,8 @@ int main() {
|
||||
wide.begin(3200, 200, 200, 3);
|
||||
assert(!wide.accept(3260, 2)); // same tile within 600 ms
|
||||
assert(wide.reason() == "same button within the debounce window");
|
||||
// No movement limit (0): a capacitive board leaves the drift decision to LVGL's press-lost.
|
||||
// No movement limit (0, the Guition): drift never drops a tap. The tile checks that the finger let go on it
|
||||
// (runtime_tiles::event, firmware 0.2.65+), and a quick flick is LVGL's gesture, which consumes the contact.
|
||||
cyd::TouchGuard free;
|
||||
free.configure(0, 20);
|
||||
free.begin(100, 200, 200, 3);
|
||||
@@ -96,6 +98,9 @@ int main() {
|
||||
free.update(205, 200, 3); free.update(205, 200, 3); free.update(205, 200, 3);
|
||||
free.update(400, 200, 3);
|
||||
assert(free.accept_repeat(1060, 7)); // -/+ keys follow the same rule
|
||||
free.begin(2000, 200, 200, 3);
|
||||
free.consume(); // a flick (the Guition's gesture handler)
|
||||
assert(!free.accept(2100, 1) && !free.accept_repeat(2100, 7) && !free.accept_slider(2100, 201));
|
||||
cyd::TouchGuard resistive;
|
||||
resistive.configure(56, 60);
|
||||
resistive.begin(100, 50, 50);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Live values on the mockup, Identify, the test alert and the changelog for the Update badge (app 0.2.73).
|
||||
|
||||
The editor asks /api/states for the tiles it shows and draws Home Assistant's values on the mockup; Identify and
|
||||
Try it call a screen's own show_alert action, with the same field rules as an alert event; the update summary
|
||||
Try it call a screen's own show_alert action, with the same field rules as an alert event; the full inventory
|
||||
carries the CHANGELOG sections so the badge can say what a screen gets.
|
||||
"""
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -37,6 +38,58 @@ class Changelog(unittest.TestCase):
|
||||
self.assertEqual(sections[0]['lines'], ['One workspace. Sidebar, pages, library with code.', 'Second line.'])
|
||||
self.assertEqual(changelog.parse(SAMPLE, limit=1)[0]['app'], '0.2.74')
|
||||
|
||||
def test_italics_go_and_lone_asterisks_and_code_stay(self):
|
||||
# What's new showed "*Full page*" (app 0.2.78).
|
||||
self.assertEqual(changelog.plain('A size can be *Full page*: (*26.0 °C*, *Off*).'), 'A size can be Full page: (26.0 °C, Off).')
|
||||
self.assertEqual(changelog.plain('5 * 3 * 2, a*b*c, snake_case_name, camera.* or image.*'), '5 * 3 * 2, a*b*c, snake_case_name, camera.* or image.*')
|
||||
self.assertEqual(changelog.plain('**Bold** with `code *x*` and [a *link*](docs/X.md)'), 'Bold with code *x* and a link')
|
||||
|
||||
def test_other_headings_star_bullets_and_wrapped_lines(self):
|
||||
text = '''# Changelog
|
||||
|
||||
## 0.2.80 (firmware 0.2.66)
|
||||
|
||||
Intro, not a bullet.
|
||||
|
||||
- **First.** It goes on
|
||||
on an indented line
|
||||
and on a line right under it.
|
||||
* A star bullet.
|
||||
- Third.
|
||||
|
||||
A second paragraph of the third one.
|
||||
|
||||
A paragraph after the list, not a bullet.
|
||||
|
||||
### Details
|
||||
|
||||
- Under a smaller heading, still this release.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Not in any release.
|
||||
|
||||
## 0.2.79 (firmware 0.2.65)
|
||||
|
||||
- Older.
|
||||
'''
|
||||
sections = changelog.parse(text)
|
||||
self.assertEqual([s['app'] for s in sections], ['0.2.80', '0.2.79'])
|
||||
self.assertEqual(sections[0]['lines'], ['First. It goes on on an indented line and on a line right under it.', 'A star bullet.',
|
||||
'Third. A second paragraph of the third one.', 'Under a smaller heading, still this release.'])
|
||||
self.assertEqual(sections[1]['lines'], ['Older.'], 'a bullet under an unknown heading belongs to no release')
|
||||
|
||||
def test_a_changelog_that_cant_be_read_leaves_the_notes_empty(self):
|
||||
# Updater() reads it when the app starts; a broken file must never stop the app (app 0.2.78).
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
folder = Path(tmp) / 'CHANGELOG.md'
|
||||
folder.mkdir()
|
||||
latin = Path(tmp) / 'latin.md'
|
||||
latin.write_bytes('## 0.2.80 (firmware 0.2.66)\n\n- caf\xe9\n'.encode('latin-1'))
|
||||
for path in (folder, latin, Path(tmp) / 'missing.md'):
|
||||
with self.assertLogs('screen_manager', 'WARNING') if path.exists() else contextlib.nullcontext():
|
||||
self.assertEqual(changelog.load(path), [], path)
|
||||
|
||||
def test_the_real_changelog_is_found_and_shipped(self):
|
||||
sections = changelog.load()
|
||||
self.assertTrue(sections and sections[0]['lines'], 'CHANGELOG.md one folder up from the app')
|
||||
@@ -100,11 +153,33 @@ class Endpoints(unittest.IsolatedAsyncioTestCase):
|
||||
bad = await self.client.post('/api/alerts/test', headers=self.headers, json={'screen': 'text.nope'})
|
||||
self.assertEqual(bad.status, 400)
|
||||
|
||||
async def test_the_update_summary_carries_the_changelog(self):
|
||||
inventory = await (await self.client.get('/api/inventory?light=1')).json()
|
||||
sections = inventory['updates']['changelog']
|
||||
async def test_home_assistant_saying_no_or_not_answering_is_a_sentence_not_a_500(self):
|
||||
# Identify and Try it answered a bare 500 with a traceback in the log (app 0.2.78).
|
||||
from server import Refused
|
||||
cases = ((Refused('Action esphome.office_1_show_alert not found'), 400,
|
||||
"Home Assistant didn't take it: Action esphome.office_1_show_alert not found."),
|
||||
(Refused(''), 400, "Home Assistant didn't take it: no reason given."),
|
||||
(ConnectionError("Home Assistant isn't connected."), 503, "Home Assistant isn't reachable right now. Try again in a moment."),
|
||||
(TimeoutError(), 503, "Home Assistant isn't reachable right now. Try again in a moment."))
|
||||
for error, status, sentence in cases:
|
||||
async def call(action, data, error=error):
|
||||
raise error
|
||||
self.ha.call = call
|
||||
for response in (await self.client.post(f"/api/screens/{self.screen['id']}/identify", headers=self.headers),
|
||||
await self.client.post('/api/alerts/test', headers=self.headers,
|
||||
json={'screen': self.screen['id'], 'data': {'title': 'Door'}})):
|
||||
self.assertEqual((response.status, await response.json()), (status, {'error': sentence}), repr(error))
|
||||
|
||||
async def test_the_full_inventory_carries_the_changelog(self):
|
||||
# Only the full inventory (app 0.2.78): the live payload goes out every few seconds and needs no notes.
|
||||
inventory = await (await self.client.get('/api/inventory')).json()
|
||||
sections = inventory['changelog']
|
||||
self.assertTrue(sections)
|
||||
self.assertEqual(set(sections[0]), {'app', 'firmware', 'lines'})
|
||||
self.assertNotIn('changelog', inventory['updates'])
|
||||
light = await (await self.client.get('/api/inventory?light=1')).json()
|
||||
self.assertNotIn('changelog', light)
|
||||
self.assertNotIn('changelog', light['updates'])
|
||||
|
||||
|
||||
class Editor(unittest.TestCase):
|
||||
|
||||
@@ -5,10 +5,12 @@ import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'screen_manager/app'))
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parent))
|
||||
from server import KEEPALIVE_SECONDS
|
||||
from core import validate_layout, state_message, packets
|
||||
from firmware import Firmware
|
||||
from test_portal import ManagerTests
|
||||
# The module, not the class: a TestCase imported by name runs its tests a second time in this module.
|
||||
import test_portal
|
||||
|
||||
class ExtendedTests(unittest.TestCase):
|
||||
def test_options_validation_and_new_domains(self):
|
||||
@@ -53,7 +55,7 @@ class FirmwareJobs(unittest.IsolatedAsyncioTestCase):
|
||||
except asyncio.CancelledError:pass
|
||||
async def test_old_browser_preserves_new_tile_options(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m=ManagerTests().setup_manager(Path(tmp)/'screens.json')
|
||||
m=test_portal.ManagerTests().setup_manager(Path(tmp)/'screens.json')
|
||||
m.save('text.screen',{'title':'Home','tiles':[{'entity':'light.a','name':'Lamp','options':{'inline':'slider'}}]})
|
||||
m.save('text.screen',{'title':'New','tiles':[{'entity':'light.a','name':'Lamp'}]})
|
||||
self.assertEqual(m.layouts['text.screen']['tiles'][0]['options'],{'inline':'slider'})
|
||||
@@ -92,7 +94,7 @@ class SpecialTiles(unittest.TestCase):
|
||||
class SpecialTileSync(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_new_domains_need_firmware_and_send_extras(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m=ManagerTests().setup_manager(Path(tmp)/'screens.json')
|
||||
m=test_portal.ManagerTests().setup_manager(Path(tmp)/'screens.json')
|
||||
layout={'title':'Home','tiles':[{'entity':'screen.clock','name':''},{'entity':'sun.sun','name':''}]}
|
||||
m.ha.states['sun.sun']={'state':'above_horizon','attributes':{'friendly_name':'Sun','next_rising':'2026-09-14T05:15:00+00:00','next_setting':'2026-09-13T17:50:12+00:00'}}
|
||||
with self.assertRaises(ValueError):m.save('text.screen',layout)
|
||||
|
||||
@@ -8,6 +8,7 @@ import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from firmware import Firmware # noqa: E402
|
||||
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
|
||||
@@ -100,8 +100,11 @@ class Layouts(unittest.TestCase):
|
||||
for entity in ('screen.page_9', 'screen.page', 'screen.page_0'):
|
||||
with self.assertRaisesRegex(ValueError, "isn't supported"):
|
||||
validate_layout({'title': 'Home', 'tiles': [tile(entity, 0)]})
|
||||
# The same navigation tile on several pages is firmware 0.2.65's (app 0.2.78); any other entity stays once.
|
||||
twice = validate_layout({'title': 'Home', 'tiles': [tile('screen.page_2', 0), tile('screen.page_2', 7)]})
|
||||
self.assertEqual(min_firmware(twice), (0, 2, 65))
|
||||
with self.assertRaisesRegex(ValueError, 'only appear once'):
|
||||
validate_layout({'title': 'Home', 'tiles': [tile('screen.page_2', 0), tile('screen.page_2', 1)]})
|
||||
validate_layout({'title': 'Home', 'tiles': [tile('light.a', 0), tile('light.a', 1)]})
|
||||
|
||||
def test_snapshot_and_wire(self):
|
||||
layout = validate_layout({'title': 'Home', 'tiles': [tile('screen.page_3', 0, icon='radiator'), tile('light.a', 6, size='full')]})
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""The firmware a screen's features go by, and what the editor may offer it (app 0.2.78).
|
||||
|
||||
A screen whose "Screen firmware" sensor had no version (offline, restarting) counted as firmware 0.0.0: a save of more
|
||||
than ten tiles or of a newer kind of tile was refused, and the editor capped the screen at ten tiles. Home Assistant's
|
||||
device registry keeps the version ESPHome reported, "<project version> (ESPHome <version>)" for our firmware, and the app
|
||||
now falls back to that. Only a plain X.Y.Z counts, as before: "0.2.65-dev" is no release.
|
||||
|
||||
Each screen in the inventory says what that means for the editor: `firmware_known`, `tile_limit`, `full_page` and
|
||||
`page_tiles_repeat` (the contract with web/src/store.ts).
|
||||
"""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
import camera_feed # noqa: E402
|
||||
from core import (alert_targets, discover_screens, firmware_features, known_firmware, parse_firmware, registry_firmware, # noqa: E402
|
||||
screen_firmware, tile_limit)
|
||||
import updates # noqa: E402
|
||||
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
if HAS_AIOHTTP:
|
||||
import test_scaling
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
from server import Manager, create_app
|
||||
|
||||
REGISTRY = '0.2.63 (ESPHome 2026.6.2)'
|
||||
|
||||
|
||||
class Versions(unittest.TestCase):
|
||||
def test_only_a_plain_version_counts(self):
|
||||
self.assertEqual(parse_firmware('0.2.63'), (0, 2, 63))
|
||||
self.assertEqual(parse_firmware('1.10.0'), (1, 10, 0))
|
||||
for text in ('0.2.65-dev', '0.2', '0.2.63.1', ' 0.2.63', '0.2.+63', REGISTRY, 'unknown', 'unavailable', '', None, 63):
|
||||
self.assertIsNone(parse_firmware(text), text)
|
||||
self.assertIs(updates.parse_version, parse_firmware, 'update offers follow the same strict rule')
|
||||
|
||||
def test_the_registry_version_of_our_firmware(self):
|
||||
self.assertEqual(registry_firmware(REGISTRY), '0.2.63')
|
||||
# Firmware without `esphome: project:` has ESPHome's own version there, which is no screen firmware.
|
||||
for text in ('2026.6.2 (Sep 17 2026, 10:00:00)', '2026.6.2', '0.2.65-dev (ESPHome 2026.6.2)', 'v0.2.63 (ESPHome 2026.6.2)', '', None):
|
||||
self.assertIsNone(registry_firmware(text), text)
|
||||
self.assertEqual(known_firmware('0.2.64', REGISTRY), '0.2.64', 'the sensor comes first')
|
||||
for sensor in ('unavailable', 'unknown', '0.2.65-dev', None):
|
||||
self.assertEqual(known_firmware(sensor, REGISTRY), '0.2.63', sensor)
|
||||
self.assertIsNone(known_firmware('unavailable', None))
|
||||
|
||||
def test_what_a_firmware_may_be_offered(self):
|
||||
self.assertEqual([tile_limit(v) for v in (None, (0, 2, 6), (0, 2, 7), (0, 2, 61), (0, 2, 62), (0, 3, 0))], [10, 10, 20, 20, 48, 48])
|
||||
self.assertEqual(firmware_features((0, 2, 64)), {'tile_limit': 48, 'full_page': True, 'page_tiles_repeat': False})
|
||||
self.assertEqual(firmware_features((0, 2, 65)), {'tile_limit': 48, 'full_page': True, 'page_tiles_repeat': True})
|
||||
self.assertEqual(firmware_features(None), {'tile_limit': 10, 'full_page': False, 'page_tiles_repeat': False})
|
||||
|
||||
def test_discovery_falls_back_to_the_registry(self):
|
||||
registry = [{'entity_id': 'text.hall_tile_settings', 'platform': 'esphome', 'original_name': 'Tile settings', 'device_id': 'd1'},
|
||||
{'entity_id': 'sensor.hall_screen_firmware', 'platform': 'esphome', 'original_name': 'Screen firmware', 'device_id': 'd1'}]
|
||||
devices = [{'id': 'd1', 'name': 'Hall', 'sw_version': REGISTRY}]
|
||||
for sensor, known in (('0.2.64', '0.2.64'), ('unavailable', '0.2.63'), ('0.2.65-dev', '0.2.63')):
|
||||
screen, = discover_screens(registry, {'sensor.hall_screen_firmware': {'state': sensor}}, devices, [])
|
||||
self.assertEqual((screen['firmware'], screen['firmware_known']), (sensor, known))
|
||||
self.assertEqual(screen_firmware(screen), parse_firmware(known))
|
||||
screen, = discover_screens(registry, {}, [{'id': 'd1', 'name': 'Hall'}], [])
|
||||
self.assertEqual((screen['firmware'], screen['firmware_known'], screen_firmware(screen)), ('unknown', None, None))
|
||||
|
||||
def test_alerts_and_camera_images_go_by_the_same_version(self):
|
||||
restarting = {'id': 'text.hall', 'name': 'Hall', 'node': 'hall', 'online': True, 'board': 'guition',
|
||||
'firmware': 'unknown', 'firmware_known': '0.2.63'}
|
||||
self.assertEqual(alert_targets([restarting]), ([restarting], []))
|
||||
self.assertTrue(camera_feed.can_show(restarting))
|
||||
unknown = {**restarting, 'firmware_known': None}
|
||||
self.assertEqual(alert_targets([unknown])[1], [(unknown, 'firmware unknown')])
|
||||
self.assertFalse(camera_feed.can_show(unknown))
|
||||
|
||||
|
||||
def tiles(count, prefix='light.l'):
|
||||
return [{'entity': f'{prefix}{i}', 'name': ''} for i in range(count)]
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class Saving(unittest.IsolatedAsyncioTestCase):
|
||||
def manager(self, tmp, sensor, sw_version=None, online=True):
|
||||
ha = test_scaling.fake_ha(firmware=sensor)
|
||||
for tile in tiles(30):
|
||||
ha.registry.append({'entity_id': tile['entity'], 'platform': 'hue'})
|
||||
ha.states[tile['entity']] = {'state': 'on', 'attributes': {}}
|
||||
if sw_version:
|
||||
ha.devices[0]['sw_version'] = sw_version
|
||||
if not online:
|
||||
ha.states['text.screen']['state'] = 'unavailable'
|
||||
return Manager(ha, Path(tmp) / 'screens.json')
|
||||
|
||||
async def test_an_offline_screen_keeps_its_features(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = self.manager(tmp, 'unavailable', REGISTRY, online=False)
|
||||
m.save('text.screen', {'title': 'Hall', 'tiles': tiles(30)})
|
||||
self.assertEqual(len(m.layouts['text.screen']['tiles']), 30)
|
||||
self.assertEqual(m.status['text.screen'], 'Saved; waiting for sync')
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = self.manager(tmp, 'unavailable', online=False)
|
||||
with self.assertRaisesRegex(ValueError, 'Install screen firmware 0.2.62 or newer first'):
|
||||
m.save('text.screen', {'title': 'Hall', 'tiles': tiles(30)})
|
||||
|
||||
async def test_a_development_version_is_no_release(self):
|
||||
back = [{'entity': 'screen.page_1', 'name': '', 'slot': 6}, {'entity': 'screen.page_1', 'name': '', 'slot': 12}]
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = self.manager(tmp, '0.2.65-dev')
|
||||
with self.assertRaisesRegex(ValueError, 'Install screen firmware 0.2.65 or newer first'):
|
||||
m.save('text.screen', {'title': 'Hall', 'tiles': back})
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = self.manager(tmp, 'unknown', '0.2.65 (ESPHome 2026.6.2)')
|
||||
m.save('text.screen', {'title': 'Hall', 'tiles': back})
|
||||
self.assertEqual([t['slot'] for t in m.layouts['text.screen']['tiles']], [6, 12])
|
||||
|
||||
async def test_every_screen_in_the_inventory_says_what_the_editor_may_offer(self):
|
||||
cases = (('0.2.6', None, '0.2.6', 10, False, False), ('0.2.7', None, '0.2.7', 20, False, False),
|
||||
('0.2.62', None, '0.2.62', 48, True, False), ('0.2.65', None, '0.2.65', 48, True, True),
|
||||
('unavailable', REGISTRY, '0.2.63', 48, True, False), ('0.2.65-dev', None, None, 10, False, False))
|
||||
for sensor, sw_version, known, limit, full, repeat in cases:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = self.manager(tmp, sensor, sw_version)
|
||||
async with TestClient(TestServer(create_app(m, True))) as client:
|
||||
for path in ('/api/inventory', '/api/inventory?light=1'):
|
||||
screen, = (await (await client.get(path)).json())['screens']
|
||||
self.assertEqual((screen['firmware'], screen['firmware_known'], screen['tile_limit'], screen['full_page'],
|
||||
screen['page_tiles_repeat']), (sensor, known, limit, full, repeat), (path, sensor))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -8,6 +8,7 @@ import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from firmware import Firmware # noqa: E402
|
||||
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
@@ -149,9 +150,12 @@ class OverrideRoutes(unittest.IsolatedAsyncioTestCase):
|
||||
response = await client.put(url, json={'content': 'api:\n reboot_timeout: 0s\n'}, headers=headers)
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertIn('api', (await response.json())['error'])
|
||||
# The override keeps its own 12 KB limit; the request limit is far above it (app 0.2.78).
|
||||
response = await client.put(url, json={'content': 'logger:\n' + ' # p\n' * 4000}, headers=headers)
|
||||
self.assertEqual(response.status, 413)
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertIn('12 KB', (await response.json())['error'])
|
||||
response = await client.put(url, json={'content': 'logger:\n' + ' # p\n' * 40000}, headers=headers)
|
||||
self.assertEqual((response.status, (await response.json())['error']), (413, 'That request is too large.'))
|
||||
self.assertEqual((await (await client.get(url)).json())['content'], 'display:\n - id: !extend my_display\n model: ST7789V\n')
|
||||
self.assertEqual((await (await client.get('/api/firmware')).json())['profiles'], [{'file': 'kitchen.yaml'}])
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ new version is always fetched and an old one is never combined with a new page.
|
||||
"""
|
||||
import importlib.util
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
@@ -13,6 +14,12 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
STATIC = ROOT / 'screen_manager/app/static'
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
if HAS_AIOHTTP:
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
import test_scaling
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
from server import Manager, create_app
|
||||
|
||||
|
||||
class Build(unittest.TestCase):
|
||||
@@ -30,12 +37,6 @@ class Build(unittest.TestCase):
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class Assets(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_the_server_serves_the_page_and_its_hashed_assets(self):
|
||||
import sys
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
import test_scaling
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
from server import Manager, create_app
|
||||
page = (STATIC / 'index.html').read_text()
|
||||
script = re.search(r'src="\./(assets/index-[\w-]+\.js)"', page)[1]
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
@@ -50,6 +51,45 @@ class Assets(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual((asset.status, await asset.read()), (200, (STATIC / script).read_bytes()))
|
||||
self.assertEqual((await client.get('/static/app.js')).status, 404)
|
||||
|
||||
async def test_hashed_assets_are_kept_and_the_page_and_api_never(self):
|
||||
# A hashed file never changes under its name: the browser keeps it instead of fetching ~310 KB on every open
|
||||
# (app 0.2.78). `private`, as the request behind ingress carries Home Assistant's session.
|
||||
page = (STATIC / 'index.html').read_text()
|
||||
names = re.findall(r'(?:src|href)="\./(assets/[^"]+)"', page)
|
||||
self.assertTrue(any(name.endswith('.js') for name in names) and any(name.endswith('.css') for name in names), names)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
manager = Manager(test_scaling.fake_ha(), Path(tmp) / 'screens.json')
|
||||
async with TestClient(TestServer(create_app(manager, True))) as client:
|
||||
for name in names:
|
||||
asset = await client.get(f'/{name}')
|
||||
self.assertEqual((asset.status, asset.headers['Cache-Control']), (200, 'private, max-age=31536000, immutable'), name)
|
||||
again = await client.get(f'/{name}', headers={'If-None-Match': asset.headers['ETag']})
|
||||
self.assertEqual((again.status, again.headers['Cache-Control']), (304, 'private, max-age=31536000, immutable'), name)
|
||||
missing = await client.get('/assets/index-nothere.js')
|
||||
self.assertEqual(missing.status, 404)
|
||||
self.assertNotIn('immutable', missing.headers.get('Cache-Control', ''))
|
||||
for path in ('/', '/api/inventory', '/api/inventory?light=1'):
|
||||
self.assertEqual((await client.get(path)).headers['Cache-Control'], 'no-store', path)
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class Guard(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_only_ingress_reaches_the_app_and_a_change_needs_the_page_token(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
manager = Manager(test_scaling.fake_ha(), Path(tmp) / 'screens.json')
|
||||
# Production only answers Home Assistant's ingress proxy (172.30.32.2), not a request from anywhere else.
|
||||
async with TestClient(TestServer(create_app(manager))) as client:
|
||||
for path in ('/', '/api/inventory', '/assets/x.js'):
|
||||
response = await client.get(path)
|
||||
self.assertEqual((response.status, await response.text()), (403, 'Open this page through Home Assistant.'), path)
|
||||
# The development server answers localhost, and a change still needs the token the page got.
|
||||
async with TestClient(TestServer(create_app(manager, True))) as client:
|
||||
self.assertEqual((await client.get('/api/inventory')).status, 200)
|
||||
for headers in ({}, {'X-Screen-CSRF': 'guessed'}):
|
||||
response = await client.put('/api/updates', json={'auto': True}, headers=headers)
|
||||
self.assertEqual((response.status, await response.text()), (403, 'Refresh this page and try again.'))
|
||||
self.assertFalse(manager.updates.auto)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""The same navigation tile on several pages (app 0.2.78, firmware 0.2.65): a "Back to page 1" on every page.
|
||||
|
||||
Every other entity is on a screen once. A layout with copies needs firmware 0.2.65, so an older screen gets the usual
|
||||
"Install screen firmware" refusal. A tile event from Claude in Home Assistant names the copy it means by its spot or its
|
||||
page (`from_slot`/`from_page` for a move) and acts on the first copy, in spot order, when it doesn't; the answer says
|
||||
which tile it was, and the layout sensor lists every copy.
|
||||
"""
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
from core import (PAGE_TILE_REPEAT_MIN_FIRMWARE, TILE_RESULT_EVENT, apply_tile_event, layout_snapshot, min_firmware, # noqa: E402
|
||||
run_tile_event, validate_layout)
|
||||
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
if HAS_AIOHTTP:
|
||||
import test_tile_events
|
||||
from server import Manager
|
||||
|
||||
BACK = 'screen.page_1'
|
||||
|
||||
|
||||
def tile(entity, slot, **options):
|
||||
item = {'entity': entity, 'name': '', 'slot': slot}
|
||||
if options:
|
||||
item['options'] = options
|
||||
return item
|
||||
|
||||
|
||||
def layout(*tiles):
|
||||
return {'title': 'Hall', 'tiles': [dict(t) for t in tiles]}
|
||||
|
||||
|
||||
def places(result):
|
||||
return [(t['entity'], t['slot']) for t in sorted(result['tiles'], key=lambda t: t['slot'])]
|
||||
|
||||
|
||||
# A light on page 1, and the way back on pages 2 and 3.
|
||||
MENU = layout(tile('light.a', 0), tile(BACK, 6, icon='home'), tile('light.b', 7), tile(BACK, 12))
|
||||
|
||||
|
||||
class Layouts(unittest.TestCase):
|
||||
def test_copies_of_a_navigation_tile_need_firmware_0_2_65(self):
|
||||
result = validate_layout(MENU)
|
||||
self.assertEqual(places(result), [('light.a', 0), (BACK, 6), ('light.b', 7), (BACK, 12)])
|
||||
self.assertEqual(PAGE_TILE_REPEAT_MIN_FIRMWARE, (0, 2, 65))
|
||||
self.assertEqual(min_firmware(result), (0, 2, 65))
|
||||
self.assertEqual(min_firmware(validate_layout(layout(tile('light.a', 0), tile(BACK, 6)))), (0, 2, 62), 'one copy: as before')
|
||||
# Two on one page is odd but harmless, and what the editor allows too.
|
||||
validate_layout(layout(tile(BACK, 0), tile(BACK, 1)))
|
||||
for entity in ('light.a', 'screen.clock', 'screen.settings'):
|
||||
with self.assertRaisesRegex(ValueError, 'only appear once'):
|
||||
validate_layout(layout(tile(entity, 0), tile(entity, 6)))
|
||||
|
||||
def test_the_sensor_lists_every_copy(self):
|
||||
snapshot = layout_snapshot({'name': 'Hall', 'node': 'hall'}, validate_layout(MENU))
|
||||
self.assertEqual([(t['entity'], t['page'], t['slot'], t.get('to_page')) for t in snapshot['tiles']],
|
||||
[('light.a', 1, 0, None), (BACK, 2, 6, 1), ('light.b', 2, 7, None), (BACK, 3, 12, 1)])
|
||||
|
||||
|
||||
class Events(unittest.TestCase):
|
||||
def test_add_puts_a_copy_on_a_page_without_one_and_changes_the_one_that_is_there(self):
|
||||
result, placed = run_tile_event(MENU, 'add', {'entity': BACK, 'page': 4, 'name': 'Back'}, repeat_pages=True)
|
||||
self.assertEqual(places(result)[-1], (BACK, 18))
|
||||
self.assertEqual((placed['slot'], placed['name']), (18, 'Back'))
|
||||
# Named by the page it is on: that copy changes and stays where it is.
|
||||
result, changed = run_tile_event(MENU, 'add', {'entity': BACK, 'page': 3, 'color': 'blue'}, repeat_pages=True)
|
||||
self.assertEqual(places(result), places(MENU))
|
||||
self.assertEqual((changed['slot'], changed['options']), (12, {'background': 'blue'}))
|
||||
self.assertEqual(next(t for t in result['tiles'] if t['slot'] == 6)['options'], {'icon': 'home'}, 'the other copy is untouched')
|
||||
# Named by nothing: the first copy.
|
||||
result, changed = run_tile_event(MENU, 'add', {'entity': BACK, 'name': 'Home'}, repeat_pages=True)
|
||||
self.assertEqual((changed['slot'], len(result['tiles'])), (6, 4))
|
||||
|
||||
def test_older_firmware_keeps_one_and_adding_it_again_moves_it(self):
|
||||
start = layout(tile('light.a', 0), tile(BACK, 6))
|
||||
result, moved = run_tile_event(start, 'add', {'entity': BACK, 'page': 3})
|
||||
self.assertEqual(places(result), [('light.a', 0), (BACK, 12)])
|
||||
self.assertEqual(moved['slot'], 12)
|
||||
self.assertEqual(places(apply_tile_event(start, 'add', {'entity': BACK, 'page': 3})), places(result), 'the old call is the same')
|
||||
|
||||
def test_remove_takes_the_copy_named_or_the_first(self):
|
||||
self.assertEqual(places(apply_tile_event(MENU, 'remove', {'entity': BACK})), [('light.a', 0), ('light.b', 7), (BACK, 12)])
|
||||
self.assertEqual(places(apply_tile_event(MENU, 'remove', {'entity': BACK, 'page': 3})), [('light.a', 0), (BACK, 6), ('light.b', 7)])
|
||||
result, removed = run_tile_event(MENU, 'remove', {'entity': BACK, 'slot': 12})
|
||||
self.assertEqual((removed['slot'], len(result['tiles'])), (12, 3))
|
||||
self.assertEqual(places(apply_tile_event(MENU, 'remove', {'entity': BACK, 'from_slot': 12})), places(result))
|
||||
with self.assertRaisesRegex(ValueError, 'screen.page_1 is not on page 4'):
|
||||
apply_tile_event(MENU, 'remove', {'entity': BACK, 'page': 4})
|
||||
with self.assertRaisesRegex(ValueError, 'screen.page_1 is not on spot 7'):
|
||||
apply_tile_event(MENU, 'remove', {'entity': BACK, 'slot': 7})
|
||||
|
||||
def test_move_takes_the_copy_named_by_from_page_or_from_slot_or_the_first(self):
|
||||
self.assertEqual(places(apply_tile_event(MENU, 'move', {'entity': BACK, 'page': 5})),
|
||||
[('light.a', 0), ('light.b', 7), (BACK, 12), (BACK, 24)])
|
||||
self.assertEqual(places(apply_tile_event(MENU, 'move', {'entity': BACK, 'from_page': 3, 'page': 5})),
|
||||
[('light.a', 0), (BACK, 6), ('light.b', 7), (BACK, 24)])
|
||||
result, moved = run_tile_event(MENU, 'move', {'entity': BACK, 'from_slot': 12, 'slot': 13})
|
||||
self.assertEqual((moved['slot'], places(result)[-1]), (13, (BACK, 13)))
|
||||
with self.assertRaisesRegex(ValueError, 'is not on page 1'):
|
||||
apply_tile_event(MENU, 'move', {'entity': BACK, 'from_page': 1, 'page': 5})
|
||||
with self.assertRaisesRegex(ValueError, 'page between 1 and 8'):
|
||||
apply_tile_event(MENU, 'move', {'entity': BACK, 'from_page': 9, 'page': 5})
|
||||
|
||||
def test_order_takes_the_next_copy_each_time_it_is_named(self):
|
||||
# The whole screen: named once, the first copy leads and the other keeps its place in the order.
|
||||
self.assertEqual(places(apply_tile_event(MENU, 'order', {'entities': [BACK]})),
|
||||
[(BACK, 0), ('light.a', 1), ('light.b', 2), (BACK, 3)])
|
||||
self.assertEqual(places(apply_tile_event(MENU, 'order', {'entities': [BACK, 'light.b', BACK]})),
|
||||
[(BACK, 0), ('light.b', 1), (BACK, 2), ('light.a', 3)])
|
||||
with self.assertRaisesRegex(ValueError, 'Named more often than it is on this screen: screen.page_1'):
|
||||
apply_tile_event(MENU, 'order', {'entities': [BACK, BACK, BACK]})
|
||||
# One page: only the copy on that page.
|
||||
self.assertEqual(places(apply_tile_event(MENU, 'order', {'page': 2, 'entities': ['light.b', BACK]})),
|
||||
[('light.a', 0), ('light.b', 6), (BACK, 7), (BACK, 12)])
|
||||
with self.assertRaisesRegex(ValueError, 'Named more often than it is on page 2'):
|
||||
apply_tile_event(MENU, 'order', {'page': 2, 'entities': [BACK, BACK]})
|
||||
self.assertIsNone(run_tile_event(MENU, 'order', {'entities': [BACK]})[1], 'an order acts on no single tile')
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class Through(unittest.IsolatedAsyncioTestCase):
|
||||
def manager(self, tmp, firmware):
|
||||
ha = test_tile_events.fake_ha()
|
||||
ha.states['sensor.d1_fw']['state'] = firmware
|
||||
ha.registry.append({'entity_id': 'light.a', 'platform': 'demo', 'original_name': 'A', 'device_id': 'd9'})
|
||||
ha.states['light.a'] = {'state': 'on', 'attributes': {'friendly_name': 'A'}}
|
||||
return Manager(ha, Path(tmp) / 'screens.json')
|
||||
|
||||
async def events(self, m, *events):
|
||||
for data in events:
|
||||
m.ha.tile_events.put_nowait(('esp_screens_add_tile', data))
|
||||
worker = asyncio.create_task(m.tile_loop())
|
||||
for _ in range(300):
|
||||
await asyncio.sleep(0.01)
|
||||
if len(m.ha.fired) == len(events):
|
||||
break
|
||||
worker.cancel()
|
||||
return [answer for _, answer in m.ha.fired]
|
||||
|
||||
async def test_firmware_0_2_65_takes_a_copy_on_every_page_and_the_answer_says_where(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = self.manager(tmp, '0.2.65')
|
||||
m.save('text.d1_tiles', {'title': 'Hall', 'tiles': [tile('light.a', 0), tile('light.reading', 6)]})
|
||||
answers = await self.events(m, {'entity': BACK, 'page': 2, 'name': 'Back'}, {'entity': BACK, 'page': 3, 'name': 'Back'})
|
||||
self.assertEqual([(a['ok'], a['page'], a['slot']) for a in answers], [(True, 2, 7), (True, 3, 12)], answers)
|
||||
self.assertEqual(answers[0]['event'], 'esp_screens_add_tile')
|
||||
saved = json.loads(m.path.read_text())['screens']['text.d1_tiles']['tiles']
|
||||
self.assertEqual([(t['entity'], t['slot']) for t in saved], [('light.a', 0), ('light.reading', 6), (BACK, 7), (BACK, 12)])
|
||||
_, attributes = m.ha.published['sensor.esp_screens_living_room']
|
||||
self.assertEqual([t['slot'] for t in attributes['tiles'] if t['entity'] == BACK], [7, 12])
|
||||
self.assertTrue(all(fired == TILE_RESULT_EVENT for fired, _ in m.ha.fired))
|
||||
|
||||
async def test_older_firmware_moves_the_one_it_has_and_refuses_copies_from_the_editor(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = self.manager(tmp, '0.2.63')
|
||||
m.save('text.d1_tiles', {'title': 'Hall', 'tiles': [tile('light.a', 0), tile(BACK, 6)]})
|
||||
answers = await self.events(m, {'entity': BACK, 'page': 3})
|
||||
self.assertEqual((answers[0]['ok'], answers[0]['page'], answers[0]['slot']), (True, 3, 12), answers)
|
||||
self.assertEqual([t['slot'] for t in m.layouts['text.d1_tiles']['tiles'] if t['entity'] == BACK], [12])
|
||||
with self.assertRaisesRegex(ValueError, 'Install screen firmware 0.2.65 or newer first'):
|
||||
m.save('text.d1_tiles', MENU)
|
||||
|
||||
async def test_each_copy_keeps_its_own_settings_when_saved(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = self.manager(tmp, '0.2.65')
|
||||
m.ha.registry.append({'entity_id': 'light.b', 'platform': 'demo', 'original_name': 'B', 'device_id': 'd9'})
|
||||
m.ha.states['light.b'] = {'state': 'on', 'attributes': {'friendly_name': 'B'}}
|
||||
m.save('text.d1_tiles', layout(tile('light.a', 0), tile(BACK, 6), tile('light.b', 7), tile(BACK, 12, icon='home')))
|
||||
# Both copies sent without options keep none: a page from before an option existed gets its old options back,
|
||||
# but such a page never sends copies, and one copy must never take another's icon.
|
||||
m.save('text.d1_tiles', layout(tile('light.a', 0), tile(BACK, 6), tile('light.b', 7), tile(BACK, 12)))
|
||||
self.assertEqual([t.get('options') for t in m.layouts['text.d1_tiles']['tiles'] if t['entity'] == BACK], [None, None])
|
||||
m.save('text.d1_tiles', MENU)
|
||||
self.assertEqual([t.get('options') for t in m.layouts['text.d1_tiles']['tiles'] if t['entity'] == BACK], [{'icon': 'home'}, None])
|
||||
m.ha.messages = []
|
||||
|
||||
async def send(inbox, message, action=None, respond=False):
|
||||
m.ha.messages.append(message)
|
||||
m.ha.send = send
|
||||
await m.sync_one('text.d1_tiles', m.layouts['text.d1_tiles'])
|
||||
self.assertEqual(m.ha.messages[0]['entities'], ['light.a', BACK, 'light.b', BACK])
|
||||
self.assertEqual(m.ha.messages[0]['slots'], [0, 6, 7, 12])
|
||||
self.assertEqual([msg['i'] for msg in m.ha.messages if msg['op'] == 'state' and msg['entity'] == BACK], [1, 3])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -3,6 +3,8 @@ import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import test_portal
|
||||
from core import MAX_SLOTS, has_gaps, pack_slots, validate_layout
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Release lint (app 0.2.78): the version numbers of a release agree, and the packages fetch only fonts that exist.
|
||||
|
||||
Every push to main is a release (docs/RELEASING.md), so these run with the rest of the suite in tools/check.sh and CI.
|
||||
Standard library only.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
from core import FIRMWARE_VERSION # noqa: E402
|
||||
|
||||
CHANGELOG = ROOT / 'screen_manager/CHANGELOG.md'
|
||||
# An app release: "## 0.2.76 (firmware 0.2.63)"; the oldest ones name no firmware. The two "## Firmware 0.2.1x"
|
||||
# sections are firmware-only releases between app 0.2.11 and 0.2.12 and carry no app version, so they don't count.
|
||||
APP_HEADING = re.compile(r'^## (\d+)\.(\d+)\.(\d+)\b(.*)$', re.M)
|
||||
FIRMWARE_IN_HEADING = re.compile(r'\(firmware (\d+\.\d+\.\d+)\)')
|
||||
# The generator (tools/generate_packages.py) turns fonts/<file> into a raw GitHub URL on main.
|
||||
FONT_URL = re.compile(r'https://raw\.githubusercontent\.com/MaxGramser/homeassistant_espscreen/[^/\s"\']+/(fonts/[^"\'\s]+)')
|
||||
|
||||
|
||||
def app_headings():
|
||||
"""[(version tuple, text after the version)] of the CHANGELOG, top to bottom."""
|
||||
return [((int(a), int(b), int(c)), rest) for a, b, c, rest in APP_HEADING.findall(CHANGELOG.read_text())]
|
||||
|
||||
|
||||
def dotted(version):
|
||||
return '.'.join(map(str, version))
|
||||
|
||||
|
||||
class ReleaseVersionTests(unittest.TestCase):
|
||||
def test_config_version_is_the_newest_changelog_entry(self):
|
||||
match = re.search(r'^version:\s*["\']?([^"\'\s]+)["\']?\s*$', (ROOT / 'screen_manager/config.yaml').read_text(), re.M)
|
||||
self.assertIsNotNone(match, 'screen_manager/config.yaml has no version line')
|
||||
headings = app_headings()
|
||||
self.assertTrue(headings, 'screen_manager/CHANGELOG.md has no "## x.y.z" heading')
|
||||
self.assertEqual(match.group(1), dotted(headings[0][0]),
|
||||
'Home Assistant offers the update by config.yaml: bump it together with a new CHANGELOG entry')
|
||||
|
||||
def test_newest_entry_names_the_shipped_firmware(self):
|
||||
version, rest = app_headings()[0]
|
||||
firmware = FIRMWARE_IN_HEADING.search(rest)
|
||||
if firmware:
|
||||
self.assertEqual(firmware.group(1), FIRMWARE_VERSION,
|
||||
f'CHANGELOG {dotted(version)} names firmware {firmware.group(1)}, core.FIRMWARE_VERSION is {FIRMWARE_VERSION}')
|
||||
|
||||
def test_changelog_versions_are_unique_and_newest_first(self):
|
||||
versions = [version for version, _ in app_headings()]
|
||||
duplicates = sorted({dotted(v) for v in versions if versions.count(v) > 1})
|
||||
self.assertEqual(duplicates, [], 'a CHANGELOG version appears twice')
|
||||
for newer, older in zip(versions, versions[1:]):
|
||||
self.assertGreater(newer, older, f'CHANGELOG {dotted(newer)} stands above {dotted(older)}')
|
||||
|
||||
|
||||
class PackageFontTests(unittest.TestCase):
|
||||
def test_every_font_a_package_fetches_is_in_the_tree(self):
|
||||
packages = sorted((ROOT / 'packages').glob('*.yaml'))
|
||||
self.assertTrue(packages, 'no packages/*.yaml')
|
||||
seen = 0
|
||||
for package in packages:
|
||||
for path in FONT_URL.findall(package.read_text()):
|
||||
seen += 1
|
||||
self.assertTrue((ROOT / path).is_file(), f'{package.name} fetches {path}, which is not in the tree')
|
||||
# A changed URL form must not turn this into a test of nothing.
|
||||
self.assertGreater(seen, 0, 'no fonts/... URL found in packages/*.yaml')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -9,6 +9,7 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
from core import extras, packets, state_message
|
||||
import test_portal
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import test_portal
|
||||
from core import validate_settings
|
||||
|
||||
|
||||
@@ -186,3 +186,68 @@ static void test_slider_colours() {
|
||||
setpoint.state = "55.0"; assert(setpoint.slider_active());
|
||||
}
|
||||
struct RunSliderColours { RunSliderColours() { test_slider_colours(); } } run_slider_colours;
|
||||
// A navigation tile may sit on several pages of one screen (firmware 0.2.65+); a Home Assistant entity still appears once.
|
||||
static void test_repeated_page_tiles() {
|
||||
using namespace runtime_tiles;
|
||||
Model m; bool changed = false, moved = false;
|
||||
std::array<Placement, MAX_TILES> p;
|
||||
assert(m.set_layout({"light.x", "screen.page_1", "screen.page_1", "sensor.y"}, "Home", changed, {0, 6, 12, 13}, moved));
|
||||
assert(changed && m.count == 4 && place(m, p) == 3 && p[1].page == 1 && p[2].page == 2);
|
||||
assert(m.tiles[1].is_page() && m.tiles[2].is_page() && m.tiles[1].page_target() == 1 && m.tiles[2].page_target() == 1);
|
||||
// Each copy is its own tile: a state message reaches it by its index, so both get their options.
|
||||
assert(m.accepts(1, "screen.page_1") && m.accepts(2, "screen.page_1") && !m.accepts(3, "screen.page_1"));
|
||||
m.tiles[1].icon = "a"; m.tiles[2].icon = "b";
|
||||
assert(m.tiles[1].icon != m.tiles[2].icon);
|
||||
for (size_t i = 0; i < m.count; ++i) m.tiles[i].received = true;
|
||||
assert(m.ready());
|
||||
assert(m.set_layout({"light.x", "screen.page_1", "screen.page_1", "sensor.y"}, "Home", changed, {0, 6, 12, 13}, moved));
|
||||
assert(!changed && !moved && m.ready()); // the repeat of the same layout keeps everything
|
||||
assert(m.set_layout({"screen.page_2", "screen.page_2", "screen.page_2"}, "Menu", changed));
|
||||
// Every Home Assistant entity and the other built-in cards stay unique.
|
||||
assert(!m.set_layout({"light.x", "screen.page_1", "light.x"}, "Home", changed, {0, 6, 12}, moved));
|
||||
assert(!m.set_layout({"screen.clock", "screen.clock"}, "Home", changed));
|
||||
assert(!m.set_layout({"screen.settings", "screen.page_1", "screen.settings"}, "Home", changed));
|
||||
assert(!m.set_layout({"screen.page_9", "screen.page_9"}, "Home", changed));
|
||||
assert(m.count == 3 && m.title == "Menu"); // a refused layout leaves the one on screen alone
|
||||
}
|
||||
struct RunRepeatedPages { RunRepeatedPages() { test_repeated_page_tiles(); } } run_repeated_pages;
|
||||
// A longer list needs one block for every tile (firmware 0.2.65+): without it the layout is refused before anything
|
||||
// changes, with a reason for the manager, instead of std::vector writing tiles through a null pointer on the ESP32.
|
||||
static size_t test_room = 0;
|
||||
static void test_tile_room() {
|
||||
using namespace runtime_tiles;
|
||||
assert(!tile_room); // the host has room and asks nothing
|
||||
Model m; bool changed = false;
|
||||
std::vector<std::string> twelve, forty_eight;
|
||||
for (int i = 0; i < 48; ++i) (i < 12 ? twelve : forty_eight).push_back("light.r" + std::to_string(i));
|
||||
for (int i = 0; i < 12; ++i) forty_eight.push_back("light.r" + std::to_string(i));
|
||||
assert(m.set_layout(twelve, "Twelve", changed) && m.count == 12 && m.refusal.empty());
|
||||
for (size_t i = 0; i < m.count; ++i) { m.tiles[i].received = true; m.tiles[i].state = "on"; }
|
||||
tile_room = [] { return test_room; };
|
||||
test_room = 48 * sizeof(Tile) - 1;
|
||||
assert(!m.set_layout(forty_eight, "Forty-eight", changed));
|
||||
assert(m.refusal == "Error: no memory for 48 tiles");
|
||||
assert(m.configured && m.count == 12 && m.title == "Twelve" && m.ready() && m.tiles[0].state == "on");
|
||||
// Fewer tiles than the list already holds need no new block, and a malformed layout is no memory question.
|
||||
assert(m.set_layout({"light.r0", "light.r1"}, "Two", changed) && changed && m.count == 2 && m.refusal.empty());
|
||||
assert(!m.set_layout({"light.r0", "light.r0"}, "Twice", changed) && m.refusal.empty());
|
||||
test_room = 48 * sizeof(Tile);
|
||||
assert(m.set_layout(forty_eight, "Forty-eight", changed) && m.count == 48 && m.refusal.empty());
|
||||
// An empty screen asks for its first tiles the same way.
|
||||
Model fresh;
|
||||
test_room = 0;
|
||||
assert(!fresh.set_layout({"light.a"}, "One", changed) && !fresh.configured && fresh.refusal == "Error: no memory for 1 tiles");
|
||||
assert(fresh.set_layout({}, "None", changed) && fresh.configured);
|
||||
tile_room = nullptr;
|
||||
}
|
||||
struct RunTileRoom { RunTileRoom() { test_tile_room(); } } run_tile_room;
|
||||
// One redraw bit for each of the 48 tiles (firmware 0.2.65+); 32 bits sent tiles 33-48 through a full redraw.
|
||||
static void test_tile_bits() {
|
||||
using namespace runtime_tiles;
|
||||
assert(tile_bit(0) == 1 && tile_bit(31) == (uint64_t{1} << 31) && tile_bit(47) == (uint64_t{1} << 47));
|
||||
assert(tile_bit(MAX_TILES - 1) && !tile_bit(64) && !tile_bit(MAX_TILES + 100));
|
||||
uint64_t dirty = tile_bit(33) | tile_bit(47);
|
||||
assert((dirty & tile_bit(33)) && (dirty & tile_bit(47)) && !(dirty & tile_bit(1)) && !(dirty & tile_bit(32)));
|
||||
for (size_t a = 0; a < MAX_TILES; ++a) for (size_t b = a + 1; b < MAX_TILES; ++b) assert(!(tile_bit(a) & tile_bit(b)));
|
||||
}
|
||||
struct RunTileBits { RunTileBits() { test_tile_bits(); } } run_tile_bits;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""What a layout of forty-eight tiles runs into, refused when it is saved instead of later (app 0.2.78).
|
||||
|
||||
- The screen takes the layout in one message of at most 4096 bytes that lists every entity id: 48 tiles with long ids
|
||||
passed Save and then never reached the screen.
|
||||
- Tiles without positions are packed in order: 48 with one double-width, or 43 with a full-page one that isn't first,
|
||||
ran past the eighth page and failed later with "Invalid tile position".
|
||||
- The request limit was 16 KB from the twenty-tile days, and a valid 48-tile layout with tap actions is larger.
|
||||
- The layout sensor keeps under the 16 KB Home Assistant's recorder stores of a state's attributes.
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
from core import (ACTION_MAX_BYTES, MAX_TILES, apply_tile_event, encode, layout_snapshot, pack_slots, packed_slots, # noqa: E402
|
||||
validate_layout, validate_tap_action, action_for_screen)
|
||||
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
if HAS_AIOHTTP:
|
||||
import test_scaling
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
from server import Manager, create_app
|
||||
|
||||
TOO_LONG = "These tiles' entity IDs are too long together to fit in one message to the screen; remove a few tiles."
|
||||
NO_ROOM = "These tiles don't fit on eight pages; remove a tile or make one smaller."
|
||||
# Home Assistant's recorder keeps a state's attributes up to this many bytes of JSON.
|
||||
RECORDER_ATTRIBUTES = 16384
|
||||
|
||||
|
||||
def entity(index, length, domain='light'):
|
||||
stem = f'{domain}.tile_{index:02d}_'
|
||||
return stem + 'x' * (length - len(stem))
|
||||
|
||||
|
||||
def tiles(count, length=12, **options):
|
||||
return [{'entity': entity(i, length), 'name': '', **({'options': dict(options)} if options else {})} for i in range(count)]
|
||||
|
||||
|
||||
class Packing(unittest.TestCase):
|
||||
def test_tiles_that_run_past_the_last_page_are_refused_at_once(self):
|
||||
wide = tiles(MAX_TILES)
|
||||
wide[5]['options'] = {'size': 'wide'}
|
||||
self.assertGreaterEqual(max(pack_slots(wide)), 48, 'what used to be stored')
|
||||
with self.assertRaisesRegex(ValueError, NO_ROOM):
|
||||
validate_layout({'title': 'Hall', 'tiles': wide})
|
||||
full = tiles(43)
|
||||
full[1]['options'] = {'size': 'full'}
|
||||
with self.assertRaisesRegex(ValueError, NO_ROOM):
|
||||
packed_slots(full)
|
||||
with self.assertRaisesRegex(ValueError, NO_ROOM):
|
||||
validate_layout({'title': 'Hall', 'tiles': full})
|
||||
# The same tiles fit with the full page first, and 48 single tiles fill every spot.
|
||||
first = [full[1]] + full[:1] + full[2:]
|
||||
self.assertEqual(validate_layout({'title': 'Hall', 'tiles': first})['tiles'][-1]['slot'], 47)
|
||||
self.assertEqual(packed_slots(tiles(MAX_TILES)), list(range(48)))
|
||||
|
||||
def test_ordering_the_whole_screen_says_so_too(self):
|
||||
placed = [{**tile, 'slot': slot} for tile, slot in zip(tiles(43), [0] + list(range(6, 48)))]
|
||||
placed[0]['options'] = {'size': 'full'}
|
||||
layout = validate_layout({'title': 'Hall', 'tiles': placed})
|
||||
with self.assertRaisesRegex(ValueError, NO_ROOM):
|
||||
apply_tile_event(layout, 'order', {'entities': [entity(1, 12)]})
|
||||
|
||||
|
||||
class Snapshot(unittest.TestCase):
|
||||
def test_the_layout_sensor_stays_within_what_the_recorder_keeps(self):
|
||||
action = {'action': 'light.turn_on', 'data': {'brightness_pct': 40, 'transition': 2, 'color_name': 'warm' + 'x' * 40}}
|
||||
layout = validate_layout({'title': 'T' * 96, 'tiles': [
|
||||
{'entity': entity(i, 60), 'name': 'N' * 30, 'slot': i,
|
||||
'options': {'size': 'single', 'controls': 'none', 'display': 'standard', 'tap': 'action' if i % 12 == 0 else 'detail',
|
||||
**({'action': action} if i % 12 == 0 else {})}} for i in range(MAX_TILES)]})
|
||||
snapshot = layout_snapshot({'name': 'Living room screen', 'node': 'living-room-screen'}, layout)
|
||||
# What publish_layouts hands Home Assistant.
|
||||
attributes = {'friendly_name': 'Living room screen tiles', 'icon': 'mdi:view-dashboard-outline', **snapshot}
|
||||
size = len(json.dumps(attributes, ensure_ascii=False, separators=(',', ':')).encode())
|
||||
self.assertLess(size, RECORDER_ATTRIBUTES, size)
|
||||
self.assertEqual(set(snapshot), {'screen', 'node', 'title', 'pages', 'tiles'})
|
||||
documented = {'entity', 'name', 'page', 'row', 'column', 'slot', 'size', 'controls', 'display', 'tap', 'action', 'to_page'}
|
||||
for item in snapshot['tiles']:
|
||||
self.assertLessEqual(set(item), documented)
|
||||
self.assertEqual(sum('action' in item for item in snapshot['tiles']), 4)
|
||||
|
||||
|
||||
def largest_tap_action():
|
||||
"""A tap action as large as validate_tap_action allows: eight fields, ACTION_MAX_BYTES on the wire."""
|
||||
data = {f'field_{n}': '' for n in range(8)}
|
||||
while True:
|
||||
for key in data:
|
||||
trial = {**data, key: data[key] + 'v'}
|
||||
act = action_for_screen({'action': 'light.turn_on', 'data': trial})
|
||||
if len(json.dumps(act, separators=(',', ':')).encode()) > ACTION_MAX_BYTES:
|
||||
return validate_tap_action({'action': 'light.turn_on', 'data': data})
|
||||
data = trial
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class Saving(unittest.IsolatedAsyncioTestCase):
|
||||
def manager(self, tmp, layout_tiles):
|
||||
ha = test_scaling.fake_ha(firmware='0.2.63')
|
||||
for tile in layout_tiles:
|
||||
ha.registry.append({'entity_id': tile['entity'], 'platform': 'hue'})
|
||||
ha.states[tile['entity']] = {'state': 'on', 'attributes': {}}
|
||||
return Manager(ha, Path(tmp) / 'screens.json')
|
||||
|
||||
async def test_long_entity_ids_are_refused_when_saved(self):
|
||||
long_ids, short_ids = tiles(MAX_TILES, 80), tiles(MAX_TILES)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = self.manager(tmp, long_ids + short_ids)
|
||||
with self.assertRaisesRegex(ValueError, TOO_LONG):
|
||||
m.save('text.screen', {'title': 'Hall', 'tiles': long_ids})
|
||||
self.assertNotIn('text.screen', m.layouts)
|
||||
m.save('text.screen', {'title': 'Hall', 'tiles': short_ids})
|
||||
self.assertEqual(len(m.layouts['text.screen']['tiles']), 48)
|
||||
self.assertTrue(await m.sync_one('text.screen', m.layouts['text.screen']))
|
||||
# A layout an older app stored anyway gets a status, not a retry every pass.
|
||||
stored = validate_layout({'title': 'Hall', 'tiles': long_ids})
|
||||
m.layouts = {'text.screen': stored}
|
||||
m.sent.clear()
|
||||
self.assertFalse(await m.sync_one('text.screen', stored))
|
||||
self.assertIn('too large for one message', m.status['text.screen'])
|
||||
|
||||
async def test_the_largest_valid_layout_is_accepted_and_a_huge_request_is_refused_plainly(self):
|
||||
# 70-character ids are about the longest 48 tiles can have in one message.
|
||||
action = largest_tap_action()
|
||||
layout_tiles = [{'entity': entity(i, 70), 'name': 'é' * 40, 'slot': i,
|
||||
'options': {'tap': 'action', 'action': action, 'background': 'purple', 'icon': 'lightbulb', 'size': 'single',
|
||||
'inline': 'none', 'display': 'standard'}} for i in range(MAX_TILES)]
|
||||
body = {'title': 'T' * 96, 'tiles': layout_tiles}
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = self.manager(tmp, layout_tiles)
|
||||
async with TestClient(TestServer(create_app(m, True))) as client:
|
||||
headers = {'X-Screen-CSRF': (await (await client.get('/api/inventory?light=1')).json())['csrf']}
|
||||
raw = json.dumps(body).encode()
|
||||
self.assertGreater(len(raw), 48 * 1024, 'far past the old 16 KB')
|
||||
response = await client.put('/api/screens/text.screen', data=raw, headers={**headers, 'Content-Type': 'application/json'})
|
||||
self.assertEqual((response.status, await response.json()), (200, {'saved': True}))
|
||||
self.assertEqual(len(m.layouts['text.screen']['tiles']), 48)
|
||||
encode(m.layout_message('text.screen', m.layouts['text.screen'], m.screen('text.screen')))
|
||||
huge = json.dumps({'title': 'Hall', 'tiles': [], 'filler': 'x' * (128 * 1024)}).encode()
|
||||
response = await client.put('/api/screens/text.screen', data=huge, headers={**headers, 'Content-Type': 'application/json'})
|
||||
self.assertEqual((response.status, await response.json()), (413, {'error': 'That request is too large.'}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -22,6 +22,7 @@ from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
from core import (FIRMWARE_VERSION, SETTING_ENTITIES, SETTING_ENTITIES_MIN_FIRMWARE, SETTING_RULES, forecast_kinds, # noqa: E402
|
||||
setting_action, setting_entities, setting_from_state, validate_settings)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
import test_portal
|
||||
from core import (BUILTIN, SETTING_RULES, SETTINGS_BESIDE_BLOCK, entity_id, min_firmware, validate_layout,
|
||||
validate_settings)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Stopping the app (app 0.2.78).
|
||||
|
||||
`init: false` came from the first add-on, whose image was Home Assistant's s6 base with its own init. The ESPHome base
|
||||
image has none, so python3 ran as process 1: it ignored SIGTERM (every stop waited ten seconds for SIGKILL) and never
|
||||
reaped a killed build. The app now gets Home Assistant's default init (tini), Docker Compose `init: true`, and the
|
||||
server ends on SIGTERM through its own cleanup, which also stops a running build.
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP = ROOT / 'screen_manager/app'
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
|
||||
# The real server.py as `python3 server.py` runs it, with every port the OS's choice instead of 8099 and 8098.
|
||||
LAUNCH = '''
|
||||
import runpy, sys
|
||||
from aiohttp import web
|
||||
class Site(web.TCPSite):
|
||||
def __init__(self, runner, host=None, port=None, **kwargs):
|
||||
super().__init__(runner, '127.0.0.1', 0, **kwargs)
|
||||
web.TCPSite = Site
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
runpy.run_path(sys.argv[1] + '/server.py', run_name='__main__')
|
||||
'''
|
||||
|
||||
|
||||
class Configuration(unittest.TestCase):
|
||||
def test_the_app_and_compose_run_an_init(self):
|
||||
config = (ROOT / 'screen_manager/config.yaml').read_text()
|
||||
self.assertNotRegex(config, r'(?m)^init:', "Home Assistant's default (init: true) gives the app tini as process 1")
|
||||
self.assertRegex((ROOT / 'docker/compose.yaml').read_text(), r'(?m)^ init: true$')
|
||||
self.assertIn('handle_signals=True', (APP / 'server.py').read_text())
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class Signal(unittest.TestCase):
|
||||
def test_sigterm_ends_the_app_at_once_and_cleanly(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
token = Path(tmp) / 'token'
|
||||
token.write_text('not-a-real-token\n')
|
||||
env = {**os.environ, 'SCREEN_DEV': '1', 'HA_TOKEN_FILE': str(token), 'HA_API': 'http://127.0.0.1:9/api',
|
||||
'SCREEN_DATA': tmp, 'ESPHOME_CONFIG': str(Path(tmp) / 'esphome'), 'HA_CONFIG': tmp, 'PYTHONUNBUFFERED': '1'}
|
||||
process = subprocess.Popen([sys.executable, '-c', LAUNCH, str(APP)], env=env, cwd=tmp,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
|
||||
try:
|
||||
# Up once it tried Home Assistant (nothing listens on port 9 here).
|
||||
deadline, lines = time.monotonic() + 20, []
|
||||
while time.monotonic() < deadline:
|
||||
line = process.stderr.readline()
|
||||
lines.append(line)
|
||||
if 'Home Assistant temporarily unavailable' in line or not line:
|
||||
break
|
||||
self.assertIn('Home Assistant temporarily unavailable', ''.join(lines), ''.join(lines))
|
||||
started = time.monotonic()
|
||||
process.send_signal(signal.SIGTERM)
|
||||
code = process.wait(timeout=10)
|
||||
self.assertEqual(code, 0, 'a normal stop, not killed by the signal')
|
||||
self.assertLess(time.monotonic() - started, 5)
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait()
|
||||
process.stderr.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -8,6 +8,7 @@ import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
from core import CONTROLS, controls_catalogue, min_firmware, packets, resolve_controls, screen_options, state_message, validate_layout
|
||||
import tile_icons
|
||||
import test_portal
|
||||
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
import test_portal
|
||||
import tile_icons
|
||||
from core import discover, packets, state_message, validate_layout
|
||||
|
||||
@@ -2,6 +2,8 @@ import re
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import test_portal
|
||||
from core import TILE_BACKGROUNDS, validate_layout
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import test_portal
|
||||
from core import validate_layout, validate_settings, packets
|
||||
|
||||
|
||||
Reference in New Issue
Block a user