feat: personalize adaptive mobile queue priority (Closes #1460)
This commit is contained in:
1 parent
890fd57531
commit
dbabd8640b
10 files changed
+383
-17
No files matched your search
@@ -166,3 +166,66 @@ def test_adaptive_queues_keep_start_continue_actionable_offline_on_phone():
|
||||
assert bounds["y"] + bounds["height"] <= viewport["height"]
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 375, "height": 667},
|
||||
{"width": 430, "height": 932},
|
||||
])
|
||||
def test_operator_reorders_routine_queues_without_leaking_priority_between_accounts(viewport):
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content((FRONTEND / "index.html").read_text())
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "mobile-queue-priority.js")
|
||||
page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js")
|
||||
page.evaluate("""() => {
|
||||
const values = new Map();
|
||||
window.login = 'alice';
|
||||
const rows = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue]'))
|
||||
.map(row => [row.dataset.mobileQueue, row]));
|
||||
window.launcher = createMobileQueueLauncher({
|
||||
getCounts:() => ({attention:2, following:3, authored:1}),
|
||||
getRoutineOrder:() => window.priority.getOrder(), rows,
|
||||
nextAction:document.querySelector('#mobile-queue-next-action'),
|
||||
activeList:document.querySelector('#mobile-queue-active-list'),
|
||||
planningList:document.querySelector('#mobile-queue-planning-list'),
|
||||
allList:document.querySelector('#mobile-queue-all-list'),
|
||||
activeSection:document.querySelector('#mobile-queue-active-list').parentElement,
|
||||
});
|
||||
window.priority = createMobileQueuePriority({
|
||||
storage:{getItem:key => values.get(key) || null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
|
||||
getLogin:() => window.login, document,
|
||||
list:document.querySelector('#mobile-queue-priority-list'),
|
||||
resetButton:document.querySelector('#reset-mobile-queue-priority'),
|
||||
status:document.querySelector('#mobile-queue-priority-status'),
|
||||
labels:{attention:'Attention',today:'Today',update:'Updates',agenda:'Agenda',following:'Following',authored:'My PRs',filed:'Filed',later:'Later',draft:'Drafts'},
|
||||
onChange:() => window.launcher.renderPresentation(),
|
||||
});
|
||||
window.priority.start(); window.launcher.renderPresentation();
|
||||
document.querySelector('#mobile-queue-priority').open = true;
|
||||
document.querySelector('#mobile-queue-sheet').showModal();
|
||||
}""")
|
||||
|
||||
for _ in range(4):
|
||||
page.get_by_role("button", name="Move Following earlier").click()
|
||||
expect(page.locator("#mobile-queue-next-action")).to_have_text("Review Following (3)")
|
||||
assert page.locator("#mobile-queue-active-list [data-mobile-queue]").evaluate_all(
|
||||
"rows => rows.map(row => row.dataset.mobileQueue)"
|
||||
) == ["following", "attention", "authored"]
|
||||
expect(page.locator("#mobile-queue-priority-status")).to_have_text("Following moved earlier.")
|
||||
controls = page.locator(".mobile-queue-priority-controls button")
|
||||
assert controls.count() == 18
|
||||
assert all((controls.nth(i).bounding_box() or {}).get("height", 0) >= 44 for i in range(controls.count()))
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.evaluate("""() => {
|
||||
window.login = 'bob'; window.priority.render(); window.launcher.renderPresentation();
|
||||
}""")
|
||||
expect(page.locator("#mobile-queue-next-action")).to_have_text("Start Attention (2)")
|
||||
assert page.locator("#mobile-queue-priority-list [data-queue-priority]").evaluate_all(
|
||||
"rows => rows.map(row => row.dataset.queuePriority)"
|
||||
)[:3] == ["attention", "today", "update"]
|
||||
browser.close()
|
||||
@@ -10,6 +10,7 @@ from tests.dashboard_bundle import dashboard
|
||||
DOCK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-task-dock.js"
|
||||
ENTRY = Path(__file__).resolve().parents[1] / "frontend" / "mobile-work-entry.js"
|
||||
QUEUE_LAUNCHER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-queue-launcher.js"
|
||||
QUEUE_PRIORITY = Path(__file__).resolve().parents[1] / "frontend" / "mobile-queue-priority.js"
|
||||
TIMER = Path(__file__).resolve().parents[1] / "frontend" / "today-timer.js"
|
||||
|
||||
|
||||
@@ -48,6 +49,88 @@ process.stdout.write(JSON.stringify({{modes, calls}}));
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_priority_persists_complete_account_scoped_routine_order():
|
||||
script = f"""
|
||||
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key, value) => values.set(key, value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
let login = 'alice';
|
||||
const priority = createPriority({{storage, getLogin:() => login}});
|
||||
const original = priority.getOrder();
|
||||
priority.move('following', -1);
|
||||
priority.move('following', -1);
|
||||
priority.move('following', -1);
|
||||
const alice = priority.getOrder();
|
||||
login = 'bob';
|
||||
const bob = priority.getOrder();
|
||||
login = '';
|
||||
const anonymous = priority.getOrder();
|
||||
login = 'alice';
|
||||
priority.reset();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
original, alice, bob, anonymous, reset:priority.getOrder(), keys:Array.from(values.keys()),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
default = ["attention", "today", "update", "agenda", "following", "authored", "filed", "later", "draft"]
|
||||
assert json.loads(result.stdout) == {
|
||||
"original": default,
|
||||
"alice": ["attention", "following", "today", "update", "agenda", "authored", "filed", "later", "draft"],
|
||||
"bob": default,
|
||||
"anonymous": default,
|
||||
"reset": default,
|
||||
"keys": [],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_priority_renders_keyboard_controls_and_updates_immediately():
|
||||
script = f"""
|
||||
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
|
||||
class Element {{
|
||||
constructor(tag='div') {{ this.tag=tag; this.children=[]; this.listeners={{}}; this.attributes={{}}; this.disabled=false; this.textContent=''; }}
|
||||
append(...items) {{ this.children.push(...items); }}
|
||||
replaceChildren(...items) {{ this.children=[...items]; }}
|
||||
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
|
||||
setAttribute(name, value) {{ this.attributes[name]=value; }}
|
||||
click() {{ this.listeners.click?.(); }}
|
||||
}}
|
||||
const values = new Map();
|
||||
const list = new Element(); const resetButton = new Element('button'); const status = new Element();
|
||||
const priority = createPriority({{
|
||||
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
||||
getLogin:()=>'alice', document:{{createElement:tag=>new Element(tag)}}, list, resetButton, status,
|
||||
labels:{{attention:'Attention',today:'Today',update:'Updates',agenda:'Agenda',following:'Following',authored:'My PRs',filed:'Filed',later:'Later',draft:'Drafts'}},
|
||||
}});
|
||||
priority.start();
|
||||
for (let index=0; index<3; index += 1) {{
|
||||
const row = list.children.find(item => item.attributes['data-queue-priority'] === 'following');
|
||||
row.children[1].children[0].click();
|
||||
}}
|
||||
const following = list.children.find(item => item.attributes['data-queue-priority'] === 'following');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
order:list.children.map(item => item.attributes['data-queue-priority']),
|
||||
earlierLabel:following.children[1].children[0].attributes['aria-label'],
|
||||
laterLabel:following.children[1].children[1].attributes['aria-label'],
|
||||
status:status.textContent,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"order": ["attention", "following", "today", "update", "agenda", "authored", "filed", "later", "draft"],
|
||||
"earlierLabel": "Move Following earlier",
|
||||
"laterLabel": "Move Following later",
|
||||
"status": "Following moved earlier.",
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_work_entry_preserves_active_today_then_launches_highest_priority_queue():
|
||||
script = f"""
|
||||
const createEntry = require({json.dumps(str(ENTRY))});
|
||||
@@ -300,6 +383,22 @@ async def test_mobile_queue_sheet_prioritizes_next_active_and_planning_without_d
|
||||
assert html.count(f'<button data-mobile-queue="{name}"') == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_queue_priority_is_packaged_account_scoped_and_touch_safe():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="mobile-queue-priority"' in html
|
||||
assert 'id="mobile-queue-priority-list"' in html
|
||||
assert 'id="reset-mobile-queue-priority"' in html
|
||||
assert 'id="mobile-queue-priority-status" role="status" aria-live="polite"' in html
|
||||
assert '<script src="static/mobile-queue-priority.js"></script>' in html
|
||||
assert "getRoutineOrder: () => mobileQueuePriority?.getOrder()" in html
|
||||
assert "createMobileQueuePriority({" in html
|
||||
assert "getLogin: () => confirmedOwnerLogin" in html
|
||||
assert "mobileQueuePriority.render();\n renderMobileQueuePresentation();" in html
|
||||
assert ".mobile-queue-priority-controls button { min-height:44px;" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_adaptive_mobile_queue_wiring_recommends_again_when_connectivity_changes():
|
||||
html = await dashboard()
|
||||
@@ -910,6 +1009,36 @@ process.stdout.write(JSON.stringify({{first, agenda, opened, fallback, calls}}))
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_launcher_applies_routine_priority_without_demoting_safety_queues():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
let online = true;
|
||||
const launcher = createLauncher({{
|
||||
getCounts:() => ({{delivery:1, gate:2, attention:3, following:4, authored:5}}),
|
||||
isOnline:() => online,
|
||||
getRoutineOrder:() => ['following','authored','attention','today','update','agenda','filed','later','draft'],
|
||||
}});
|
||||
const onlineView = launcher.presentation();
|
||||
online = false;
|
||||
const offlineView = launcher.presentation();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
onlineNext:onlineView.nextUp.name,
|
||||
onlineActive:onlineView.active.map(item => item.name),
|
||||
offlineNext:offlineView.nextUp.name,
|
||||
offlineActive:offlineView.active.map(item => item.name),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"onlineNext": "delivery",
|
||||
"onlineActive": ["delivery", "gate", "following", "authored", "attention"],
|
||||
"offlineNext": "following",
|
||||
"offlineActive": ["delivery", "gate", "following", "authored", "attention"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_launcher_uses_one_adaptive_action_for_daily_preparation():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
|
||||
@@ -1527,6 +1527,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/mobile-task-dock.js",
|
||||
"/dashboard/static/mobile-first-task.js",
|
||||
"/dashboard/static/mobile-work-entry.js",
|
||||
"/dashboard/static/mobile-queue-priority.js",
|
||||
"/dashboard/static/mobile-queue-launcher.js",
|
||||
"/dashboard/static/mobile-delivery-recovery.js",
|
||||
"/dashboard/static/mobile-start-day.js",
|
||||
|
||||
Reference in new issue
Block a user