feat: require estimate before create and start (Closes #849)
This commit is contained in:
1 parent
8edc83893f
commit
5c9420ed9c
10 files changed
+253
-14
No files matched your search
@@ -16,7 +16,7 @@ def run_node(script):
|
||||
).stdout)
|
||||
|
||||
|
||||
def test_confirmed_issue_is_planned_synced_warmed_and_started_once():
|
||||
def test_confirmed_issue_persists_and_syncs_estimate_before_starting_once():
|
||||
script = f"""
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync({json.dumps(str(CREATE_AND_START))})) {{
|
||||
@@ -26,13 +26,22 @@ if (!fs.existsSync({json.dumps(str(CREATE_AND_START))})) {{
|
||||
const calls = [];
|
||||
const issue = {{kind:'issue', repository:'stackchain/dashboard', number:387}};
|
||||
const flow = createCreateAndStart({{
|
||||
todayWork: {{limit:5, read:()=>[], identity:item=>'issue:'+item.repository+':'+item.number+':', add:()=>{{calls.push('add');return 'added';}}}},
|
||||
todaySync: {{enqueue:(action,id)=>{{calls.push(action+':'+id);return true;}}, flush:()=>calls.push('flush')}},
|
||||
todayWork: {{
|
||||
limit:5, read:()=>[], identity:item=>'issue:'+item.repository+':'+item.number+':',
|
||||
add:()=>{{calls.push('add');return 'added';}},
|
||||
planning:()=>({{capacity_minutes:120,estimates:{{}}}}),
|
||||
replacePlanning:plan=>{{calls.push('plan:'+JSON.stringify(plan));return true;}},
|
||||
}},
|
||||
todaySync: {{
|
||||
enqueue:(action,id)=>{{calls.push(action+':'+id);return true;}},
|
||||
enqueueConfiguration:(capacity,estimates)=>{{calls.push('configure:'+capacity+':'+JSON.stringify(estimates));return true;}},
|
||||
flush:()=>calls.push('flush')
|
||||
}},
|
||||
refresh:()=>calls.push('refresh'), warm:()=>calls.push('warm'), start:item=>calls.push('start:'+item.number),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
const result = flow.complete(issue);
|
||||
flow.complete(issue);
|
||||
const result = flow.complete(issue, 45);
|
||||
flow.complete(issue, 45);
|
||||
process.stdout.write(JSON.stringify({{available:true,result,calls}}));
|
||||
}}
|
||||
"""
|
||||
@@ -42,7 +51,9 @@ if (!fs.existsSync({json.dumps(str(CREATE_AND_START))})) {{
|
||||
"result": "started",
|
||||
"calls": [
|
||||
"add",
|
||||
'plan:{"capacity_minutes":120,"estimates":{"issue:stackchain/dashboard:387:":45}}',
|
||||
"add:issue:stackchain/dashboard:387:",
|
||||
'configure:120:{"issue:stackchain/dashboard:387:":45}',
|
||||
"refresh",
|
||||
"flush",
|
||||
"warm",
|
||||
@@ -63,7 +74,12 @@ def test_mobile_capture_wires_distinct_create_and_start_intent_into_the_offline_
|
||||
assert "const createAndStart = createCreateAndStart({" in dashboard
|
||||
assert "event.submitter.id === 'create-and-start-issue'" in dashboard
|
||||
assert "review.intent === 'create-and-start'" in dashboard
|
||||
assert "createAndStart.complete(created)" in dashboard
|
||||
assert 'id="create-issue-estimate"' in html
|
||||
assert 'min="5" max="1440" step="5"' in html
|
||||
assert "createAndStart.capacity(qs('#create-issue-estimate').value)" in dashboard
|
||||
assert "estimateMinutes: startCapacity.minutes" in dashboard
|
||||
assert "createAndStart.complete(created, completion.estimateMinutes)" in dashboard
|
||||
assert ".create-issue-estimate" in css
|
||||
assert ".create-issue-actions" in css and "grid-template-columns" in css
|
||||
assert "BASE + 'static/create-and-start.js'" in worker
|
||||
|
||||
@@ -126,3 +142,61 @@ process.stdout.write(JSON.stringify({{first,second,calls}}));
|
||||
"refresh", "flush", "warm", "start",
|
||||
"Created, added to Today, and ready to work.",
|
||||
]
|
||||
|
||||
|
||||
def test_create_and_start_reports_validation_and_remaining_today_capacity():
|
||||
script = f"""
|
||||
const createCreateAndStart=require({json.dumps(str(CREATE_AND_START))});
|
||||
const flow=createCreateAndStart({{
|
||||
todayWork:{{limit:5,read:()=>['first','second'],planning:()=>({{
|
||||
capacity_minutes:120,estimates:{{first:30,second:40}}
|
||||
}})}},
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
empty:flow.capacity(''),tooSmall:flow.capacity('4'),fits:flow.capacity('45'),over:flow.capacity('55')
|
||||
}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"empty": {"valid": False, "reason": "required"},
|
||||
"tooSmall": {"valid": False, "reason": "range"},
|
||||
"fits": {"valid": True, "minutes": 45, "capacityMinutes": 120, "plannedMinutes": 70,
|
||||
"remainingMinutes": 50, "projectedMinutes": 5, "fits": True},
|
||||
"over": {"valid": True, "minutes": 55, "capacityMinutes": 120, "plannedMinutes": 70,
|
||||
"remainingMinutes": 50, "projectedMinutes": -5, "fits": False},
|
||||
}
|
||||
|
||||
|
||||
def test_create_and_start_does_not_start_when_estimate_cannot_be_saved():
|
||||
script = f"""
|
||||
const createCreateAndStart=require({json.dumps(str(CREATE_AND_START))});const calls=[];
|
||||
const flow=createCreateAndStart({{
|
||||
todayWork:{{limit:5,read:()=>[],identity:()=> 'issue:o/r:849:',add:()=> 'added',
|
||||
planning:()=>({{capacity_minutes:60,estimates:{{}}}}),replacePlanning:()=>false}},
|
||||
todaySync:{{enqueue:()=>true,enqueueConfiguration:()=>true,flush:()=>calls.push('flush')}},
|
||||
refresh:()=>calls.push('refresh'),warm:()=>calls.push('warm'),start:()=>calls.push('start'),
|
||||
announce:message=>calls.push(message),
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{result:flow.complete({{repository:'o/r',number:849}},30),calls}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"result": "estimate-unavailable",
|
||||
"calls": ["Created and added to Today, but its estimate could not be saved on this device."],
|
||||
}
|
||||
|
||||
|
||||
def test_create_and_start_does_not_start_when_estimate_sync_cannot_be_queued():
|
||||
script = f"""
|
||||
const createCreateAndStart=require({json.dumps(str(CREATE_AND_START))});const calls=[];
|
||||
const flow=createCreateAndStart({{
|
||||
todayWork:{{limit:5,read:()=>[],identity:()=> 'issue:o/r:849:',add:()=> 'added',
|
||||
planning:()=>({{capacity_minutes:60,estimates:{{}}}}),replacePlanning:()=>true}},
|
||||
todaySync:{{enqueue:()=>true,enqueueConfiguration:()=>false,flush:()=>calls.push('flush')}},
|
||||
refresh:()=>calls.push('refresh'),warm:()=>calls.push('warm'),start:()=>calls.push('start'),
|
||||
announce:message=>calls.push(message),
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{result:flow.complete({{repository:'o/r',number:849}},30),calls}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"result": "sync-unavailable",
|
||||
"calls": ["Created and estimated on this device, but Today plan sync is unavailable."],
|
||||
}
|
||||
@@ -113,6 +113,23 @@ process.stdout.write(JSON.stringify({{issueType:issueType.textContent}}));
|
||||
assert run_node(script)["issueType"] == "Bug report"
|
||||
|
||||
|
||||
def test_create_and_start_review_shows_estimate_and_projected_capacity():
|
||||
script = f"""
|
||||
const createReview=require({json.dumps(str(MODULE))});
|
||||
function target(){{return{{hidden:true,disabled:false,textContent:'',addEventListener:()=>{{}},replaceChildren:()=>{{}},focus:()=>{{}}}};}}
|
||||
const metadata=target();
|
||||
const review=createReview({{sheet:target(),confirmButton:target(),backButton:target(),evidenceList:target(),
|
||||
repository:target(),intent:target(),title:target(),body:target(),metadata,
|
||||
document:{{createElement:()=>target(),addEventListener:()=>{{}}}},onConfirm:async()=>{{}}}});
|
||||
review.open({{draft:{{repository:'o/r',title:'Sized work',estimateMinutes:45,
|
||||
todayCapacity:{{remainingMinutes:60,projectedMinutes:15}}}},intent:'create-and-start'}},target());
|
||||
process.stdout.write(JSON.stringify({{metadata:metadata.textContent}}));
|
||||
"""
|
||||
metadata = run_node(script)["metadata"]
|
||||
assert "Estimate: 45 min" in metadata
|
||||
assert "Today after start: 15 min free" in metadata
|
||||
|
||||
|
||||
def test_escape_returns_to_the_unchanged_issue_form():
|
||||
script = f"""
|
||||
const createReview = require({json.dumps(str(MODULE))});
|
||||
|
||||
@@ -105,6 +105,43 @@ process.stdout.write(JSON.stringify(updated));
|
||||
assert output["operationId"] != "1"
|
||||
|
||||
|
||||
def test_create_and_start_estimate_survives_outbox_delivery_and_completion():
|
||||
script = f"""
|
||||
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const outbox=createIssueOutbox({{
|
||||
storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'estimated-start',
|
||||
fetchJson:async()=>({{number:849,title:'Estimated work'}}),
|
||||
}});
|
||||
const queued=outbox.enqueue({{repository:'stackchain/dashboard',title:'Estimated work',
|
||||
completionIntent:'create-and-start',estimateMinutes:45}});
|
||||
(async()=>{{
|
||||
const delivered=await outbox.flush('timmy');
|
||||
process.stdout.write(JSON.stringify({{queued,completion:delivered.completions[0]}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["queued"]["estimateMinutes"] == 45
|
||||
assert output["completion"]["estimateMinutes"] == 45
|
||||
|
||||
|
||||
def test_editing_queued_start_estimate_rotates_the_delivery_operation():
|
||||
script = f"""
|
||||
const createIssueOutbox=require({json.dumps(str(OUTBOX))});const values=new Map();let sequence=0;
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=> 'op-'+(++sequence)}});
|
||||
const queued=outbox.enqueue({{repository:'o/r',title:'Sized',completionIntent:'create-and-start',estimateMinutes:30}});
|
||||
const updated=outbox.update(queued.id,{{...queued,estimateMinutes:60}});
|
||||
process.stdout.write(JSON.stringify({{queued,updated}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["updated"]["estimateMinutes"] == 60
|
||||
assert output["updated"]["operationId"] != output["queued"]["operationId"]
|
||||
|
||||
|
||||
def test_issue_outbox_preserves_bounded_ordered_evidence_bundle():
|
||||
script = f"""
|
||||
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
|
||||
|
||||
Reference in new issue
Block a user