feat: recover conflicted Week-to-Today transfers (Closes #1254)
This commit is contained in:
1 parent
24cadf88f8
commit
6e72f59fcf
4 files changed
+244
-5
No files matched your search
@@ -8,6 +8,8 @@ import pytest
|
||||
FRONTEND = Path(__file__).parents[1] / "frontend"
|
||||
CONTROLLER = FRONTEND / "week-plan.js"
|
||||
COORDINATOR = FRONTEND / "outbox-coordinator.js"
|
||||
INDEX = FRONTEND / "index.html"
|
||||
CSS = FRONTEND / "dashboard.css"
|
||||
|
||||
|
||||
def run_controller(scenario: str) -> dict:
|
||||
@@ -20,6 +22,32 @@ const createOutboxCoordinator = require({json.dumps(str(COORDINATOR))});
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_conflicted_pull_review_has_accessible_touch_safe_recovery_actions():
|
||||
markup = INDEX.read_text()
|
||||
styles = CSS.read_text()
|
||||
|
||||
assert 'id="week-pull-conflict-review"' in markup
|
||||
assert 'aria-labelledby="week-pull-conflict-title"' in markup
|
||||
assert 'id="week-pull-conflict-item"' in markup
|
||||
assert 'id="week-pull-conflict-context"' in markup
|
||||
assert 'id="rebase-week-pull"' in markup
|
||||
assert 'id="keep-week-pull"' in markup
|
||||
assert "Add using current plans" in markup
|
||||
assert "Keep in Week Ahead" in markup
|
||||
assert ".week-pull-conflict-actions button { min-height:44px" in styles
|
||||
assert "overflow-wrap:anywhere" in styles
|
||||
|
||||
|
||||
def test_week_workflow_wires_both_conflict_decisions_to_the_durable_controller():
|
||||
source = CONTROLLER.read_text()
|
||||
|
||||
assert "resolvePullConflict('add')" in source
|
||||
assert "resolvePullConflict('keep')" in source
|
||||
assert "controller.pullConflict?.()" in source
|
||||
assert "renderPullConflict" in source
|
||||
assert "#week-pull-conflict-review" in source
|
||||
|
||||
|
||||
def test_week_controller_requires_executable_days_before_calendar_handoff():
|
||||
result = run_controller("""
|
||||
const week=createWeekPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||
@@ -514,6 +542,116 @@ console.log(JSON.stringify({outcome,state:week.state(),pending:week.pendingPull(
|
||||
]
|
||||
|
||||
|
||||
def test_week_controller_describes_the_conflicted_transfer_from_current_plans():
|
||||
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 remoteToday={revision:9,ids:['active'],capacity_minutes:90,estimates:{active:30}};
|
||||
const remoteWeek={revision:12,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['pull'],capacity_minutes:90,estimates:{pull:45}}]};
|
||||
const fetchJson=async(url,options={})=>{if(options.method){const error=new Error('changed');error.status=409;throw error;}return url.endsWith('/today')?remoteToday:remoteWeek;};
|
||||
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||
week.adopt({revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['pull'],capacity_minutes:90,estimates:{pull:45}}]});
|
||||
week.rememberItems({'pull':{title:'Ship recovery',kind:'issue',repository:'acme/app',number:7}});
|
||||
await week.pullItem('pull',{revision:4,ids:['old'],capacity_minutes:90,estimates:{old:30}},'stable-pull');
|
||||
console.log(JSON.stringify(week.pullConflict()));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"identity": "pull", "title": "Ship recovery", "source_date": "2026-08-21",
|
||||
"today_items": 1, "today_minutes": 30, "today_capacity": 90,
|
||||
"operation_id": "stable-pull", "queue_length": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_week_controller_rechecks_capacity_before_rebasing_a_conflicted_pull():
|
||||
result = run_controller("""
|
||||
const values=new Map();let posts=0;
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
const remoteToday={revision:9,ids:['active'],capacity_minutes:50,estimates:{active:40}};
|
||||
const remoteWeek={revision:12,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['pull'],capacity_minutes:90,estimates:{pull:20}}]};
|
||||
const fetchJson=async(url,options={})=>{if(options.method){posts+=1;const error=new Error('changed');error.status=409;throw error;}return url.endsWith('/today')?remoteToday:remoteWeek;};
|
||||
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||
week.adopt({revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['pull'],capacity_minutes:90,estimates:{pull:20}}]});
|
||||
await week.pullItem('pull',{revision:4,ids:['old'],capacity_minutes:90,estimates:{old:30}},'stable-pull');
|
||||
let code=null;try{await week.resolvePullConflict('add');}catch(error){code=error.code;}
|
||||
console.log(JSON.stringify({code,posts,pending:week.pendingPull()}));
|
||||
""")
|
||||
|
||||
assert result["code"] == "today_over_capacity"
|
||||
assert result["posts"] == 1
|
||||
assert result["pending"]["conflict"] is True
|
||||
|
||||
|
||||
def test_week_controller_rebases_a_conflicted_pull_and_drains_the_fifo_with_stable_operations():
|
||||
result = run_controller("""
|
||||
const values=new Map(),delivered=[];
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
let offline=true;
|
||||
let serverToday={revision:10,ids:['active'],capacity_minutes:180,estimates:{active:30}};
|
||||
let serverWeek={revision:12,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['first','second'],capacity_minutes:180,estimates:{first:35,second:40}}]};
|
||||
const fetchJson=async(url,options={})=>{
|
||||
if(offline)throw new Error('offline');
|
||||
if(!options.method)return url.endsWith('/today')?serverToday:serverWeek;
|
||||
const body=JSON.parse(options.body);delivered.push(body);
|
||||
if(body.today_revision!==serverToday.revision||body.week_revision!==serverWeek.revision){const error=new Error('changed');error.status=409;throw error;}
|
||||
serverToday={...serverToday,revision:serverToday.revision+1,ids:[...serverToday.ids,body.identity],estimates:{...serverToday.estimates,[body.identity]:serverWeek.days[0].estimates[body.identity]}};
|
||||
serverWeek={...serverWeek,revision:serverWeek.revision+1,days:serverWeek.days.map(day=>({...day,ids:day.ids.filter(id=>id!==body.identity),estimates:Object.fromEntries(Object.entries(day.estimates).filter(([id])=>id!==body.identity))}))};
|
||||
return {today:serverToday,week:serverWeek};
|
||||
};
|
||||
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||
week.adopt({revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['first','second'],capacity_minutes:180,estimates:{first:35,second:40}}]});
|
||||
let today={revision:4,ids:['active'],capacity_minutes:180,estimates:{active:30}};
|
||||
today=(await week.pullItem('first',today,'pull-first')).today;
|
||||
await week.pullItem('second',today,'pull-second');
|
||||
offline=false;await week.flushPull();
|
||||
const resolved=await week.resolvePullConflict('add');
|
||||
console.log(JSON.stringify({resolved,delivered,pending:week.pendingPull(),state:week.state()}));
|
||||
""")
|
||||
|
||||
assert [request["operation_id"] for request in result["delivered"]] == [
|
||||
"pull-first", "pull-first", "pull-second"
|
||||
]
|
||||
assert result["delivered"][1]["today_revision"] == 10
|
||||
assert result["delivered"][1]["week_revision"] == 12
|
||||
assert result["resolved"]["today"]["ids"] == ["active", "first", "second"]
|
||||
assert result["pending"] is None
|
||||
assert result["state"]["revision"] == 14
|
||||
|
||||
|
||||
def test_week_controller_keeps_the_conflicted_item_in_week_and_advances_only_the_fifo_head():
|
||||
result = run_controller("""
|
||||
const values=new Map(),delivered=[];
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
let offline=true;
|
||||
let serverToday={revision:10,ids:['active'],capacity_minutes:180,estimates:{active:30}};
|
||||
let serverWeek={revision:12,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['first','second'],capacity_minutes:180,estimates:{first:35,second:40}}]};
|
||||
const fetchJson=async(url,options={})=>{
|
||||
if(offline)throw new Error('offline');
|
||||
if(!options.method)return url.endsWith('/today')?serverToday:serverWeek;
|
||||
const body=JSON.parse(options.body);delivered.push(body);
|
||||
if(body.today_revision!==serverToday.revision||body.week_revision!==serverWeek.revision){const error=new Error('changed');error.status=409;throw error;}
|
||||
serverToday={...serverToday,revision:11,ids:[...serverToday.ids,body.identity],estimates:{...serverToday.estimates,[body.identity]:40}};
|
||||
serverWeek={...serverWeek,revision:13,days:serverWeek.days.map(day=>({...day,ids:day.ids.filter(id=>id!==body.identity)}))};
|
||||
return {today:serverToday,week:serverWeek};
|
||||
};
|
||||
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||
week.adopt({revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['first','second'],capacity_minutes:180,estimates:{first:35,second:40}}]});
|
||||
let today={revision:4,ids:['active'],capacity_minutes:180,estimates:{active:30}};
|
||||
today=(await week.pullItem('first',today,'pull-first')).today;
|
||||
await week.pullItem('second',today,'pull-second');
|
||||
offline=false;await week.flushPull();
|
||||
const resolved=await week.resolvePullConflict('keep');
|
||||
console.log(JSON.stringify({resolved,delivered,pending:week.pendingPull()}));
|
||||
""")
|
||||
|
||||
assert [request["operation_id"] for request in result["delivered"]] == ["pull-first", "pull-second"]
|
||||
assert result["delivered"][1]["today_revision"] == 10
|
||||
assert result["delivered"][1]["week_revision"] == 12
|
||||
assert result["resolved"]["today"]["ids"] == ["active", "second"]
|
||||
assert result["resolved"]["week"]["days"][0]["ids"] == ["first"]
|
||||
assert result["pending"] is None
|
||||
|
||||
|
||||
def test_week_controller_retires_completed_work_from_every_day_in_one_durable_transition():
|
||||
result = run_controller("""
|
||||
const values=new Map();let writes=0;
|
||||
|
||||
Reference in new issue
Block a user