feat: autocomplete mobile teammate mentions (#447)
This commit is contained in:
1 parent
5a6ef62c4c
commit
0311f85d96
17 files changed
+418
-17
No files matched your search
@@ -1141,6 +1141,32 @@ async def test_gitea_handoff_candidates_exclude_current_and_malformed_users():
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_mention_candidates_match_login_or_name_and_are_bounded():
|
||||
async def handler(request):
|
||||
assert request.url.path == "/api/v1/repos/stackchain/api/assignees"
|
||||
return httpx.Response(200, json=[
|
||||
{"login": "alexa", "full_name": "Alexa Dev"},
|
||||
{"login": "buildbot", "full_name": "Alex Builder"},
|
||||
{"login": "alexb", "full_name": "Alex Backup"},
|
||||
{"login": "casey", "full_name": "Casey"},
|
||||
{"login": "AL", "full_name": "Al"},
|
||||
{"login": "bad space", "full_name": "Malformed"},
|
||||
{"full_name": "Missing login"},
|
||||
])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.mention_candidates("stackchain/api", "alex", limit=2)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == [
|
||||
{"login": "alexa", "name": "Alexa Dev"},
|
||||
{"login": "alexb", "name": "Alex Backup"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_handoff_replaces_operator_and_preserves_coassignees():
|
||||
requests = []
|
||||
@@ -1255,6 +1281,31 @@ async def test_issue_handoff_endpoints_list_candidates_and_confirm_transfer(monk
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mention_candidate_endpoint_bounds_query_and_disables_caching(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def candidates(repository, query, *, limit):
|
||||
calls.append((repository, query, limit))
|
||||
return [{"login": "alex", "name": "Alexander"}]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "mention_candidates", candidates, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
too_short = await client.get(
|
||||
"/api/v1/repos/stackchain/api/mention-candidates?q=a"
|
||||
)
|
||||
response = await client.get(
|
||||
"/api/v1/repos/stackchain/api/mention-candidates?q=AlEx"
|
||||
)
|
||||
|
||||
assert too_short.status_code == 422
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == [{"login": "alex", "name": "Alexander"}]
|
||||
assert calls == [("stackchain/api", "alex", 8)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_handoff_returns_conflict_when_assignment_or_recipient_changed(monkeypatch):
|
||||
async def handoff(_repository, _number, _recipient):
|
||||
|
||||
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v75" in source
|
||||
assert "stackchain-dashboard-shell-v76" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v75" in worker
|
||||
assert "stackchain-dashboard-shell-v76" in worker
|
||||
@@ -0,0 +1,85 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MENTIONS = ROOT / "frontend" / "mention-composer.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
completed = subprocess.run(
|
||||
["node", "-e", script],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_active_mention_replaces_only_the_token_at_the_caret():
|
||||
script = f"""
|
||||
const mentions=require({json.dumps(str(MENTIONS))});
|
||||
const text='Thanks @al for this; keep @casey informed';
|
||||
const caret=text.indexOf(' for');
|
||||
const active=mentions.activeMention(text, caret);
|
||||
const inserted=mentions.insertMention(text, active, 'alex');
|
||||
process.stdout.write(JSON.stringify({{active,inserted}}));
|
||||
"""
|
||||
|
||||
result = run_node(script)
|
||||
|
||||
assert result["active"] == {"start": 7, "end": 10, "query": "al"}
|
||||
assert result["inserted"] == {
|
||||
"value": "Thanks @alex for this; keep @casey informed",
|
||||
"caret": 13,
|
||||
}
|
||||
|
||||
|
||||
def test_controller_ignores_stale_results_and_keyboard_selects_a_teammate():
|
||||
script = f"""
|
||||
const mentions=require({json.dumps(str(MENTIONS))});
|
||||
class Element {{
|
||||
constructor() {{ this.listeners={{}}; this.children=[]; this.attrs={{}}; this.dataset={{}}; this.hidden=true; this.value=''; this.selectionStart=0; this.selectionEnd=0; this.textContent=''; }}
|
||||
addEventListener(type, fn) {{ (this.listeners[type] ||= []).push(fn); }}
|
||||
dispatch(type, extra={{}}) {{ const event={{preventDefault(){{ this.prevented=true; }}, ...extra}}; (this.listeners[type] || []).forEach(fn=>fn(event)); return event; }}
|
||||
replaceChildren(...children) {{ this.children=children; }}
|
||||
appendChild(child) {{ this.children.push(child); }}
|
||||
setAttribute(name, value) {{ this.attrs[name]=String(value); }}
|
||||
removeAttribute(name) {{ delete this.attrs[name]; }}
|
||||
setSelectionRange(start, end) {{ this.selectionStart=start; this.selectionEnd=end; }}
|
||||
focus() {{ this.focused=true; }}
|
||||
}}
|
||||
const textarea=new Element(), listbox=new Element(), status=new Element();
|
||||
const pending=[];
|
||||
const controller=mentions.create({{
|
||||
textarea,listbox,status,getRepository:()=> 'stackchain/api',
|
||||
loadCandidates:(repository, query)=>new Promise(resolve=>pending.push({{repository,query,resolve}})),
|
||||
setTimer:fn=>{{ fn(); return 1; }}, clearTimer:()=>{{}},
|
||||
createOption:()=>new Element(),
|
||||
}});
|
||||
controller.start();
|
||||
(async()=>{{
|
||||
textarea.value='Ping @al'; textarea.selectionStart=textarea.value.length; textarea.dispatch('input');
|
||||
textarea.value='Ping @alex'; textarea.selectionStart=textarea.value.length; textarea.dispatch('input');
|
||||
pending[1].resolve([{{login:'alex',name:'Alexander'}}]); await Promise.resolve(); await Promise.resolve();
|
||||
pending[0].resolve([{{login:'alice',name:'Alice'}}]); await Promise.resolve(); await Promise.resolve();
|
||||
const before={{queries:pending.map(item=>item.query), options:listbox.children.map(item=>item.dataset.login), expanded:textarea.attrs['aria-expanded']}};
|
||||
textarea.dispatch('keydown', {{key:'ArrowDown'}});
|
||||
const enter=textarea.dispatch('keydown', {{key:'Enter'}});
|
||||
process.stdout.write(JSON.stringify({{before,value:textarea.value,caret:textarea.selectionStart,focused:textarea.focused,prevented:enter.prevented,hidden:listbox.hidden}}));
|
||||
}})().catch(error=>{{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
result = run_node(script)
|
||||
|
||||
assert result["before"] == {
|
||||
"queries": ["al", "alex"],
|
||||
"options": ["alex"],
|
||||
"expanded": "true",
|
||||
}
|
||||
assert result["value"] == "Ping @alex "
|
||||
assert result["caret"] == 11
|
||||
assert result["focused"] is True
|
||||
assert result["prevented"] is True
|
||||
assert result["hidden"] is True
|
||||
@@ -35,4 +35,22 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v75" in worker
|
||||
assert "stackchain-dashboard-shell-v76" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert '<script src="static/mention-composer.js"></script>' in html
|
||||
for composer in ("issue-comment", "pull-comment", "update-reply"):
|
||||
assert f'id="{composer}-mentions"' in html
|
||||
assert f'id="{composer}-mention-status"' in html
|
||||
assert f"textarea:qs('#{composer}')" in dashboard
|
||||
assert f"listbox:qs('#{composer}-mentions')" in dashboard
|
||||
assert dashboard.count("createMentionComposer({") == 3
|
||||
assert "mention-candidates?q=" in dashboard
|
||||
assert ".mention-options" in css
|
||||
assert "min-height:44px" in css
|
||||
assert "max-width:100%" in css
|
||||
@@ -232,6 +232,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v75" in source
|
||||
assert "stackchain-dashboard-shell-v76" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -122,7 +122,7 @@ async function dispatchNotificationClick(route) {{
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v75" in source
|
||||
assert "stackchain-dashboard-shell-v76" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -131,7 +131,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v75" in source
|
||||
assert "stackchain-dashboard-shell-v76" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -139,7 +139,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v75" in source
|
||||
assert "stackchain-dashboard-shell-v76" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -147,14 +147,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v75" in source
|
||||
assert "stackchain-dashboard-shell-v76" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v75" in source
|
||||
assert "stackchain-dashboard-shell-v76" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -163,21 +163,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v75" in source
|
||||
assert "stackchain-dashboard-shell-v76" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v75" in source
|
||||
assert "stackchain-dashboard-shell-v76" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v75" in source
|
||||
assert "stackchain-dashboard-shell-v76" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -358,7 +358,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v75" in source
|
||||
assert "stackchain-dashboard-shell-v76" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
@@ -425,6 +425,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/install-app.js",
|
||||
"/dashboard/static/mobile-search-viewport.js",
|
||||
"/dashboard/static/mobile-composer-viewport.js",
|
||||
"/dashboard/static/mention-composer.js",
|
||||
"/dashboard/static/background-issue-sync.js",
|
||||
}
|
||||
|
||||
|
||||
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
||||
def test_readiness_runtime_is_available_in_offline_shell():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v75';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v76';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
|
||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v75" in source
|
||||
assert "stackchain-dashboard-shell-v76" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user