feat: save photo-only captures as drafts (Closes #939)
CI / lint (pull_request) Successful in 2m9s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m2s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-16 04:59:08 +00:00
1 parent 8a85b344f8
commit caa0fbc2fd
7 files changed
+118 -14

No files matched your search

@@ -186,6 +186,12 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
assert page.evaluate("() => navigator.serviceWorker.controller !== null")
new_action.click()
expect(page.locator("#create-issue-sheet")).to_have_class("create-issue-sheet open")
assert page.evaluate("document.activeElement?.id") != "create-issue-title"
for control in page.locator(".photo-evidence-actions .issue-attachment-trigger").all():
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
expect(page.locator("#voice-issue-capture")).to_be_visible()
page.locator("#start-voice-issue-capture").click()
page.evaluate("""([title, body]) => {
+63 -4
View File
@@ -68,7 +68,11 @@ const planned=createUnfiledCaptures.summary({{
attachmentCount:2,
}});
const minimal=createUnfiledCaptures.summary({{}});
process.stdout.write(JSON.stringify({{planned,minimal}}));
const titles=[
createUnfiledCaptures.displayTitle({{title:'Pump failure',hasAttachment:true}}),
createUnfiledCaptures.displayTitle({{title:'',hasAttachment:true}}),
];
process.stdout.write(JSON.stringify({{planned,minimal,titles}}));
"""
assert run_node(script) == {
@@ -86,6 +90,24 @@ process.stdout.write(JSON.stringify({{planned,minimal}}));
"action": "Choose repository",
"details": "Create issue · No screenshots",
},
"titles": ["Pump failure", "Untitled photo draft"],
}
def test_capture_title_focus_defers_phone_keyboard_until_draft_continuation():
script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
const shouldFocus=createUnfiledCaptures.shouldFocusTitle;
process.stdout.write(JSON.stringify({{
freshPhone:shouldFocus({{mobile:true,continuing:false}}),
resumedPhoto:shouldFocus({{mobile:true,continuing:true}}),
freshDesktop:shouldFocus({{mobile:false,continuing:false}}),
}}));
"""
assert run_node(script) == {
"freshPhone": False,
"resumedPhoto": True,
"freshDesktop": True,
}
@@ -198,6 +220,41 @@ process.stdout.write(JSON.stringify({{listed:captures.list()[0],names:resumed.at
assert output["stored"] == output["notes"]
def test_unfiled_capture_saves_untitled_photo_only_draft_but_rejects_empty_capture():
script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
const values=new Map(),blobs=new Map();
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const attachmentStore={{put:async(id,value)=>blobs.set(id,value),get:async id=>blobs.get(id),delete:async id=>blobs.delete(id)}};
const photo={{filename:'field.jpg',contentType:'image/jpeg',blob:new Blob(['photo'],{{type:'image/jpeg'}}),note:'Pump label'}};
(async()=>{{
const captures=createUnfiledCaptures({{storage,attachmentStore,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'photo-only'}});
const saved=await captures.save({{title:' ',body:'',attachments:[photo]}});
const listed=captures.list()[0];
const resumed=await captures.resume(saved.id,'timmy');
const remoteValues=new Map(),remoteBlobs=new Map();
const remote=createUnfiledCaptures({{
storage:{{getItem:k=>remoteValues.get(k)||null,setItem:(k,v)=>remoteValues.set(k,v)}},
attachmentStore:{{put:async(id,value)=>remoteBlobs.set(id,value),get:async id=>remoteBlobs.get(id),delete:async id=>remoteBlobs.delete(id)}},
getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'unused'
}});
await remote.mergeRemote(await captures.exportOwned('timmy'),'timmy');
const remoteResumed=await remote.resume(saved.id,'timmy');
let emptyError='';
try {{ await captures.save({{title:' ',body:''}}); }} catch(error) {{ emptyError=error.message; }}
process.stdout.write(JSON.stringify({{saved,listed,resumedTitle:resumed.title,resumedEvidence:resumed.attachments.map(item=>item.filename),remoteTitle:remoteResumed.title,remoteEvidence:remoteResumed.attachments.map(item=>item.filename),emptyError}}));
}})().catch(error=>{{console.error(error);process.exit(1)}});
"""
output = run_node(script)
assert output["saved"]["title"] == ""
assert output["listed"]["title"] == ""
assert output["resumedTitle"] == ""
assert output["resumedEvidence"] == ["field.jpg"]
assert output["remoteTitle"] == ""
assert output["remoteEvidence"] == ["field.jpg"]
assert output["emptyError"] == "Add a title or photo before saving."
def test_unfiled_captures_export_and_import_ordered_evidence_between_devices():
script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
@@ -643,7 +700,7 @@ process.stdout.write(JSON.stringify({{errors,size:values.size}}));
output = run_node(script)
assert output == {
"errors": ["Add a title before saving.", "Offline identity is unavailable. Reconnect once before saving private work."],
"errors": ["Add a title or photo before saving.", "Offline identity is unavailable. Reconnect once before saving private work."],
"size": 0,
}
@@ -670,7 +727,8 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
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
assert "title:unfiledDraftDisplayTitle(item)" in html
assert "item.hasAttachment ? 'Photo evidence attached' : ''" in html
assert "issueCapture.saveDraft(resumed)" in DRAFT_SESSION.read_text()
assert "item.kind === 'unfiled-issue'" in html
assert '.create-issue-actions button { min-height:44px;' in html
@@ -759,7 +817,8 @@ async def test_mobile_new_opens_capture_first_and_progressively_reveals_filing_f
assert 'id="file-new-issue" type="button">File now' in html
assert "function setIssueFilingMode(enabled)" in html
assert "setIssueFilingMode(Boolean(captureDraft.repository))" in html
assert "qs('#create-issue-title').focus();" in html
assert "window.matchMedia?.('(max-width: 600px)').matches === true" in html
assert "unfiledShouldFocusTitle({mobile:mobileCapture, continuing:continuingCapture})" in html
assert "qs('#file-new-issue').addEventListener('click'" in html
assert '.create-issue-capture-actions button { min-height:44px;' in html
assert '.create-issue-capture-actions[hidden] { display:none;' in html
+14
View File
@@ -51,6 +51,20 @@ def test_unfiled_drafts_bound_collection_and_decoded_evidence(tmp_path):
}])])
def test_unfiled_drafts_allow_untitled_photo_evidence_but_reject_empty_records(tmp_path):
store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3")
photo_only = draft(title="")
validated = main.UnfiledDraft.model_validate(photo_only).model_dump()
assert store.replace("timmy", 0, [validated])["drafts"][0]["title"] == ""
empty = draft(title="", evidence=[])
with pytest.raises(ValueError, match="title or evidence is required"):
main.UnfiledDraft.model_validate(empty)
with pytest.raises(ValueError, match="title or evidence is required"):
store.replace("timmy", 1, [empty])
def test_unfiled_drafts_validate_and_round_trip_complete_filing_plan(tmp_path):
store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3")
planned = draft(evidence=[])