Compare commits
No commits in common. "ea3204c78cc3e60d5e188828d54df969af1fd920" and "24cadf88f88bf5293733ef7d96fb1c048a4daf91" have entirely different histories.
ea3204c78c
...
24cadf88f8
|
|
@ -329,12 +329,6 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.week-conflict-plans { grid-template-columns:1fr; }
|
.week-conflict-plans { grid-template-columns:1fr; }
|
||||||
.week-conflict-actions { flex-direction:column; }
|
.week-conflict-actions { flex-direction:column; }
|
||||||
}
|
}
|
||||||
.week-pull-conflict-review { min-width:0; margin-top:14px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow-wrap:anywhere; }
|
|
||||||
.week-pull-conflict-item { margin-bottom:6px; color:#eff6ff; font-weight:700; }
|
|
||||||
.week-pull-conflict-actions { display:flex; gap:8px; margin-top:14px; }
|
|
||||||
.week-pull-conflict-actions button { min-height:44px; flex:1 1 0; }
|
|
||||||
.week-pull-conflict-status { min-height:1.4em; margin-top:8px; }
|
|
||||||
@media (max-width:480px) { .week-pull-conflict-actions { flex-direction:column; } }
|
|
||||||
.week-review { margin-top:14px; }
|
.week-review { margin-top:14px; }
|
||||||
.week-review h2 { margin-bottom:6px; }
|
.week-review h2 { margin-bottom:6px; }
|
||||||
.week-review-days { display:grid; gap:12px; }
|
.week-review-days { display:grid; gap:12px; }
|
||||||
|
|
|
||||||
|
|
@ -505,17 +505,6 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="small week-conflict-status" id="week-conflict-status" role="status" aria-live="assertive"></div>
|
<div class="small week-conflict-status" id="week-conflict-status" role="status" aria-live="assertive"></div>
|
||||||
</section>
|
</section>
|
||||||
<section class="week-pull-conflict-review" id="week-pull-conflict-review" aria-labelledby="week-pull-conflict-title" tabindex="-1" hidden>
|
|
||||||
<div class="small">Saved transfer needs review</div>
|
|
||||||
<h3 id="week-pull-conflict-title">Resolve Today transfer</h3>
|
|
||||||
<p id="week-pull-conflict-item" class="week-pull-conflict-item"></p>
|
|
||||||
<p id="week-pull-conflict-context" class="small muted"></p>
|
|
||||||
<div class="week-pull-conflict-actions">
|
|
||||||
<button id="rebase-week-pull" type="button">Add using current plans</button>
|
|
||||||
<button id="keep-week-pull" type="button">Keep in Week Ahead</button>
|
|
||||||
</div>
|
|
||||||
<div class="small week-pull-conflict-status" id="week-pull-conflict-status" role="status" aria-live="assertive"></div>
|
|
||||||
</section>
|
|
||||||
<section class="week-review" id="week-review" aria-labelledby="week-review-title" hidden>
|
<section class="week-review" id="week-review" aria-labelledby="week-review-title" hidden>
|
||||||
<div class="small">Seven-day overview</div>
|
<div class="small">Seven-day overview</div>
|
||||||
<h2 id="week-review-title">Week Ahead</h2>
|
<h2 id="week-review-title">Week Ahead</h2>
|
||||||
|
|
|
||||||
|
|
@ -527,46 +527,6 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||||
const result=await fetchJson('api/v1/week/pull-item',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
const result=await fetchJson('api/v1/week/pull-item',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||||
adoptConfirmed(result.week,{...confirmedItems,...pendingItems});return result;
|
adoptConfirmed(result.week,{...confirmedItems,...pendingItems});return result;
|
||||||
}
|
}
|
||||||
function pullConflict() {
|
|
||||||
const queue=pullQueue(),entry=queue[0];
|
|
||||||
if(!entry?.conflict||!entry.current)return null;
|
|
||||||
const identity=entry.body.identity,today=entry.current.today;
|
|
||||||
const source=(entry.current.week.days||[]).find(day=>(day.ids||[]).includes(identity));
|
|
||||||
return {identity,title:(pendingItems[identity]||confirmedItems[identity])?.title||identity,
|
|
||||||
source_date:source?.plan_date||null,today_items:(today.ids||[]).length,
|
|
||||||
today_minutes:(today.ids||[]).reduce((total,id)=>total+(Number(today.estimates?.[id])||0),0),
|
|
||||||
today_capacity:Number(today.capacity_minutes)||0,operation_id:entry.body.operation_id,queue_length:queue.length};
|
|
||||||
}
|
|
||||||
async function resolvePullConflict(action) {
|
|
||||||
const queue=pullQueue(),current=queue[0];
|
|
||||||
if(!current?.conflict||!current.current||!['add','keep'].includes(action))return false;
|
|
||||||
const latest=current.current,identity=current.body.identity;
|
|
||||||
const alreadyToday=(latest.today.ids||[]).includes(identity);
|
|
||||||
const stillInWeek=(latest.week.days||[]).some(day=>(day.ids||[]).includes(identity));
|
|
||||||
let remaining=queue.slice(1);
|
|
||||||
if(action==='add'&&!alreadyToday&&stillInWeek){
|
|
||||||
if((latest.today.ids||[]).length>=5){const error=new Error('Today is full.');error.code='today_full';throw error;}
|
|
||||||
const source=latest.week.days.find(day=>(day.ids||[]).includes(identity));
|
|
||||||
const estimate=Number(source?.estimates?.[identity])||0;
|
|
||||||
const minutes=(latest.today.ids||[]).reduce((total,id)=>total+(Number(latest.today.estimates?.[id])||0),0);
|
|
||||||
const capacity=Number(latest.today.capacity_minutes)||0;
|
|
||||||
if(capacity&&minutes+estimate>capacity&&!current.body.allow_over_capacity){
|
|
||||||
const error=new Error('Today would exceed its current capacity.');error.code='today_over_capacity';throw error;
|
|
||||||
}
|
|
||||||
const rebased={...current,conflict:false,current:undefined,body:{...current.body,
|
|
||||||
today_revision:latest.today.revision,week_revision:latest.week.revision}};
|
|
||||||
remaining=[rebased,...remaining];
|
|
||||||
}
|
|
||||||
if(remaining.length){
|
|
||||||
const head=remaining[0];
|
|
||||||
remaining[0]={...head,conflict:false,current:undefined,body:{...head.body,
|
|
||||||
today_revision:latest.today.revision,week_revision:latest.week.revision}};
|
|
||||||
}
|
|
||||||
if(!writePullQueue(remaining))throw new Error('Could not update queued Week Ahead work on this device.');
|
|
||||||
adopt(latest.week);
|
|
||||||
if(!remaining.length)return {...latest,sync_pending:false,resolved:alreadyToday?'already-moved':(!stillInWeek?'no-longer-planned':'kept')};
|
|
||||||
return flushPull();
|
|
||||||
}
|
|
||||||
function flushPull() {
|
function flushPull() {
|
||||||
if(pulling)return pulling;
|
if(pulling)return pulling;
|
||||||
if(!pendingPull()||!pullKey())return Promise.resolve(false);
|
if(!pendingPull()||!pullKey())return Promise.resolve(false);
|
||||||
|
|
@ -639,7 +599,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||||
return label+(pending()?' · sync pending':'');
|
return label+(pending()?' · sync pending':'');
|
||||||
}
|
}
|
||||||
return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,review,previewReflow,applyReflow,move,retire,unplan,restore,placement,place,pending,flush,conflict,chooseDay,saveMerged,
|
return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,review,previewReflow,applyReflow,move,retire,unplan,restore,placement,place,pending,flush,conflict,chooseDay,saveMerged,
|
||||||
keepLocal,useRemote,promote,pullItem,pendingPull,resumePull,pullConflict,resolvePullConflict,flushPull,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem,
|
keepLocal,useRemote,promote,pullItem,pendingPull,resumePull,flushPull,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem,
|
||||||
item:id=>pendingItems[id]||confirmedItems[id]||null,
|
item:id=>pendingItems[id]||confirmedItems[id]||null,
|
||||||
offline:()=>offlineSnapshot,request:fetchJson,reschedule:()=>({storage,getLogin})};
|
offline:()=>offlineSnapshot,request:fetchJson,reschedule:()=>({storage,getLogin})};
|
||||||
}
|
}
|
||||||
|
|
@ -1053,63 +1013,19 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
|
||||||
qs('#my-work-action-status').textContent=message;
|
qs('#my-work-action-status').textContent=message;
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
function renderPullConflict({open=false}={}) {
|
|
||||||
const detail=controller.pullConflict?.(),root=qs('#week-pull-conflict-review');
|
|
||||||
if(!root)return false;
|
|
||||||
root.hidden=!detail;
|
|
||||||
if(!detail)return false;
|
|
||||||
const item=qs('#week-pull-conflict-item'),context=qs('#week-pull-conflict-context');
|
|
||||||
if(item)item.textContent=detail.title;
|
|
||||||
if(context)context.textContent=(detail.source_date?'Planned for '+detail.source_date+' · ':'')+
|
|
||||||
'Today has '+detail.today_items+' item'+(detail.today_items===1?'':'s')+' · '+detail.today_minutes+
|
|
||||||
(detail.today_capacity?' of '+detail.today_capacity:'')+' min'+(detail.queue_length>1?' · '+detail.queue_length+' saved transfers waiting':'')+'.';
|
|
||||||
if(open){openPlanner(null,false);const title=qs('#plan-today-title');if(title)title.textContent='Resolve Today transfer';}
|
|
||||||
root.focus?.();
|
|
||||||
return detail;
|
|
||||||
}
|
|
||||||
async function resolvePullConflict(action) {
|
|
||||||
const status=qs('#week-pull-conflict-status'),add=qs('#rebase-week-pull'),keep=qs('#keep-week-pull');
|
|
||||||
if(add)add.disabled=true;if(keep)keep.disabled=true;
|
|
||||||
if(status)status.textContent=action==='add'?'Checking current plans…':'Keeping this work in Week Ahead…';
|
|
||||||
try{
|
|
||||||
const result=await controller.resolvePullConflict?.(action);
|
|
||||||
if(!result)return false;
|
|
||||||
adoptPull(result,action==='add'?'Saved transfer added using current plans.':'Work kept in Week Ahead.');
|
|
||||||
if(controller.pullConflict?.())return renderPullConflict();
|
|
||||||
const root=qs('#week-pull-conflict-review');if(root)root.hidden=true;
|
|
||||||
overviewing=true;reviewing=true;setReviewMode(true);
|
|
||||||
const reviewRoot=qs('#week-review');if(reviewRoot){reviewRoot.hidden=false;renderReview();}
|
|
||||||
return result;
|
|
||||||
}catch(error){
|
|
||||||
if(status)status.textContent=error?.code==='today_full'?'Today is full. Finish or move work, then retry.':
|
|
||||||
(error?.code==='today_over_capacity'?'Today would exceed its current capacity. Reopen Week Ahead and review Today first.':
|
|
||||||
(error.message||'Current plans could not be checked.')+' Your saved transfer is still waiting.');
|
|
||||||
return false;
|
|
||||||
}finally{if(add)add.disabled=false;if(keep)keep.disabled=false;}
|
|
||||||
}
|
|
||||||
qs('#rebase-week-pull')?.addEventListener('click',()=>controller.resolvePullConflict&&resolvePullConflict('add'));
|
|
||||||
qs('#keep-week-pull')?.addEventListener('click',()=>controller.resolvePullConflict&&resolvePullConflict('keep'));
|
|
||||||
function resumePull() {
|
function resumePull() {
|
||||||
const restored=controller.resumePull?.();
|
const restored=controller.resumePull?.();
|
||||||
if(!restored)return false;
|
return restored?adoptPull(restored,'Week-to-Today pull saved on this phone · sync pending.'):false;
|
||||||
const adopted=adoptPull(restored,restored.conflict?'Today or Week Ahead changed. Choose how to finish the saved transfer.':
|
|
||||||
'Week-to-Today pull saved on this phone · sync pending.');
|
|
||||||
if(restored.conflict)renderPullConflict({open:true});
|
|
||||||
return adopted;
|
|
||||||
}
|
}
|
||||||
async function flushPull() {
|
async function flushPull() {
|
||||||
const pendingPull=controller.pendingPull?.();
|
const pendingPull=controller.pendingPull?.();
|
||||||
if(!pendingPull)return false;
|
if(!pendingPull)return false;
|
||||||
if(pendingPull.conflict){
|
if(pendingPull.conflict)return adoptPull(pendingPull.current,
|
||||||
const restored=adoptPull(pendingPull.current,'Today or Week Ahead changed on another device. Choose how to finish the saved transfer.');
|
'Today or Week Ahead changed on another device. Saved pull needs review.');
|
||||||
renderPullConflict({open:true});return restored;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const result=await controller.flushPull();
|
const result=await controller.flushPull();
|
||||||
const adopted=adoptPull(result,result.conflict?'Today or Week Ahead changed on another device. Choose how to finish the saved transfer.':
|
return adoptPull(result,result.conflict?'Today or Week Ahead changed on another device. Saved pull needs review.':
|
||||||
'Week Ahead work added to Today.');
|
'Week Ahead work added to Today.');
|
||||||
if(result.conflict)renderPullConflict({open:true});
|
|
||||||
return adopted;
|
|
||||||
} catch(error) {
|
} catch(error) {
|
||||||
qs('#my-work-action-status').textContent=(error.message||'Week-to-Today sync is unavailable.')+' Saved on this phone · sync pending.';
|
qs('#my-work-action-status').textContent=(error.message||'Week-to-Today sync is unavailable.')+' Saved on this phone · sync pending.';
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ import pytest
|
||||||
FRONTEND = Path(__file__).parents[1] / "frontend"
|
FRONTEND = Path(__file__).parents[1] / "frontend"
|
||||||
CONTROLLER = FRONTEND / "week-plan.js"
|
CONTROLLER = FRONTEND / "week-plan.js"
|
||||||
COORDINATOR = FRONTEND / "outbox-coordinator.js"
|
COORDINATOR = FRONTEND / "outbox-coordinator.js"
|
||||||
INDEX = FRONTEND / "index.html"
|
|
||||||
CSS = FRONTEND / "dashboard.css"
|
|
||||||
|
|
||||||
|
|
||||||
def run_controller(scenario: str) -> dict:
|
def run_controller(scenario: str) -> dict:
|
||||||
|
|
@ -22,32 +20,6 @@ const createOutboxCoordinator = require({json.dumps(str(COORDINATOR))});
|
||||||
return json.loads(completed.stdout)
|
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():
|
def test_week_controller_requires_executable_days_before_calendar_handoff():
|
||||||
result = run_controller("""
|
result = run_controller("""
|
||||||
const week=createWeekPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
const week=createWeekPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||||
|
|
@ -542,116 +514,6 @@ 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():
|
def test_week_controller_retires_completed_work_from_every_day_in_one_durable_transition():
|
||||||
result = run_controller("""
|
result = run_controller("""
|
||||||
const values=new Map();let writes=0;
|
const values=new Map();let writes=0;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user