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:
Max Gramser
2026-09-18 17:25:23 +02:00
parent c09e4e333f
commit 1a6308aba7
80 changed files with 2623 additions and 320 deletions
+46 -6
View File
@@ -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()