649 lines
33 KiB
Python
649 lines
33 KiB
Python
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_advances_through_one_continuous_seven_day_pass():
|
|
result = run_controller("""
|
|
const week=createWeekPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
|
week.adopt({revision:4,timezone:'UTC',days:[
|
|
{plan_date:'2026-08-21',ids:['planned'],capacity_minutes:60,estimates:{planned:30}},
|
|
{plan_date:'2026-08-23',ids:['also-planned'],capacity_minutes:90,estimates:{'also-planned':45}}
|
|
]});
|
|
const first=week.pass('2026-08-21');
|
|
const middle=week.pass('2026-08-24');
|
|
const last=week.pass('2026-08-27');
|
|
console.log(JSON.stringify({first,middle,last}));
|
|
""")
|
|
|
|
assert result == {
|
|
"first": {"position": 1, "total": 7, "planned": 2, "next_date": "2026-08-22", "last": False},
|
|
"middle": {"position": 4, "total": 7, "planned": 2, "next_date": "2026-08-25", "last": False},
|
|
"last": {"position": 7, "total": 7, "planned": 2, "next_date": None, "last": True},
|
|
}
|
|
|
|
|
|
def test_week_controller_reviews_capacity_duplicates_and_moves_work_without_copying():
|
|
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 week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson:async()=>({}),
|
|
localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
|
week.adopt({revision:6,timezone:'UTC',days:[
|
|
{plan_date:'2026-08-21',ids:['shared','one'],capacity_minutes:45,estimates:{shared:30,one:30}},
|
|
{plan_date:'2026-08-22',ids:['shared','two'],capacity_minutes:120,estimates:{shared:30,two:40}}
|
|
]});
|
|
const before=week.review();
|
|
const moved=week.move('shared','2026-08-23');
|
|
const after=week.review();
|
|
console.log(JSON.stringify({before,moved,after,pending:week.pending()}));
|
|
""")
|
|
|
|
assert len(result["before"]["days"]) == 7
|
|
assert result["before"]["days"][0]["planned_minutes"] == 60
|
|
assert result["before"]["days"][0]["overloaded"] is True
|
|
assert result["before"]["duplicates"] == [{
|
|
"id": "shared", "dates": ["2026-08-21", "2026-08-22"]
|
|
}]
|
|
assert result["moved"] is True
|
|
assert result["after"]["duplicates"] == []
|
|
assert [day["ids"] for day in result["after"]["days"][:3]] == [["one"], ["two"], ["shared"]]
|
|
assert result["pending"]["days"][2]["estimates"] == {"shared": 30}
|
|
|
|
|
|
def test_week_controller_places_new_work_or_explicitly_moves_existing_work_without_duplicates():
|
|
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 week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson:async()=>({}),
|
|
localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
|
week.adopt({revision:6,timezone:'UTC',days:[
|
|
{plan_date:'2026-08-21',ids:['existing'],capacity_minutes:60,estimates:{existing:30}},
|
|
{plan_date:'2026-08-22',ids:['other'],capacity_minutes:120,estimates:{other:45}}
|
|
]});
|
|
const before=week.placement('existing');
|
|
const refused=week.place('existing','2026-08-22',50);
|
|
const moved=week.place('existing','2026-08-22',50,{move:true});
|
|
const added=week.place('new','2026-08-23',25);
|
|
console.log(JSON.stringify({before,refused,moved,added,state:week.state(),pending:week.pending()}));
|
|
""")
|
|
|
|
assert result["before"] == {"date": "2026-08-21", "estimate": 30}
|
|
assert result["refused"] is False
|
|
assert result["moved"] is True
|
|
assert result["added"] is True
|
|
assert [day["ids"] for day in result["state"]["days"]] == [[], ["other", "existing"], ["new"]]
|
|
assert result["state"]["days"][1]["estimates"]["existing"] == 50
|
|
assert result["pending"]["days"] == result["state"]["days"]
|
|
|
|
|
|
def test_week_workflow_saves_and_advances_without_closing_the_planner():
|
|
result = run_controller("""
|
|
const createWorkflow=createWeekPlan.Workflow;
|
|
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
|
const elements=new Map();
|
|
const makeElement=()=>({hidden:false,textContent:'',disabled:false,innerHTML:'',addEventListener:()=>{},focus:()=>{},
|
|
querySelectorAll:()=>[],querySelector:()=>null,scrollIntoView:()=>{}});
|
|
const root=makeElement();
|
|
Object.defineProperty(root,'innerHTML',{set(value){this.value=value;this.buttons=[...value.matchAll(/data-week-plan-date=\"([^\"]+)/g)].map(match=>({
|
|
dataset:{weekPlanDate:match[1]},addEventListener:()=>{},scrollIntoView(){this.scrolled=true;}
|
|
}));},get(){return this.value||'';}});
|
|
root.querySelectorAll=()=>root.buttons||[];
|
|
root.querySelector=selector=>(root.buttons||[]).find(button=>selector.includes(button.dataset.weekPlanDate))||null;
|
|
elements.set('#week-plan-dates',root);
|
|
for(const selector of ['#mobile-week-summary','#my-work-action-status','#week-plan-progress','#save-today-plan']) elements.set(selector,makeElement());
|
|
let opened=0,staged=[];
|
|
const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+(index+1)})),day:date=>({plan_date:date,ids:[]}),
|
|
pass:date=>{const index=dates.indexOf(date);return {position:index+1,total:7,planned:staged.length,next_date:dates[index+1]||null,last:index===6};},
|
|
load:async()=>({}),summary:()=> 'Nothing planned',stageDay:(date,plan)=>{staged.push({date,plan});return true;},flush:async()=>({})};
|
|
const workflow=createWorkflow({controller,qs:selector=>elements.get(selector),getLogin:()=> 'timmy',
|
|
openPlanner:()=>{opened+=1;},escapeHtml:value=>value,escapeAttribute:value=>value,
|
|
todayWork:{replace:()=>{},replacePlanning:()=>{}},refresh:()=>{},warm:()=>{}});
|
|
await workflow.open({disabled:false});
|
|
workflow.save({ids:['issue:r:1:'],capacity_minutes:60,estimates:{'issue:r:1:':30}});
|
|
const continued=workflow.advance();
|
|
console.log(JSON.stringify({continued,selected:workflow.selectedDate(),opened,staged,
|
|
progress:elements.get('#week-plan-progress').textContent,label:elements.get('#save-today-plan').textContent}));
|
|
""")
|
|
|
|
assert result["continued"] is True
|
|
assert result["selected"] == "2026-08-22"
|
|
assert result["opened"] == 3
|
|
assert result["staged"][0]["date"] == "2026-08-21"
|
|
assert result["progress"] == "Day 2 of 7 · 1 planned"
|
|
assert result["label"] == "Save & next"
|
|
|
|
|
|
def test_week_workflow_opens_review_after_day_seven_and_blocks_duplicate_confirmation():
|
|
result = run_controller("""
|
|
const createWorkflow=createWeekPlan.Workflow;
|
|
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
|
const elements=new Map();
|
|
const makeElement=()=>({hidden:false,textContent:'',disabled:false,innerHTML:'',addEventListener:()=>{},focus:()=>{},
|
|
querySelectorAll:()=>[],querySelector:()=>null,scrollIntoView:()=>{}});
|
|
const root=makeElement();
|
|
Object.defineProperty(root,'innerHTML',{set(value){this.value=value;this.buttons=[];},get(){return this.value||'';}});
|
|
elements.set('#week-plan-dates',root);
|
|
for(const selector of ['#mobile-week-summary','#my-work-action-status','#week-plan-progress','#save-today-plan',
|
|
'#week-review','#week-review-days','#week-review-duplicates','#confirm-week-plan','#week-review-status']) elements.set(selector,makeElement());
|
|
let reviewMode=false;
|
|
const review={days:dates.map((date,index)=>({plan_date:date,label:'Day '+(index+1),ids:index<2?['shared']:[],
|
|
capacity_minutes:index===0?30:60,planned_minutes:index===0?45:15,overloaded:index===0})),
|
|
duplicates:[{id:'shared',dates:dates.slice(0,2)}],can_confirm:false};
|
|
const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+(index+1)})),day:date=>review.days.find(day=>day.plan_date===date),
|
|
pass:date=>{const index=dates.indexOf(date);return {position:index+1,total:7,planned:2,next_date:dates[index+1]||null,last:index===6};},
|
|
load:async()=>({}),summary:()=> '2 items across 2 days',review:()=>review,move:()=>true,flush:async()=>({})};
|
|
const workflow=createWorkflow({controller,qs:selector=>elements.get(selector),getLogin:()=> 'timmy',
|
|
openPlanner:()=>{},setReviewMode:value=>{reviewMode=value;},escapeHtml:value=>value,escapeAttribute:value=>value,
|
|
todayWork:{replace:()=>{},replacePlanning:()=>{}},refresh:()=>{},warm:()=>{}});
|
|
await workflow.open({disabled:false});
|
|
for(let index=0;index<6;index++) workflow.advance();
|
|
const advanced=workflow.advance();
|
|
console.log(JSON.stringify({advanced,reviewing:workflow.reviewing(),reviewMode,
|
|
reviewHidden:elements.get('#week-review').hidden,progress:elements.get('#week-plan-progress').textContent,
|
|
confirmDisabled:elements.get('#confirm-week-plan').disabled,days:elements.get('#week-review-days').innerHTML,
|
|
duplicates:elements.get('#week-review-duplicates').innerHTML}));
|
|
""")
|
|
|
|
assert result["advanced"] is True
|
|
assert result["reviewing"] is True
|
|
assert result["reviewMode"] is True
|
|
assert result["reviewHidden"] is False
|
|
assert result["progress"] == "Review week · 2 planned"
|
|
assert result["confirmDisabled"] is True
|
|
assert "45 of 30 min · over capacity" in result["days"]
|
|
assert "shared" in result["duplicates"]
|
|
|
|
|
|
def test_week_review_names_work_and_can_edit_a_day_then_return_to_review():
|
|
result = run_controller("""
|
|
const createWorkflow=createWeekPlan.Workflow;
|
|
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
|
const elements=new Map();
|
|
const makeElement=()=>({hidden:false,textContent:'',disabled:false,innerHTML:'',dataset:{},listeners:{},
|
|
addEventListener(name,listener){this.listeners[name]=listener;},focus(){this.focused=true;},
|
|
querySelectorAll:()=>[],querySelector:()=>null,scrollIntoView:()=>{}});
|
|
const dateRoot=makeElement();
|
|
Object.defineProperty(dateRoot,'innerHTML',{set(value){this.value=value;this.buttons=[...value.matchAll(/data-week-plan-date=\"([^\"]+)/g)].map(match=>({
|
|
dataset:{weekPlanDate:match[1]},addEventListener:()=>{},scrollIntoView:()=>{},focus:()=>{}
|
|
}));},get(){return this.value||'';}});
|
|
dateRoot.querySelectorAll=()=>dateRoot.buttons||[];
|
|
dateRoot.querySelector=selector=>(dateRoot.buttons||[]).find(button=>selector.includes(button.dataset.weekPlanDate))||null;
|
|
elements.set('#week-plan-dates',dateRoot);
|
|
const reviewDays=makeElement();
|
|
Object.defineProperty(reviewDays,'innerHTML',{set(value){
|
|
this.value=value;this.editButtons=[...value.matchAll(/data-week-edit-day=\"([^\"]+)/g)].map(match=>{
|
|
const button=makeElement();button.dataset.weekEditDay=match[1];return button;
|
|
});
|
|
},get(){return this.value||'';}});
|
|
reviewDays.querySelectorAll=selector=>selector==='[data-week-edit-day]'?reviewDays.editButtons||[]:[];
|
|
reviewDays.querySelector=selector=>(reviewDays.editButtons||[]).find(button=>selector.includes(button.dataset.weekEditDay))||null;
|
|
elements.set('#week-review-days',reviewDays);
|
|
for(const selector of ['#mobile-week-summary','#my-work-action-status','#week-plan-progress','#save-today-plan',
|
|
'#week-review','#week-review-duplicates','#confirm-week-plan','#week-review-status','#back-to-week-review']) {
|
|
if(!elements.has(selector))elements.set(selector,makeElement());
|
|
}
|
|
let reviewMode=false;
|
|
const review={days:dates.map((date,index)=>({plan_date:date,label:'Day '+(index+1),
|
|
ids:index===0?['issue:stackchain/dashboard:42:']:[],capacity_minutes:60,
|
|
estimates:index===0?{'issue:stackchain/dashboard:42:':30}:{},planned_minutes:index===0?30:0,overloaded:false})),
|
|
duplicates:[],can_confirm:true};
|
|
const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+(index+1)})),day:date=>review.days.find(day=>day.plan_date===date),
|
|
pass:date=>{const index=dates.indexOf(date);return {position:index+1,total:7,planned:1,next_date:dates[index+1]||null,last:index===6};},
|
|
load:async()=>({}),summary:()=> '1 item across 1 day',review:()=>review,pending:()=>false,move:()=>true,flush:async()=>({})};
|
|
const workflow=createWorkflow({controller,qs:selector=>elements.get(selector),getLogin:()=> 'timmy',
|
|
getItem:id=>id==='issue:stackchain/dashboard:42:'?{kind:'issue',title:'Fix mobile sign in',repository:'stackchain/dashboard',number:42}:null,
|
|
openPlanner:()=>{},setReviewMode:value=>{reviewMode=value;},escapeHtml:value=>value,escapeAttribute:value=>value,
|
|
todayWork:{replace:()=>{},replacePlanning:()=>{}},refresh:()=>{},warm:()=>{}});
|
|
await workflow.open({disabled:false});
|
|
for(let index=0;index<7;index++)workflow.advance();
|
|
const markup=reviewDays.innerHTML;
|
|
const edit=reviewDays.editButtons[0];edit.listeners.click();
|
|
const whileEditing={selected:workflow.selectedDate(),reviewing:workflow.reviewing(),reviewMode,
|
|
reviewHidden:elements.get('#week-review').hidden,backHidden:elements.get('#back-to-week-review').hidden};
|
|
elements.get('#back-to-week-review').listeners.click();
|
|
console.log(JSON.stringify({markup,whileEditing,after:{selected:workflow.selectedDate(),reviewing:workflow.reviewing(),reviewMode,
|
|
reviewHidden:elements.get('#week-review').hidden,editFocused:reviewDays.editButtons[0].focused}}));
|
|
""")
|
|
|
|
assert "Fix mobile sign in" in result["markup"]
|
|
assert "stackchain/dashboard #42 · Issue · 30 min" in result["markup"]
|
|
assert "<code>issue:stackchain/dashboard:42:</code>" not in result["markup"]
|
|
assert result["whileEditing"] == {
|
|
"selected": "2026-08-21", "reviewing": False, "reviewMode": False,
|
|
"reviewHidden": True, "backHidden": False,
|
|
}
|
|
assert result["after"] == {
|
|
"selected": None, "reviewing": True, "reviewMode": True,
|
|
"reviewHidden": False, "editFocused": True,
|
|
}
|
|
|
|
|
|
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_drains_work_staged_while_a_save_is_in_flight():
|
|
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 releaseFirst;const bodies=[];
|
|
const fetchJson=async(_url,options={})=>{
|
|
const body=JSON.parse(options.body);bodies.push(body);
|
|
if(bodies.length===1)await new Promise(resolve=>{releaseFirst=resolve});
|
|
return {revision:8+bodies.length,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:['one'],capacity_minutes:60,estimates:{one:30}});
|
|
const first=week.flush();await Promise.resolve();
|
|
week.stageDay('2026-08-22',{ids:['two'],capacity_minutes:90,estimates:{two:45}});
|
|
const second=week.flush();releaseFirst();
|
|
const [saved,reused]=await Promise.all([first,second]);
|
|
console.log(JSON.stringify({bodies,saved,reused,pending:week.pending(),state:week.state()}));
|
|
""")
|
|
|
|
assert len(result["bodies"]) == 2
|
|
assert [day["plan_date"] for day in result["bodies"][1]["days"]] == ["2026-08-21", "2026-08-22"]
|
|
assert result["bodies"][1]["base_revision"] == 9
|
|
assert result["saved"]["revision"] == 10
|
|
assert result["reused"]["revision"] == 10
|
|
assert result["pending"] is False
|
|
assert result["state"]["revision"] == 10
|
|
|
|
|
|
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 '<script src="static/week-plan.js"></script>' 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_review_is_rendered_touch_safe_and_confirmed_explicitly():
|
|
index = (FRONTEND / "index.html").read_text()
|
|
css = (FRONTEND / "dashboard.css").read_text()
|
|
dashboard = (FRONTEND / "dashboard.js").read_text()
|
|
|
|
assert 'id="week-review"' in index
|
|
assert 'id="week-review-days"' in index
|
|
assert 'id="week-review-duplicates"' in index
|
|
assert 'id="confirm-week-plan"' in index
|
|
assert "setReviewMode:value=>qs('#plan-today-sheet').classList.toggle('week-review-mode',value)" in dashboard
|
|
assert "weekWorkflow.confirm()" in dashboard
|
|
assert ".week-review-day.is-overloaded" in css
|
|
assert ".week-review-day button { min-height:44px;" in css
|
|
assert ".week-review-mode .mobile-plan-today-nav" 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
|