stackchain-dashboard/tests/test_mobile_start_day.py
timmy 62f92105ec
All checks were successful
CI / lint (pull_request) Successful in 4m12s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 8m4s
CI / release-candidate (pull_request) Has been skipped
feat: keep Human Gate decisions in reach on mobile (Closes #1439)
2026-08-26 18:10:22 +00:00

493 lines
18 KiB
Python

import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
START_DAY = Path(__file__).resolve().parents[1] / "frontend" / "mobile-start-day.js"
def run_node(script: str) -> dict:
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_start_day_briefing_guides_urgent_review_before_today():
script = f"""
const createStartDay = require({json.dumps(str(START_DAY))});
let counts = {{agenda:2, attention:3, update:4, filed:1, today:5}};
const opened = [];
const controller = createStartDay({{
getCounts: () => counts,
openQueue: name => opened.push(name),
}});
const first = controller.briefing();
controller.startNext();
counts = {{agenda:0, attention:3, update:4, filed:1, today:5}};
const second = controller.briefing();
controller.startNext();
counts = {{agenda:0, attention:0, update:0, filed:0, today:5}};
const ready = controller.briefing();
controller.startNext();
process.stdout.write(JSON.stringify({{first, second, ready, opened}}));
"""
result = run_node(script)
assert result == {
"first": {
"total": 10,
"next": "agenda",
"label": "Review Agenda",
"summary": "10 items before Today · 5 planned",
"phases": [
{"name": "agenda", "label": "Agenda", "count": 2},
{"name": "attention", "label": "Attention", "count": 3},
{"name": "update", "label": "Updates", "count": 4},
{"name": "filed", "label": "Filed", "count": 1},
],
},
"second": {
"total": 8,
"next": "attention",
"label": "Review Attention",
"summary": "8 items before Today · 5 planned",
"phases": [
{"name": "attention", "label": "Attention", "count": 3},
{"name": "update", "label": "Updates", "count": 4},
{"name": "filed", "label": "Filed", "count": 1},
],
},
"ready": {
"total": 0,
"next": "today",
"label": "Continue Today",
"summary": "Review clear · 5 planned",
"phases": [],
},
"opened": ["agenda", "attention", "today"],
}
def test_prepare_today_puts_actionable_delivery_recovery_before_other_work():
script = f"""
const createStartDay = require({json.dumps(str(START_DAY))});
let counts = {{delivery:2, agenda:1, attention:0, update:0, filed:0, today:4}};
const opened = [];
const handoffs = [];
const saved = new Map();
const controller = createStartDay({{
getCounts: () => counts,
getLogin: () => 'timmy',
getDay: () => '2026-08-17',
storage: {{getItem:key=>saved.get(key)||null,setItem:(key,value)=>saved.set(key,value),removeItem:key=>saved.delete(key)}},
openQueue: name => opened.push(name),
onHandoff: state => handoffs.push(state.next),
}});
const blocked = controller.briefing();
controller.startNext();
counts = {{delivery:0, agenda:1, attention:0, update:0, filed:0, today:4}};
const handed = controller.reconcile({{authoritative:true}});
process.stdout.write(JSON.stringify({{blocked, handed, opened, handoffs}}));
"""
output = run_node(script)
assert output["blocked"] == {
"total": 3,
"next": "delivery",
"label": "Review Delivery",
"summary": "2 deliveries need action before Today · 1 other item · 4 planned",
"phases": [
{"name": "delivery", "label": "Delivery recovery", "count": 2},
{"name": "agenda", "label": "Agenda", "count": 1},
],
}
assert output["handed"] is True
assert output["opened"] == ["delivery"]
assert output["handoffs"] == ["agenda"]
def test_prepare_today_reviews_human_gates_after_delivery_before_agenda():
script = f"""
const createStartDay = require({json.dumps(str(START_DAY))});
const opened = [];
const controller = createStartDay({{
getCounts: () => ({{delivery:1, gate:2, agenda:1, today:4}}),
openQueue: name => opened.push(name),
}});
const briefing = controller.briefing();
controller.startNext();
process.stdout.write(JSON.stringify({{briefing, opened}}));
"""
output = run_node(script)
assert output == {
"briefing": {
"total": 4,
"next": "delivery",
"label": "Review Delivery",
"summary": "1 delivery needs action before Today · 3 other items · 4 planned",
"phases": [
{"name": "delivery", "label": "Delivery recovery", "count": 1},
{"name": "gate", "label": "Human Gates", "count": 2},
{"name": "agenda", "label": "Agenda", "count": 1},
],
},
"opened": ["delivery"],
}
def test_prepare_today_keeps_unavailable_human_gates_retryable_before_today():
script = f"""
const createStartDay = require({json.dumps(str(START_DAY))});
const opened = [];
const controller = createStartDay({{
getCounts: () => ({{gateUnavailable:true, today:2}}),
openQueue: name => opened.push(name),
}});
const briefing = controller.briefing();
controller.startNext();
process.stdout.write(JSON.stringify({{briefing, opened}}));
"""
assert run_node(script) == {
"briefing": {
"total": 0,
"next": "gate",
"label": "Retry Human Gates",
"summary": "Human Gates need retry before Today · 2 planned",
"phases": [{"name": "gate", "label": "Human Gates unavailable · retry", "count": 0}],
},
"opened": ["gate"],
}
def test_prepare_today_counts_each_work_identity_in_only_its_highest_priority_phase():
script = f"""
const createStartDay = require({json.dumps(str(START_DAY))});
const agendaUpdate = {{kind:'issue', key:'lab#1'}};
const attentionUpdate = {{kind:'pull', key:'lab#2'}};
const filed = {{kind:'issue', key:'lab#3'}};
const delivery = {{outbox_id:'draft-4'}};
const controller = createStartDay({{
getCounts: () => ({{today:2}}),
getPhaseItems: () => ({{
delivery:[delivery],
agenda:[agendaUpdate],
attention:[agendaUpdate, attentionUpdate],
update:[agendaUpdate, attentionUpdate],
filed:[filed],
}}),
openQueue: () => {{}},
}});
process.stdout.write(JSON.stringify(controller.briefing()));
"""
output = run_node(script)
assert output == {
"total": 4,
"next": "delivery",
"label": "Review Delivery",
"summary": "1 delivery needs action before Today · 3 other items · 2 planned",
"phases": [
{"name": "delivery", "label": "Delivery recovery", "count": 1},
{"name": "agenda", "label": "Agenda", "count": 1},
{"name": "attention", "label": "Attention", "count": 1},
{"name": "filed", "label": "Filed", "count": 1},
],
}
def test_prepare_today_reviews_unseen_following_after_filed_before_today():
script = f"""
const createStartDay = require({json.dumps(str(START_DAY))});
const opened = [];
const controller = createStartDay({{
getCounts: () => ({{filed:1, following:2, today:3}}),
openQueue: name => opened.push(name),
}});
const briefing = controller.briefing();
controller.startNext();
process.stdout.write(JSON.stringify({{briefing, opened}}));
"""
output = run_node(script)
assert output == {
"briefing": {
"total": 3,
"next": "filed",
"label": "Review Filed",
"summary": "3 items before Today · 3 planned",
"phases": [
{"name": "filed", "label": "Filed", "count": 1},
{"name": "following", "label": "Following", "count": 2},
],
},
"opened": ["filed"],
}
def test_prepare_today_keeps_failed_following_refresh_retryable_before_today():
script = f"""
const createStartDay = require({json.dumps(str(START_DAY))});
const opened = [];
const controller = createStartDay({{
getCounts: () => ({{followingUnavailable:true, today:2}}),
openQueue: name => opened.push(name),
}});
const briefing = controller.briefing();
controller.startNext();
process.stdout.write(JSON.stringify({{briefing, opened}}));
"""
output = run_node(script)
assert output == {
"briefing": {
"total": 0,
"next": "following",
"label": "Retry Following",
"summary": "Following needs retry before Today · 2 planned",
"phases": [{"name": "following", "label": "Following unavailable", "count": 0}],
},
"opened": ["following"],
}
def test_start_day_view_renders_refreshed_phases_and_launches_primary_action():
script = f"""
const createStartDay = require({json.dumps(str(START_DAY))});
const elements = {{
summary: {{textContent:''}},
phases: {{textContent:''}},
action: {{textContent:'', listeners:{{}}, addEventListener(name, fn) {{ this.listeners[name] = fn; }}}},
}};
const opened = [];
let counts = {{agenda:0, attention:2, update:1, filed:0, today:3}};
const controller = createStartDay({{
getCounts: () => counts,
openQueue: name => opened.push(name),
elements,
}});
controller.start();
const first = {{summary:elements.summary.textContent, phases:elements.phases.textContent, action:elements.action.textContent}};
elements.action.listeners.click();
counts = {{agenda:0, attention:0, update:0, filed:0, today:3}};
controller.render();
const ready = {{summary:elements.summary.textContent, phases:elements.phases.textContent, action:elements.action.textContent}};
process.stdout.write(JSON.stringify({{first, ready, opened}}));
"""
assert run_node(script) == {
"first": {
"summary": "3 items before Today · 3 planned",
"phases": "Attention 2 · Updates 1",
"action": "Review Attention",
},
"ready": {
"summary": "Review clear · 3 planned",
"phases": "All urgent queues reviewed",
"action": "Continue Today",
},
"opened": ["attention"],
}
def test_prepare_today_pass_persists_per_account_and_hands_off_using_live_counts():
script = f"""
const createStartDay = require({json.dumps(str(START_DAY))});
const saved = new Map();
const storage = {{
getItem: key => saved.has(key) ? saved.get(key) : null,
setItem: (key, value) => saved.set(key, value),
removeItem: key => saved.delete(key),
}};
let login = 'timmy';
let day = '2026-08-15';
let counts = {{agenda:2, attention:1, update:1, filed:1, today:3}};
const opened = [];
const handoffs = [];
const controller = createStartDay({{
storage,
getLogin: () => login,
getDay: () => day,
getCounts: () => counts,
openQueue: name => opened.push(name),
onHandoff: state => handoffs.push({{next:state.next, label:state.label}}),
}});
const started = controller.startNext();
counts = {{agenda:0, attention:0, update:1, filed:1, today:3}};
const handed = controller.completePhase('agenda');
const resumed = createStartDay({{
storage,
getLogin: () => login,
getDay: () => day,
getCounts: () => counts,
openQueue: name => opened.push(name),
}}).state();
login = 'alexander';
const isolated = createStartDay({{
storage,
getLogin: () => login,
getDay: () => day,
getCounts: () => counts,
openQueue: name => opened.push(name),
}}).state();
login = 'timmy';
day = '2026-08-16';
const expired = createStartDay({{
storage,
getLogin: () => login,
getDay: () => day,
getCounts: () => counts,
openQueue: name => opened.push(name),
}}).state();
day = '2026-08-15';
const finished = controller.finish();
const cleared = controller.state();
process.stdout.write(JSON.stringify({{started, handed, resumed, isolated, expired, finished, cleared, opened, handoffs}}));
"""
assert run_node(script) == {
"started": "agenda",
"handed": True,
"resumed": {"active": True, "next": "update", "label": "Review Updates"},
"isolated": {"active": False, "next": "update", "label": "Review Updates"},
"expired": {"active": False, "next": "update", "label": "Review Updates"},
"finished": True,
"cleared": {"active": False, "next": "update", "label": "Review Updates"},
"opened": ["agenda"],
"handoffs": [{"next": "update", "label": "Review Updates"}],
}
def test_prepare_today_attention_hands_off_once_only_after_authoritative_refresh():
script = f"""
const createStartDay = require({json.dumps(str(START_DAY))});
const saved = new Map();
const storage = {{
getItem: key => saved.has(key) ? saved.get(key) : null,
setItem: (key, value) => saved.set(key, value),
removeItem: key => saved.delete(key),
}};
let counts = {{agenda:0, attention:1, update:2, filed:0, today:3}};
const opened = [];
const handoffs = [];
const controller = createStartDay({{
storage,
getLogin: () => 'timmy',
getDay: () => '2026-08-15',
getCounts: () => counts,
openQueue: name => opened.push(name),
onHandoff: current => handoffs.push({{next:current.next, label:current.label}}),
}});
const launchedAttention = controller.startNext();
counts = {{agenda:0, attention:0, update:2, filed:0, today:3}};
const ignoredPartial = controller.reconcile({{authoritative:false}});
const handedToUpdates = controller.reconcile({{authoritative:true}});
const duplicateRefresh = controller.reconcile({{authoritative:true}});
const launchedUpdates = controller.startNext();
counts = {{agenda:0, attention:0, update:0, filed:0, today:3}};
const handedToToday = controller.reconcile({{authoritative:true}});
const duplicateReadyRefresh = controller.reconcile({{authoritative:true}});
process.stdout.write(JSON.stringify({{
launchedAttention, ignoredPartial, handedToUpdates, duplicateRefresh,
launchedUpdates, handedToToday, duplicateReadyRefresh, opened, handoffs,
}}));
"""
assert run_node(script) == {
"launchedAttention": "attention",
"ignoredPartial": False,
"handedToUpdates": True,
"duplicateRefresh": False,
"launchedUpdates": "update",
"handedToToday": True,
"duplicateReadyRefresh": False,
"opened": ["attention", "update"],
"handoffs": [
{"next": "update", "label": "Review Updates"},
{"next": "today", "label": "Continue Today"},
],
}
def test_prepare_today_checkpoint_uses_the_device_local_day():
script = f"""
process.env.TZ = 'Pacific/Honolulu';
const RealDate = Date;
global.Date = class extends RealDate {{
constructor(...args) {{ super(...(args.length ? args : ['2026-08-16T01:00:00Z'])); }}
}};
const createStartDay = require({json.dumps(str(START_DAY))});
const saved = new Map();
const storage = {{
getItem: key => saved.has(key) ? saved.get(key) : null,
setItem: (key, value) => saved.set(key, value),
removeItem: key => saved.delete(key),
}};
createStartDay({{
storage,
getLogin: () => 'timmy',
getCounts: () => ({{agenda:1}}),
openQueue: () => {{}},
}}).startNext();
process.stdout.write(JSON.stringify(JSON.parse(saved.get('stackchain.mobile-start-day.v1'))));
"""
assert run_node(script) == {"login": "timmy", "day": "2026-08-15", "phase": "agenda"}
@pytest.mark.anyio
async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile_bundle():
html = await dashboard()
service_worker = (START_DAY.parent / "service-worker.js").read_text()
assert 'class="mobile-start-day"' in html
assert 'id="mobile-start-day-summary"' in html
assert 'id="mobile-start-day-phases"' in html
assert 'id="mobile-start-day-action"' in html
assert 'id="finish-mobile-start-day"' in html
assert '<script src="static/mobile-start-day.js"></script>' in html
assert "const mobileStartDay = createMobileStartDay({" in html
assert "getLogin: () => confirmedOwnerLogin" in html
assert "onHandoff: current =>" in html
assert "mobileStartDay.completePhase('agenda')" in html
assert "mobileStartDay.completePhase('update')" in html
assert "mobileStartDay.completePhase('filed')" in html
assert "authoritative:authoritativeMyWorkRefresh" in html
assert "authoritativePhases:['delivery']" in html
assert "mobileStartDay.finish()" in html
assert "openQueue: name =>" in html
assert "if (sheet.open) sheet.close();" in html
assert "queueCounts.followingUnavailable ? followingQueue.open()" in html
assert "Checking Agenda, Attention, Updates, Filed, and Following" in html
assert "mobileStartDay.render();" in html
assert ".mobile-start-day-action { width:100%; min-height:48px;" in html
assert ".mobile-start-day-finish { min-height:44px;" in html
assert "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker
assert "stackchain-dashboard-shell-v147" in service_worker
@pytest.mark.anyio
async def test_primary_mobile_work_action_opens_a_unique_item_prepare_today_briefing():
html = await dashboard()
assert "getPreparationState: () => mobileStartDay.state()" in html
assert "prepareToday: openMobileStartDay" in html
assert "function openMobileStartDay()" in html
assert "qs('#mobile-queue-heading').textContent = state.active ? 'Resume Prepare Today' : 'Prepare Today';" in html
assert "qs('#mobile-queue-sheet').showModal();" in html
assert "qs('#mobile-start-day-action').focus();" in html
assert "let preparationItems = {};" in html
assert "getPhaseItems: () => preparationItems" in html
assert "preparationItems = {" in html
assert "delivery:draftInbox.partition(lastDrafts).deliveries" in html
assert "agenda:agendaMyWork(activeMyWork)" in html
assert "attention:activeMyWork.filter(item => item.needs_attention)" in html
assert "update:activeMyWork.filter(item => item.has_update)" in html
assert "filed:activeMyWork.filter(item => item.is_filed)" in html