feat: preserve Search work details offline (Closes #1220)
This commit is contained in:
parent
ba50784022
commit
2b874d8427
|
|
@ -133,7 +133,10 @@
|
||||||
const existing = week.placement(id);
|
const existing = week.placement(id);
|
||||||
if (!week.place(id, choice.date, Number(choice.estimate), {move:Boolean(existing && existing.date !== choice.date)})) {
|
if (!week.place(id, choice.date, Number(choice.estimate), {move:Boolean(existing && existing.date !== choice.date)})) {
|
||||||
failed.push({id:originalId, reason:'assigned-not-planned'});
|
failed.push({id:originalId, reason:'assigned-not-planned'});
|
||||||
} else planned.push(id);
|
} else {
|
||||||
|
week.rememberPendingItem?.(id, confirmed);
|
||||||
|
planned.push(id);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failed.push({id:originalId, reason:error?.message || 'assignment failed'});
|
failed.push({id:originalId, reason:error?.message || 'assignment failed'});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ function createSearchWeekPlan({week,claim,accept=item=>item,identity,maxItems=5}
|
||||||
const id=identity(item);
|
const id=identity(item);
|
||||||
if(!week.place(id,options.date,estimate,{move:Boolean(options.move)}))
|
if(!week.place(id,options.date,estimate,{move:Boolean(options.move)}))
|
||||||
return {status:assigned?'assigned-not-planned':'not-planned',item};
|
return {status:assigned?'assigned-not-planned':'not-planned',item};
|
||||||
|
week.rememberPendingItem?.(id,item);
|
||||||
try {
|
try {
|
||||||
await week.flush();
|
await week.flush();
|
||||||
return {status:'planned',date:options.date,estimate,item};
|
return {status:'planned',date:options.date,estimate,item};
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,23 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
|
||||||
let offlineSnapshot=false;
|
let offlineSnapshot=false;
|
||||||
let refreshedAt=null;
|
let refreshedAt=null;
|
||||||
let confirmedItems={};
|
let confirmedItems={};
|
||||||
|
let pendingItems={};
|
||||||
const storagePrefix='stackchain.week-sync.v1.';
|
const storagePrefix='stackchain.week-sync.v1.';
|
||||||
const confirmedPrefix='stackchain.week-confirmed.v1.';
|
const confirmedPrefix='stackchain.week-confirmed.v1.';
|
||||||
const cloneDay=day=>({
|
const cloneDay=day=>({
|
||||||
plan_date:day.plan_date,ids:[...(day.ids||[])],capacity_minutes:day.capacity_minutes??null,
|
plan_date:day.plan_date,ids:[...(day.ids||[])],capacity_minutes:day.capacity_minutes??null,
|
||||||
estimates:{...(day.estimates||{})},
|
estimates:{...(day.estimates||{})},
|
||||||
});
|
});
|
||||||
|
const sanitizeItem=value=>value?{kind:value.kind==='pull'?'pull':'issue',
|
||||||
|
title:String(value.title||'').slice(0,500),repository:String(value.repository||'').slice(0,255),
|
||||||
|
number:Number.isInteger(value.number)?value.number:null}:null;
|
||||||
|
function sanitizeItems(days,sources={}) {
|
||||||
|
const sanitized={};
|
||||||
|
new Set((days||[]).flatMap(day=>day.ids||[])).forEach(id=>{
|
||||||
|
const value=sanitizeItem(sources[id]);if(value)sanitized[id]=value;
|
||||||
|
});
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
const state=()=>({revision:week.revision,timezone:week.timezone,days:week.days.map(cloneDay),
|
const state=()=>({revision:week.revision,timezone:week.timezone,days:week.days.map(cloneDay),
|
||||||
offline_snapshot:offlineSnapshot,...(refreshedAt?{refreshed_at:refreshedAt}:{}),
|
offline_snapshot:offlineSnapshot,...(refreshedAt?{refreshed_at:refreshedAt}:{}),
|
||||||
...(week.sync_pending?{base_revision:week.base_revision,sync_pending:true}:{})});
|
...(week.sync_pending?{base_revision:week.base_revision,sync_pending:true}:{})});
|
||||||
|
|
@ -41,14 +52,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
|
||||||
catch(_error){return false;}
|
catch(_error){return false;}
|
||||||
}
|
}
|
||||||
function rememberItems(items={}) {
|
function rememberItems(items={}) {
|
||||||
const ids=new Set(week.days.flatMap(day=>day.ids||[])),sanitized={};
|
confirmedItems=sanitizeItems(week.days,{...confirmedItems,...items});
|
||||||
ids.forEach(id=>{
|
|
||||||
const value=items[id];
|
|
||||||
if(!value)return;
|
|
||||||
sanitized[id]={kind:value.kind==='pull'?'pull':'issue',title:String(value.title||'').slice(0,500),
|
|
||||||
repository:String(value.repository||'').slice(0,255),number:Number.isInteger(value.number)?value.number:null};
|
|
||||||
});
|
|
||||||
confirmedItems=sanitized;
|
|
||||||
return persistConfirmed();
|
return persistConfirmed();
|
||||||
}
|
}
|
||||||
function pending() {
|
function pending() {
|
||||||
|
|
@ -59,6 +63,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
|
||||||
return Number.isInteger(value?.base_revision)&&Array.isArray(value?.days)?{
|
return Number.isInteger(value?.base_revision)&&Array.isArray(value?.days)?{
|
||||||
revision:value.base_revision,base_revision:value.base_revision,timezone:value.timezone||null,
|
revision:value.base_revision,base_revision:value.base_revision,timezone:value.timezone||null,
|
||||||
days:value.days.map(cloneDay).sort((left,right)=>left.plan_date.localeCompare(right.plan_date)),sync_pending:true,
|
days:value.days.map(cloneDay).sort((left,right)=>left.plan_date.localeCompare(right.plan_date)),sync_pending:true,
|
||||||
|
items:sanitizeItems(value.days,value.items||{}),
|
||||||
...(Array.isArray(value.base_days)?{base_days:value.base_days.map(cloneDay)
|
...(Array.isArray(value.base_days)?{base_days:value.base_days.map(cloneDay)
|
||||||
.sort((left,right)=>left.plan_date.localeCompare(right.plan_date))}:{}),
|
.sort((left,right)=>left.plan_date.localeCompare(right.plan_date))}:{}),
|
||||||
...(value.resolutions&&typeof value.resolutions==='object'?{resolutions:{...value.resolutions}}:{}),
|
...(value.resolutions&&typeof value.resolutions==='object'?{resolutions:{...value.resolutions}}:{}),
|
||||||
|
|
@ -70,12 +75,13 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
|
||||||
week={revision:value.revision,timezone:value.timezone||null,days:value.days.map(cloneDay)
|
week={revision:value.revision,timezone:value.timezone||null,days:value.days.map(cloneDay)
|
||||||
.sort((left,right)=>left.plan_date.localeCompare(right.plan_date))};
|
.sort((left,right)=>left.plan_date.localeCompare(right.plan_date))};
|
||||||
offlineSnapshot=false;
|
offlineSnapshot=false;
|
||||||
|
pendingItems={};
|
||||||
lastConflict=null;
|
lastConflict=null;
|
||||||
return state();
|
return state();
|
||||||
}
|
}
|
||||||
function adoptConfirmed(value) {
|
function adoptConfirmed(value,items=confirmedItems) {
|
||||||
const adopted=adopt(value);
|
const adopted=adopt(value);
|
||||||
if(adopted)persistConfirmed();
|
if(adopted){confirmedItems=sanitizeItems(week.days,items);persistConfirmed();}
|
||||||
return adopted;
|
return adopted;
|
||||||
}
|
}
|
||||||
function addDays(value, amount) {
|
function addDays(value, amount) {
|
||||||
|
|
@ -101,16 +107,21 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
|
||||||
}
|
}
|
||||||
async function load() {
|
async function load() {
|
||||||
const queued=pending();
|
const queued=pending();
|
||||||
if(queued){week=queued;offlineSnapshot=false;return state();}
|
if(queued){
|
||||||
|
week=queued;pendingItems=queued.items||{};
|
||||||
|
confirmedItems=sanitizeItems(week.days,readConfirmed()?.items||{});
|
||||||
|
offlineSnapshot=false;return state();
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
adopt(await fetchJson('api/v1/week'));
|
adopt(await fetchJson('api/v1/week'));
|
||||||
const cached=readConfirmed();confirmedItems=cached?.items||{};persistConfirmed();
|
const cached=readConfirmed();confirmedItems=sanitizeItems(week.days,cached?.items||{});persistConfirmed();
|
||||||
return state();
|
return state();
|
||||||
} catch(error) {
|
} catch(error) {
|
||||||
if(error?.status===401||error?.status===403)throw error;
|
if(error?.status===401||error?.status===403)throw error;
|
||||||
const cached=readConfirmed();
|
const cached=readConfirmed();
|
||||||
if(!cached)throw error;
|
if(!cached)throw error;
|
||||||
adopt(cached.week);offlineSnapshot=true;refreshedAt=cached.refreshed_at;confirmedItems=cached.items||{};
|
adopt(cached.week);offlineSnapshot=true;refreshedAt=cached.refreshed_at;
|
||||||
|
confirmedItems=sanitizeItems(week.days,cached.items||{});
|
||||||
return state();
|
return state();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -121,12 +132,27 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
|
||||||
const queued={base_revision:Number.isInteger(week.base_revision)?week.base_revision:week.revision,
|
const queued={base_revision:Number.isInteger(week.base_revision)?week.base_revision:week.revision,
|
||||||
timezone:timeZone(),days:days.map(cloneDay).sort((left,right)=>left.plan_date.localeCompare(right.plan_date)),
|
timezone:timeZone(),days:days.map(cloneDay).sort((left,right)=>left.plan_date.localeCompare(right.plan_date)),
|
||||||
base_days:(week.base_days||week.days).map(cloneDay)};
|
base_days:(week.base_days||week.days).map(cloneDay)};
|
||||||
|
queued.items=sanitizeItems(queued.days,{...confirmedItems,...pendingItems,...(pending()?.items||{})});
|
||||||
try { storage.setItem(key,JSON.stringify(queued)); }
|
try { storage.setItem(key,JSON.stringify(queued)); }
|
||||||
catch(_error) { return false; }
|
catch(_error) { return false; }
|
||||||
lastConflict=null;
|
lastConflict=null;
|
||||||
week={revision:queued.base_revision,...queued,sync_pending:true};
|
week={revision:queued.base_revision,...queued,sync_pending:true};
|
||||||
|
pendingItems=queued.items;
|
||||||
return state();
|
return state();
|
||||||
}
|
}
|
||||||
|
function rememberPendingItem(id,value) {
|
||||||
|
const key=storageKey(),item=sanitizeItem(value);
|
||||||
|
if(!key||!storage||!item)return false;
|
||||||
|
try {
|
||||||
|
const raw=JSON.parse(storage.getItem(key)||'null');
|
||||||
|
if(!Number.isInteger(raw?.base_revision)||!Array.isArray(raw?.days)||
|
||||||
|
!raw.days.some(day=>(day.ids||[]).includes(id)))return false;
|
||||||
|
raw.items=sanitizeItems(raw.days,{...(raw.items||{}),[id]:item});
|
||||||
|
storage.setItem(key,JSON.stringify(raw));pendingItems=raw.items;
|
||||||
|
if(week.sync_pending)week={...week,items:raw.items};
|
||||||
|
return true;
|
||||||
|
} catch(_error){return false;}
|
||||||
|
}
|
||||||
function stageDay(planDate,value) {
|
function stageDay(planDate,value) {
|
||||||
return stageDays(week.days.filter(item=>item.plan_date!==planDate).concat([{
|
return stageDays(week.days.filter(item=>item.plan_date!==planDate).concat([{
|
||||||
plan_date:planDate,ids:[...(value.ids||[])],capacity_minutes:value.capacity_minutes??null,
|
plan_date:planDate,ids:[...(value.ids||[])],capacity_minutes:value.capacity_minutes??null,
|
||||||
|
|
@ -244,7 +270,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
|
||||||
body:JSON.stringify({base_revision:remote.revision,timezone:local.timezone,days:merged.days})});
|
body:JSON.stringify({base_revision:remote.revision,timezone:local.timezone,days:merged.days})});
|
||||||
const current=pending();
|
const current=pending();
|
||||||
if(current&&sameBody(current,deliveryBody(local)))storage.removeItem(key);
|
if(current&&sameBody(current,deliveryBody(local)))storage.removeItem(key);
|
||||||
if(!pending())adoptConfirmed(saved);
|
if(!pending())adoptConfirmed(saved,{...confirmedItems,...(local.items||{})});
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
lastConflict={key,local,remote:{revision:remote.revision,timezone:remote.timezone||null,
|
lastConflict={key,local,remote:{revision:remote.revision,timezone:remote.timezone||null,
|
||||||
|
|
@ -257,7 +283,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
|
||||||
}
|
}
|
||||||
const current=pending();
|
const current=pending();
|
||||||
if(current&&sameBody(current,body)){
|
if(current&&sameBody(current,body)){
|
||||||
storage.removeItem(key);adoptConfirmed(saved);return saved;
|
storage.removeItem(key);adoptConfirmed(saved,{...confirmedItems,...(current.items||{})});return saved;
|
||||||
}
|
}
|
||||||
if(current){
|
if(current){
|
||||||
try {
|
try {
|
||||||
|
|
@ -265,10 +291,11 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
|
||||||
raw.base_revision=saved.revision;raw.base_days=saved.days.map(cloneDay);
|
raw.base_revision=saved.revision;raw.base_days=saved.days.map(cloneDay);
|
||||||
storage.setItem(key,JSON.stringify(raw));
|
storage.setItem(key,JSON.stringify(raw));
|
||||||
week={revision:raw.base_revision,...raw,sync_pending:true};
|
week={revision:raw.base_revision,...raw,sync_pending:true};
|
||||||
|
pendingItems=raw.items||{};
|
||||||
} catch(_error){return saved;}
|
} catch(_error){return saved;}
|
||||||
return deliver();
|
return deliver();
|
||||||
}
|
}
|
||||||
adoptConfirmed(saved);return saved;
|
adoptConfirmed(saved,{...confirmedItems,...(queued.items||{})});return saved;
|
||||||
};
|
};
|
||||||
flushing=deliver().finally(()=>{flushing=null;});
|
flushing=deliver().finally(()=>{flushing=null;});
|
||||||
return flushing;
|
return flushing;
|
||||||
|
|
@ -329,7 +356,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
|
||||||
const saved=await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
const saved=await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||||
const current=pending();
|
const current=pending();
|
||||||
if(current&&JSON.stringify(deliveryBody(current))===JSON.stringify(deliveryBody(local))) storage.removeItem(storageKey());
|
if(current&&JSON.stringify(deliveryBody(current))===JSON.stringify(deliveryBody(local))) storage.removeItem(storageKey());
|
||||||
if(!pending()) adoptConfirmed(saved);
|
if(!pending()) adoptConfirmed(saved,{...confirmedItems,...(local.items||{})});
|
||||||
lastConflict=null;
|
lastConflict=null;
|
||||||
return saved;
|
return saved;
|
||||||
} catch(error) {
|
} catch(error) {
|
||||||
|
|
@ -350,7 +377,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
|
||||||
const saved=await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
const saved=await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||||
const current=pending();
|
const current=pending();
|
||||||
if(current&&JSON.stringify(deliveryBody(current))===JSON.stringify(deliveryBody(local))) storage.removeItem(storageKey());
|
if(current&&JSON.stringify(deliveryBody(current))===JSON.stringify(deliveryBody(local))) storage.removeItem(storageKey());
|
||||||
if(!pending()) adoptConfirmed(saved);
|
if(!pending()) adoptConfirmed(saved,{...confirmedItems,...(local.items||{})});
|
||||||
lastConflict=null;
|
lastConflict=null;
|
||||||
return saved;
|
return saved;
|
||||||
} catch(error) {
|
} catch(error) {
|
||||||
|
|
@ -398,7 +425,8 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D
|
||||||
return label+(pending()?' · sync pending':'');
|
return label+(pending()?' · sync pending':'');
|
||||||
}
|
}
|
||||||
return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,review,move,placement,place,pending,flush,conflict,chooseDay,saveMerged,
|
return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,review,move,placement,place,pending,flush,conflict,chooseDay,saveMerged,
|
||||||
keepLocal,useRemote,promote,reconcile:reconcilePromotion,summary,rememberItems,item:id=>confirmedItems[id]||null,
|
keepLocal,useRemote,promote,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem,
|
||||||
|
item:id=>pendingItems[id]||confirmedItems[id]||null,
|
||||||
offline:()=>offlineSnapshot};
|
offline:()=>offlineSnapshot};
|
||||||
}
|
}
|
||||||
function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPlanner,setReviewMode=()=>{},escapeHtml,escapeAttribute,
|
function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPlanner,setReviewMode=()=>{},escapeHtml,escapeAttribute,
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,30 @@ const flow=createWeekBatchPlan({{
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_week_batch_stages_details_only_for_successfully_placed_canonical_items():
|
||||||
|
script = f"""
|
||||||
|
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
|
||||||
|
const flow=createSearchBatchPlan.createWeekBatchPlan({{
|
||||||
|
week:{{load:async()=>{{}},dates:()=>[{{date:'2026-08-21'}}],day:()=>({{ids:[],estimates:{{}}}}),
|
||||||
|
placement:()=>null,place:id=>!id.endsWith('#2'),
|
||||||
|
rememberPendingItem:(id,item)=>calls.push(['details',id,item.title]),flush:async()=>calls.push(['flush'])}},
|
||||||
|
prepare:async item=>({{...item,title:'Canonical '+item.number}}),
|
||||||
|
identity:item=>item.repository+'#'+item.number,
|
||||||
|
}});
|
||||||
|
const calls=[];const items=[
|
||||||
|
{{repository:'r',number:1,title:'Search one'}},{{repository:'r',number:2,title:'Search two'}}];
|
||||||
|
(async()=>{{const outcome=await flow.run(items,{{'r#1':{{date:'2026-08-21',estimate:20}},'r#2':{{date:'2026-08-21',estimate:30}}}});
|
||||||
|
process.stdout.write(JSON.stringify({{outcome,calls}}));}})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = run_node(script)
|
||||||
|
assert result["outcome"] == {
|
||||||
|
"status": "partial", "planned": 1,
|
||||||
|
"failed": [{"id": "r#2", "reason": "assigned-not-planned"}],
|
||||||
|
}
|
||||||
|
assert result["calls"] == [["details", "r#1", "Canonical 1"], ["flush"]]
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls():
|
def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls():
|
||||||
html = HTML.read_text()
|
html = HTML.read_text()
|
||||||
dashboard = DASHBOARD.read_text()
|
dashboard = DASHBOARD.read_text()
|
||||||
|
|
|
||||||
|
|
@ -99,3 +99,26 @@ console.log(JSON.stringify(outcome));
|
||||||
""")
|
""")
|
||||||
|
|
||||||
assert result == {"status": "planned-pending", "date": "2026-08-22", "estimate": 45}
|
assert result == {"status": "planned-pending", "date": "2026-08-22", "estimate": 45}
|
||||||
|
|
||||||
|
|
||||||
|
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"],
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -577,6 +577,58 @@ console.log(JSON.stringify({loaded,restored,item:fallback.item('issue:r:42:'),ot
|
||||||
assert "secret" not in result["snapshot"]["items"]["issue:r:42:"]
|
assert "secret" not in result["snapshot"]["items"]["issue:r:42:"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_week_controller_promotes_bounded_pending_work_details_after_matching_receipt():
|
||||||
|
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 online=true;const requests=[];
|
||||||
|
const options={storage,getLogin:()=> 'timmy',now:()=>Date.parse('2026-08-20T12:34:00Z'),
|
||||||
|
fetchJson:async(_url,request={})=>{
|
||||||
|
requests.push(request.body?JSON.parse(request.body):null);
|
||||||
|
if(!online)throw new Error('offline');
|
||||||
|
if(request.method==='PUT')return {revision:8,timezone:'UTC',days:JSON.parse(request.body).days};
|
||||||
|
return {revision:7,timezone:'UTC',days:[]};
|
||||||
|
},localDate:()=> '2026-08-20',timeZone:()=> 'UTC'};
|
||||||
|
const live=createWeekPlan(options);await live.load();
|
||||||
|
live.place('issue:r:42:','2026-08-21',45);
|
||||||
|
const admitted=live.rememberPendingItem('issue:r:42:',{kind:'issue',title:'T'.repeat(600),repository:'r'.repeat(300),
|
||||||
|
number:42,body:'private body',token:'drop'});
|
||||||
|
const queued=live.pending();
|
||||||
|
await live.flush();
|
||||||
|
const confirmed=JSON.parse(values.get('stackchain.week-confirmed.v1.timmy'));
|
||||||
|
online=false;const offline=createWeekPlan(options);const restored=await offline.load();
|
||||||
|
console.log(JSON.stringify({admitted,queued,confirmed,restored,item:offline.item('issue:r:42:'),requests}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result["admitted"] is True
|
||||||
|
assert result["queued"]["items"]["issue:r:42:"]["title"] == "T" * 500
|
||||||
|
assert len(result["queued"]["items"]["issue:r:42:"]["repository"]) == 255
|
||||||
|
assert result["requests"][1].keys() == {"base_revision", "timezone", "days"}
|
||||||
|
assert result["confirmed"]["week"]["revision"] == 8
|
||||||
|
assert result["confirmed"]["items"]["issue:r:42:"] == result["item"]
|
||||||
|
assert result["item"]["number"] == 42
|
||||||
|
assert "body" not in result["item"] and "token" not in result["item"]
|
||||||
|
assert result["restored"]["offline_snapshot"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_week_controller_keeps_pending_work_details_across_a_cold_offline_reload():
|
||||||
|
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 options={storage,getLogin:()=> 'timmy',fetchJson:async()=>{throw new Error('offline');},
|
||||||
|
localDate:()=> '2026-08-20',timeZone:()=> 'UTC'};
|
||||||
|
const first=createWeekPlan(options);first.adopt({revision:7,timezone:'UTC',days:[]});
|
||||||
|
first.place('issue:r:42:','2026-08-21',45);
|
||||||
|
first.rememberPendingItem('issue:r:42:',{kind:'issue',title:'Pending work',repository:'r',number:42});
|
||||||
|
const restored=createWeekPlan(options);const state=await restored.load();
|
||||||
|
console.log(JSON.stringify({state,item:restored.item('issue:r:42:'),pending:restored.pending()}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result["state"]["sync_pending"] is True
|
||||||
|
assert result["item"] == {"kind": "issue", "title": "Pending work", "repository": "r", "number": 42}
|
||||||
|
assert result["pending"]["items"]["issue:r:42:"] == result["item"]
|
||||||
|
|
||||||
|
|
||||||
def test_week_controller_does_not_use_a_confirmed_snapshot_after_authentication_fails():
|
def test_week_controller_does_not_use_a_confirmed_snapshot_after_authentication_fails():
|
||||||
result = run_controller("""
|
result = run_controller("""
|
||||||
const values=new Map();
|
const values=new Map();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user