stackchain-dashboard/tests/test_batch_find_work.py
timmy 0767ccaa9d
All checks were successful
CI / lint (pull_request) Successful in 1m28s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
feat: budget Find Work batches by time (Closes #675)
2026-08-12 19:53:57 +00:00

292 lines
11 KiB
Python

import json
import subprocess
from pathlib import Path
BATCH_FIND_WORK = Path(__file__).parents[1] / "frontend" / "batch-find-work.js"
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
HTML = Path(__file__).parents[1] / "frontend" / "index.html"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
def run_node(script):
return json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
def test_batch_preflights_today_capacity_before_claiming_any_issue():
script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
const calls=[];
const flow=createBatchFindWork({{
capacity:()=>1,
claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
onProgress:progress=>calls.push('progress:'+progress.status),
}});
flow.run([
{{repository:'stackchain/dashboard',number:671}},
{{repository:'stackchain/dashboard',number:672}},
]).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
"""
assert run_node(script) == {
"result": {
"status": "full",
"selected": 2,
"available": 1,
"queued": [],
"failed": [],
},
"calls": ["progress:full"],
}
def test_batch_claims_in_order_continues_after_conflict_and_reports_truthfully():
script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
const calls=[];
const flow=createBatchFindWork({{
capacity:()=>3,
claim:item=>{{
calls.push('claim:'+item.number);
return item.number===672 ? Promise.reject(new Error('already claimed')) :
Promise.resolve({{...item,assignees:['timmy']}});
}},
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
onProgress:progress=>calls.push('progress:'+progress.status+':'+progress.processed),
}});
flow.run([671,672,673].map(number=>({{repository:'stackchain/dashboard',number}})))
.then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
"""
assert run_node(script) == {
"result": {
"status": "complete",
"selected": 3,
"available": 3,
"queued": ["stackchain/dashboard#671", "stackchain/dashboard#673"],
"failed": [{"key": "stackchain/dashboard#672", "reason": "already claimed"}],
},
"calls": [
"claim:671", "queue:671", "progress:running:1",
"claim:672", "progress:running:2",
"claim:673", "queue:673", "progress:running:3",
"progress:complete:3",
],
}
def test_batch_does_not_reclaim_confirmed_issue_when_today_sync_needs_recovery():
script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
const calls=[];
const flow=createBatchFindWork({{
capacity:()=>1,
claim:item=>{{calls.push('claim');return Promise.resolve({{...item,assignees:['timmy']}});}},
queue:item=>{{calls.push('queue');return 'sync-unavailable';}},
}});
flow.run([{{repository:'stackchain/dashboard',number:671}}])
.then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
"""
assert run_node(script) == {
"result": {
"status": "complete",
"selected": 1,
"available": 1,
"queued": [],
"failed": [{
"key": "stackchain/dashboard#671",
"reason": "assigned but Today sync is unavailable",
"assigned": True,
}],
},
"calls": ["claim", "queue"],
}
def test_time_budget_blocks_claims_until_every_estimate_fits():
script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
const calls=[];
const flow=createBatchFindWork({{
capacity:()=>3,
timeBudget:()=>({{capacity_minutes:90,planned_minutes:30}}),
claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
}});
const items=[701,702].map(number=>({{repository:'stackchain/dashboard',number}}));
Promise.all([
flow.run(items, {{'stackchain/dashboard#701':30}}),
flow.run(items, {{'stackchain/dashboard#701':30,'stackchain/dashboard#702':45}}),
]).then(results=>process.stdout.write(JSON.stringify({{results,calls}})));
"""
assert run_node(script) == {
"results": [
{
"status": "estimates-required", "selected": 2, "available": 3,
"queued": [], "failed": [], "remaining_minutes": 60,
"requested_minutes": 30,
"invalid": ["stackchain/dashboard#702"],
},
{
"status": "over-budget", "selected": 2, "available": 3,
"queued": [], "failed": [], "remaining_minutes": 60,
"requested_minutes": 75, "over_minutes": 15,
},
],
"calls": [],
}
def test_time_budget_persists_estimates_only_for_successfully_queued_claims():
script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
const calls=[];
const flow=createBatchFindWork({{
capacity:()=>3,
timeBudget:()=>({{capacity_minutes:120,planned_minutes:30}}),
claim:item=>item.number===702 ? Promise.reject(new Error('already claimed')) : Promise.resolve(item),
queue:item=>'queued',
persistEstimate:(item,minutes)=>calls.push(item.number+':'+minutes),
}});
flow.run([701,702].map(number=>({{repository:'stackchain/dashboard',number}})),{{
'stackchain/dashboard#701':30,
'stackchain/dashboard#702':45,
}}).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
"""
assert run_node(script) == {
"result": {
"status": "complete", "selected": 2, "available": 3,
"queued": ["stackchain/dashboard#701"],
"failed": [{"key": "stackchain/dashboard#702", "reason": "already claimed"}],
},
"calls": ["701:30"],
}
def test_mobile_find_work_has_preclaim_time_review_controls():
html = HTML.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
assert 'id="find-work-estimate-review"' in html
assert 'id="find-work-estimate-list"' in html
assert 'id="confirm-find-work-estimates"' in html
assert 'inputmode="numeric"' in dashboard
assert "timeBudget: () =>" in dashboard
assert "persistEstimate:" in dashboard
assert ".find-work-estimate-review" in css
assert ".find-work-estimate-review input" in css and "min-height:44px" in css
def test_find_work_selection_survives_loaded_pages_and_removes_confirmed_claims():
script = f"""
const createFindWork=require({json.dumps(str(PICK_WORK))});
const pages={{
1:{{items:[{{id:1,repository:'stackchain/dashboard',number:671}}],page:1,total:2,has_more:true}},
2:{{items:[{{id:2,repository:'stackchain/dashboard',number:672}}],page:2,total:2,has_more:false}},
}};
const controller=createFindWork({{
fetchJson:path=>path.includes('/claim') ? Promise.resolve({{repository:'stackchain/dashboard',number:671,assignees:['timmy']}}) :
Promise.resolve(pages[path.endsWith('=2') ? 2 : 1]),
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
}});
controller.load().then(()=>{{
controller.startSelection();
controller.toggleSelection(controller.items()[0]);
return controller.loadMore();
}}).then(()=>{{
controller.toggleSelection(controller.items()[1]);
return controller.claim(controller.items()[0]);
}}).then(()=>process.stdout.write(JSON.stringify({{
selected:controller.selectedItems().map(item=>item.number),
selecting:controller.selection().active,
}})));
"""
assert run_node(script) == {"selected": [672], "selecting": True}
def test_mobile_find_work_exposes_accessible_batch_controls_and_offline_asset():
html = HTML.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
worker = WORKER.read_text()
assert 'id="select-find-work"' in html
assert 'id="batch-find-work-actions"' in html
assert 'id="claim-selected-work"' in html
assert 'aria-live="polite"' in html
assert "createBatchFindWork({" in dashboard
assert "findWorkController.startSelection()" in dashboard
assert "findWorkController.toggleSelection(item)" in dashboard
assert ".find-work-batch-actions" in css
assert "position:sticky" in css
assert "env(safe-area-inset-bottom)" in css
assert "BASE + 'static/batch-find-work.js'" in worker
assert '<script src="static/batch-find-work.js"></script>' in html
def test_find_work_search_ignores_stale_response_and_preserves_cross_query_selection():
script = f"""
const createFindWork=require({json.dumps(str(PICK_WORK))});
const pending=[];
const rendered=[];
const fetchJson=path=>new Promise(resolve=>pending.push({{path,resolve}}));
const controller=createFindWork({{
fetchJson,
onItems:items=>rendered.push(items.map(item=>item.number)),
onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
}});
const first=controller.search('api');
const second=controller.search('dashboard');
pending[1].resolve({{items:[{{id:2,repository:'stackchain/dashboard',number:673}}],page:1,total:1,has_more:false}});
second.then(()=>{{
controller.startSelection();
controller.toggleSelection(controller.items()[0]);
const third=controller.search('worker');
pending[2].resolve({{items:[{{id:3,repository:'stackchain/worker',number:674}}],page:1,total:1,has_more:false}});
return third;
}}).then(()=>{{
controller.toggleSelection(controller.items()[0]);
pending[0].resolve({{items:[{{id:1,repository:'stackchain/api',number:1}}],page:1,total:1,has_more:false}});
return first;
}}).then(()=>process.stdout.write(JSON.stringify({{
calls:pending.map(entry=>entry.path),
rendered,
items:controller.items().map(item=>item.number),
selected:controller.selectedItems().map(item=>item.number),
}})));
"""
assert run_node(script) == {
"calls": [
"api/v1/available-issues?page=1&q=api",
"api/v1/available-issues?page=1&q=dashboard",
"api/v1/available-issues?page=1&q=worker",
],
"rendered": [[673], [674]],
"items": [674],
"selected": [673, 674],
}
def test_mobile_find_work_search_has_clear_live_results_and_touch_targets():
html = HTML.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
assert 'id="find-work-search"' in html
assert 'id="clear-find-work-search"' in html
assert 'id="find-work-match-status"' in html
assert 'aria-live="polite"' in html
assert "findWorkController.search(" in dashboard
assert "#find-work-search" in dashboard
assert ".find-work-search" in css
assert ".find-work-search button" in css and "min-height:44px" in css