feat: resume fully planned drafts across devices (Closes #859)
This commit is contained in:
1 parent
7cab4e8749
commit
f23839f2b9
10 files changed
+380
-30
No files matched your search
@@ -4091,6 +4091,26 @@ capture.saveDraft({{repository:'o/r',title:'Backlog capture',body:'Context',labe
|
||||
}]
|
||||
|
||||
|
||||
def test_issue_capture_persists_estimate_and_completion_intent_for_resumed_drafts():
|
||||
script = f"""
|
||||
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const values=new Map();
|
||||
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
|
||||
const capture=createIssueCapture({{storage,fetchJson:async()=>{{}}}});
|
||||
capture.saveDraft({{
|
||||
repository:'o/r',title:'Planned work',body:'Context',labelIds:[3],
|
||||
estimateMinutes:45,completionIntent:'create-and-start',
|
||||
}});
|
||||
process.stdout.write(JSON.stringify(capture.loadDraft()));
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["estimateMinutes"] == 45
|
||||
assert output["completionIntent"] == "create-and-start"
|
||||
|
||||
|
||||
def test_issue_capture_applies_and_switches_repository_templates_without_losing_authored_work():
|
||||
script = f"""
|
||||
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
|
||||
@@ -38,6 +38,28 @@ process.stdout.write(JSON.stringify({{opened,skipped,afterFiled,previous}}));
|
||||
}
|
||||
|
||||
|
||||
def test_draft_filing_session_restores_all_saved_evidence():
|
||||
script = f"""
|
||||
const createDraftFilingSession=require({json.dumps(str(DRAFT_SESSION))});
|
||||
const evidence=[{{filename:'one.png'}},{{filename:'two.png'}}];
|
||||
const restored=[];
|
||||
const elements=new Map();
|
||||
const qs=selector=>{{
|
||||
if(!elements.has(selector)) elements.set(selector,{{hidden:false,disabled:false,textContent:'',addEventListener:()=>{{}}}});
|
||||
return elements.get(selector);
|
||||
}};
|
||||
const session=createDraftFilingSession({{list:()=>[{{id:'planned'}}]}});
|
||||
session.attach(qs,{{
|
||||
captures:{{resume:async()=>({{repository:'o/r',title:'Planned',body:'',labelIds:[],attachments:evidence}})}},
|
||||
issueCapture:{{saveDraft:()=>{{}}}},attachment:{{restore:value=>restored.push(value),clear:()=>restored.push(null)}},
|
||||
getLogin:()=>'timmy',setResumedId:()=>{{}},openSheet:async()=>{{}},setFilingMode:()=>{{}},
|
||||
}});
|
||||
session.start('planned');
|
||||
(async()=>{{await session.nextCapture();process.stdout.write(JSON.stringify(restored));}})();
|
||||
"""
|
||||
assert run_node(script) == [[{"filename": "one.png"}, {"filename": "two.png"}]]
|
||||
|
||||
|
||||
def test_unfiled_captures_block_at_capacity_until_oldest_is_explicitly_replaced():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
@@ -145,6 +167,65 @@ function device() {{
|
||||
assert output["blockers"] == [{"repository": "o/api", "number": 7, "title": "API"}]
|
||||
|
||||
|
||||
def test_unfiled_captures_resume_complete_filing_plan_on_another_device():
|
||||
script = f"""
|
||||
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
|
||||
function device(id) {{
|
||||
const values=new Map();
|
||||
return createUnfiledCaptures({{
|
||||
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
|
||||
getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>id,now:()=>42,
|
||||
}});
|
||||
}}
|
||||
(async()=>{{
|
||||
const phone=device('planned-draft'), desktop=device('unused');
|
||||
await phone.save({{
|
||||
repository:'stackchain/dashboard',title:'Restore my plan',body:'Do not make me plan twice',
|
||||
labelIds:[7,3,7],milestoneId:12,dueDate:'2026-08-21',
|
||||
templateName:'Bug report',templateId:'bug.yml',capturedBody:'Original field notes',
|
||||
assignee:'alexander',assigneeName:'Alexander',unassigned:false,
|
||||
estimateMinutes:45,completionIntent:'create-and-start',
|
||||
blockers:[{{repository:'stackchain/api',number:9,title:'API rollout'}}],
|
||||
}});
|
||||
const exported=await phone.exportOwned('timmy');
|
||||
await desktop.mergeRemote(exported,'timmy');
|
||||
const resumed=await desktop.resume('planned-draft','timmy');
|
||||
process.stdout.write(JSON.stringify({{exported:exported[0],resumed}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1)}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["exported"]["filing_plan"] == {
|
||||
"repository": "stackchain/dashboard",
|
||||
"label_ids": [7, 3],
|
||||
"milestone_id": 12,
|
||||
"due_date": "2026-08-21",
|
||||
"template_name": "Bug report",
|
||||
"template_id": "bug.yml",
|
||||
"captured_body": "Original field notes",
|
||||
"assignee": "alexander",
|
||||
"assignee_name": "Alexander",
|
||||
"estimate_minutes": 45,
|
||||
"completion_intent": "create-and-start",
|
||||
}
|
||||
assert output["resumed"] == {
|
||||
"repository": "stackchain/dashboard",
|
||||
"title": "Restore my plan",
|
||||
"body": "Do not make me plan twice",
|
||||
"labelIds": [7, 3],
|
||||
"milestoneId": 12,
|
||||
"dueDate": "2026-08-21",
|
||||
"templateName": "Bug report",
|
||||
"templateId": "bug.yml",
|
||||
"capturedBody": "Original field notes",
|
||||
"assignee": "alexander",
|
||||
"assigneeName": "Alexander",
|
||||
"estimateMinutes": 45,
|
||||
"completionIntent": "create-and-start",
|
||||
"blockers": [{"repository": "stackchain/api", "number": 9, "title": "API rollout"}],
|
||||
}
|
||||
|
||||
|
||||
def test_unfiled_captures_reconcile_remote_replaces_stale_content_and_evidence():
|
||||
script = f"""
|
||||
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
|
||||
@@ -510,11 +591,11 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
|
||||
assert "data-capture-id=\"' + escapeHtml(item.capture_id) + '\"" in html
|
||||
assert "requestAnimationFrame(() =>" in html
|
||||
assert "savedCard?.scrollIntoView({block:'nearest'})" in html
|
||||
assert "Saved to Drafts. Choose a repository when you’re ready to file it." in html
|
||||
assert "Saved to Drafts. Choose a repository when you’re ready to file it." in UNFILED.read_text()
|
||||
assert "await dependencies.captures.resume(captureId, dependencies.getLogin())" in DRAFT_SESSION.read_text()
|
||||
assert "await createIssueAttachmentController.serialize()" in html
|
||||
assert "createUnfiledAttachmentStore()" in html
|
||||
assert "dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment)" in DRAFT_SESSION.read_text()
|
||||
assert "dependencies.attachment[evidence ? 'restore' : 'clear'](evidence)" in DRAFT_SESSION.read_text()
|
||||
assert "await unfiledCaptures.completeResume(rUC)" in html
|
||||
assert "...(rUC ? { sourceCaptureId: rUC } : {})" in html
|
||||
assert "item.hasAttachment ? ' · Screenshot attached' : ''" in html
|
||||
@@ -526,6 +607,22 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
|
||||
assert '@media(max-width:320px)' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_planned_draft_can_be_saved_and_resumed_without_replanning():
|
||||
html = await dashboard()
|
||||
feature = UNFILED.read_text()
|
||||
|
||||
assert "const captureDraft = currentIssueCaptureDraft();" in html
|
||||
assert "return withFilingEstimate(issueTemplatePicker.fields(" in html
|
||||
assert "draft.estimateMinutes = Number(value) || null" in feature
|
||||
assert "qs('#create-issue-estimate').value = captureDraft.estimateMinutes || '';" in html
|
||||
assert "qs('#file-new-issue').hidden = enabled" in feature
|
||||
assert "qs('.create-issue-capture-actions').hidden = false" in feature
|
||||
assert "label:item.filingPlan?.repository ? 'Ready to file' : 'Needs filing'" in feature
|
||||
assert "repository:item.filingPlan?.repository || ''" in feature
|
||||
assert "Saved planned Draft" in feature
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_drafts_expose_a_safe_sequential_filing_session():
|
||||
html = await dashboard()
|
||||
@@ -556,6 +653,8 @@ async def test_mobile_capture_capacity_requires_an_explicit_preserving_decision(
|
||||
assert 'id="keep-editing-draft"' in html
|
||||
assert "showDraftCapacityDialog(unfiledCaptures)" in html
|
||||
feature = (Path(__file__).parents[1] / "frontend" / "draft-capacity-dialog.js").read_text()
|
||||
assert "const draft = currentIssueCaptureDraft();" in feature
|
||||
assert "Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence}" in feature
|
||||
assert "unfiledCaptures.replaceOldest(draft, oldest.id)" in feature
|
||||
assert '.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh;' in html
|
||||
assert '.draft-capacity-actions button { min-height:44px;' in html
|
||||
|
||||
@@ -51,6 +51,32 @@ def test_unfiled_drafts_bound_collection_and_decoded_evidence(tmp_path):
|
||||
}])])
|
||||
|
||||
|
||||
def test_unfiled_drafts_validate_and_round_trip_complete_filing_plan(tmp_path):
|
||||
store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3")
|
||||
planned = draft(evidence=[])
|
||||
planned.pop("evidence")
|
||||
planned["filing_plan"] = {
|
||||
"repository": "stackchain/dashboard",
|
||||
"label_ids": [7, 3],
|
||||
"milestone_id": 12,
|
||||
"due_date": "2026-08-21",
|
||||
"template_name": "Bug report",
|
||||
"template_id": "bug.yml",
|
||||
"captured_body": "Original field notes",
|
||||
"assignee": "alexander",
|
||||
"assignee_name": "Alexander",
|
||||
"estimate_minutes": 45,
|
||||
"completion_intent": "create-and-start",
|
||||
}
|
||||
|
||||
assert store.replace("timmy", 0, [planned])["drafts"] == [planned]
|
||||
|
||||
invalid = draft(evidence=[])
|
||||
invalid["filing_plan"] = {**planned["filing_plan"], "repository": "not-a-repository"}
|
||||
with pytest.raises(ValueError, match="filing repository is invalid"):
|
||||
store.replace("timmy", 1, [invalid])
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unfiled_draft_api_is_account_scoped_csrf_protected_and_conflict_safe(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
@@ -69,7 +95,21 @@ async def test_unfiled_draft_api_is_account_scoped_csrf_protected_and_conflict_s
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
payload = {"revision": 0, "drafts": [draft(evidence=[])]}
|
||||
planned = draft(evidence=[])
|
||||
planned["filing_plan"] = {
|
||||
"repository": "stackchain/dashboard",
|
||||
"label_ids": [7, 3],
|
||||
"milestone_id": 12,
|
||||
"due_date": "2026-08-21",
|
||||
"template_name": "Bug report",
|
||||
"template_id": "bug.yml",
|
||||
"captured_body": "Original field notes",
|
||||
"assignee": "alexander",
|
||||
"assignee_name": "Alexander",
|
||||
"estimate_minutes": 45,
|
||||
"completion_intent": "create-and-start",
|
||||
}
|
||||
payload = {"revision": 0, "drafts": [planned]}
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
|
||||
forbidden = await client.put("/api/v1/unfiled-drafts", json=payload)
|
||||
@@ -82,7 +122,7 @@ async def test_unfiled_draft_api_is_account_scoped_csrf_protected_and_conflict_s
|
||||
|
||||
assert forbidden.status_code == 403
|
||||
assert saved.status_code == 200
|
||||
expected = draft(evidence=[])
|
||||
expected = planned.copy()
|
||||
expected.pop("evidence")
|
||||
assert saved.json() == {"revision": 1, "drafts": [expected]}
|
||||
assert stale.status_code == 409
|
||||
|
||||
Reference in new issue
Block a user