stackchain-dashboard/tests/test_tomorrow_plan_frontend.py
timmy 0fd6314391
All checks were successful
CI / lint (pull_request) Successful in 3m7s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 3m53s
CI / release-candidate (pull_request) Has been skipped
feat: make Tomorrow a first-class mobile queue (Closes #1158)
2026-08-20 01:20:27 +00:00

191 lines
7.6 KiB
Python

import json
import subprocess
from pathlib import Path
FRONTEND = Path(__file__).parents[1] / "frontend"
CONTROLLER = FRONTEND / "tomorrow-plan.js"
INDEX = FRONTEND / "index.html"
CSS = FRONTEND / "dashboard.css"
def run_controller(scenario: str) -> dict:
harness = f"""
const createTomorrowPlan = require({json.dumps(str(CONTROLLER))});
(async()=>{{ {scenario} }})().catch(error=>{{console.error(error);process.exit(1);}});
"""
completed = subprocess.run(
["node", "-e", harness], check=True, capture_output=True, text=True
)
return json.loads(completed.stdout)
def test_tomorrow_planner_loads_and_saves_independently_from_today():
result = run_controller("""
const requests=[];
const fetchJson=async (url, options={})=>{
requests.push({url,method:options.method||'GET',body:options.body ? JSON.parse(options.body) : null});
if ((options.method||'GET') === 'GET') return {
revision:2, ids:['issue:r:2:'], capacity_minutes:90,
estimates:{'issue:r:2:':45}, plan_date:'2026-08-20', timezone:'UTC'
};
return {revision:3,...JSON.parse(options.body)};
};
const planner=createTomorrowPlan({fetchJson, localDate:()=> '2026-08-19', timeZone:()=> 'UTC'});
const loaded=await planner.load();
const saved=await planner.save({ids:['issue:r:3:'],capacity_minutes:120,estimates:{'issue:r:3:':60}});
console.log(JSON.stringify({loaded,saved,requests}));
""")
assert result["loaded"]["ids"] == ["issue:r:2:"]
assert result["saved"]["plan_date"] == "2026-08-20"
assert result["requests"] == [
{"url": "api/v1/tomorrow", "method": "GET", "body": None},
{
"url": "api/v1/tomorrow",
"method": "PUT",
"body": {
"base_revision": 2,
"ids": ["issue:r:3:"],
"capacity_minutes": 120,
"estimates": {"issue:r:3:": 60},
"plan_date": "2026-08-20",
"timezone": "UTC",
},
},
]
assert all(request["url"] != "api/v1/today" for request in result["requests"])
def test_tomorrow_planner_promotes_overdue_plans_with_a_stable_retry_identity():
result = run_controller("""
const requests=[];
const fetchJson=async (url, options={})=>{
requests.push({url,body:options.body ? JSON.parse(options.body) : null});
return {ids:['issue:r:2:'],revision:8};
};
const before=createTomorrowPlan({fetchJson,localDate:()=> '2026-08-19',timeZone:()=> 'UTC'});
before.adopt({revision:3,ids:['issue:r:2:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'});
const early=await before.promote(7);
const overdue=createTomorrowPlan({fetchJson,localDate:()=> '2026-08-22',timeZone:()=> 'UTC'});
overdue.adopt({revision:3,ids:['issue:r:2:'],capacity_minutes:60,estimates:{},plan_date:'2026-08-20',timezone:'UTC'});
const promoted=await overdue.promote(7);
const retried=await overdue.promote(7);
console.log(JSON.stringify({early,promoted,retried,requests}));
""")
assert result["early"] is False
assert result["promoted"]["revision"] == 8
assert result["retried"]["revision"] == 8
assert result["requests"] == [
{
"url": "api/v1/tomorrow/promote",
"body": {
"promotion_id": "tomorrow-2026-08-20-r3",
"tomorrow_revision": 3,
"today_revision": 7,
},
},
{
"url": "api/v1/tomorrow/promote",
"body": {
"promotion_id": "tomorrow-2026-08-20-r3",
"tomorrow_revision": 3,
"today_revision": 7,
},
},
]
def test_tomorrow_rollover_checks_startup_reconnect_foreground_and_next_midnight_single_flight():
result = run_controller("""
const listeners={};
const timers=[];
const cleared=[];
let checks=0;
const planner=createTomorrowPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
const windowObject={addEventListener:(name,fn)=>listeners['window:'+name]=fn};
const documentObject={hidden:false,addEventListener:(name,fn)=>listeners['document:'+name]=fn};
const lifecycle=planner.startLifecycle({
windowObject,documentObject,check:async()=>{checks+=1;await Promise.resolve();return true;},
setTimer:(fn,delay)=>{timers.push({fn,delay});return timers.length;},
clearTimer:id=>cleared.push(id),nextDelay:()=>1234,
});
await lifecycle.run();
const reconnect=listeners['window:online']();
const foreground=listeners['document:visibilitychange']();
await Promise.all([reconnect,foreground]);
await timers[timers.length-1].fn();
console.log(JSON.stringify({checks,delays:timers.map(timer=>timer.delay),cleared}));
""")
assert result["checks"] == 3
assert result["delays"] == [1234, 1234, 1234]
assert result["cleared"] == [1, 2]
def test_tomorrow_rollover_falls_back_when_the_reported_timezone_is_invalid():
result = run_controller("""
let scheduled=null;
const planner=createTomorrowPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'Etc/Unknown'});
const lifecycle=planner.startLifecycle({
check:async()=>false,setTimer:(_fn,delay)=>{scheduled=delay;return 1;},clearTimer:()=>{},
});
await lifecycle.run();
console.log(JSON.stringify({scheduled:Number.isFinite(scheduled)&&scheduled>0}));
""")
assert result == {"scheduled": True}
def test_tomorrow_planner_summarizes_empty_and_capacity_aware_plans_for_mobile_queues():
result = run_controller("""
const planner=createTomorrowPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
const empty=planner.summary({ids:[],capacity_minutes:null,estimates:{}});
const planned=planner.summary({
ids:['issue:r:1:','issue:r:2:','issue:r:3:'], capacity_minutes:120,
estimates:{'issue:r:1:':30,'issue:r:2:':45,'issue:r:3:':30}
});
const unestimated=planner.summary({ids:['issue:r:4:'],capacity_minutes:null,estimates:{}});
console.log(JSON.stringify({empty,planned,unestimated}));
""")
assert result == {
"empty": "Nothing planned",
"planned": "3 planned · 105 of 120 min",
"unestimated": "1 planned",
}
def test_mobile_queues_expose_tomorrow_and_keep_duplicate_header_actions_hidden():
index = INDEX.read_text()
css = CSS.read_text()
dashboard = (FRONTEND / "dashboard.js").read_text()
assert 'data-mobile-queue="tomorrow"' in index
assert 'id="mobile-tomorrow-summary"' in index
assert '>Nothing planned<' in index
assert "name === 'tomorrow' ? openTomorrowPlanner" in dashboard
assert "tomorrowPlan.summary()" in dashboard
assert "renderTomorrowQueueSummary(saved)" in dashboard
assert "planTodayTrigger?.dataset.mobileQueue === 'tomorrow'" in dashboard
assert "qs('#mobile-queue-sheet').showModal()" in dashboard
assert ".my-work-actions { display:none; }\n .my-work-actions { display:flex; }" not in css
assert ".my-work-actions { display:none; }" in css
def test_tomorrow_plan_has_a_touch_safe_mobile_entry_and_reuses_the_ordered_planner():
index = INDEX.read_text()
css = CSS.read_text()
dashboard = (FRONTEND / "dashboard.js").read_text()
assert '<button class="plan-tomorrow" id="plan-tomorrow" type="button">Plan Tomorrow</button>' in index
assert ".plan-tomorrow { min-height:44px;" in css
assert '<script src="static/tomorrow-plan.js"></script>' in index
assert "const tomorrowPlan = createTomorrowPlan" in dashboard
assert "qs('#plan-tomorrow').addEventListener('click'" in dashboard
assert "planningTomorrow ? saveTomorrowPlan(plan) : saveTodayPlan(plan)" in dashboard
assert "tomorrowPlan.load()" in dashboard
assert "tomorrowPlan.promote(plan.revision)" in dashboard
assert "tomorrowPlan.startLifecycle" in dashboard