import json import subprocess from pathlib import Path FRONTEND = Path(__file__).parents[1] / "frontend" CONTROLLER = FRONTEND / "week-plan.js" def run_controller(scenario: str) -> dict: harness = f""" const createWeekPlan = 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_week_controller_lists_seven_local_dates_and_saves_one_without_changing_other_days(): result = run_controller(""" const requests=[]; const initial={revision:4,timezone:'UTC',days:[ {plan_date:'2026-08-21',ids:['issue:r:1:'],capacity_minutes:60,estimates:{'issue:r:1:':30}}, {plan_date:'2026-08-23',ids:['issue:r:3:'],capacity_minutes:90,estimates:{'issue:r:3:':45}} ]}; const fetchJson=async(url,options={})=>{ requests.push({url,method:options.method||'GET',body:options.body?JSON.parse(options.body):null}); if(!options.method)return initial; return {revision:5,timezone:'UTC',days:JSON.parse(options.body).days}; }; const week=createWeekPlan({fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'}); await week.load(); const dates=week.dates(); const saved=await week.saveDay('2026-08-22',{ids:['issue:r:2:'],capacity_minutes:120,estimates:{'issue:r:2:':50}}); console.log(JSON.stringify({dates,saved,requests})); """) assert [item["date"] for item in result["dates"]] == [ "2026-08-21", "2026-08-22", "2026-08-23", "2026-08-24", "2026-08-25", "2026-08-26", "2026-08-27", ] assert [day["plan_date"] for day in result["saved"]["days"]] == [ "2026-08-21", "2026-08-22", "2026-08-23" ] assert result["requests"][1]["body"]["base_revision"] == 4 assert result["requests"][1]["body"]["days"][0]["ids"] == ["issue:r:1:"] assert result["requests"][1]["body"]["days"][2]["ids"] == ["issue:r:3:"] def test_week_controller_preserves_both_versions_when_another_device_changes_the_week(): result = run_controller(""" let calls=0; const remote={revision:8,timezone:'UTC',days:[{plan_date:'2026-08-22',ids:['server'],capacity_minutes:60,estimates:{}}]}; const fetchJson=async(_url,options={})=>{ calls+=1; if(options.method==='PUT'){const error=new Error('changed');error.status=409;throw error;} return calls===1?{revision:7,timezone:'UTC',days:[]}:remote; }; const week=createWeekPlan({fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'}); await week.load(); try { await week.saveDay('2026-08-21',{ids:['phone'],capacity_minutes:90,estimates:{}}); } catch(_error) {} console.log(JSON.stringify({conflict:week.conflict(),state:week.state()})); """) assert result["conflict"]["local"]["days"][0]["ids"] == ["phone"] assert result["conflict"]["remote"]["days"][0]["ids"] == ["server"] assert result["state"]["revision"] == 7 def test_week_controller_automatically_merges_changes_made_to_different_days(): result = run_controller(""" const values=new Map(); const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; const requests=[];let puts=0; const base={revision:7,timezone:'UTC',days:[ {plan_date:'2026-08-21',ids:['tuesday-base'],capacity_minutes:60,estimates:{'tuesday-base':30}}, {plan_date:'2026-08-22',ids:['friday-base'],capacity_minutes:90,estimates:{'friday-base':45}} ]}; const remote={revision:8,timezone:'UTC',days:[ {plan_date:'2026-08-21',ids:['tuesday-account'],capacity_minutes:60,estimates:{'tuesday-account':30}}, {plan_date:'2026-08-22',ids:['friday-base'],capacity_minutes:90,estimates:{'friday-base':45}} ]}; const fetchJson=async(_url,options={})=>{ const body=options.body?JSON.parse(options.body):null;requests.push({method:options.method||'GET',body}); if(options.method==='PUT'&&puts++===0){const error=new Error('changed');error.status=409;throw error;} if(options.method==='PUT')return {revision:9,timezone:body.timezone,days:body.days}; return remote; }; const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'}); week.adopt(base); week.stageDay('2026-08-22',{ids:['friday-phone'],capacity_minutes:90,estimates:{'friday-phone':50}}); const saved=await week.flush(); console.log(JSON.stringify({saved,requests,pending:week.pending(),conflict:week.conflict()})); """) assert [request["method"] for request in result["requests"]] == ["PUT", "GET", "PUT"] assert result["requests"][2]["body"]["base_revision"] == 8 assert result["saved"]["days"] == [ {"plan_date": "2026-08-21", "ids": ["tuesday-account"], "capacity_minutes": 60, "estimates": {"tuesday-account": 30}}, {"plan_date": "2026-08-22", "ids": ["friday-phone"], "capacity_minutes": 90, "estimates": {"friday-phone": 50}}, ] assert result["pending"] is False assert result["conflict"] is None def test_week_controller_resolves_only_the_same_day_conflict_and_keeps_other_account_changes(): result = run_controller(""" const values=new Map(); const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; const requests=[];let puts=0; const base={revision:4,timezone:'UTC',days:[ {plan_date:'2026-08-21',ids:['same-base'],capacity_minutes:60,estimates:{}}, {plan_date:'2026-08-22',ids:['other-base'],capacity_minutes:60,estimates:{}} ]}; const remote={revision:5,timezone:'UTC',days:[ {plan_date:'2026-08-21',ids:['same-account'],capacity_minutes:60,estimates:{}}, {plan_date:'2026-08-22',ids:['other-account'],capacity_minutes:60,estimates:{}} ]}; const fetchJson=async(_url,options={})=>{ const body=options.body?JSON.parse(options.body):null;requests.push({method:options.method||'GET',body}); if(options.method==='PUT'&&puts++===0){const error=new Error('changed');error.status=409;throw error;} if(options.method==='PUT')return {revision:6,timezone:body.timezone,days:body.days}; return remote; }; const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'}); week.adopt(base); week.stageDay('2026-08-21',{ids:['same-phone'],capacity_minutes:90,estimates:{}}); try { await week.flush(); } catch(_error) {} const before=week.conflict(); week.chooseDay('2026-08-21','phone'); const selected=JSON.parse(values.get('stackchain.week-sync.v1.timmy')); const saved=await week.saveMerged(); console.log(JSON.stringify({before,selected,saved,requests,pending:week.pending(),conflict:week.conflict()})); """) assert [item["plan_date"] for item in result["before"]["conflicts"]] == ["2026-08-21"] assert result["before"]["conflicts"][0]["local"]["ids"] == ["same-phone"] assert result["before"]["conflicts"][0]["remote"]["ids"] == ["same-account"] assert result["selected"]["resolutions"] == {"2026-08-21": "phone"} assert result["requests"][2]["body"]["base_revision"] == 5 assert [day["ids"] for day in result["saved"]["days"]] == [["same-phone"], ["other-account"]] assert result["pending"] is False assert result["conflict"] is None def test_week_controller_restores_an_account_bound_week_before_network_delivery(): result = run_controller(""" const values=new Map(); const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; let login='Timmy'; let requests=0; const options={storage,getLogin:()=>login,fetchJson:async()=>{requests+=1;throw new Error('offline');}, localDate:()=> '2026-08-20',timeZone:()=> 'America/New_York'}; const first=createWeekPlan(options); first.adopt({revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['old'],capacity_minutes:30,estimates:{old:15}}]}); const queued=first.stageDay('2026-08-22',{ids:['issue:r:3:','issue:r:2:'],capacity_minutes:120, estimates:{'issue:r:3:':45,'issue:r:2:':30}}); const restored=await createWeekPlan(options).load(); login='alexander'; let otherError=''; try { await createWeekPlan(options).load(); } catch(error) { otherError=error.message; } console.log(JSON.stringify({queued,restored,requests,otherError,keys:[...values.keys()]})); """) assert result["queued"]["sync_pending"] is True assert result["restored"] == result["queued"] assert result["restored"]["timezone"] == "America/New_York" assert [day["plan_date"] for day in result["restored"]["days"]] == ["2026-08-21", "2026-08-22"] assert result["requests"] == 1 assert result["otherError"] == "offline" assert result["keys"] == ["stackchain.week-sync.v1.timmy"] def test_week_controller_flushes_once_and_removes_only_the_matching_pending_week(): result = run_controller(""" const values=new Map(); const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; let release; let requests=0; const fetchJson=async(_url,options={})=>{ requests+=1;const body=JSON.parse(options.body); await new Promise(resolve=>{release=resolve}); return {revision:9,timezone:body.timezone,days:body.days}; }; const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'}); week.adopt({revision:8,timezone:'UTC',days:[]}); week.stageDay('2026-08-21',{ids:['phone'],capacity_minutes:60,estimates:{phone:30}}); const first=week.flush(),second=week.flush(); await Promise.resolve(); const during=week.pending(); release(); const [saved,reused]=await Promise.all([first,second]); console.log(JSON.stringify({requests,during,saved,reused,pendingAfter:week.pending(),state:week.state()})); """) assert result["requests"] == 1 assert result["during"]["days"][0]["ids"] == ["phone"] assert result["saved"]["revision"] == 9 assert result["reused"]["revision"] == 9 assert result["pendingAfter"] is False assert result["state"]["revision"] == 9 def test_week_controller_keeps_phone_week_by_rebasing_onto_the_latest_revision(): result = run_controller(""" const values=new Map(); const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; const requests=[];let puts=0; const remote={revision:12,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['server'],capacity_minutes:60,estimates:{}}]}; const fetchJson=async(_url,options={})=>{ requests.push({method:options.method||'GET',body:options.body?JSON.parse(options.body):null}); if(options.method==='PUT'&&puts++===0){const error=new Error('changed');error.status=409;throw error;} if(options.method==='PUT')return {revision:13,timezone:'UTC',days:JSON.parse(options.body).days}; return remote; }; const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'}); week.adopt({revision:11,timezone:'UTC',days:[]}); week.stageDay('2026-08-21',{ids:['phone'],capacity_minutes:90,estimates:{phone:45}}); try { await week.flush(); } catch(_error) {} const before=week.conflict(); const saved=await week.keepLocal(); console.log(JSON.stringify({before,saved,requests,pending:week.pending(),conflict:week.conflict()})); """) assert result["before"]["local"]["days"][0]["ids"] == ["phone"] assert result["before"]["remote"]["days"][0]["ids"] == ["server"] assert [request["method"] for request in result["requests"]] == ["PUT", "GET", "PUT"] assert result["requests"][2]["body"]["base_revision"] == 12 assert result["saved"]["revision"] == 13 assert result["pending"] is False assert result["conflict"] is None def test_week_controller_refreshes_account_week_after_a_second_conflict(): result = run_controller(""" const values=new Map(); const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; let revision=12; const fetchJson=async(_url,options={})=>{ if(options.method==='PUT'){const error=new Error('changed again');error.status=409;throw error;} return {revision:revision++,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['server-'+(revision-1)],capacity_minutes:60,estimates:{}}]}; }; const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'}); week.adopt({revision:11,timezone:'UTC',days:[]}); week.stageDay('2026-08-21',{ids:['phone'],capacity_minutes:90,estimates:{}}); try { await week.flush(); } catch(_error) {} try { await week.keepLocal(); } catch(_error) {} console.log(JSON.stringify({conflict:week.conflict(),pending:week.pending()})); """) assert result["conflict"]["local"]["days"][0]["ids"] == ["phone"] assert result["conflict"]["remote"]["revision"] == 13 assert result["conflict"]["remote"]["days"][0]["ids"] == ["server-13"] assert result["pending"]["days"][0]["ids"] == ["phone"] def test_week_controller_uses_account_week_without_a_second_write(): result = run_controller(""" const values=new Map(); const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; const requests=[]; const remote={revision:12,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['server'],capacity_minutes:60,estimates:{server:30}}]}; const fetchJson=async(_url,options={})=>{ requests.push(options.method||'GET'); if(options.method==='PUT'){const error=new Error('changed');error.status=409;throw error;} return remote; }; const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'}); week.adopt({revision:11,timezone:'UTC',days:[]}); week.stageDay('2026-08-21',{ids:['phone'],capacity_minutes:90,estimates:{}}); try { await week.flush(); } catch(_error) {} const adopted=week.useRemote(); console.log(JSON.stringify({adopted,requests,pending:week.pending(),conflict:week.conflict(),state:week.state()})); """) assert result["requests"] == ["PUT", "GET"] assert result["adopted"]["days"][0]["ids"] == ["server"] assert result["pending"] is False assert result["conflict"] is None assert result["state"]["revision"] == 12 def test_week_controller_promotes_only_the_earliest_due_plan_with_stable_identity(): result = run_controller(""" const requests=[]; const week=createWeekPlan({fetchJson:async(url,options={})=>{ requests.push({url,body:options.body?JSON.parse(options.body):null}); return {revision:9,ids:['oldest']}; },localDate:()=> '2026-08-23',timeZone:()=> 'UTC'}); week.adopt({revision:6,timezone:'UTC',days:[ {plan_date:'2026-08-21',ids:['oldest'],capacity_minutes:60,estimates:{}}, {plan_date:'2026-08-22',ids:['newer'],capacity_minutes:60,estimates:{}} ]}); const promoted=await week.promote(3); console.log(JSON.stringify({promoted,requests})); """) assert result["requests"] == [{ "url": "api/v1/week/promote", "body": { "promotion_id": "week-2026-08-21-r6", "week_revision": 6, "plan_date": "2026-08-21", "today_revision": 3, }, }] def test_week_workflow_routes_blocked_rollover_into_today_review(): result = run_controller(""" const createWorkflow=createWeekPlan.Workflow; const elements={'#mobile-week-summary':{textContent:''},'#my-work-action-status':{textContent:''}}; let opened=0,replaced=0,refreshed=0; const blocked=new Error('blocked');blocked.code='week_today_in_progress'; const workflow=createWorkflow({ controller:{load:async()=>({}),promote:async()=>{throw blocked;},summary:()=> '1 item across 1 day'}, qs:selector=>elements[selector],getLogin:()=> 'timmy',openPlanner:()=>{opened+=1;}, escapeHtml:value=>value,escapeAttribute:value=>value, todayWork:{replace:()=>{replaced+=1;},replacePlanning:()=>{replaced+=1;}}, refresh:()=>{refreshed+=1;},warm:()=>{}, }); const promoted=await workflow.promote({revision:4,ids:['unfinished']}); const retried=await workflow.promote({revision:4,ids:['unfinished']}); console.log(JSON.stringify({promoted,retried,opened,replaced,refreshed,status:elements['#my-work-action-status'].textContent})); """) assert result == { "promoted": False, "retried": False, "opened": 1, "replaced": 0, "refreshed": 0, "status": "Review unfinished Today before starting the saved Week Ahead day.", } def test_week_controller_does_not_promote_a_pending_week(): result = run_controller(""" const values=new Map(); const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; let requests=0; const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson:async()=>{requests+=1;return {};}, localDate:()=> '2026-08-23',timeZone:()=> 'UTC'}); week.adopt({revision:6,timezone:'UTC',days:[]}); week.stageDay('2026-08-21',{ids:['oldest'],capacity_minutes:60,estimates:{}}); const promoted=await week.promote(3); console.log(JSON.stringify({promoted,requests})); """) assert result == {"promoted": False, "requests": 0} def test_mobile_week_ahead_entry_and_date_strip_are_touch_safe(): index = (FRONTEND / "index.html").read_text() css = (FRONTEND / "dashboard.css").read_text() dashboard = (FRONTEND / "dashboard.js").read_text() assert 'data-mobile-queue="week"' in index assert 'id="week-plan-dates"' in index assert '' in index assert "name === 'week' ? openWeekPlanner" in dashboard assert "weekWorkflow.save" in dashboard assert ".week-plan-dates button { min-height:44px;" in css assert "overflow-x:auto" in css def test_mobile_week_ahead_exposes_durable_save_and_touch_safe_conflict_choices(): index = (FRONTEND / "index.html").read_text() css = (FRONTEND / "dashboard.css").read_text() dashboard = (FRONTEND / "dashboard.js").read_text() assert 'id="week-conflict-review"' in index assert 'id="week-conflict-days"' in index assert 'id="save-merged-week"' in index assert 'id="keep-phone-week"' in index assert 'id="use-server-week"' in index assert "storage:localStorage" in dashboard assert "controller.stageDay" in CONTROLLER.read_text() assert "weekPlan.flush" in dashboard assert "week-conflict-mode" in dashboard assert "controller.chooseDay" in CONTROLLER.read_text() assert "weekPlan.saveMerged" in dashboard assert 'data-week-conflict-choice' in CONTROLLER.read_text() assert ".week-conflict-actions button { min-height:44px;" in css assert ".week-conflict-choice { min-height:44px;" in css