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_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 '' 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}, ]