182 lines
8.9 KiB
Python
182 lines
8.9 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT = Path(__file__).parents[1]
|
|
TODAY_WRAP_UP = ROOT / "frontend" / "today-wrap-up.js"
|
|
|
|
|
|
def run_node(script: str):
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
assert result.returncode == 0, result.stderr
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def test_wrap_up_appends_selected_work_to_the_ordered_tomorrow_plan_before_removing_it_from_today():
|
|
script = f"""
|
|
const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))});
|
|
const items = [
|
|
{{kind:'issue', repository:'stackchain/dashboard', number:1, title:'First'}},
|
|
{{kind:'issue', repository:'stackchain/dashboard', number:2, title:'Second'}},
|
|
{{kind:'pull', repository:'stackchain/dashboard', number:3, title:'Third'}},
|
|
];
|
|
const ids = items.map(item => (item.kind || 'work') + ':' + item.repository + ':' + item.number + ':');
|
|
const today = [...ids];
|
|
const todayOps = [];
|
|
const events = [];
|
|
let tomorrow = {{revision:7, ids:[ids[0]], capacity_minutes:120, estimates:{{[ids[0]]:45}}, plan_date:'2026-08-21'}};
|
|
const todayWork = {{
|
|
identity:item => (item.kind || 'work') + ':' + item.repository + ':' + item.number + ':',
|
|
read:() => [...today],
|
|
contains:item => today.includes((item.kind || 'work') + ':' + item.repository + ':' + item.number + ':'),
|
|
remove:item => {{
|
|
const id=(item.kind || 'work') + ':' + item.repository + ':' + item.number + ':';
|
|
const index=today.indexOf(id); if(index < 0) return false; events.push('remove:'+id); today.splice(index,1); return true;
|
|
}},
|
|
}};
|
|
const tomorrowPlan = {{
|
|
load:async()=>tomorrow,
|
|
state:()=>({{...tomorrow,ids:[...tomorrow.ids],estimates:{{...tomorrow.estimates}}}}),
|
|
stage:value=>{{events.push('stage');tomorrow={{...tomorrow,...value,plan_date:'2026-08-21',sync_pending:true}};return tomorrow;}},
|
|
}};
|
|
const todaySync = {{enqueue:(action,id) => {{todayOps.push([action,id]); return true;}}, flush:()=>Promise.resolve(true)}};
|
|
const wrap = createWrapUp({{todayWork,tomorrowPlan,todaySync}});
|
|
wrap.open(items);
|
|
wrap.choose(ids[1], true);
|
|
const result = await wrap.finish();
|
|
process.stdout.write(JSON.stringify({{result,today,tomorrow,todayOps,events}}));
|
|
"""
|
|
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
|
assert output["result"] == {
|
|
"scheduled": 1,
|
|
"left": 2,
|
|
"plan_count": 2,
|
|
"plan_date": "2026-08-21",
|
|
"sync_pending": True,
|
|
}
|
|
assert output["today"] == ["issue:stackchain/dashboard:1:", "pull:stackchain/dashboard:3:"]
|
|
assert output["tomorrow"]["ids"] == [
|
|
"issue:stackchain/dashboard:1:",
|
|
"issue:stackchain/dashboard:2:",
|
|
]
|
|
assert output["tomorrow"]["capacity_minutes"] == 120
|
|
assert output["tomorrow"]["estimates"] == {"issue:stackchain/dashboard:1:": 45}
|
|
assert output["todayOps"] == [["remove", "issue:stackchain/dashboard:2:"]]
|
|
assert output["events"] == ["stage", "remove:issue:stackchain/dashboard:2:"]
|
|
|
|
|
|
def test_wrap_up_keeps_today_unchanged_when_the_combined_tomorrow_plan_exceeds_five_items():
|
|
script = f"""
|
|
const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))});
|
|
const item={{kind:'issue',repository:'acme/mobile',number:41,title:'Sixth'}};
|
|
const id='issue:acme/mobile:41:'; let present=true; let staged=0; let enqueued=0;
|
|
const todayWork={{identity:()=>id,read:()=>[id],contains:()=>present,remove:()=>{{present=false;return true;}}}};
|
|
const tomorrowPlan={{
|
|
load:async()=>({{revision:3,ids:['a','b','c','d','e'],capacity_minutes:null,estimates:{{}},plan_date:'2026-08-21'}}),
|
|
stage:()=>{{staged+=1;return {{}};}},
|
|
}};
|
|
const todaySync={{enqueue:()=>{{enqueued+=1;return true;}},flush:async()=>true}};
|
|
const wrap=createWrapUp({{todayWork,tomorrowPlan,todaySync}}); wrap.open([item]); wrap.choose(id,true);
|
|
let error=''; try {{ await wrap.finish(); }} catch (caught) {{ error=caught.message; }}
|
|
process.stdout.write(JSON.stringify({{error,present,staged,enqueued}}));
|
|
"""
|
|
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
|
assert output == {
|
|
"error": "Tomorrow can hold 5 items. Keep 1 in Today or edit Tomorrow first.",
|
|
"present": True,
|
|
"staged": 0,
|
|
"enqueued": 0,
|
|
}
|
|
|
|
|
|
def test_wrap_up_keeps_today_unchanged_when_tomorrow_is_over_capacity():
|
|
script = f"""
|
|
const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))});
|
|
const item={{kind:'issue',repository:'acme/mobile',number:41,title:'Carry'}};
|
|
const id='issue:acme/mobile:41:'; let present=true; let staged=0;
|
|
const todayWork={{identity:()=>id,read:()=>[id],contains:()=>present,remove:()=>{{present=false;return true;}}}};
|
|
const tomorrowPlan={{
|
|
load:async()=>({{revision:3,ids:['a','b'],capacity_minutes:60,estimates:{{a:40,b:30}},plan_date:'2026-08-21'}}),
|
|
stage:()=>{{staged+=1;return {{}};}},
|
|
}};
|
|
const todaySync={{enqueue:()=>true,flush:async()=>true}};
|
|
const wrap=createWrapUp({{todayWork,tomorrowPlan,todaySync}}); wrap.open([item]); wrap.choose(id,true);
|
|
let error=''; try {{ await wrap.finish(); }} catch (caught) {{ error=caught.message; }}
|
|
process.stdout.write(JSON.stringify({{error,present,staged}}));
|
|
"""
|
|
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
|
assert output == {
|
|
"error": "Tomorrow is over capacity. Edit the plan before finishing wrap-up.",
|
|
"present": True,
|
|
"staged": 0,
|
|
}
|
|
|
|
|
|
def test_wrap_up_keeps_today_unchanged_when_tomorrow_cannot_be_saved_on_the_phone():
|
|
script = f"""
|
|
const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))});
|
|
const item={{kind:'issue',repository:'acme/mobile',number:41,title:'Carry'}};
|
|
const id='issue:acme/mobile:41:'; let present=true; let enqueued=0;
|
|
const todayWork={{identity:()=>id,read:()=>[id],contains:()=>present,remove:()=>{{present=false;return true;}}}};
|
|
const tomorrowPlan={{
|
|
load:async()=>({{revision:3,ids:[],capacity_minutes:null,estimates:{{}},plan_date:'2026-08-21'}}),
|
|
stage:()=>false,
|
|
}};
|
|
const todaySync={{enqueue:()=>{{enqueued+=1;return true;}},flush:async()=>true}};
|
|
const wrap=createWrapUp({{todayWork,tomorrowPlan,todaySync}}); wrap.open([item]); wrap.choose(id,true);
|
|
let error=''; try {{ await wrap.finish(); }} catch (caught) {{ error=caught.message; }}
|
|
process.stdout.write(JSON.stringify({{error,present,enqueued}}));
|
|
"""
|
|
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
|
assert output == {
|
|
"error": "Tomorrow could not be saved on this device. Your Today plan is unchanged.",
|
|
"present": True,
|
|
"enqueued": 0,
|
|
}
|
|
|
|
|
|
def test_dashboard_packages_a_mobile_wrap_up_dialog_and_opens_it_after_recap_save():
|
|
from src import main
|
|
|
|
html = main.FRONTEND_BUILD.dashboard_html
|
|
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
|
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
|
recap = (ROOT / "frontend" / "today-recap.js").read_text()
|
|
|
|
assert 'id="today-wrap-up-sheet" role="dialog"' in html
|
|
assert 'id="today-wrap-up-items"' in html
|
|
assert 'Carry selected work into the ordered Tomorrow plan.' in html
|
|
assert 'Tomorrow 09:00' not in html
|
|
assert 'id="finish-today-wrap-up"' in html
|
|
assert 'static/today-wrap-up.js' in main.FRONTEND_BUILD.page_sources
|
|
assert 'setupTodayWrapUp({' in dashboard
|
|
assert 'todayWork, tomorrowPlan, todaySync' in dashboard
|
|
assert 'syncPendingTomorrow();' in dashboard
|
|
assert 'todayWrapUpView.open' in dashboard
|
|
assert 'openWrapUp(handoff.actual_minutes, workedItems)' in recap
|
|
assert '.today-wrap-up-actions button { min-height:44px;' in css
|
|
assert '.today-wrap-up-panel' in css and 'overflow-x:hidden' in css
|
|
workflow = (ROOT / '.gitea' / 'workflows' / 'ci.yml').read_text()
|
|
assert 'python3 -m pytest tests/e2e -q' in workflow
|
|
assert (ROOT / 'tests/e2e/test_mobile_today_wrap_up_release.py').is_file()
|
|
|
|
|
|
def test_wrap_up_repeat_confirmation_does_not_repeat_tomorrow_or_today_operations():
|
|
script = f"""
|
|
const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))});
|
|
const item={{kind:'issue',repository:'acme/mobile',number:41,title:'Ship'}};
|
|
const id='issue:acme/mobile:41:';
|
|
let present=true; let staged=0; let removed=0;
|
|
let tomorrow={{revision:1,ids:[],capacity_minutes:null,estimates:{{}},plan_date:'2026-08-21'}};
|
|
const todayWork={{identity:()=>id,read:()=>present?[id]:[],contains:()=>present,remove:()=>{{present=false;return true;}}}};
|
|
const tomorrowPlan={{load:async()=>tomorrow,stage:value=>{{staged+=1;tomorrow={{...tomorrow,...value,sync_pending:true}};return tomorrow;}}}};
|
|
const todaySync={{enqueue:()=>{{removed+=1;return true;}},flush:()=>Promise.resolve(true)}};
|
|
const wrap=createWrapUp({{todayWork,tomorrowPlan,todaySync}});
|
|
wrap.open([item]); wrap.choose(id,true);
|
|
await wrap.finish(); await wrap.finish();
|
|
process.stdout.write(JSON.stringify({{staged,removed,present}}));
|
|
"""
|
|
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
|
assert output == {"staged": 1, "removed": 1, "present": False}
|