stackchain-dashboard/tests/test_mobile_app_badge.py
timmy 7158e00b97
Some checks failed
CI / lint (pull_request) Successful in 4m18s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Failing after 7m57s
CI / release-candidate (pull_request) Has been skipped
feat: notify pending Human Gate decisions (Closes #1437)
2026-08-26 16:47:57 +00:00

311 lines
12 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('updates', 7, true);
await controller.reconcile('updates', 12, false);
control.checked = true;
await listeners.change();
await controller.reconcile('updates', 7, true);
await controller.reconcile('updates', 0, 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_combines_authoritative_update_and_following_channels_independently():
result = run_badge("""
const values = [];
let clears = 0;
const controller = createMobileAppBadge({
control:{checked:true, disabled:false, addEventListener() {}},
status:{textContent:''}, container:{hidden:false},
navigator:{
async setAppBadge(value) { values.push(value); },
async clearAppBadge() { clears++; },
},
storage:{getItem() { return 'true'; }, setItem() {}, removeItem() {}},
});
controller.start();
await controller.reconcile('updates', 3, true);
await controller.reconcile('following', 2, true);
await controller.reconcile('following', 0, true);
await controller.reconcile('following', 4, false);
await controller.reconcile('updates', 0, true);
await controller.reconcile('following', 2, true);
await controller.reconcile('updates', 5, true);
await controller.reconcile('following', 2, true);
console.log(JSON.stringify({values, clears}));
""")
assert result == {"values": [3, 5, 3, 2, 7], "clears": 1}
def test_app_badge_combines_pending_human_gates_with_other_review_channels():
result = run_badge("""
const values = [];
let clears = 0;
const controller = createMobileAppBadge({
control:{checked:true, disabled:false, addEventListener() {}},
status:{textContent:''}, container:{hidden:false},
navigator:{async setAppBadge(value) { values.push(value); }, async clearAppBadge() { clears++; }},
storage:{getItem() { return 'true'; }, setItem() {}, removeItem() {}},
});
controller.start();
await controller.reconcile('updates', 3, true);
await controller.reconcile('human-gates', 2, true);
await controller.reconcile('following', 1, true);
await controller.reconcile('human-gates', 0, true);
console.log(JSON.stringify({values, clears}));
""")
assert result == {"values": [3, 5, 6, 4], "clears": 0}
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 appBadge = createMobileAppBadge({" in dashboard
assert "appBadge.start();" in dashboard
assert "appBadge.reconcile('updates', pagination.total, true);" in dashboard
assert "appBadge.reconcile('following', count, true);" in dashboard
assert "Show review attention on the app icon" in index
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('updates', 4, 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 review attention without opening Stackchain.",
},
"after": {
"state": "complete",
"detail": "The app icon shows review attention.",
},
"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:() => appBadge.readiness()," in dashboard
assert "enableAppBadge:() => appBadge.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('updates', 3, 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.",
}
def test_app_badge_synchronizes_opt_in_with_the_active_service_worker():
result = run_badge("""
const listeners = {};
const messages = [];
const control = {checked:false, disabled:false, addEventListener(name, callback) { listeners[name] = callback; }};
const controller = createMobileAppBadge({
control, status:{textContent:''}, container:{hidden:false},
navigator:{async setAppBadge() {}, async clearAppBadge() {}},
storage:{getItem() { return null; }, setItem() {}, removeItem() {}},
serviceWorker:{ready:Promise.resolve({active:{postMessage(message) { messages.push(message); }}})},
});
controller.start();
control.checked = true;
await listeners.change();
control.checked = false;
await listeners.change();
console.log(JSON.stringify(messages));
""")
assert result == [
{"type": "stackchain-app-badge-preference", "enabled": True},
{"type": "stackchain-app-badge-preference", "enabled": False},
]
def test_app_badge_synchronizes_authoritative_channel_counts_with_worker():
result = run_badge("""
const messages = [];
const controller = createMobileAppBadge({
control:{checked:true, disabled:false, addEventListener() {}}, status:{textContent:''}, container:{hidden:false},
navigator:{async setAppBadge() {}, async clearAppBadge() {}},
storage:{getItem() { return 'true'; }, setItem() {}, removeItem() {}},
serviceWorker:{ready:Promise.resolve({active:{postMessage(message) { messages.push(message); }}})},
});
controller.start();
await controller.reconcile('updates', 3, true);
await controller.reconcile('following', 2, true);
await controller.reconcile('following', 9, false);
await new Promise(resolve => setImmediate(resolve));
console.log(JSON.stringify(messages));
""")
assert result == [
{"type": "stackchain-app-badge-preference", "enabled": True},
{"type": "stackchain-app-badge-count", "channel": "updates", "count": 3},
{"type": "stackchain-app-badge-count", "channel": "following", "count": 2},
]
def test_enabling_badge_synchronizes_counts_confirmed_while_opted_out():
result = run_badge("""
const listeners = {};
const messages = [];
const control = {checked:false, disabled:false, addEventListener(name, callback) { listeners[name] = callback; }};
const controller = createMobileAppBadge({
control, status:{textContent:''}, container:{hidden:false},
navigator:{async setAppBadge() {}, async clearAppBadge() {}},
storage:{getItem() { return null; }, setItem() {}, removeItem() {}},
serviceWorker:{ready:Promise.resolve({active:{postMessage(message) { messages.push(message); }}})},
});
controller.start();
await controller.reconcile('updates', 3, true);
await controller.reconcile('following', 2, true);
control.checked = true;
await listeners.change();
console.log(JSON.stringify(messages));
""")
assert result == [
{"type": "stackchain-app-badge-preference", "enabled": True},
{"type": "stackchain-app-badge-count", "channel": "updates", "count": 3},
{"type": "stackchain-app-badge-count", "channel": "following", "count": 2},
]