452 lines
17 KiB
Python
452 lines
17 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_interrupted_batch_resumes_confirmed_assignment_without_claiming_twice():
|
|
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 item={{repository:'stackchain/dashboard',number:679,title:'Recover me'}};
|
|
const first=createBatchFindWork({{
|
|
capacity:()=>1, storage, owner:()=> 'timmy',
|
|
claim:value=>{{calls.push('claim');return Promise.resolve({{...value,assignees:['timmy']}});}},
|
|
queue:()=>{{calls.push('queue-failed');return 'sync-unavailable';}},
|
|
}});
|
|
first.run([item]).then(firstResult=>{{
|
|
const second=createBatchFindWork({{
|
|
capacity:()=>1, storage, owner:()=> 'timmy',
|
|
claim:()=>{{calls.push('duplicate-claim');return Promise.resolve(item);}},
|
|
queue:value=>{{calls.push('queue-resumed:'+value.assignees[0]);return 'queued';}},
|
|
}});
|
|
return second.resume().then(resumed=>process.stdout.write(JSON.stringify({{
|
|
firstResult, resumed, calls, keys:Object.keys(values),
|
|
}})));
|
|
}});
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"firstResult": {
|
|
"status": "complete", "selected": 1, "available": 1, "queued": [],
|
|
"failed": [{
|
|
"key": "stackchain/dashboard#679",
|
|
"reason": "assigned but Today sync is unavailable",
|
|
"assigned": True,
|
|
}],
|
|
},
|
|
"resumed": {
|
|
"status": "complete", "selected": 1, "available": 1,
|
|
"queued": ["stackchain/dashboard#679"], "failed": [],
|
|
},
|
|
"calls": ["claim", "queue-failed", "queue-resumed:timmy"],
|
|
"keys": [],
|
|
}
|
|
|
|
|
|
def test_resumed_batch_skips_queued_items_and_continues_in_original_order():
|
|
script = f"""
|
|
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
|
const key='stackchain.find-work-batch.v1.timmy';
|
|
const values={{[key]:JSON.stringify({{
|
|
owner:'timmy', available:2, items:[
|
|
{{item:{{repository:'stackchain/dashboard',number:678}},state:'queued'}},
|
|
{{item:{{repository:'stackchain/dashboard',number:679}},state:'pending'}},
|
|
],
|
|
}})}};
|
|
const calls=[];
|
|
const flow=createBatchFindWork({{
|
|
capacity:()=>2, owner:()=> 'timmy',
|
|
storage:{{getItem:key=>values[key]||null,setItem:(key,value)=>values[key]=value,removeItem:key=>delete values[key]}},
|
|
claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
|
|
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
|
}});
|
|
flow.resume().then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"result": {
|
|
"status": "complete", "selected": 2, "available": 2,
|
|
"queued": ["stackchain/dashboard#678", "stackchain/dashboard#679"],
|
|
"failed": [],
|
|
},
|
|
"calls": ["claim:679", "queue:679"],
|
|
}
|
|
|
|
|
|
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_fill_today_selects_ranked_matches_up_to_remaining_capacity():
|
|
script = f"""
|
|
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
|
const states=[];
|
|
const controller=createFindWork({{
|
|
fetchJson:()=>Promise.resolve({{items:[
|
|
{{id:1,repository:'stackchain/dashboard',number:701}},
|
|
{{id:2,repository:'stackchain/dashboard',number:702}},
|
|
{{id:3,repository:'stackchain/dashboard',number:703}},
|
|
{{id:4,repository:'stackchain/dashboard',number:704}},
|
|
],page:1,total:4,has_more:false}}),
|
|
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}},
|
|
onSelection:state=>states.push(state),
|
|
}});
|
|
controller.load().then(()=>{{
|
|
controller.startSelection();
|
|
controller.toggleSelection(controller.items()[1]);
|
|
const outcome=controller.fillSelection(3);
|
|
process.stdout.write(JSON.stringify({{
|
|
outcome,
|
|
selected:controller.selectedItems().map(item=>item.number),
|
|
lastState:states.at(-1),
|
|
}}));
|
|
}});
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"outcome": {"selected": 3, "added": 2, "limit": 3},
|
|
"selected": [702, 701, 703],
|
|
"lastState": {
|
|
"active": True,
|
|
"count": 3,
|
|
"ids": [
|
|
"stackchain/dashboard#702",
|
|
"stackchain/dashboard#701",
|
|
"stackchain/dashboard#703",
|
|
],
|
|
},
|
|
}
|
|
|
|
|
|
def test_fill_today_with_no_capacity_does_not_change_selection():
|
|
script = f"""
|
|
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
|
const controller=createFindWork({{
|
|
fetchJson:()=>Promise.resolve({{items:[{{id:1,repository:'stackchain/dashboard',number:701}}]}}),
|
|
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
|
|
}});
|
|
controller.load().then(()=>{{
|
|
const outcome=controller.fillSelection(0);
|
|
process.stdout.write(JSON.stringify({{outcome,selection:controller.selection()}}));
|
|
}});
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"outcome": {"selected": 0, "added": 0, "limit": 0},
|
|
"selection": {"active": False, "count": 0, "ids": []},
|
|
}
|
|
|
|
|
|
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_mobile_find_work_exposes_touch_accessible_batch_recovery():
|
|
html = HTML.read_text()
|
|
dashboard = DASHBOARD.read_text()
|
|
css = CSS.read_text()
|
|
|
|
assert 'id="resume-find-work-batch"' in html
|
|
assert 'aria-describedby="find-work-status"' in html
|
|
assert "mountRecovery(" in BATCH_FIND_WORK.read_text()
|
|
assert "typeof localStorage === 'undefined'" in BATCH_FIND_WORK.read_text()
|
|
assert ".find-work-batch-recovery" in css and "min-height:44px" in css
|
|
|
|
|
|
def test_mobile_find_work_exposes_fill_today_action_with_capacity_handoff():
|
|
html = HTML.read_text()
|
|
dashboard = DASHBOARD.read_text()
|
|
css = CSS.read_text()
|
|
|
|
assert 'id="fill-find-work-today"' in html
|
|
assert 'aria-describedby="find-work-selection-status"' in html
|
|
assert "findWorkController.fillSelection(remainingSlots)" in dashboard
|
|
assert "openFindWorkEstimateReview(findWorkController.selectedItems())" in dashboard
|
|
assert ".fill-find-work-today" in css and "min-height:44px" in css
|
|
|
|
|
|
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
|