Send one alert to every screen, teach Claude about ESP Screens, and add a Settings page (0.2.45)
- The app listens for the Home Assistant events esp_screens_show_alert and esp_screens_dismiss_alert and calls each screen's show_alert/dismiss_alert action, screens added later included. Fields are typed like Home Assistant's ESPHome action schema; an unusable value is left empty instead of losing the alert. - Settings → Claude installs an ESP Screens skill for Claude Code in Home Assistant (/homeassistant/.claude/skills/esp-screens) or downloads it as a zip for claude.ai. It is generated from the alert reference, so fields, colors and icons match the firmware. - New screen, Firmware & USB, firmware updates, Alerts and Claude move to a Settings view; the header keeps one button. Unselected screens in My screens get a light grey card. App only; firmware stays 0.2.38. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
"""One alert for every screen (app 0.2.45): Home Assistant fires esp_screens_show_alert or
|
||||
esp_screens_dismiss_alert, and the app calls that action on each screen that can show it."""
|
||||
import asyncio
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
from core import (ALERT_FIELDS, ALERT_MAX_TIMEOUT, BROADCAST_DISMISS, BROADCAST_EVENTS, BROADCAST_SHOW, # noqa: E402
|
||||
alert_data, alert_reference, alert_targets)
|
||||
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
if HAS_AIOHTTP:
|
||||
from aiohttp import WSMsgType
|
||||
from server import HomeAssistant, Manager
|
||||
|
||||
STATIC = ROOT / 'screen_manager/app/static'
|
||||
|
||||
|
||||
class AlertData(unittest.TestCase):
|
||||
def test_complete_data_passes_through_typed(self):
|
||||
data = {'title': 'Mail!', 'subtitle': 'There is post', 'icon': 'mailbox', 'color': 'orange',
|
||||
'button_text': 'OK', 'timeout': 30, 'flash': True}
|
||||
self.assertEqual(alert_data(data), (data, []))
|
||||
self.assertEqual(list(alert_data(data)[0]), [name for name, *_ in ALERT_FIELDS], 'all seven, in field order')
|
||||
|
||||
def test_missing_fields_become_empty_without_a_warning(self):
|
||||
empty = {'title': '', 'subtitle': '', 'icon': '', 'color': '', 'button_text': '', 'timeout': 0, 'flash': False}
|
||||
for data in ({}, None, 'text', {'title': None, 'timeout': '', 'flash': ''}):
|
||||
self.assertEqual(alert_data(data), (empty, []), data)
|
||||
|
||||
def test_values_are_coerced_like_home_assistant_does(self):
|
||||
service, unusable = alert_data({'title': 42, 'timeout': '90', 'flash': 'on'})
|
||||
self.assertEqual((service['title'], service['timeout'], service['flash'], unusable), ('42', 90, True, []))
|
||||
self.assertEqual(alert_data({'timeout': 12.7})[0]['timeout'], 12)
|
||||
self.assertEqual(alert_data({'timeout': -5})[0]['timeout'], 0, 'the firmware clamps too')
|
||||
self.assertEqual(alert_data({'timeout': 10**9})[0]['timeout'], ALERT_MAX_TIMEOUT)
|
||||
self.assertEqual(alert_data({'flash': 1})[0]['flash'], True)
|
||||
self.assertEqual(alert_data({'flash': 'Off'})[0]['flash'], False)
|
||||
|
||||
def test_one_unusable_value_is_left_empty_and_named(self):
|
||||
# A bare `Yes` in YAML arrives as a boolean; the rest of the alert still goes out.
|
||||
service, unusable = alert_data({'title': 'Door', 'button_text': True, 'timeout': 'soon', 'flash': 'maybe', 'icon': ['bell']})
|
||||
self.assertEqual(service, {'title': 'Door', 'subtitle': '', 'icon': '', 'color': '', 'button_text': '', 'timeout': 0, 'flash': False})
|
||||
self.assertEqual(unusable, ['icon', 'button_text', 'timeout', 'flash'])
|
||||
self.assertEqual(alert_data({'timeout': float('nan')})[1], ['timeout'])
|
||||
|
||||
def test_limits_follow_the_firmware(self):
|
||||
header = (ROOT / 'components/smart_display/alert_overlay.h').read_text()
|
||||
self.assertEqual(int(re.search(r'MAX_TIMEOUT_SECONDS = (\d+);', header)[1]), ALERT_MAX_TIMEOUT)
|
||||
self.assertEqual(BROADCAST_EVENTS, {'esp_screens_show_alert': 'show_alert', 'esp_screens_dismiss_alert': 'dismiss_alert'})
|
||||
self.assertEqual(alert_reference()['broadcast'], {'show': BROADCAST_SHOW, 'dismiss': BROADCAST_DISMISS})
|
||||
|
||||
|
||||
class AlertTargets(unittest.TestCase):
|
||||
def test_only_screens_that_can_show_it_now(self):
|
||||
screens = [{'name': 'Kitchen', 'node': 'kitchen', 'online': True, 'firmware': '0.2.31'},
|
||||
{'name': 'Hall', 'node': 'hall', 'online': True, 'firmware': '0.2.38'},
|
||||
{'name': 'Attic', 'node': 'attic', 'online': False, 'firmware': '0.2.38'},
|
||||
{'name': 'Garage', 'node': 'garage', 'online': True, 'firmware': '0.2.30'},
|
||||
{'name': 'Shed', 'node': 'shed', 'online': True, 'firmware': 'unknown'},
|
||||
{'name': 'New', 'node': None, 'online': True, 'firmware': '0.2.38'},
|
||||
{'name': 'Hall again', 'node': 'hall', 'online': True, 'firmware': '0.2.38'}]
|
||||
ready, skipped = alert_targets(screens)
|
||||
self.assertEqual([s['name'] for s in ready], ['Kitchen', 'Hall'])
|
||||
self.assertEqual([(s['name'], reason) for s, reason in skipped],
|
||||
[('Attic', 'offline'), ('Garage', 'firmware 0.2.30'), ('Shed', 'firmware unknown'), ('New', 'device name unknown')])
|
||||
self.assertEqual(alert_targets([]), ([], []))
|
||||
|
||||
|
||||
def fake_ha():
|
||||
"""Three paired screens as Home Assistant's registry describes them: two current, one too old."""
|
||||
class HA:
|
||||
online = True
|
||||
|
||||
def __init__(self):
|
||||
self.registry, self.devices, self.states = [], [], {}
|
||||
for device, node, firmware in (('d1', 'kitchen-screen', '0.2.38'), ('d2', 'hall', '0.2.31'), ('d3', 'attic', '0.2.30')):
|
||||
self.registry += [{'entity_id': f'text.{device}_tiles', 'platform': 'esphome', 'original_name': 'Tile settings', 'device_id': device},
|
||||
{'entity_id': f'sensor.{device}_node', 'platform': 'esphome', 'original_name': 'Device name', 'device_id': device},
|
||||
{'entity_id': f'sensor.{device}_fw', 'platform': 'esphome', 'original_name': 'Screen firmware', 'device_id': device}]
|
||||
self.devices.append({'id': device, 'name': node.title()})
|
||||
self.states.update({f'text.{device}_tiles': {'state': 'Synced'}, f'sensor.{device}_node': {'state': node},
|
||||
f'sensor.{device}_fw': {'state': firmware}})
|
||||
self.areas = []
|
||||
self.changed = asyncio.Event()
|
||||
self.broadcasts = asyncio.Queue()
|
||||
self.calls, self.failing = [], set()
|
||||
|
||||
async def call(self, action, data):
|
||||
self.calls.append((action, data))
|
||||
if action in self.failing:
|
||||
raise ConnectionError('Home Assistant refused the command.')
|
||||
return HA()
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class Broadcast(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_show_and_dismiss_reach_every_current_screen(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = Manager(fake_ha(), Path(tmp) / 'screens.json')
|
||||
with self.assertLogs('screen_manager', 'INFO') as logs:
|
||||
result = await m.broadcast(BROADCAST_SHOW, {'title': 'Mail!', 'icon': 'mailbox', 'flash': True})
|
||||
self.assertEqual(result, {'sent': 2, 'skipped': 1, 'failed': 0})
|
||||
data = {'title': 'Mail!', 'subtitle': '', 'icon': 'mailbox', 'color': '', 'button_text': '', 'timeout': 0, 'flash': True}
|
||||
self.assertEqual(sorted(m.ha.calls), [('esphome.hall_show_alert', data), ('esphome.kitchen_screen_show_alert', data)])
|
||||
self.assertIn('esp_screens_show_alert: 2 of 3 screens (not: Attic firmware 0.2.30)', logs.output[-1])
|
||||
m.ha.calls.clear()
|
||||
await m.broadcast(BROADCAST_DISMISS, {'title': 'ignored'})
|
||||
self.assertEqual(sorted(m.ha.calls), [('esphome.hall_dismiss_alert', {}), ('esphome.kitchen_screen_dismiss_alert', {})])
|
||||
|
||||
async def test_a_screen_added_later_gets_the_next_alert(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
ha = fake_ha()
|
||||
m = Manager(ha, Path(tmp) / 'screens.json')
|
||||
await m.broadcast(BROADCAST_SHOW, {'title': 'One'})
|
||||
ha.registry = ha.registry + [{'entity_id': 'text.d4_tiles', 'platform': 'esphome', 'original_name': 'Tile settings', 'device_id': 'd4'},
|
||||
{'entity_id': 'sensor.d4_node', 'platform': 'esphome', 'original_name': 'Device name', 'device_id': 'd4'},
|
||||
{'entity_id': 'sensor.d4_fw', 'platform': 'esphome', 'original_name': 'Screen firmware', 'device_id': 'd4'}]
|
||||
ha.devices = ha.devices + [{'id': 'd4', 'name': 'Study'}]
|
||||
ha.states.update({'text.d4_tiles': {'state': 'Synced'}, 'sensor.d4_node': {'state': 'study'}, 'sensor.d4_fw': {'state': '0.2.38'}})
|
||||
ha.calls.clear()
|
||||
self.assertEqual((await m.broadcast(BROADCAST_SHOW, {'title': 'Two'}))['sent'], 3)
|
||||
self.assertIn('esphome.study_show_alert', [action for action, _ in ha.calls])
|
||||
|
||||
async def test_one_failing_screen_does_not_stop_the_others(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = Manager(fake_ha(), Path(tmp) / 'screens.json')
|
||||
m.ha.failing.add('esphome.hall_show_alert')
|
||||
with self.assertLogs('screen_manager', 'INFO') as logs:
|
||||
result = await m.broadcast(BROADCAST_SHOW, {'title': 'Smoke', 'timeout': 'later'})
|
||||
self.assertEqual(result, {'sent': 1, 'skipped': 1, 'failed': 1})
|
||||
self.assertEqual(len(m.ha.calls), 2)
|
||||
self.assertTrue(any('unusable timeout left empty' in line for line in logs.output), logs.output)
|
||||
self.assertIn('1 of 3 screens (not: Attic firmware 0.2.30; Hall ConnectionError)', logs.output[-1])
|
||||
|
||||
async def test_the_loop_keeps_the_order_and_survives_errors(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = Manager(fake_ha(), Path(tmp) / 'screens.json')
|
||||
original = m.broadcast
|
||||
async def broadcast(event_type, data):
|
||||
if data.get('title') == 'boom':
|
||||
raise RuntimeError('unexpected')
|
||||
return await original(event_type, data)
|
||||
m.broadcast = broadcast
|
||||
task = asyncio.create_task(m.alert_loop())
|
||||
for item in ((BROADCAST_SHOW, {'title': 'boom'}), (BROADCAST_SHOW, {'title': 'Door'}), (BROADCAST_DISMISS, {})):
|
||||
m.ha.broadcasts.put_nowait(item)
|
||||
with self.assertLogs('screen_manager', 'INFO'):
|
||||
for _ in range(50):
|
||||
if len(m.ha.calls) == 4:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
task.cancel()
|
||||
self.assertEqual([action.rsplit('_', 2)[-2:] for action, _ in m.ha.calls], [['show', 'alert']] * 2 + [['dismiss', 'alert']] * 2)
|
||||
|
||||
async def test_home_assistant_queues_the_events_instead_of_calling_from_the_reader(self):
|
||||
class Message:
|
||||
type = WSMsgType.TEXT
|
||||
def __init__(self, data): self.data = data
|
||||
def json(self): return self.data
|
||||
class Socket:
|
||||
def __init__(self, messages): self.messages = messages
|
||||
def __aiter__(self): return self._iterate()
|
||||
async def _iterate(self):
|
||||
for message in self.messages:
|
||||
yield Message(message)
|
||||
ha = HomeAssistant(None, 'http://ha/api', 'token')
|
||||
ha.ws = Socket([{'type': 'event', 'event': {'event_type': BROADCAST_SHOW, 'data': {'title': 'Mail!'}}},
|
||||
{'type': 'event', 'event': {'event_type': 'call_service', 'data': {}}},
|
||||
{'type': 'event', 'event': {'event_type': BROADCAST_DISMISS, 'data': {}}}])
|
||||
with self.assertRaises(ConnectionError):
|
||||
await ha.read()
|
||||
self.assertEqual([ha.broadcasts.get_nowait() for _ in range(ha.broadcasts.qsize())],
|
||||
[(BROADCAST_SHOW, {'title': 'Mail!'}), (BROADCAST_DISMISS, {})])
|
||||
self.assertIn('*BROADCAST_EVENTS', (ROOT / 'screen_manager/app/server.py').read_text(), 'subscribed on connect')
|
||||
|
||||
|
||||
class Page(unittest.TestCase):
|
||||
def test_cheatsheet_explains_the_event(self):
|
||||
html = (STATIC / 'index.html').read_text()
|
||||
cheatsheet = html.split('id="alerts-dialog"', 1)[1]
|
||||
for marker in ('id="alerts-all"', 'data-jump="alerts-all"', 'id="alerts-all-example"', 'id="alerts-all-copy"',
|
||||
'esp_screens_show_alert', 'esp_screens_dismiss_alert', 'Settings → Claude'):
|
||||
self.assertIn(marker, cheatsheet, marker)
|
||||
script = (STATIC / 'app.js').read_text()
|
||||
for marker in ('function alertAllYaml', 'broadcast?.show', 'renderAlertBroadcast();', '#alerts-all-copy").onclick'):
|
||||
self.assertIn(marker, script, marker)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,137 @@
|
||||
"""The Claude Code skill behind Alerts → Install for Claude: complete, valid, and written only on request."""
|
||||
import asyncio
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
import claude_skill # noqa: E402
|
||||
import tile_icons # noqa: E402
|
||||
from core import ALERT_EVENT, ALERT_FIELDS, ALERT_LIMITS, ALERT_MIN_FIRMWARE, BROADCAST_DISMISS, BROADCAST_SHOW, TILE_BACKGROUNDS # noqa: E402
|
||||
|
||||
HAS_YAML = importlib.util.find_spec('yaml') is not None
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
if HAS_AIOHTTP:
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
from server import Manager, create_app
|
||||
|
||||
|
||||
class SkillText(unittest.TestCase):
|
||||
def test_frontmatter_names_the_skill_and_says_when_to_use_it(self):
|
||||
text = claude_skill.text()
|
||||
front = re.match(r'---\nname: (.+)\ndescription: (.+)\n---\n', text)
|
||||
self.assertIsNotNone(front)
|
||||
self.assertEqual(front[1], 'esp-screens')
|
||||
self.assertLessEqual(len(front[2]), 200, 'claude.ai refuses longer descriptions')
|
||||
for words in ('ESP Screens', 'alerts', 'all screens', 'automation', 'icons'):
|
||||
self.assertIn(words, front[2])
|
||||
self.assertEqual(text, claude_skill.text(), 'same text every time, so status() can compare')
|
||||
self.assertNotRegex(text, r'\{[A-Z_]+\}', 'no placeholder left over')
|
||||
self.assertLess(text.count('\n'), 500, 'Claude Code advises SKILL.md under 500 lines')
|
||||
|
||||
def test_it_carries_everything_the_cheatsheet_does(self):
|
||||
text = claude_skill.text()
|
||||
for needle in (BROADCAST_SHOW, BROADCAST_DISMISS, ALERT_EVENT, ALERT_MIN_FIRMWARE, '_show_alert', '_dismiss_alert'):
|
||||
self.assertIn(needle, text)
|
||||
for name, kind, *_ in ALERT_FIELDS:
|
||||
self.assertRegex(text, rf'\| `{name}` \| ', name)
|
||||
for field, limit in ALERT_LIMITS['guition'].items():
|
||||
self.assertIn(f"CYD {ALERT_LIMITS['cyd'][field]} · Guition {limit} bytes", text)
|
||||
for name, item in TILE_BACKGROUNDS.items():
|
||||
if item['color']:
|
||||
self.assertIn(f'`{name}`', text)
|
||||
icons = text.split('## Icons', 1)[1].split('## When an alert ends', 1)[0]
|
||||
self.assertEqual(set(re.findall(r'`([a-z0-9-]+)`', icons)) - {'icon'}, set(tile_icons.GLYPHS), 'exactly the glyphs the firmware carries')
|
||||
|
||||
@unittest.skipUnless(HAS_YAML, 'PyYAML needed')
|
||||
def test_every_yaml_example_parses(self):
|
||||
import yaml
|
||||
blocks = re.findall(r'```yaml\n(.*?)```', claude_skill.text(), re.S)
|
||||
self.assertEqual(len(blocks), 4)
|
||||
parsed = [yaml.safe_load(block) for block in blocks]
|
||||
self.assertEqual(parsed[0]['actions'][0]['event'], BROADCAST_SHOW)
|
||||
self.assertEqual(set(parsed[0]['actions'][0]['event_data']), {name for name, *_ in ALERT_FIELDS})
|
||||
self.assertEqual(set(parsed[1]['actions'][0]['data']), {name for name, *_ in ALERT_FIELDS}, 'the per-screen action needs all seven')
|
||||
self.assertEqual(parsed[2][0]['wait_for_trigger'][0]['event_type'], ALERT_EVENT)
|
||||
choose = parsed[3]['actions'][0]['choose']
|
||||
self.assertEqual([option['sequence'][0]['event'] for option in choose], [BROADCAST_SHOW, BROADCAST_DISMISS])
|
||||
|
||||
|
||||
class Archive(unittest.TestCase):
|
||||
def test_zip_holds_the_skill_folder_as_claude_ai_expects(self):
|
||||
import io
|
||||
import zipfile
|
||||
data = claude_skill.archive()
|
||||
self.assertEqual(data, claude_skill.archive(), 'same bytes every time')
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as bundle:
|
||||
self.assertEqual(bundle.namelist(), ['esp-screens/SKILL.md'])
|
||||
self.assertEqual(bundle.read('esp-screens/SKILL.md').decode('utf8'), claude_skill.text())
|
||||
|
||||
|
||||
class Install(unittest.TestCase):
|
||||
def test_status_install_update(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
target = claude_skill.skill_dir(tmp)
|
||||
self.assertEqual(target, Path(tmp) / '.claude' / 'skills' / 'esp-screens')
|
||||
self.assertEqual(claude_skill.status(target), {'installed': False, 'current': False, 'path': str(target)})
|
||||
first = claude_skill.install(target)
|
||||
self.assertEqual(first, {'installed': True, 'current': True, 'path': str(target), 'restart': True})
|
||||
self.assertEqual((target / 'SKILL.md').read_text(encoding='utf8'), claude_skill.text())
|
||||
self.assertEqual(sorted(p.name for p in target.iterdir()), ['SKILL.md'], 'no temp file left behind')
|
||||
self.assertFalse(claude_skill.install(target)['restart'], 'the skills folder already existed')
|
||||
(target / 'SKILL.md').write_text('older text', encoding='utf8')
|
||||
self.assertEqual(claude_skill.status(target)['current'], False)
|
||||
self.assertTrue(claude_skill.install(target)['current'])
|
||||
|
||||
def test_a_new_skill_next_to_existing_skills_needs_no_restart(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / '.claude' / 'skills' / 'other').mkdir(parents=True)
|
||||
self.assertFalse(claude_skill.install(claude_skill.skill_dir(tmp))['restart'])
|
||||
self.assertTrue((Path(tmp) / '.claude' / 'skills' / 'other').is_dir(), 'other skills untouched')
|
||||
|
||||
def test_a_folder_that_cannot_be_written_gives_a_message(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / '.claude').write_text('a file, not a folder')
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
claude_skill.install(claude_skill.skill_dir(tmp))
|
||||
self.assertIn("Couldn't write the skill", str(caught.exception))
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP, 'Run using .venv-portal/bin/python for server tests')
|
||||
class Endpoint(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_inventory_reports_and_the_button_installs(self):
|
||||
class HA:
|
||||
online = True
|
||||
registry, devices, areas, states = [], [], [], {}
|
||||
changed = asyncio.Event()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
manager = Manager(HA(), Path(tmp) / 'data' / 'screens.json')
|
||||
manager.skill_dir = claude_skill.skill_dir(Path(tmp) / 'config')
|
||||
async with TestClient(TestServer(create_app(manager, True))) as client:
|
||||
full = await (await client.get('/api/inventory')).json()
|
||||
self.assertEqual(full['claude_skill'], {'installed': False, 'current': False, 'path': str(manager.skill_dir)})
|
||||
self.assertNotIn('claude_skill', await (await client.get('/api/inventory?light=1')).json())
|
||||
download = await client.get('/api/claude-skill.zip')
|
||||
self.assertEqual(download.status, 200)
|
||||
self.assertEqual(download.headers['Content-Type'], 'application/zip')
|
||||
self.assertEqual(download.headers['Content-Disposition'], 'attachment; filename="esp-screens.zip"')
|
||||
self.assertEqual(await download.read(), claude_skill.archive())
|
||||
self.assertFalse(manager.skill_dir.exists(), 'opening the page or downloading writes nothing')
|
||||
refused = await client.post('/api/claude-skill')
|
||||
self.assertEqual(refused.status, 403, 'CSRF like every other change')
|
||||
response = await client.post('/api/claude-skill', headers={'X-Screen-CSRF': full['csrf']})
|
||||
self.assertEqual(await response.json(), {'installed': True, 'current': True, 'path': str(manager.skill_dir), 'restart': True})
|
||||
self.assertTrue((manager.skill_dir / 'SKILL.md').is_file())
|
||||
(Path(tmp) / 'blocked').write_text('a file, not a folder')
|
||||
manager.skill_dir = claude_skill.skill_dir(Path(tmp) / 'blocked')
|
||||
failed = await client.post('/api/claude-skill', headers={'X-Screen-CSRF': full['csrf']})
|
||||
self.assertEqual(failed.status, 400)
|
||||
self.assertIn("Couldn't write the skill", (await failed.json())['error'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Settings (app 0.2.45): New screen, Firmware & USB, firmware updates, Alerts and Claude moved off the main
|
||||
page into a view of their own, so the header keeps one button and My screens keeps the editor."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import unittest
|
||||
|
||||
STATIC = Path(__file__).resolve().parents[1] / 'screen_manager/app/static'
|
||||
|
||||
|
||||
def between(text, start, end):
|
||||
return text.split(start, 1)[1].split(end, 1)[0]
|
||||
|
||||
|
||||
class SettingsView(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.html = (STATIC / 'index.html').read_text()
|
||||
self.script = (STATIC / 'app.js').read_text()
|
||||
self.css = (STATIC / 'style.css').read_text()
|
||||
|
||||
def test_header_keeps_only_the_settings_button(self):
|
||||
header = between(self.html, '<header>', '</header>')
|
||||
self.assertEqual(re.findall(r'<button id="([\w-]+)"', header), ['open-settings'])
|
||||
|
||||
def test_every_tool_lives_in_the_settings_view_once(self):
|
||||
settings = between(self.html, '<section id="settings-view"', '</main>')
|
||||
home = between(self.html, '<div id="home-view">', '<section id="settings-view"')
|
||||
for element in ('new-screen', 'open-firmware', 'updates', 'update-all', 'auto-update', 'open-alerts',
|
||||
'claude-install', 'claude-status', 'claude-path', 'claude-download', 'close-settings'):
|
||||
self.assertEqual(self.html.count(f'id="{element}"'), 1, element)
|
||||
self.assertIn(f'id="{element}"', settings, element)
|
||||
self.assertNotIn(f'id="{element}"', home, element)
|
||||
# The first-run button stays where a new user starts.
|
||||
self.assertIn('id="start"', home)
|
||||
self.assertIn('href="api/claude-skill.zip"', settings)
|
||||
|
||||
def test_the_view_switches_by_hash_and_renders_what_it_shows(self):
|
||||
for marker in ('function showView', 'location.hash === "#settings"', 'window.addEventListener("hashchange", showView)',
|
||||
'$("#home-view").hidden = settings', 'function renderClaude', 'api("claude-skill", { method: "POST" })',
|
||||
'if (full) renderClaude();', '$("#new-screen").onclick = $("#start").onclick = openInstaller'):
|
||||
self.assertIn(marker, self.script, marker)
|
||||
self.assertNotIn('Not flashed yet? Firmware & USB', self.script, 'texts point to Settings')
|
||||
for marker in ('.settings-groups', '.settings-group', '.claude-skill', '.screen:hover:not(.selected)'):
|
||||
self.assertIn(marker, self.css, marker)
|
||||
|
||||
def test_unselected_screens_get_a_grey_card(self):
|
||||
rule = between(self.css, '\n.screen {', '}')
|
||||
self.assertIn('background: #e9ecf1', rule)
|
||||
self.assertIn('background: white', between(self.css, '\n.screen.selected {', '}'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user