mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 03:00:18 +02:00
fix(comfort): harden timer recovery and notification UX after review
This commit is contained in:
1 parent
d3fa282377
commit
222d5eccc9
7 files changed
+220
-28
No files matched your search
+64
-1
@@ -23,7 +23,7 @@ class SessionTests(unittest.TestCase):
|
||||
self.warn, self.home = Mock(), Mock()
|
||||
|
||||
def step(self, now, **sample):
|
||||
comfort.tick(self.s, now, sample, self.warn, self.home)
|
||||
comfort.tick(self.s, now, sample, self.warn, self.home, read_clock=lambda: now)
|
||||
|
||||
def test_warning_then_home_never_closes_a_game(self):
|
||||
self.step(119)
|
||||
@@ -46,6 +46,16 @@ class SessionTests(unittest.TestCase):
|
||||
self.step(460)
|
||||
self.home.assert_called_once()
|
||||
|
||||
def test_slow_warning_still_leaves_a_full_minute(self):
|
||||
comfort.tick(self.s, 120, {}, self.warn, self.home, read_clock=lambda: 140)
|
||||
self.assertEqual(self.s['warned'], 140)
|
||||
self.step(180)
|
||||
self.home.assert_not_called()
|
||||
self.step(199)
|
||||
self.home.assert_not_called()
|
||||
self.step(200)
|
||||
self.home.assert_called_once()
|
||||
|
||||
def test_failed_warning_never_stops_session(self):
|
||||
self.warn.side_effect = RuntimeError('offline')
|
||||
with self.assertRaises(RuntimeError):
|
||||
@@ -144,6 +154,56 @@ class SessionTests(unittest.TestCase):
|
||||
spawn.assert_called_once()
|
||||
self.assertEqual((Path(tmp) / 'session.json').stat().st_mode & 0o777, 0o600)
|
||||
|
||||
@unittest.skipUnless(os.name == "posix", "on-headset state uses POSIX flock")
|
||||
def test_cancel_clears_stale_worker_error(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, patch.object(comfort, 'ROOT', Path(tmp)), \
|
||||
patch.object(comfort, 'boot', return_value='boot-one'), \
|
||||
patch.object(comfort, 'clock', return_value=200):
|
||||
with comfort.locked():
|
||||
comfort.save(self.s)
|
||||
self.assertIn('not responding', comfort.command({'action': 'status'})['error'])
|
||||
cancelled = comfort.command({'action': 'cancel'})
|
||||
self.assertFalse(cancelled['active'])
|
||||
self.assertIsNone(cancelled['error'])
|
||||
self.assertIsNone(comfort.command({'action': 'status'})['error'])
|
||||
|
||||
@unittest.skipUnless(os.name == "posix", "on-headset state uses POSIX flock")
|
||||
def test_failed_spawn_leaves_session_inactive_and_retryable(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, patch.object(comfort, 'ROOT', Path(tmp)), \
|
||||
patch.object(comfort, 'boot', return_value='boot-one'), \
|
||||
patch.object(comfort, 'clock', return_value=0), patch.object(comfort.subprocess, 'Popen') as spawn:
|
||||
spawn.side_effect = OSError('process limit')
|
||||
with self.assertRaises(OSError):
|
||||
comfort.command(OPTIONS)
|
||||
failed = comfort.command({'action': 'status'})
|
||||
self.assertFalse(failed['active'])
|
||||
self.assertIn('Could not start', failed['error'])
|
||||
spawn.side_effect = None
|
||||
self.assertTrue(comfort.command(OPTIONS)['active'])
|
||||
|
||||
@unittest.skipUnless(os.name == "posix", "on-headset state uses POSIX flock")
|
||||
def test_unreadable_state_is_preserved_and_can_be_replaced(self):
|
||||
for contents in (b'{broken', b'\xff', b'null', b'[]', b'42', b'"x"'):
|
||||
with self.subTest(contents=contents):
|
||||
with tempfile.TemporaryDirectory() as tmp, patch.object(comfort, 'ROOT', Path(tmp)), \
|
||||
patch.object(comfort, 'boot', return_value='boot-one'), \
|
||||
patch.object(comfort, 'clock', return_value=0), patch.object(comfort.subprocess, 'Popen'):
|
||||
(Path(tmp) / 'session.json').write_bytes(contents)
|
||||
failed = comfort.command({'action': 'status'})
|
||||
self.assertFalse(failed['active'])
|
||||
self.assertIn('unreadable', failed['error'])
|
||||
backups = list(Path(tmp).glob('session-unreadable-*.json'))
|
||||
self.assertEqual(len(backups), 1)
|
||||
self.assertEqual(backups[0].read_bytes(), contents)
|
||||
self.assertTrue(comfort.command(OPTIONS)['active'])
|
||||
|
||||
def test_home_has_total_process_deadline_and_propagates_timeout(self):
|
||||
with patch.object(comfort.subprocess, 'run', side_effect=subprocess.TimeoutExpired('home', 15)) as run:
|
||||
with self.assertRaises(subprocess.TimeoutExpired):
|
||||
comfort.home()
|
||||
self.assertEqual(run.call_args.kwargs['timeout'], 15)
|
||||
self.assertEqual(run.call_args.args[0][-1], '--home')
|
||||
|
||||
def test_native_warning_reports_failures_and_quotes_as_one_argument(self):
|
||||
with patch.object(comfort.subprocess, 'run') as run:
|
||||
run.return_value = subprocess.CompletedProcess([], 0, 'Notification succeeded', '')
|
||||
@@ -190,5 +250,8 @@ class SensorTests(unittest.TestCase):
|
||||
self.assertIsNone(status.thermal_alerts())
|
||||
with patch.object(status, 'run', return_value='unavailable'):
|
||||
self.assertIsNone(status.activity_level())
|
||||
for malformed in ('{}', '[null, 42, "bad"]'):
|
||||
with patch.object(status, 'run', return_value=malformed):
|
||||
self.assertIsNone(status.activity_level())
|
||||
with patch.object(status, 'run', return_value='[{"operation":"status","activity_level":3}]'):
|
||||
self.assertEqual(status.activity_level(), 3)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Run the actual shared page's comfort renderer against a minimal DOM/bridge."""
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node exercises the shared page JS')
|
||||
class ComfortUI(unittest.TestCase):
|
||||
def test_notification_failure_survives_poll_until_success(self):
|
||||
page = (ROOT / 'ui/index.html').read_text()
|
||||
code = page[page.index('let comfortBusy ='):page.index('async function pollComfort()')]
|
||||
setup = r'''
|
||||
const assert = require('node:assert/strict');
|
||||
const elements = new Map();
|
||||
const $ = id => {
|
||||
if (!elements.has(id)) elements.set(id, {textContent:'', hidden:true, disabled:false, type: 'number'});
|
||||
return elements.get(id);
|
||||
};
|
||||
let denied = 0;
|
||||
const window = {frameApp:{notify:async()=>{denied++;throw Error('permission denied');}}};
|
||||
const log = ()=>{}, toast = ()=>{};
|
||||
'''
|
||||
checks = r'''
|
||||
(async()=>{
|
||||
const active = {id:'session-one',active:true,time:100,remaining:120,
|
||||
options:{minutes:2,breakMinutes:1,stillMinutes:1,batteryAlert:true,heatAlert:true},
|
||||
events:[{id:'event-one',kind:'battery',time:99,message:'Low battery'}]};
|
||||
renderComfort(active); // initial history must not replay even a fresh event
|
||||
assert.equal(denied,0);
|
||||
assert.equal($('comfortAnnouncement').textContent,'');
|
||||
active.events.push({id:'event-two',kind:'break',time:100,message:'Take a break'});
|
||||
renderComfort(active);
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(denied,1);
|
||||
assert.equal($('comfortAnnouncement').textContent,'Take a break');
|
||||
assert.equal($('comfortNotificationStatus').hidden,false);
|
||||
assert.match($('comfortNotificationStatus').textContent,/notification settings/);
|
||||
renderComfort({...active,time:105}); // the next normal poll must not erase failure
|
||||
assert.equal($('comfortNotificationStatus').hidden,false);
|
||||
assert.equal($('sessionStart').disabled,true);
|
||||
assert.equal($('sessionMinutes').disabled,true);
|
||||
assert.equal($('sessionCancel').disabled,false);
|
||||
let requests=0;
|
||||
window.frameApp.notify=async()=>{requests++;};
|
||||
renderComfort({...active,time:106});
|
||||
assert.equal($('comfortAnnouncement').textContent,'Take a break');
|
||||
assert.equal(requests,0); // polling does not replay an already-seen event
|
||||
await localNotification('test',true);
|
||||
assert.equal($('comfortNotificationStatus').hidden,true);
|
||||
renderComfort({...active,active:false});
|
||||
assert.equal($('sessionStart').disabled,false);
|
||||
assert.equal($('sessionMinutes').disabled,false);
|
||||
assert.equal($('sessionCancel').disabled,true);
|
||||
})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
'''
|
||||
result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
Reference in new issue
Block a user