feat: batch-defer mobile search results (Closes #813)
This commit is contained in:
1 parent
3694f2501b
commit
1ef3e291c2
9 files changed
+196
-17
No files matched your search
@@ -155,6 +155,55 @@ first.run([item]).then(firstResult=>{{
|
||||
}
|
||||
|
||||
|
||||
def test_interrupted_batch_restores_operation_context_for_idempotent_recovery():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const values={{}};
|
||||
const storage={{
|
||||
getItem:key=>values[key] || null,
|
||||
setItem:(key,value)=>{{values[key]=value;}},
|
||||
removeItem:key=>{{delete values[key];}},
|
||||
}};
|
||||
const calls=[];
|
||||
const options={{
|
||||
capacity:()=>2, storage, owner:()=> 'timmy', journalName:'search-later-batch',
|
||||
queueFailureReason:'assigned but Later could not be saved',
|
||||
claim:item=>Promise.resolve({{...item,assigned_to_me:true}}),
|
||||
}};
|
||||
const first=createBatchFindWork({{...options, queue:(item,context)=>{{
|
||||
calls.push(['first',item.number,context?.until]);
|
||||
return item.number===813 ? 'queued' : 'unavailable';
|
||||
}}}});
|
||||
const items=[813,814].map(number=>({{repository:'stackchain/dashboard',number}}));
|
||||
first.run(items, {{}}, {{until:'2026-08-15T09:00:00.000Z'}}).then(firstResult=>{{
|
||||
const second=createBatchFindWork({{...options, queue:(item,context)=>{{
|
||||
calls.push(['resume',item.number,context?.until]);
|
||||
return 'queued';
|
||||
}}}});
|
||||
return second.resume().then(resumed=>process.stdout.write(JSON.stringify({{
|
||||
firstResult,resumed,calls,keys:Object.keys(values),
|
||||
}})));
|
||||
}});
|
||||
"""
|
||||
|
||||
result = run_node(script)
|
||||
assert result["firstResult"]["queued"] == ["stackchain/dashboard#813"]
|
||||
assert result["firstResult"]["failed"] == [{
|
||||
"key": "stackchain/dashboard#814",
|
||||
"reason": "assigned but Later could not be saved",
|
||||
"assigned": True,
|
||||
}]
|
||||
assert result["resumed"]["queued"] == [
|
||||
"stackchain/dashboard#813", "stackchain/dashboard#814",
|
||||
]
|
||||
assert result["calls"] == [
|
||||
["first", 813, "2026-08-15T09:00:00.000Z"],
|
||||
["first", 814, "2026-08-15T09:00:00.000Z"],
|
||||
["resume", 814, "2026-08-15T09:00:00.000Z"],
|
||||
]
|
||||
assert result["keys"] == []
|
||||
|
||||
|
||||
def test_resumed_batch_skips_queued_items_and_continues_in_original_order():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
|
||||
@@ -132,3 +132,22 @@ process.stdout.write(JSON.stringify({{first, second, confirmations}}));
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {"first": True, "second": False, "confirmations": 1}
|
||||
|
||||
|
||||
def test_picker_item_can_own_batch_confirmation_and_run_after_close():
|
||||
script = f"""
|
||||
const createLaterPicker = require({json.dumps(str(LATER_PICKER))});
|
||||
const order=[];
|
||||
const picker=createLaterPicker({{
|
||||
now:() => new Date(2026,7,14,8,0),
|
||||
onConfirm:()=>{{order.push('global');return true;}},
|
||||
}});
|
||||
picker.open({{confirm:until=>() => order.push(until.toISOString())}},null,'search-batch');
|
||||
const saved=picker.submit('2026-08-15T09:00');
|
||||
process.stdout.write(JSON.stringify({{saved,order}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"saved": True,
|
||||
"order": ["2026-08-15T09:00:00.000Z"],
|
||||
}
|
||||
@@ -79,10 +79,13 @@ def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls():
|
||||
assert 'id="select-search-results"' in html
|
||||
assert 'id="search-batch-actions"' in html
|
||||
assert 'id="queue-selected-search-results"' in html
|
||||
assert 'id="defer-selected-search-results"' in html
|
||||
assert 'id="resume-search-batch"' in html
|
||||
assert 'src="static/search-batch-plan.js"' in html
|
||||
assert "mountSearchBatchPlanning(" in dashboard
|
||||
assert "escapeHtml, escAttr, laterWork, laterPicker" in dashboard
|
||||
assert "journalName:'search-today-batch'" in controller
|
||||
assert "journalName:'search-later-batch'" in controller
|
||||
assert "autoMount:false" in controller
|
||||
assert "plan.toggle(result)" in controller
|
||||
assert "processor.run(plan.snapshot().items, estimateValues())" in controller
|
||||
@@ -100,6 +103,73 @@ def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls():
|
||||
assert "BASE + 'static/search-batch-plan.js'" in worker
|
||||
|
||||
|
||||
def test_selected_search_issues_choose_one_future_time_before_durable_batch_defer():
|
||||
script = f"""
|
||||
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
|
||||
const listeners={{}};
|
||||
const elements={{}};
|
||||
for (const id of [
|
||||
'search-batch-actions','select-search-results','queue-selected-search-results',
|
||||
'defer-selected-search-results','search-selection-status','cmd-search-action-status',
|
||||
'resume-search-batch','open-palette','cmd-results','cancel-search-selection',
|
||||
'search-batch-estimate-review','search-batch-estimate-summary','search-batch-estimate-list',
|
||||
'cancel-search-batch-estimates','confirm-search-batch-estimates'
|
||||
]) elements['#'+id]={{hidden:false,disabled:false,textContent:'',innerHTML:'',
|
||||
addEventListener:(name,fn)=>listeners[id+':'+name]=fn,focus:()=>{{}}}};
|
||||
const document={{querySelector:selector=>elements[selector] || null,querySelectorAll:()=>[]}};
|
||||
const processorOptions=[];
|
||||
const runs=[];
|
||||
const processors=[];
|
||||
const batchFactory=options=>{{
|
||||
processorOptions.push(options);
|
||||
const processor={{
|
||||
run:(items,estimates,context)=>{{runs.push({{items,estimates,context}});return Promise.resolve();}},
|
||||
resume:()=>Promise.resolve(),pending:()=>0,
|
||||
}};
|
||||
processors.push(processor);
|
||||
return processor;
|
||||
}};
|
||||
const planning={{capacity_minutes:null,estimates:{{}}}};
|
||||
const todayWork={{limit:8,read:()=>[],planning:()=>planning,identity:item=>item.repository+'#'+item.number,
|
||||
replacePlanning:()=>{{}}}};
|
||||
const laterCalls=[];
|
||||
const laterWork={{defer:(item,until)=>{{laterCalls.push([item.number,until]);return 'deferred';}}}};
|
||||
let pickerRequest=null;
|
||||
const laterPicker={{open:(payload,trigger,context)=>{{pickerRequest={{payload,trigger,context}};return true;}}}};
|
||||
const item={{kind:'issue',state:'open',repository:'stackchain/dashboard',number:813,title:'Batch defer'}};
|
||||
const mounted=createSearchBatchPlan.mount(
|
||||
document,batchFactory,todayWork,()=> 'timmy',()=>Promise.resolve(),()=>'',()=>Promise.resolve('queued'),
|
||||
{{enqueueConfiguration:()=>{{}},flush:()=>{{}}}},value=>({{...value,accepted:true}}),()=>item,()=>{{}},
|
||||
value=>value,value=>value,laterWork,laterPicker
|
||||
);
|
||||
mounted.plan.start();
|
||||
mounted.plan.toggle(item);
|
||||
listeners['defer-selected-search-results:click']({{currentTarget:elements['#defer-selected-search-results']}});
|
||||
const before={{runs:runs.slice(),count:pickerRequest.payload.items.length,context:pickerRequest.context,journals:processorOptions.map(value=>value.journalName)}};
|
||||
pickerRequest.payload.confirm(new Date('2026-08-15T09:00:00.000Z'))();
|
||||
const laterResult=processorOptions[1].queue({{...item,assigned_to_me:true}},{{until:'2026-08-15T09:00:00.000Z'}});
|
||||
process.stdout.write(JSON.stringify({{before,runs,laterResult,laterCalls}}));
|
||||
"""
|
||||
|
||||
result = run_node(script)
|
||||
assert result["before"] == {
|
||||
"runs": [],
|
||||
"count": 1,
|
||||
"context": "search-batch",
|
||||
"journals": ["search-today-batch", "search-later-batch"],
|
||||
}
|
||||
assert result["runs"] == [{
|
||||
"items": [{
|
||||
"kind": "issue", "state": "open", "repository": "stackchain/dashboard",
|
||||
"number": 813, "title": "Batch defer",
|
||||
}],
|
||||
"estimates": {},
|
||||
"context": {"until": "2026-08-15T09:00:00.000Z"},
|
||||
}]
|
||||
assert result["laterResult"] == "queued"
|
||||
assert result["laterCalls"] == [[813, "2026-08-15T09:00:00.000Z"]]
|
||||
|
||||
|
||||
def test_search_batch_reviews_time_estimates_before_any_assignment_and_persists_queued_estimates():
|
||||
script = f"""
|
||||
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
|
||||
@@ -107,6 +177,7 @@ const listeners={{}};
|
||||
const elements={{}};
|
||||
for (const id of [
|
||||
'search-batch-actions','select-search-results','queue-selected-search-results',
|
||||
'defer-selected-search-results',
|
||||
'search-selection-status','cmd-search-action-status','resume-search-batch',
|
||||
'open-palette','cmd-results','cancel-search-selection','search-batch-estimate-review',
|
||||
'search-batch-estimate-summary','search-batch-estimate-list',
|
||||
@@ -122,12 +193,12 @@ const document={{
|
||||
querySelectorAll:selector=>selector==='[data-search-batch-estimate]' ? estimateInputs : [],
|
||||
}};
|
||||
const calls=[];
|
||||
let processorOptions;
|
||||
const processorOptions=[];
|
||||
const processor={{
|
||||
run:(items,estimates)=>{{calls.push({{type:'run',items,estimates}});return Promise.resolve();}},
|
||||
resume:()=>Promise.resolve(),pending:()=>0,
|
||||
}};
|
||||
const batchFactory=options=>{{processorOptions=options;return processor;}};
|
||||
const batchFactory=options=>{{processorOptions.push(options);return processor;}};
|
||||
const planning={{capacity_minutes:90,estimates:{{'stackchain/existing#1':20}}}};
|
||||
const todayWork={{limit:8,read:()=>[],planning:()=>planning,identity:item=>item.repository+'#'+item.number,
|
||||
replacePlanning:value=>Object.assign(planning,value)}};
|
||||
@@ -149,11 +220,11 @@ const before={{
|
||||
reviewHidden:elements['#search-batch-estimate-review'].hidden,
|
||||
actionsHidden:elements['#search-batch-actions'].hidden,
|
||||
markup:elements['#search-batch-estimate-list'].innerHTML,
|
||||
budget:processorOptions.timeBudget(),
|
||||
budget:processorOptions[0].timeBudget(),
|
||||
}};
|
||||
listeners['confirm-search-batch-estimates:click']();
|
||||
processorOptions.persistEstimate(items[0],25);
|
||||
processorOptions.onProgress({{status:'complete',queued:['stackchain/dashboard#811','stackchain/api#12'],failed:[]}});
|
||||
processorOptions[0].persistEstimate(items[0],25);
|
||||
processorOptions[0].onProgress({{status:'complete',queued:['stackchain/dashboard#811','stackchain/api#12'],failed:[]}});
|
||||
const afterCompleteHidden=elements['#search-batch-estimate-review'].hidden;
|
||||
process.stdout.write(JSON.stringify({{before,calls,afterCompleteHidden}}));
|
||||
"""
|
||||
|
||||
Reference in new issue
Block a user