feat: review and reorder mobile evidence (Closes #825)
CI / lint (pull_request) Successful in 1m46s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-14 12:43:31 +00:00
1 parent 6a9271cabb
commit 6315323a48
10 files changed
+345 -73

No files matched your search

+115 -7
View File
@@ -89,6 +89,33 @@ controller.select(image('replacement.png'));
assert len({key for _, key in output["calls"]}) == 5
def test_mobile_evidence_bundle_reorders_selected_image_for_serialization_and_upload():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
const uploaded=[];
const image=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
const controller=attachment.create({{
maxFiles:5,
upload:async payload=>{{uploaded.push(payload.filename);return {{markdown:'!['+payload.filename+'](url)'}};}},
}});
['one.png','two.png','three.png'].forEach(name=>controller.select(image(name)));
controller.move(2, 0);
controller.move(0, 99);
controller.move(0, 1);
(async()=>{{
const serialized=await controller.serialize();
const comment=await controller.prepareComment({{repository:'o/r',number:825}},'Reviewed');
process.stdout.write(JSON.stringify({{names:serialized.map(x=>x.filename),uploaded,comment}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = json.loads(run_node(script))
assert output["names"] == ["one.png", "three.png", "two.png"]
assert output["uploaded"] == output["names"]
assert output["comment"].endswith(
"![one.png](url)\n\n![three.png](url)\n\n![two.png](url)"
)
def test_mobile_attachment_retry_reuses_operation_key_until_file_changes():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
@@ -273,6 +300,53 @@ process.stdout.write(JSON.stringify({{restored,src:image.src,meta:meta.textConte
def test_evidence_review_tray_selects_reorders_and_removes_any_screenshot():
script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
class Element{{
constructor(tag='div'){{this.tag=tag;this.listeners={{}};this.children=[];this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false;this.attributes={{}};this.dataset={{}};}}
addEventListener(type,fn){{this.listeners[type]=fn;}}
dispatch(type){{return this.listeners[type]({{target:this}});}}
appendChild(child){{this.children.push(child);return child;}}
replaceChildren(...children){{this.children=children;}}
setAttribute(name,value){{this.attributes[name]=String(value);}}
}}
const document={{createElement:tag=>new Element(tag)}};
const input=new Element('input'),preview=new Element(),image=new Element('img'),meta=new Element(),remove=new Element('button'),status=new Element(),tray=new Element(),earlier=new Element('button'),later=new Element('button');
input.multiple=true;
const urls=[];
const controller=attachment.mount({{input,preview,image,meta,remove,status,tray,earlier,later,document,
removedMessage:'Screenshot removed. Your issue draft is unchanged.',
createObjectURL:blob=>{{const url='blob:'+blob.name;urls.push(url);return url;}},revokeObjectURL:()=>{{}},upload:async()=>{{}}}});
const file=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
input.files=['one.png','two.png','three.png'].map(file);
(async()=>{{
await input.dispatch('change');
await tray.children[1].dispatch('click');
await earlier.dispatch('click');
const reordered=(await controller.serialize()).map(item=>item.filename);
await remove.dispatch('click');
const remaining=(await controller.serialize()).map(item=>item.filename);
process.stdout.write(JSON.stringify({{reordered,remaining,preview:image.src,meta:meta.textContent,
selected:tray.children.map(button=>button.attributes['aria-pressed']),
labels:tray.children.map(button=>button.attributes['aria-label']),
earlierDisabled:earlier.disabled,laterDisabled:later.disabled,status:status.textContent}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = json.loads(run_node(script))
assert output["reordered"] == ["two.png", "one.png", "three.png"]
assert output["remaining"] == ["one.png", "three.png"]
assert output["preview"] == "blob:one.png"
assert output["meta"] == "Screenshot 1 of 2 · one.png · 1 KB"
assert output["selected"] == ["true", "false"]
assert output["labels"] == [
"Review screenshot 1 of 2: one.png", "Review screenshot 2 of 2: three.png"
]
assert output["earlierDisabled"] is True
assert output["laterDisabled"] is False
assert output["status"] == "Screenshot removed. Your issue draft is unchanged."
def test_metadata_only_attachment_cannot_render_as_an_empty_screenshot():
script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
@@ -317,11 +391,25 @@ def test_new_issue_sheet_captures_screenshot_into_durable_outbox():
assert 'Up to 5' in html
assert 'id="create-issue-attachment-preview"' in html
assert 'id="remove-create-issue-attachment"' in html
assert 'id="create-issue-evidence-tray"' in html
assert 'role="toolbar"' in html
assert 'aria-label="Evidence screenshots"' in html
assert 'id="move-create-issue-attachment-earlier"' in html
assert 'id="move-create-issue-attachment-later"' in html
assert "tray: qs('#create-issue-evidence-tray')" in source
assert "earlier: qs('#move-create-issue-attachment-earlier')" in source
assert "later: qs('#move-create-issue-attachment-later')" in source
assert "const createIssueAttachmentController = issueAttachment.mount({" in source
assert "attachments:evidence" in source
assert "hydrated.attachments || hydrated.attachment" in source
assert "createIssueAttachmentController.clear();" in source
assert ".create-issue-attachment" in css
assert ".issue-evidence-tray" in css
assert ".issue-evidence-review-actions" in css
assert "#create-issue-attachment-preview { grid-template-columns:1fr;" in css
assert "#create-issue-attachment-image { grid-row:auto; width:100%;" in css
assert "overflow-x:auto" in css
assert "min-width:44px" in css
assert "overflow-x:hidden" in css
@@ -461,21 +549,38 @@ def test_pending_comment_admission_locks_attachment_replacement_and_remove_contr
script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
class Element {{
constructor() {{ this.listeners={{}};this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false; }}
constructor() {{ this.listeners={{}};this.children=[];this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false;this.attributes={{}}; }}
addEventListener(type,fn) {{ this.listeners[type]=fn; }}
appendChild(child) {{ this.children.push(child); }}
replaceChildren(...children) {{ this.children=children; }}
setAttribute(name,value) {{ this.attributes[name]=String(value); }}
}}
const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element();
const controller=attachment.mount({{input,preview,image,meta,remove,status,
const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element(),tray=new Element(),earlier=new Element(),later=new Element();
input.multiple=true;
const document={{createElement:()=>new Element()}};
const controller=attachment.mount({{input,preview,image,meta,remove,status,tray,earlier,later,document,
createObjectURL:()=> 'blob:preview',revokeObjectURL:()=>{{}},upload:async()=>{{}}}});
controller.restore([
{{filename:'one.png',contentType:'image/png',blob:new Blob(['one'],{{type:'image/png'}})}},
{{filename:'two.png',contentType:'image/png',blob:new Blob(['two'],{{type:'image/png'}})}},
]);
controller.setBusy(true);
const pending={{input:input.disabled,remove:remove.disabled}};
const pending={{input:input.disabled,remove:remove.disabled,earlier:earlier.disabled,later:later.disabled,
thumbnails:tray.children.map(button=>button.disabled)}};
controller.setBusy(false);
process.stdout.write(JSON.stringify({{pending,released:{{input:input.disabled,remove:remove.disabled}}}}));
process.stdout.write(JSON.stringify({{pending,released:{{input:input.disabled,remove:remove.disabled,
earlier:earlier.disabled,later:later.disabled,thumbnails:tray.children.map(button=>button.disabled)}}}}));
"""
assert json.loads(run_node(script)) == {
"pending": {"input": True, "remove": True},
"released": {"input": False, "remove": False},
"pending": {
"input": True, "remove": True, "earlier": True, "later": True,
"thumbnails": [True, True],
},
"released": {
"input": False, "remove": False, "earlier": False, "later": True,
"thumbnails": [False, False],
},
}
@@ -513,6 +618,9 @@ def test_readme_documents_mobile_screenshot_limits_and_delivery_order():
assert "uploads before the comment is posted" in readme
assert "New issue" in readme
assert "retry resumes with the confirmed issue" in readme
assert "thumbnail tray" in readme
assert "Move earlier" in readme
assert "Remove selected" in readme
def test_issue_screenshot_comments_queue_serialized_bytes_before_clearing_or_advancing():
+1
View File
@@ -837,6 +837,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/search-batch-plan.js",
"/dashboard/static/conversation.js",
"/dashboard/static/comment-actions.js",
"/dashboard/static/issue-evidence-review.js",
"/dashboard/static/issue-attachment.js",
"/dashboard/static/issue-sheet.js",
"/dashboard/static/create-issue-sheet.js",