319 lines
11 KiB
Python
319 lines
11 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
|
|
ASSIGN_AND_START = Path(__file__).parents[1] / "frontend" / "assign-and-start.js"
|
|
FILED_CLAIM = Path(__file__).parents[1] / "frontend" / "filed-claim.js"
|
|
QUEUE_TODAY = Path(__file__).parents[1] / "frontend" / "queue-today.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_filed_claim_actions_require_open_authored_unassigned_canonical_issue():
|
|
script = f"""
|
|
const eligible=require({json.dumps(str(FILED_CLAIM))});
|
|
const item={{is_filed:true,is_assigned:false,is_completed:false,state:'open'}};
|
|
process.stdout.write(JSON.stringify({{
|
|
eligible:eligible(item, {{state:'open',assignees:[]}}),
|
|
assigned:eligible(item, {{state:'open',assignees:['alex']}}),
|
|
closed:eligible(item, {{state:'closed',assignees:[]}}),
|
|
ordinary:eligible({{...item,is_filed:false}}, {{state:'open',assignees:[]}}),
|
|
}}));
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"eligible": True,
|
|
"assigned": False,
|
|
"closed": False,
|
|
"ordinary": False,
|
|
}
|
|
|
|
|
|
def test_filed_sheet_exposes_phone_safe_claim_actions_after_canonical_detail():
|
|
html = HTML.read_text()
|
|
dashboard = DASHBOARD.read_text()
|
|
css = CSS.read_text()
|
|
worker = WORKER.read_text()
|
|
|
|
assert 'id="filed-claim-actions"' in html
|
|
assert 'id="queue-filed-issue"' in html
|
|
assert 'id="start-filed-issue"' in html
|
|
assert "filedClaimEligible(item, detail)" in dashboard
|
|
assert "filedAssignAndStart.run(item, { destination:'queue' })" in dashboard
|
|
assert "filedAssignAndStart.run(item)" in dashboard
|
|
assert "Claim failed; this issue is still in Filed." in dashboard
|
|
assert ".filed-claim-actions" in css
|
|
action_rule = css.split(".filed-claim-actions", 1)[1].split("}", 1)[0]
|
|
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in action_rule
|
|
assert "width:min(560px,100%)" in action_rule
|
|
assert "BASE + 'static/filed-claim.js'" in worker
|
|
assert '<script src="static/filed-claim.js"></script>' in html
|
|
|
|
|
|
def test_repeated_assign_and_start_taps_claim_once_then_start_confirmed_issue():
|
|
script = f"""
|
|
const fs=require('fs');
|
|
if (!fs.existsSync({json.dumps(str(ASSIGN_AND_START))})) {{
|
|
process.stdout.write(JSON.stringify({{available:false}}));
|
|
}} else {{
|
|
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
|
const calls=[];
|
|
let release;
|
|
const claimResult=new Promise(resolve=>{{release=resolve;}});
|
|
const flow=createAssignAndStart({{
|
|
available:()=>true,
|
|
claim:item=>{{calls.push('claim:'+item.number);return claimResult;}},
|
|
start:item=>{{calls.push('start:'+item.number);return 'started';}},
|
|
recover:item=>calls.push('recover:'+item.number),
|
|
announce:message=>calls.push('announce:'+message),
|
|
}});
|
|
const issue={{repository:'stackchain/dashboard',number:411}};
|
|
const first=flow.run(issue);
|
|
const second=flow.run(issue);
|
|
release({{...issue,assignees:['timmy']}});
|
|
Promise.all([first,second]).then(results=>process.stdout.write(JSON.stringify({{available:true,calls,results,same:first===second}})));
|
|
}}
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"available": True,
|
|
"calls": [
|
|
"claim:411",
|
|
"start:411",
|
|
"announce:Assigned, added to Today, and ready to work.",
|
|
],
|
|
"results": ["started", "started"],
|
|
"same": True,
|
|
}
|
|
|
|
|
|
def test_full_today_blocks_before_assignment_and_explains_recovery():
|
|
script = f"""
|
|
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
|
const calls=[];
|
|
const flow=createAssignAndStart({{
|
|
available:()=>false,
|
|
claim:()=>{{calls.push('claim');return Promise.resolve({{}});}},
|
|
start:()=>calls.push('start'),
|
|
recover:()=>calls.push('recover'),
|
|
announce:message=>calls.push('announce:'+message),
|
|
}});
|
|
flow.run({{repository:'stackchain/dashboard',number:411}}).then(result=>
|
|
process.stdout.write(JSON.stringify({{result,calls}}))
|
|
);
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"result": "full",
|
|
"calls": ["announce:Today is full—remove an item before assigning this issue."],
|
|
}
|
|
|
|
|
|
def test_confirmed_assignment_recovers_in_issue_detail_when_today_start_fails():
|
|
script = f"""
|
|
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
|
const calls=[];
|
|
const issue={{repository:'stackchain/dashboard',number:411,assignees:['timmy']}};
|
|
const flow=createAssignAndStart({{
|
|
available:()=>true,
|
|
claim:()=>Promise.resolve(issue),
|
|
start:()=> 'sync-unavailable',
|
|
recover:item=>calls.push('recover:'+item.number),
|
|
announce:message=>calls.push('announce:'+message),
|
|
}});
|
|
flow.run(issue).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"result": "recovery",
|
|
"calls": [
|
|
"announce:Assigned to you, but Today could not start. The issue is open so you can recover.",
|
|
"recover:411",
|
|
],
|
|
}
|
|
|
|
|
|
def test_failed_assignment_does_not_start_today_and_can_be_retried():
|
|
script = f"""
|
|
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
|
const calls=[];let attempt=0;
|
|
const issue={{repository:'stackchain/dashboard',number:411}};
|
|
const flow=createAssignAndStart({{
|
|
available:()=>true,
|
|
claim:()=>{{attempt+=1;calls.push('claim:'+attempt);return attempt===1 ? Promise.reject(new Error('conflict')) : Promise.resolve(issue);}},
|
|
start:()=>{{calls.push('start');return 'started';}},
|
|
recover:()=>calls.push('recover'),
|
|
announce:message=>calls.push('announce:'+message),
|
|
}});
|
|
flow.run(issue).catch(error=>{{
|
|
calls.push('error:'+error.message);
|
|
return flow.run(issue);
|
|
}}).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"result": "started",
|
|
"calls": [
|
|
"claim:1",
|
|
"error:conflict",
|
|
"claim:2",
|
|
"start",
|
|
"announce:Assigned, added to Today, and ready to work.",
|
|
],
|
|
}
|
|
|
|
|
|
def test_already_owned_search_result_starts_without_claiming():
|
|
script = f"""
|
|
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
|
const calls=[];
|
|
const issue={{repository:'stackchain/dashboard',number:413,assigned_to_me:true}};
|
|
const flow=createAssignAndStart({{
|
|
available:()=>true,
|
|
claim:()=>{{calls.push('claim');return Promise.resolve(issue);}},
|
|
start:item=>{{calls.push('start:'+item.number);return 'started';}},
|
|
recover:item=>calls.push('recover:'+item.number),
|
|
announce:message=>calls.push('announce:'+message),
|
|
}});
|
|
flow.run(issue, {{alreadyOwned:true}}).then(result=>
|
|
process.stdout.write(JSON.stringify({{result,calls}}))
|
|
);
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"result": "started",
|
|
"calls": [
|
|
"start:413",
|
|
"announce:Added to Today and ready to work.",
|
|
],
|
|
}
|
|
|
|
|
|
def test_queue_today_claims_once_without_starting_the_active_session():
|
|
script = f"""
|
|
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
|
const calls=[];
|
|
let release;
|
|
const claimResult=new Promise(resolve=>{{release=resolve;}});
|
|
const issue={{repository:'stackchain/dashboard',number:415}};
|
|
const flow=createAssignAndStart({{
|
|
available:()=>true,
|
|
claim:item=>{{calls.push('claim:'+item.number);return claimResult;}},
|
|
start:item=>{{calls.push('start:'+item.number);return 'started';}},
|
|
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
|
recover:item=>calls.push('recover:'+item.number),
|
|
announce:message=>calls.push('announce:'+message),
|
|
}});
|
|
const first=flow.run(issue, {{destination:'queue'}});
|
|
const second=flow.run(issue, {{destination:'queue'}});
|
|
release({{...issue,assignees:['timmy']}});
|
|
Promise.all([first,second]).then(results=>process.stdout.write(JSON.stringify({{
|
|
calls,results,same:first===second
|
|
}})));
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"calls": [
|
|
"claim:415",
|
|
"queue:415",
|
|
"announce:Assigned and queued in Today. Keep finding work when ready.",
|
|
],
|
|
"results": ["queued", "queued"],
|
|
"same": True,
|
|
}
|
|
|
|
|
|
def test_queue_today_persists_syncs_and_warms_without_a_session_side_effect():
|
|
script = f"""
|
|
const fs=require('fs');
|
|
if (!fs.existsSync({json.dumps(str(QUEUE_TODAY))})) {{
|
|
process.stdout.write(JSON.stringify({{available:false}}));
|
|
}} else {{
|
|
const createQueueToday=require({json.dumps(str(QUEUE_TODAY))});
|
|
const calls=[];
|
|
const issue={{kind:'issue',repository:'stackchain/dashboard',number:415}};
|
|
const queue=createQueueToday({{
|
|
todayWork:{{identity:()=> 'issue:stackchain/dashboard:415:',add:()=>{{calls.push('add');return 'added';}}}},
|
|
todaySync:{{enqueue:(action,id)=>{{calls.push(action+':'+id);return true;}},flush:()=>calls.push('flush')}},
|
|
refresh:()=>calls.push('refresh'),
|
|
warm:()=>calls.push('warm'),
|
|
}});
|
|
process.stdout.write(JSON.stringify({{available:true,result:queue(issue),calls}}));
|
|
}}
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"available": True,
|
|
"result": "queued",
|
|
"calls": [
|
|
"add",
|
|
"add:issue:stackchain/dashboard:415:",
|
|
"refresh",
|
|
"flush",
|
|
"warm",
|
|
],
|
|
}
|
|
|
|
|
|
def test_queue_today_reports_assignment_when_local_planning_needs_recovery():
|
|
script = f"""
|
|
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
|
const calls=[];
|
|
const issue={{repository:'stackchain/dashboard',number:415,assignees:['timmy']}};
|
|
const flow=createAssignAndStart({{
|
|
available:()=>true,
|
|
claim:()=>Promise.resolve(issue),
|
|
start:()=> 'started',
|
|
queue:()=> 'sync-unavailable',
|
|
recover:item=>calls.push('recover:'+item.number),
|
|
announce:message=>calls.push('announce:'+message),
|
|
}});
|
|
flow.run(issue, {{destination:'queue'}}).then(result=>
|
|
process.stdout.write(JSON.stringify({{result,calls}}))
|
|
);
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"result": "recovery",
|
|
"calls": [
|
|
"announce:Assigned and saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.",
|
|
"recover:415",
|
|
],
|
|
}
|
|
|
|
|
|
def test_find_work_renders_phone_safe_assign_and_start_and_wires_offline_shell():
|
|
html = HTML.read_text()
|
|
dashboard = DASHBOARD.read_text()
|
|
css = CSS.read_text()
|
|
worker = WORKER.read_text()
|
|
|
|
assert "data-claim-start-index" in dashboard
|
|
assert "data-claim-queue-index" in dashboard
|
|
assert ">Start now</button>" in dashboard
|
|
assert ">Queue Today</button>" in dashboard
|
|
assert "const assignAndStart = createAssignAndStart({" in dashboard
|
|
assert "assignAndStart.run(item)" in dashboard
|
|
assert "assignAndStart.run(item, { destination:'queue' })" in dashboard
|
|
assert "queue: confirmed =>" in dashboard
|
|
assert "const queueToday = createQueueToday({" in dashboard
|
|
assert "return queueToday(claimed)" in dashboard
|
|
assert "createAndStart.available" in dashboard
|
|
assert "createAndStart.complete" in dashboard
|
|
assert ".find-work-claim-actions" in css
|
|
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
|
|
assert ".find-work-claim-actions [data-claim-start-index]" in css
|
|
assert "grid-column:1 / -1" in css
|
|
assert "BASE + 'static/assign-and-start.js'" in worker
|
|
assert "BASE + 'static/queue-today.js'" in worker
|
|
assert '<script src="static/queue-today.js"></script>' in html
|