feat: batch-defer mobile search results (Closes #813)
This commit is contained in:
parent
3694f2501b
commit
1ef3e291c2
|
|
@ -8,6 +8,7 @@ function createBatchFindWork({
|
||||||
storage = typeof localStorage === 'undefined' ? null : localStorage,
|
storage = typeof localStorage === 'undefined' ? null : localStorage,
|
||||||
owner = () => '',
|
owner = () => '',
|
||||||
journalName = 'find-work-batch',
|
journalName = 'find-work-batch',
|
||||||
|
queueFailureReason = 'assigned but Today sync is unavailable',
|
||||||
autoMount = true,
|
autoMount = true,
|
||||||
}) {
|
}) {
|
||||||
let request = null;
|
let request = null;
|
||||||
|
|
@ -58,7 +59,7 @@ function createBatchFindWork({
|
||||||
entry.state = 'assigned';
|
entry.state = 'assigned';
|
||||||
writeJournal(journal);
|
writeJournal(journal);
|
||||||
}
|
}
|
||||||
const queueResult = await queue(confirmed);
|
const queueResult = await queue(confirmed, journal.context);
|
||||||
if (queueResult === 'queued' || queueResult === 'exists') {
|
if (queueResult === 'queued' || queueResult === 'exists') {
|
||||||
queued.push(key(item));
|
queued.push(key(item));
|
||||||
entry.state = 'queued';
|
entry.state = 'queued';
|
||||||
|
|
@ -67,7 +68,7 @@ function createBatchFindWork({
|
||||||
}
|
}
|
||||||
writeJournal(journal);
|
writeJournal(journal);
|
||||||
} else {
|
} else {
|
||||||
failed.push({ key: key(item), reason: 'assigned but Today sync is unavailable', assigned: true });
|
failed.push({ key: key(item), reason: queueFailureReason, assigned: true });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failed.push({ key: key(item), reason: error?.message || 'assignment failed' });
|
failed.push({ key: key(item), reason: error?.message || 'assignment failed' });
|
||||||
|
|
@ -81,7 +82,7 @@ function createBatchFindWork({
|
||||||
return outcome;
|
return outcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
function run(items, estimates = {}) {
|
function run(items, estimates = {}, context = null) {
|
||||||
if (request) return request;
|
if (request) return request;
|
||||||
const selected = Array.isArray(items) ? items.slice() : [];
|
const selected = Array.isArray(items) ? items.slice() : [];
|
||||||
const available = Math.max(0, Number(capacity()) || 0);
|
const available = Math.max(0, Number(capacity()) || 0);
|
||||||
|
|
@ -112,7 +113,7 @@ function createBatchFindWork({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const journal = {
|
const journal = {
|
||||||
owner: String(owner() || ''), available,
|
owner: String(owner() || ''), available, context,
|
||||||
items: selected.map(item => ({ item, estimate: estimates[key(item)] || null, state: 'pending' })),
|
items: selected.map(item => ({ item, estimate: estimates[key(item)] || null, state: 'pending' })),
|
||||||
};
|
};
|
||||||
writeJournal(journal);
|
writeJournal(journal);
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.cmd-select-result { min-height:44px; width:100%; display:grid; grid-template-columns:28px 1fr auto; gap:10px; align-items:center; text-align:left; }
|
.cmd-select-result { min-height:44px; width:100%; display:grid; grid-template-columns:28px 1fr auto; gap:10px; align-items:center; text-align:left; }
|
||||||
.cmd-select-result input { width:22px; height:22px; }
|
.cmd-select-result input { width:22px; height:22px; }
|
||||||
.cmd-select-result[disabled] { cursor:not-allowed; opacity:.62; }
|
.cmd-select-result[disabled] { cursor:not-allowed; opacity:.62; }
|
||||||
.search-batch-actions { position:sticky; bottom:0; z-index:3; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0 calc(10px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; }
|
.search-batch-actions { position:sticky; bottom:0; z-index:3; display:grid; grid-template-columns:1fr 1fr 1fr; gap:8px; padding:10px 0 calc(10px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; }
|
||||||
.search-batch-actions[hidden] { display:none; }
|
.search-batch-actions[hidden] { display:none; }
|
||||||
.search-batch-actions span { grid-column:1 / -1; }
|
.search-batch-actions span { grid-column:1 / -1; }
|
||||||
.search-batch-actions button, .search-batch-recovery { min-height:44px; }
|
.search-batch-actions button, .search-batch-recovery { min-height:44px; }
|
||||||
|
|
|
||||||
|
|
@ -4424,8 +4424,8 @@
|
||||||
});
|
});
|
||||||
searchBatchPlanning = mountSearchBatchPlanning(
|
searchBatchPlanning = mountSearchBatchPlanning(
|
||||||
document, createBatchFindWork, todayWork, ()=>planningOwnerLogin, fetchReviewJson,
|
document, createBatchFindWork, todayWork, ()=>planningOwnerLogin, fetchReviewJson,
|
||||||
searchPreviewPath, queueToday, todaySync, acceptClaimedIssue, index=>commandItems[index]?.result,
|
searchPreviewPath, queueToday, todaySync, acceptClaimedIssue, i=>commandItems[i]?.result,
|
||||||
()=>renderCommands(qs('#cmd-input').value), escapeHtml, escAttr
|
()=>renderCommands(qs('#cmd-input').value), escapeHtml, escAttr, laterWork, laterPicker
|
||||||
);
|
);
|
||||||
searchDefer = createSearchDefer({
|
searchDefer = createSearchDefer({
|
||||||
claim: detail => searchPreview.claim(detail),
|
claim: detail => searchPreview.claim(detail),
|
||||||
|
|
|
||||||
|
|
@ -458,6 +458,7 @@
|
||||||
<div class="search-batch-actions" id="search-batch-actions" hidden>
|
<div class="search-batch-actions" id="search-batch-actions" hidden>
|
||||||
<span id="search-selection-status" class="small" aria-live="polite">No issues selected.</span>
|
<span id="search-selection-status" class="small" aria-live="polite">No issues selected.</span>
|
||||||
<button id="cancel-search-selection" type="button">Cancel</button>
|
<button id="cancel-search-selection" type="button">Cancel</button>
|
||||||
|
<button id="defer-selected-search-results" type="button" disabled>Defer</button>
|
||||||
<button id="queue-selected-search-results" type="button" disabled>Assign & add to Today</button>
|
<button id="queue-selected-search-results" type="button" disabled>Assign & add to Today</button>
|
||||||
</div>
|
</div>
|
||||||
<section class="search-batch-estimate-review" id="search-batch-estimate-review"
|
<section class="search-batch-estimate-review" id="search-batch-estimate-review"
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,8 @@ function createLaterPicker({
|
||||||
onState({ open:true, message:result.message, value:String(input || '') });
|
onState({ open:true, message:result.message, value:String(input || '') });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const confirmed = onConfirm(item, result.value, context);
|
const confirmed = typeof item?.confirm === 'function' ? item.confirm(result.value) :
|
||||||
|
onConfirm(item, result.value, context);
|
||||||
if (confirmed !== true && typeof confirmed !== 'function') return false;
|
if (confirmed !== true && typeof confirmed !== 'function') return false;
|
||||||
committed = true;
|
committed = true;
|
||||||
afterClose = typeof confirmed === 'function' ? confirmed : null;
|
afterClose = typeof confirmed === 'function' ? confirmed : null;
|
||||||
|
|
|
||||||
|
|
@ -62,10 +62,12 @@
|
||||||
|
|
||||||
createSearchBatchPlan.mount = function mountSearchBatchPlanning(
|
createSearchBatchPlan.mount = function mountSearchBatchPlanning(
|
||||||
document, batchFactory, todayWork, getOwner, fetchJson, previewPath, queueToday,
|
document, batchFactory, todayWork, getOwner, fetchJson, previewPath, queueToday,
|
||||||
todaySync, acceptIssue, lookup, render, escapeHtml, escapeAttribute
|
todaySync, acceptIssue, lookup, render, escapeHtml, escapeAttribute,
|
||||||
|
laterWork = null, laterPicker = null
|
||||||
) {
|
) {
|
||||||
const get = selector => document.querySelector(selector);
|
const get = selector => document.querySelector(selector);
|
||||||
let processor;
|
let processor;
|
||||||
|
let laterProcessor;
|
||||||
const plan = createSearchBatchPlan({
|
const plan = createSearchBatchPlan({
|
||||||
resolve:item => fetchJson(previewPath(item), {headers:{Accept:'application/json'}}),
|
resolve:item => fetchJson(previewPath(item), {headers:{Accept:'application/json'}}),
|
||||||
claim:detail => fetchJson(
|
claim:detail => fetchJson(
|
||||||
|
|
@ -77,6 +79,7 @@
|
||||||
get('#search-batch-actions').hidden = !state.active;
|
get('#search-batch-actions').hidden = !state.active;
|
||||||
get('#select-search-results').hidden = state.active;
|
get('#select-search-results').hidden = state.active;
|
||||||
get('#queue-selected-search-results').disabled = state.count === 0;
|
get('#queue-selected-search-results').disabled = state.count === 0;
|
||||||
|
get('#defer-selected-search-results').disabled = state.count === 0;
|
||||||
get('#search-selection-status').textContent = state.count ?
|
get('#search-selection-status').textContent = state.count ?
|
||||||
state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No issues selected.';
|
state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No issues selected.';
|
||||||
render();
|
render();
|
||||||
|
|
@ -125,6 +128,30 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
laterProcessor = batchFactory({
|
||||||
|
capacity:() => plan.limit,
|
||||||
|
owner:getOwner,
|
||||||
|
journalName:'search-later-batch',
|
||||||
|
queueFailureReason:'assigned but Later could not be saved',
|
||||||
|
autoMount:false,
|
||||||
|
claim:item => plan.prepare(item),
|
||||||
|
queue:(confirmed, context) => {
|
||||||
|
const outcome = laterWork?.defer(acceptIssue(confirmed), context?.until);
|
||||||
|
return outcome === 'deferred' ? 'queued' : outcome;
|
||||||
|
},
|
||||||
|
onProgress:progress => {
|
||||||
|
get('#defer-selected-search-results').disabled = progress.status === 'running';
|
||||||
|
if (progress.status === 'running') {
|
||||||
|
get('#search-selection-status').textContent = 'Deferring ' + progress.processed + ' of ' + progress.selected + '…';
|
||||||
|
} else if (progress.status === 'complete') {
|
||||||
|
get('#cmd-search-action-status').textContent = progress.failed.length ?
|
||||||
|
progress.queued.length + ' deferred · ' + progress.failed.length + ' need retry.' :
|
||||||
|
progress.queued.length + ' added to Later.';
|
||||||
|
restore();
|
||||||
|
if (!progress.failed.length) plan.cancel();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
function estimateValues() {
|
function estimateValues() {
|
||||||
return Object.fromEntries(Array.from(document.querySelectorAll('[data-search-batch-estimate]')).map(input =>
|
return Object.fromEntries(Array.from(document.querySelectorAll('[data-search-batch-estimate]')).map(input =>
|
||||||
[input.dataset.searchBatchEstimate, Number(input.value)]
|
[input.dataset.searchBatchEstimate, Number(input.value)]
|
||||||
|
|
@ -161,11 +188,21 @@
|
||||||
if (Number.isInteger(capacity) && capacity > 0) openEstimateReview(items);
|
if (Number.isInteger(capacity) && capacity > 0) openEstimateReview(items);
|
||||||
else processor.run(items);
|
else processor.run(items);
|
||||||
});
|
});
|
||||||
|
get('#defer-selected-search-results').addEventListener('click', event => {
|
||||||
|
const items = plan.snapshot().items;
|
||||||
|
if (!items.length) return;
|
||||||
|
laterPicker?.open({ items, confirm:until => () => laterProcessor.run(
|
||||||
|
items, {}, { until:new Date(until).toISOString() }
|
||||||
|
) }, event.currentTarget, 'search-batch');
|
||||||
|
});
|
||||||
get('#cancel-search-batch-estimates').addEventListener('click', closeEstimateReview);
|
get('#cancel-search-batch-estimates').addEventListener('click', closeEstimateReview);
|
||||||
get('#confirm-search-batch-estimates').addEventListener('click', () =>
|
get('#confirm-search-batch-estimates').addEventListener('click', () =>
|
||||||
processor.run(plan.snapshot().items, estimateValues())
|
processor.run(plan.snapshot().items, estimateValues())
|
||||||
);
|
);
|
||||||
get('#resume-search-batch').addEventListener('click', () => processor.resume());
|
get('#resume-search-batch').addEventListener('click', () => {
|
||||||
|
if (processor.pending()) return processor.resume();
|
||||||
|
return laterProcessor.resume();
|
||||||
|
});
|
||||||
get('#open-palette').addEventListener('click', () => restore());
|
get('#open-palette').addEventListener('click', () => restore());
|
||||||
get('#cmd-results').addEventListener('change', event => {
|
get('#cmd-results').addEventListener('change', event => {
|
||||||
const index = event.target?.dataset?.searchSelect;
|
const index = event.target?.dataset?.searchSelect;
|
||||||
|
|
@ -174,7 +211,7 @@
|
||||||
if (result) plan.toggle(result);
|
if (result) plan.toggle(result);
|
||||||
});
|
});
|
||||||
function restore() {
|
function restore() {
|
||||||
const pending = processor.pending();
|
const pending = processor.pending() + laterProcessor.pending();
|
||||||
get('#resume-search-batch').hidden = pending === 0;
|
get('#resume-search-batch').hidden = pending === 0;
|
||||||
get('#resume-search-batch').textContent = 'Resume ' + pending + ' interrupted';
|
get('#resume-search-batch').textContent = 'Resume ' + pending + ' interrupted';
|
||||||
}
|
}
|
||||||
|
|
@ -188,7 +225,7 @@
|
||||||
escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' +
|
escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' +
|
||||||
(allowed ? 'Open issue' : 'Not eligible') + '</span></label>';
|
(allowed ? 'Open issue' : 'Not eligible') + '</span></label>';
|
||||||
}
|
}
|
||||||
return { plan, processor, restore, resultHtml };
|
return { plan, processor, laterProcessor, restore, resultHtml };
|
||||||
};
|
};
|
||||||
|
|
||||||
return createSearchBatchPlan;
|
return createSearchBatchPlan;
|
||||||
|
|
|
||||||
|
|
@ -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():
|
def test_resumed_batch_skips_queued_items_and_continues_in_original_order():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
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)
|
output = run_node(script)
|
||||||
|
|
||||||
assert output == {"first": True, "second": False, "confirmations": 1}
|
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="select-search-results"' in html
|
||||||
assert 'id="search-batch-actions"' in html
|
assert 'id="search-batch-actions"' in html
|
||||||
assert 'id="queue-selected-search-results"' 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 'id="resume-search-batch"' in html
|
||||||
assert 'src="static/search-batch-plan.js"' in html
|
assert 'src="static/search-batch-plan.js"' in html
|
||||||
assert "mountSearchBatchPlanning(" in dashboard
|
assert "mountSearchBatchPlanning(" in dashboard
|
||||||
|
assert "escapeHtml, escAttr, laterWork, laterPicker" in dashboard
|
||||||
assert "journalName:'search-today-batch'" in controller
|
assert "journalName:'search-today-batch'" in controller
|
||||||
|
assert "journalName:'search-later-batch'" in controller
|
||||||
assert "autoMount:false" in controller
|
assert "autoMount:false" in controller
|
||||||
assert "plan.toggle(result)" in controller
|
assert "plan.toggle(result)" in controller
|
||||||
assert "processor.run(plan.snapshot().items, estimateValues())" 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
|
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():
|
def test_search_batch_reviews_time_estimates_before_any_assignment_and_persists_queued_estimates():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
|
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
|
||||||
|
|
@ -107,6 +177,7 @@ const listeners={{}};
|
||||||
const elements={{}};
|
const elements={{}};
|
||||||
for (const id of [
|
for (const id of [
|
||||||
'search-batch-actions','select-search-results','queue-selected-search-results',
|
'search-batch-actions','select-search-results','queue-selected-search-results',
|
||||||
|
'defer-selected-search-results',
|
||||||
'search-selection-status','cmd-search-action-status','resume-search-batch',
|
'search-selection-status','cmd-search-action-status','resume-search-batch',
|
||||||
'open-palette','cmd-results','cancel-search-selection','search-batch-estimate-review',
|
'open-palette','cmd-results','cancel-search-selection','search-batch-estimate-review',
|
||||||
'search-batch-estimate-summary','search-batch-estimate-list',
|
'search-batch-estimate-summary','search-batch-estimate-list',
|
||||||
|
|
@ -122,12 +193,12 @@ const document={{
|
||||||
querySelectorAll:selector=>selector==='[data-search-batch-estimate]' ? estimateInputs : [],
|
querySelectorAll:selector=>selector==='[data-search-batch-estimate]' ? estimateInputs : [],
|
||||||
}};
|
}};
|
||||||
const calls=[];
|
const calls=[];
|
||||||
let processorOptions;
|
const processorOptions=[];
|
||||||
const processor={{
|
const processor={{
|
||||||
run:(items,estimates)=>{{calls.push({{type:'run',items,estimates}});return Promise.resolve();}},
|
run:(items,estimates)=>{{calls.push({{type:'run',items,estimates}});return Promise.resolve();}},
|
||||||
resume:()=>Promise.resolve(),pending:()=>0,
|
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 planning={{capacity_minutes:90,estimates:{{'stackchain/existing#1':20}}}};
|
||||||
const todayWork={{limit:8,read:()=>[],planning:()=>planning,identity:item=>item.repository+'#'+item.number,
|
const todayWork={{limit:8,read:()=>[],planning:()=>planning,identity:item=>item.repository+'#'+item.number,
|
||||||
replacePlanning:value=>Object.assign(planning,value)}};
|
replacePlanning:value=>Object.assign(planning,value)}};
|
||||||
|
|
@ -149,11 +220,11 @@ const before={{
|
||||||
reviewHidden:elements['#search-batch-estimate-review'].hidden,
|
reviewHidden:elements['#search-batch-estimate-review'].hidden,
|
||||||
actionsHidden:elements['#search-batch-actions'].hidden,
|
actionsHidden:elements['#search-batch-actions'].hidden,
|
||||||
markup:elements['#search-batch-estimate-list'].innerHTML,
|
markup:elements['#search-batch-estimate-list'].innerHTML,
|
||||||
budget:processorOptions.timeBudget(),
|
budget:processorOptions[0].timeBudget(),
|
||||||
}};
|
}};
|
||||||
listeners['confirm-search-batch-estimates:click']();
|
listeners['confirm-search-batch-estimates:click']();
|
||||||
processorOptions.persistEstimate(items[0],25);
|
processorOptions[0].persistEstimate(items[0],25);
|
||||||
processorOptions.onProgress({{status:'complete',queued:['stackchain/dashboard#811','stackchain/api#12'],failed:[]}});
|
processorOptions[0].onProgress({{status:'complete',queued:['stackchain/dashboard#811','stackchain/api#12'],failed:[]}});
|
||||||
const afterCompleteHidden=elements['#search-batch-estimate-review'].hidden;
|
const afterCompleteHidden=elements['#search-batch-estimate-review'].hidden;
|
||||||
process.stdout.write(JSON.stringify({{before,calls,afterCompleteHidden}}));
|
process.stdout.write(JSON.stringify({{before,calls,afterCompleteHidden}}));
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user