stackchain-dashboard/tests/test_mobile_device_setup.py
timmy a08a51af02
All checks were successful
CI / lint (pull_request) Successful in 1m31s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
feat: finish Today plans with inline estimates (Closes #635)
2026-08-12 08:55:52 +00:00

196 lines
7.2 KiB
Python

import json
import subprocess
from pathlib import Path
MODULE = Path(__file__).parents[1] / "frontend" / "mobile-device-setup.js"
def run_scenario(script: str) -> dict:
harness = r"""
const createMobileDeviceSetup = require(__MODULE__);
class FakeTarget {
constructor() { this.listeners = {}; this.hidden = false; this.disabled = false; this.textContent = ''; }
addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); }
async dispatch(name, event = {}) { for (const callback of this.listeners[name] || []) await callback(event); }
focus() { state.focused = this; }
}
const state = {installCalls:0, offlineCalls:0, pushCalls:0, focused:null};
const launcher = new FakeTarget();
const closeButton = new FakeTarget();
const sheet = new FakeTarget(); sheet.hidden = true;
const installButton = new FakeTarget();
const offlineButton = new FakeTarget();
const pushButton = new FakeTarget();
const installStatus = new FakeTarget();
const offlineStatus = new FakeTarget();
const pushStatus = new FakeTarget();
const readyStatus = new FakeTarget();
const escapeTarget = new FakeTarget();
const promptCard = new FakeTarget(); promptCard.hidden = true;
const promptSummary = new FakeTarget();
const promptLauncher = new FakeTarget();
const promptDismiss = new FakeTarget();
let now = 1000;
const promptStorage = {
values:{},
getItem(key) { return this.values[key] ?? null; },
setItem(key, value) { this.values[key] = String(value); },
};
let readiness = {
install:{state:'complete', detail:'Stackchain is installed.'},
offline:{state:'incomplete', detail:'Offline work is off.'},
push:{state:'unavailable', detail:'Notifications are unavailable.'},
};
const setup = createMobileDeviceSetup({
launcher, closeButton, sheet, installButton, offlineButton, pushButton,
installStatus, offlineStatus, pushStatus, readyStatus, escapeTarget,
promptCard, promptSummary, promptLauncher, promptDismiss,
promptStorage, now:() => now,
getReadiness:() => readiness,
install:async () => { state.installCalls += 1; },
enableOffline:async () => { state.offlineCalls += 1; },
enablePush:async () => { state.pushCalls += 1; },
});
(async () => { await setup.start(); __SCENARIO__ })().catch(error => { console.error(error); process.exit(1); });
""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__SCENARIO__", script)
completed = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
def test_opening_setup_reports_actual_readiness_without_triggering_permissions():
result = run_scenario("""
await launcher.dispatch('click', {currentTarget:launcher});
process.stdout.write(JSON.stringify({
hidden:sheet.hidden,
install:installStatus.textContent,
offline:offlineStatus.textContent,
push:pushStatus.textContent,
summary:readyStatus.textContent,
calls:[state.installCalls,state.offlineCalls,state.pushCalls],
}));
""")
assert result == {
"hidden": False,
"install": "Stackchain is installed.",
"offline": "Offline work is off.",
"push": "Notifications are unavailable.",
"summary": "1 of 2 available steps ready.",
"calls": [0, 0, 0],
}
def test_incomplete_device_is_discoverable_without_triggering_setup_actions():
result = run_scenario("""
process.stdout.write(JSON.stringify({
hidden:promptCard.hidden,
summary:promptSummary.textContent,
calls:[state.installCalls,state.offlineCalls,state.pushCalls],
}));
""")
assert result == {
"hidden": False,
"summary": "1 of 2 steps complete",
"calls": [0, 0, 0],
}
def test_readiness_card_opens_setup_and_receives_focus_back_on_close():
result = run_scenario("""
await promptLauncher.dispatch('click', {currentTarget:promptLauncher});
const opened = !sheet.hidden && state.focused === closeButton;
await closeButton.dispatch('click');
process.stdout.write(JSON.stringify({
opened,
closed:sheet.hidden,
promptFocused:state.focused === promptLauncher,
}));
""")
assert result == {"opened": True, "closed": True, "promptFocused": True}
def test_not_now_hides_prompt_for_seven_days_then_allows_it_to_return():
result = run_scenario("""
await promptDismiss.dispatch('click');
const hiddenAfterDismiss = promptCard.hidden;
const dismissedUntil = Number(promptStorage.values['stackchain.device-setup-prompt-dismissed-until']);
now = dismissedUntil + 1;
await setup.render();
process.stdout.write(JSON.stringify({hiddenAfterDismiss, delay:dismissedUntil - 1000, hiddenAfterExpiry:promptCard.hidden}));
""")
assert result == {
"hiddenAfterDismiss": True,
"delay": 7 * 24 * 60 * 60 * 1000,
"hiddenAfterExpiry": False,
}
def test_setup_action_rechecks_real_state_before_marking_step_ready():
result = run_scenario("""
await launcher.dispatch('click', {currentTarget:launcher});
readiness.offline = {state:'complete', detail:'Offline work is saved.'};
await offlineButton.dispatch('click');
process.stdout.write(JSON.stringify({
calls:state.offlineCalls,
offline:offlineStatus.textContent,
hidden:offlineButton.hidden,
promptHidden:promptCard.hidden,
summary:readyStatus.textContent,
}));
""")
assert result == {
"calls": 1,
"offline": "Offline work is saved.",
"hidden": True,
"promptHidden": True,
"summary": "This device is ready.",
}
def test_escape_closes_setup_and_restores_launcher_focus():
result = run_scenario("""
await launcher.dispatch('click', {currentTarget:launcher});
await escapeTarget.dispatch('keydown', {key:'Escape'});
process.stdout.write(JSON.stringify({
hidden:sheet.hidden,
launcherFocused:state.focused === launcher,
}));
""")
assert result == {"hidden": True, "launcherFocused": True}
def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
root = MODULE.parents[1]
html = (root / "frontend" / "index.html").read_text()
dashboard = (root / "frontend" / "dashboard.js").read_text()
worker = (root / "frontend" / "service-worker.js").read_text()
css = (root / "frontend" / "dashboard.css").read_text()
assert 'id="open-device-setup"' in html
assert 'id="device-readiness-card"' in html
assert 'id="finish-device-setup"' in html
assert 'id="dismiss-device-readiness"' in html
assert html.index('id="device-readiness-card"') < html.index('id="my-work-list"')
assert 'id="device-setup-sheet"' in html
assert 'aria-labelledby="device-setup-heading"' in html
assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "push"))
assert '<script src="static/mobile-device-setup.js"></script>' in html
assert "createMobileDeviceSetup.mount({" in dashboard
assert "promptStorage:localStorage" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
assert "stackchain-dashboard-shell-v94" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css
assert "env(safe-area-inset-bottom)" in css
assert ".device-setup-action { min-height:44px;" in css
assert ".device-readiness-actions button { min-height:44px;" in css