164 lines
8.2 KiB
Python
164 lines
8.2 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
|
|
FRONTEND = Path(__file__).parents[1] / "frontend"
|
|
MODULE = FRONTEND / "search-week-plan.js"
|
|
|
|
|
|
def run_planner(scenario: str) -> dict:
|
|
harness = f"""
|
|
const createSearchWeekPlan = require({json.dumps(str(MODULE))});
|
|
(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_search_week_plan_previews_seven_days_and_requires_explicit_overload_confirmation():
|
|
result = run_planner("""
|
|
let claims=0,stages=0;
|
|
const days=Array.from({length:7},(_,index)=>({date:`2026-08-${21+index}`,label:`Day ${index+1}`}));
|
|
const week={load:async()=>({}),dates:()=>days,day:date=>date==='2026-08-21'
|
|
? {plan_date:date,ids:['existing'],capacity_minutes:60,estimates:{existing:50}}
|
|
: {plan_date:date,ids:[],capacity_minutes:90,estimates:{}},
|
|
placement:()=>null,place:()=>{stages+=1;return true;},flush:async()=>({revision:2})};
|
|
const planner=createSearchWeekPlan({week,claim:async item=>{claims+=1;return {...item,assigned_to_me:true,claimable:false};},
|
|
accept:item=>item,identity:item=>`issue:${item.repository}:${item.number}:`});
|
|
const detail={kind:'issue',state:'open',repository:'stackchain/dashboard',number:42,claimable:true};
|
|
const preview=await planner.preview(detail);
|
|
const invalid=await planner.plan(detail,{date:'2026-08-21',estimate:0});
|
|
const overload=await planner.plan(detail,{date:'2026-08-21',estimate:30});
|
|
console.log(JSON.stringify({preview,invalid,overload,claims,stages}));
|
|
""")
|
|
|
|
assert len(result["preview"]["days"]) == 7
|
|
assert result["preview"]["days"][0]["planned_minutes"] == 50
|
|
assert result["preview"]["days"][0]["capacity_minutes"] == 60
|
|
assert result["invalid"] == {"status": "estimate-required"}
|
|
assert result["overload"]["status"] == "overload-confirmation-required"
|
|
assert result["overload"]["planned_minutes"] == 80
|
|
assert result["claims"] == 0
|
|
assert result["stages"] == 0
|
|
|
|
|
|
def test_search_week_plan_assigns_once_then_durably_places_canonical_issue():
|
|
result = run_planner("""
|
|
let release,claims=0,places=0,flushes=0;
|
|
const week={load:async()=>({}),dates:()=>[{date:'2026-08-22',label:'Sat, Aug 22'}],
|
|
day:date=>({plan_date:date,ids:[],capacity_minutes:120,estimates:{}}),placement:()=>null,
|
|
place:(id,date,estimate)=>{places+=1;return {id,date,estimate};},flush:async()=>{flushes+=1;return {revision:3};}};
|
|
const planner=createSearchWeekPlan({week,
|
|
claim:async item=>{claims+=1;await new Promise(resolve=>{release=resolve});return {...item,assigned_to_me:true,claimable:false,title:'Canonical'};},
|
|
accept:item=>({...item,accepted:true}),identity:item=>`issue:${item.repository}:${item.number}:`});
|
|
const detail={kind:'issue',state:'open',repository:'stackchain/dashboard',number:42,claimable:true};
|
|
const first=planner.plan(detail,{date:'2026-08-22',estimate:45});
|
|
const second=planner.plan(detail,{date:'2026-08-22',estimate:45});
|
|
await Promise.resolve();release();
|
|
const outcomes=await Promise.all([first,second]);
|
|
console.log(JSON.stringify({outcomes,claims,places,flushes}));
|
|
""")
|
|
|
|
assert result["claims"] == 1
|
|
assert result["places"] == 1
|
|
assert result["flushes"] == 1
|
|
assert result["outcomes"][0] == result["outcomes"][1]
|
|
assert result["outcomes"][0]["status"] == "planned"
|
|
assert result["outcomes"][0]["item"]["accepted"] is True
|
|
|
|
|
|
def test_search_week_plan_reports_existing_placement_and_partial_assignment_truthfully():
|
|
result = run_planner("""
|
|
const base={load:async()=>({}),dates:()=>[{date:'2026-08-22',label:'Sat'}],
|
|
day:date=>({plan_date:date,ids:[],capacity_minutes:120,estimates:{}}),flush:async()=>({})};
|
|
const detail={kind:'issue',state:'open',repository:'stackchain/dashboard',number:42,claimable:true};
|
|
const existing=createSearchWeekPlan({week:{...base,placement:()=>({date:'2026-08-23',estimate:30}),place:()=>true},
|
|
claim:async item=>item,accept:item=>item,identity:()=> 'work'});
|
|
const blocked=await existing.plan(detail,{date:'2026-08-22',estimate:45});
|
|
const partial=createSearchWeekPlan({week:{...base,placement:()=>null,place:()=>false},
|
|
claim:async item=>({...item,assigned_to_me:true,claimable:false}),accept:item=>item,identity:()=> 'work'});
|
|
const failed=await partial.plan(detail,{date:'2026-08-22',estimate:45});
|
|
console.log(JSON.stringify({blocked,failed}));
|
|
""")
|
|
|
|
assert result["blocked"] == {"status": "already-planned", "date": "2026-08-23", "estimate": 30}
|
|
assert result["failed"]["status"] == "assigned-not-planned"
|
|
|
|
|
|
def test_search_week_plan_moves_without_duplicates_and_keeps_pending_sync_on_network_failure():
|
|
result = run_planner("""
|
|
const week={load:async()=>({}),dates:()=>[{date:'2026-08-22',label:'Sat'}],
|
|
day:date=>({plan_date:date,ids:[],capacity_minutes:120,estimates:{}}),
|
|
placement:()=>({date:'2026-08-21',estimate:30}),place:()=>true,
|
|
flush:async()=>{throw new Error('offline');}};
|
|
const planner=createSearchWeekPlan({week,claim:async item=>item,accept:item=>item,identity:()=> 'work'});
|
|
const outcome=await planner.plan({kind:'issue',state:'open',assigned_to_me:true},
|
|
{date:'2026-08-22',estimate:45,move:true});
|
|
console.log(JSON.stringify(outcome));
|
|
""")
|
|
|
|
assert result == {
|
|
"status": "planned-pending",
|
|
"date": "2026-08-22",
|
|
"estimate": 45,
|
|
"item": {"kind": "issue", "state": "open", "assigned_to_me": True},
|
|
}
|
|
|
|
|
|
def test_search_week_plan_stages_canonical_details_before_flushing():
|
|
result = run_planner("""
|
|
const calls=[];
|
|
const week={load:async()=>({}),dates:()=>[{date:'2026-08-22',label:'Sat'}],
|
|
day:date=>({plan_date:date,ids:[],capacity_minutes:120,estimates:{}}),placement:()=>null,
|
|
place:(id)=>{calls.push(['place',id]);return true;},
|
|
rememberPendingItem:(id,item)=>{calls.push(['details',id,item.title,item.repository,item.number]);return true;},
|
|
flush:async()=>{calls.push(['flush']);return {revision:3};}};
|
|
const planner=createSearchWeekPlan({week,claim:async item=>({...item,title:'Canonical title'}),
|
|
accept:item=>({...item,accepted:true}),identity:item=>`issue:${item.repository}:${item.number}:`});
|
|
const outcome=await planner.plan({kind:'issue',state:'open',repository:'stackchain/dashboard',number:42,claimable:true},
|
|
{date:'2026-08-22',estimate:45});
|
|
console.log(JSON.stringify({outcome,calls}));
|
|
""")
|
|
|
|
assert result["outcome"]["status"] == "planned"
|
|
assert result["calls"] == [
|
|
["place", "issue:stackchain/dashboard:42:"],
|
|
["details", "issue:stackchain/dashboard:42:", "Canonical title", "stackchain/dashboard", 42],
|
|
["flush"],
|
|
]
|
|
|
|
|
|
def test_search_week_plan_waits_for_success_callback_before_closing():
|
|
result = run_planner("""
|
|
const events=[];
|
|
const nodes={};
|
|
function node(extra={}) { return Object.assign({
|
|
hidden:true,disabled:false,textContent:'',value:'',dataset:{},
|
|
addEventListener(){},focus(){events.push('focus');},
|
|
setAttribute(){},querySelectorAll(){return[];}
|
|
},extra); }
|
|
nodes['#search-week-plan-sheet']=node();
|
|
nodes['#search-week-plan-status']=node();
|
|
nodes['#confirm-search-week-plan']=node();
|
|
nodes['#search-week-plan-copy']=node();
|
|
nodes['#search-week-plan-days']=node({dataset:{selected:'2026-08-22'}});
|
|
nodes['#search-week-plan-estimate']=node({value:'45'});
|
|
nodes['#plan-search-result']=node(); nodes['#cancel-search-week-plan']=node();
|
|
const document={querySelector:selector=>nodes[selector],addEventListener(){}};
|
|
const window={history:{state:{},pushState(value){this.state=value;},back(){events.push('back');nodes['#search-week-plan-sheet'].hidden=true;}},location:{href:'/'},addEventListener(){}};
|
|
const planner={pending:()=>false,preview:async()=>({days:[{date:'2026-08-22',label:'Sat',planned_minutes:0,capacity_minutes:90,ids:[]}]}),
|
|
plan:async()=>({status:'planned',date:'2026-08-22',estimate:45,item:{number:42}})};
|
|
const ui=createSearchWeekPlan.UI({planner,document,window,getDetail:()=>({title:'Issue',number:42}),
|
|
escapeHtml:value=>value,escapeAttribute:value=>value,announce:message=>events.push(message),
|
|
onPlanned:async()=>{events.push('callback-start');await Promise.resolve();events.push('callback-end');}});
|
|
await ui.open(node());
|
|
await ui.confirm();
|
|
events.push(nodes['#search-week-plan-sheet'].hidden?'closed':'open');
|
|
console.log(JSON.stringify({events}));
|
|
""")
|
|
|
|
assert result["events"][-4:] == [
|
|
"callback-start", "callback-end", "back", "closed"
|
|
]
|