stackchain-dashboard/tests/test_mobile_app_badge.py
timmy be9de6596f
All checks were successful
CI / lint (pull_request) Successful in 3m47s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 6m10s
CI / release-candidate (pull_request) Has been skipped
feat: show unread Updates on app icon (Closes #1278)
2026-08-22 20:39:09 +00:00

189 lines
7.1 KiB
Python

import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).parents[1]
APP_BADGE = ROOT / "frontend" / "mobile-app-badge.js"
INDEX = ROOT / "frontend" / "index.html"
DASHBOARD = ROOT / "frontend" / "dashboard.js"
CSS = ROOT / "frontend" / "dashboard.css"
DEVICE_SETUP = ROOT / "frontend" / "mobile-device-setup.js"
def run_badge(scenario: str) -> dict:
assert APP_BADGE.exists(), "mobile app badge controller is not implemented"
script = f"""
const createMobileAppBadge = require({json.dumps(str(APP_BADGE))});
(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }});
"""
completed = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
return json.loads(completed.stdout)
def test_app_badge_opt_in_reconciles_only_authoritative_unread_totals_without_churn():
result = run_badge("""
const listeners = {};
const control = {checked:false, disabled:false, addEventListener(name, callback) { listeners[name] = callback; }};
const status = {textContent:''};
const container = {hidden:false};
const values = [];
let clears = 0;
const storage = new Map();
const controller = createMobileAppBadge({
control, status, container,
navigator: {
async setAppBadge(value) { values.push(value); },
async clearAppBadge() { clears++; },
},
storage: {
getItem(key) { return storage.has(key) ? storage.get(key) : null; },
setItem(key, value) { storage.set(key, value); },
removeItem(key) { storage.delete(key); },
},
});
controller.start();
await controller.reconcile(7, {authoritative:true});
await controller.reconcile(12, {authoritative:false});
control.checked = true;
await listeners.change();
await controller.reconcile(7, {authoritative:true});
await controller.reconcile(0, {authoritative:true});
console.log(JSON.stringify({values, clears, saved:storage.get('stackchain.app-badge.enabled.v1'), status:status.textContent}));
""")
assert result == {
"values": [7],
"clears": 1,
"saved": "true",
"status": "App icon badge is on and up to date.",
}
def test_app_badge_hides_unsupported_device_control_without_touching_storage():
result = run_badge("""
const control = {checked:false, disabled:false, addEventListener() { throw new Error('must not wire'); }};
const status = {textContent:''};
const container = {hidden:false};
let reads = 0;
const controller = createMobileAppBadge({
control, status, container, navigator:{},
storage:{getItem() { reads++; return 'true'; }},
});
const started = controller.start();
console.log(JSON.stringify({started, hidden:container.hidden, disabled:control.disabled, reads, status:status.textContent}));
""")
assert result == {
"started": False,
"hidden": True,
"disabled": True,
"reads": 0,
"status": "App icon badges are unavailable in this browser.",
}
def test_app_badge_is_packaged_touch_safe_and_reconciled_from_server_total():
index = INDEX.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
assert '<script src="static/mobile-app-badge.js"></script>' in index
assert 'id="app-badge-control"' in index
assert 'id="app-badge-status" role="status"' in index
assert ".app-badge-control { min-height:44px;" in css
assert "const mobileAppBadge = createMobileAppBadge({" in dashboard
assert "mobileAppBadge.start();" in dashboard
assert "mobileAppBadge.reconcile(pagination.total, {authoritative:true});" in dashboard
def test_app_badge_exposes_device_setup_readiness_and_enable_action():
result = run_badge("""
const control = {checked:false, disabled:false, addEventListener() {}};
const status = {textContent:''};
const saved = new Map();
const values = [];
const controller = createMobileAppBadge({
control, status, container:{hidden:false},
navigator:{async setAppBadge(value) { values.push(value); }, async clearAppBadge() {}},
storage:{
getItem(key) { return saved.get(key) || null; },
setItem(key, value) { saved.set(key, value); },
removeItem(key) { saved.delete(key); },
},
});
controller.start();
await controller.reconcile(4, {authoritative:true});
const before = controller.readiness();
await controller.enable();
const after = controller.readiness();
console.log(JSON.stringify({before, after, checked:control.checked, values}));
""")
assert result == {
"before": {
"state": "incomplete",
"detail": "Show the confirmed unread Updates count without opening Stackchain.",
},
"after": {
"state": "complete",
"detail": "The app icon shows the confirmed unread Updates count.",
},
"checked": True,
"values": [4],
}
def test_device_setup_offers_app_badge_as_an_optional_supported_step():
index = INDEX.read_text()
setup = DEVICE_SETUP.read_text()
dashboard = DASHBOARD.read_text()
assert 'id="device-setup-app-badge-status" role="status"' in index
assert 'id="device-setup-app-badge" type="button"' in index
assert "['appBadge', options.appBadgeButton, options.appBadgeStatus, options.enableAppBadge, 'Enable']" in setup
assert "appBadge:options.appBadgeReadiness()," in setup
assert "appBadgeReadiness:() => mobileAppBadge.readiness()," in dashboard
assert "enableAppBadge:() => mobileAppBadge.enable()," in dashboard
def test_app_badge_api_failure_is_reported_without_rejecting_live_refresh():
result = run_badge("""
const control = {checked:true, disabled:false, addEventListener() {}};
const status = {textContent:''};
const controller = createMobileAppBadge({
control, status, container:{hidden:false},
navigator:{async setAppBadge() { throw new Error('platform failure'); }, async clearAppBadge() {}},
storage:{getItem() { return 'true'; }, setItem() {}, removeItem() {}},
});
controller.start();
const reconciled = await controller.reconcile(3, {authoritative:true});
console.log(JSON.stringify({reconciled, status:status.textContent}));
""")
assert result == {
"reconciled": False,
"status": "Could not update the app icon badge. Stackchain still has the confirmed count.",
}
def test_disabling_badge_keeps_preference_off_when_platform_clear_fails():
result = run_badge("""
const listeners = {};
const control = {checked:true, disabled:false, addEventListener(name, callback) { listeners[name] = callback; }};
const status = {textContent:''};
const saved = new Map([['stackchain.app-badge.enabled.v1', 'true']]);
const controller = createMobileAppBadge({
control, status, container:{hidden:false},
navigator:{async setAppBadge() {}, async clearAppBadge() { throw new Error('platform failure'); }},
storage:{getItem(key) { return saved.get(key) || null; }, setItem(key, value) { saved.set(key, value); }, removeItem(key) { saved.delete(key); }},
});
controller.start();
control.checked = false;
const changed = await listeners.change();
console.log(JSON.stringify({changed, saved:saved.has('stackchain.app-badge.enabled.v1'), status:status.textContent}));
""")
assert result == {
"changed": False,
"saved": False,
"status": "App icon badge is off, but the browser could not clear the old count.",
}