213 lines
8.0 KiB
Python
213 lines
8.0 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.",
|
|
}
|
|
|
|
|
|
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},
|
|
]
|