import json import subprocess from pathlib import Path import pytest 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" TIMER = Path(__file__).resolve().parents[1] / "frontend" / "today-timer.js" def test_mobile_work_entry_prioritizes_continue_resume_start_plan_then_fallback(): script = f""" const createEntry = require({json.dumps(str(ENTRY))}); const state = {{active:true, resumable:true, today:2, eligible:3}}; const calls = []; const entry = createEntry({{ isTodayActive: () => state.active, isTodayResumable: () => state.resumable, getTodayCount: () => state.today, getEligibleCount: () => state.eligible, continueToday: () => calls.push('continue'), resumeToday: () => calls.push('resume'), startToday: () => calls.push('start'), planToday: () => calls.push('plan'), openFallback: () => calls.push('fallback'), }}); const modes = []; modes.push(entry.open()); state.active = false; modes.push(entry.open()); state.resumable = false; modes.push(entry.open()); state.today = 0; modes.push(entry.open()); state.eligible = 0; modes.push(entry.open()); process.stdout.write(JSON.stringify({{modes, calls}})); """ result = subprocess.run( ["node", "-e", script], capture_output=True, text=True ) assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == { "modes": ["continue", "resume", "start", "plan", "work"], "calls": ["continue", "resume", "start", "plan", "fallback"], } def test_mobile_task_dock_routes_actions_hides_for_overlays_and_restores_focus(): script = f""" const createDock = require({json.dumps(str(DOCK))}); class FakeElement {{ constructor() {{ this.listeners = {{}}; this.attributes = {{}}; this.classList = {{ values:new Set(), contains:value => this.classList.values.has(value) }}; this.hidden = false; this.focuses = 0; }} addEventListener(name, callback) {{ this.listeners[name] = callback; }} click() {{ this.listeners.click({{currentTarget:this}}); }} setAttribute(name, value) {{ this.attributes[name] = value; }} removeAttribute(name) {{ delete this.attributes[name]; }} focus() {{ this.focuses += 1; }} }} const nav = new FakeElement(); const sessionHud = new FakeElement(); const buttons = Object.fromEntries(['work','attention','find','new','search','drafts'].map(name => [name, new FakeElement()])); buttons.attention.hidden = true; const overlay = new FakeElement(); const calls = []; let observerCallback; const dock = createDock({{ nav, sessionHud, buttons, overlays:[overlay], actions: Object.fromEntries(Object.keys(buttons).map(name => [name, () => calls.push(name)])), observe(callback) {{ observerCallback = callback; return {{disconnect() {{}}}}; }}, }}); dock.start(); buttons.work.click(); buttons.find.click(); overlay.classList.values.add('open'); observerCallback(); const hiddenWhileOpen = nav.hidden; const hudHiddenWhileOpen = sessionHud.attributes['data-overlay-hidden']; overlay.classList.values.delete('open'); observerCallback(); buttons.drafts.click(); process.stdout.write(JSON.stringify({{ calls, hiddenWhileOpen, hiddenAfterClose:nav.hidden, hudHiddenWhileOpen, hudHiddenAfterClose:sessionHud.attributes['data-overlay-hidden'] || null, attentionHidden:buttons.attention.hidden, findFocuses:buttons.find.focuses, current:Object.fromEntries(Object.entries(buttons).map(([name, button]) => [name, button.attributes['aria-current'] || null])), }})); """ result = subprocess.run( ["node", "-e", script], capture_output=True, text=True ) assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == { "calls": ["work", "find", "drafts"], "hiddenWhileOpen": True, "hiddenAfterClose": False, "hudHiddenWhileOpen": "true", "hudHiddenAfterClose": None, "attentionHidden": True, "findFocuses": 1, "current": { "work": None, "attention": None, "find": None, "new": None, "search": None, "drafts": "page", }, } def test_mobile_task_dock_programmatically_selects_destination_without_running_action(): script = f""" const createDock = require({json.dumps(str(DOCK))}); const calls = []; const makeButton = () => ({{ attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }}, removeAttribute(name) {{ delete this.attributes[name]; }}, }}); const buttons = {{work:makeButton(), drafts:makeButton()}}; const dock = createDock({{ nav:{{}}, buttons, actions:{{work:()=>calls.push('work'), drafts:()=>calls.push('drafts')}}, }}); const selected = dock.select('drafts'); process.stdout.write(JSON.stringify({{ selected, calls, work:buttons.work.attributes['aria-current'] || null, drafts:buttons.drafts.attributes['aria-current'] || null, }})); """ result = subprocess.run( ["node", "-e", script], capture_output=True, text=True ) assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == { "selected": True, "calls": [], "work": None, "drafts": "page", } def test_mobile_task_dock_keeps_today_label_separate_from_attention_count(): script = f""" const createDock = require({json.dumps(str(DOCK))}); const work = {{ attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }} }}; const workLabel = {{ textContent:'' }}; const dock = createDock({{ nav:{{}}, buttons:{{work}}, workLabel }}); dock.updateWork('continue', 3); const continuing = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }}; dock.updateWork('resume', 0); const resuming = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }}; dock.updateWork('start', 0); const starting = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }}; dock.updateWork('plan', 0); const planning = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }}; dock.updateWork('work', 0); process.stdout.write(JSON.stringify({{ continuing, resuming, starting, planning, fallback:{{ text:workLabel.textContent, label:work.attributes['aria-label'] }}, }})); """ result = subprocess.run( ["node", "-e", script], capture_output=True, text=True ) assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == { "continuing": { "text": "Continue", "label": "Continue Today", }, "resuming": {"text": "Resume", "label": "Resume Today"}, "starting": {"text": "Start", "label": "Start Today"}, "planning": {"text": "Plan", "label": "Plan Today"}, "fallback": {"text": "Work", "label": "Work"}, } def test_mobile_today_hud_completes_current_item_and_labels_final_item_for_recap(): script = f""" const createView = require({json.dumps(str(TIMER))}).createView; class FakeElement {{ constructor() {{ this.listeners = {{}}; this.hidden = true; this.textContent = ''; this.attributes = {{}}; }} addEventListener(name, callback) {{ this.listeners[name] = callback; }} click() {{ return this.listeners.click?.(); }} setAttribute(name, value) {{ this.attributes[name] = value; }} }} const hud = new FakeElement(); const open = new FakeElement(); const toggle = new FakeElement(); const complete = new FakeElement(); const progress = new FakeElement(); const elements = {{ '[data-mobile-today-hud]':[hud], '[data-mobile-today-open]':[open], '[data-mobile-today-toggle]':[toggle], '[data-mobile-today-complete]':[complete], '[data-work-session-progress]':[progress], '[data-work-session-adjust-plan]':[], '[data-work-session-timer-toggle]':[toggle], }}; let active = true; const snapshot = {{identity:'issue:r:1:', elapsed_ms:0, running:true}}; const completed = []; const view = createView({{ timer:{{snapshot:()=>snapshot,totalElapsed:()=>0,pause:()=>true,resume:()=>true}}, isActive:()=>active, queryAll:selector=>elements[selector] || [], formatEstimate:value=>value + 'm', getItem:()=>({{title:'Ship mobile flow'}}), onComplete:identity=>{{ completed.push(identity); return false; }}, }}); view.update({{index:1,total:2}}, null); const next = {{hidden:complete.hidden,label:complete.textContent,aria:complete.attributes['aria-label']}}; complete.click(); const preservedAfterFailure = {{hudHidden:hud.hidden,identity:snapshot.identity}}; view.update({{index:2,total:2}}, null); const recap = {{hidden:complete.hidden,label:complete.textContent}}; active = false; view.render(); process.stdout.write(JSON.stringify({{next,recap,completed,preservedAfterFailure,hiddenWhenInactive:complete.hidden}})); """ result = subprocess.run( ["node", "-e", script], capture_output=True, text=True ) assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == { "next": { "hidden": False, "label": "Done & next", "aria": "Complete Ship mobile flow and open next Today item", }, "recap": {"hidden": False, "label": "Done & recap"}, "completed": ["issue:r:1:"], "preservedAfterFailure": {"hudHidden": False, "identity": "issue:r:1:"}, "hiddenWhenInactive": True, } def test_mobile_task_dock_exposes_independent_attention_action_and_six_column_state(): script = f""" const createDock = require({json.dumps(str(DOCK))}); const nav = {{ attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }}, removeAttribute(name) {{ delete this.attributes[name]; }} }}; const attention = {{ attributes: {{}}, hidden:true, setAttribute(name, value) {{ this.attributes[name] = value; }} }}; const badge = {{ textContent:'', hidden:true }}; const dock = createDock({{ nav, buttons:{{attention}}, attentionBadge:badge }}); dock.updateAttention(3); const pending = {{ count:badge.textContent, badgeHidden:badge.hidden, actionHidden:attention.hidden, label:attention.attributes['aria-label'], layout:nav.attributes['data-attention'] }}; dock.updateAttention(0); process.stdout.write(JSON.stringify({{ pending, empty:{{ count:badge.textContent, badgeHidden:badge.hidden, actionHidden:attention.hidden, label:attention.attributes['aria-label'], layout:nav.attributes['data-attention'] || null }}, }})); """ result = subprocess.run( ["node", "-e", script], capture_output=True, text=True ) assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == { "pending": {"count": "3", "badgeHidden": False, "actionHidden": False, "label": "Attention, 3 items", "layout": "true"}, "empty": {"count": "0", "badgeHidden": True, "actionHidden": True, "label": "Attention, 0 items", "layout": None}, } def test_mobile_task_dock_opens_stable_queue_switcher_and_routes_each_queue(): script = f""" const createDock = require({json.dumps(str(DOCK))}); class FakeElement {{ constructor() {{ this.listeners = {{}}; this.attributes = {{}}; this.hidden = false; this.textContent = ''; this.focuses = 0; this.classList = {{contains:()=>false}}; }} addEventListener(name, callback) {{ this.listeners[name] = callback; }} click() {{ return this.listeners.click?.({{currentTarget:this}}); }} setAttribute(name, value) {{ this.attributes[name] = value; }} removeAttribute(name) {{ delete this.attributes[name]; }} focus() {{ this.focuses += 1; }} }} const nav = new FakeElement(); const queues = new FakeElement(); const sheet = new FakeElement(); sheet.open = false; sheet.showModal = function () {{ this.open = true; }}; sheet.close = function () {{ this.open = false; this.listeners.close?.(); }}; const close = new FakeElement(); const badge = new FakeElement(); const rows = Object.fromEntries(['today','attention','update','later','draft','recaps'].map(name => [name, new FakeElement()])); const counts = Object.fromEntries(Object.keys(rows).map(name => [name, new FakeElement()])); const selected = []; const utilities = []; const dock = createDock({{ nav, buttons:{{queues}}, actions:{{queues:()=>{{}}}}, overlays:[sheet], queueSheet:sheet, queueClose:close, queueRows:rows, queueCounts:counts, queueBadge:badge, onSelectQueue:(name,row)=>name === 'recaps' ? utilities.push(row === rows.recaps ? 'recaps:trigger' : 'recaps:missing-trigger') : selected.push(name), observe() {{}}, }}); dock.start(); dock.updateQueues({{today:2, attention:1, update:5, later:3, draft:4}}); queues.click(); const opened = sheet.open; rows.update.click(); const closedAfterSelect = !sheet.open; queues.click(); rows.recaps.click(); const closedAfterUtility = !sheet.open; queues.click(); close.click(); process.stdout.write(JSON.stringify({{ opened, closedAfterSelect, closedAfterUtility, selected, utilities, badge:badge.textContent, badgeLabel:queues.attributes['aria-label'], counts:Object.fromEntries(Object.entries(counts).map(([name, node]) => [name, node.textContent])), updateLabel:rows.update.attributes['aria-label'], queueFocuses:queues.focuses, columnState:nav.attributes['data-attention'] || null, }})); """ result = subprocess.run(["node", "-e", script], capture_output=True, text=True) assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == { "opened": True, "closedAfterSelect": True, "closedAfterUtility": True, "selected": ["update"], "utilities": ["recaps:trigger"], # Updates are a drill-down within Attention and must not inflate the aggregate badge. "badge": "10", "badgeLabel": "Queues, 10 items", "counts": {"today": "2", "attention": "1", "update": "5", "later": "3", "draft": "4", "recaps": "0"}, "updateLabel": "Updates, 5 unread conversations", "queueFocuses": 3, "columnState": None, } def test_mobile_queue_launcher_opens_first_actionable_item_after_selecting_queue(): script = f""" const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))}); const calls = []; const launcher = createLauncher({{ selectFilter: name => calls.push('filter:' + name), firstAction: name => name === 'attention' ? {{click() {{ calls.push('open:first'); }}}} : null, announce: message => calls.push('announce:' + message), }}); const opened = launcher.open('attention'); const empty = launcher.open('later'); process.stdout.write(JSON.stringify({{opened, empty, calls}})); """ result = subprocess.run(["node", "-e", script], capture_output=True, text=True) assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == { "opened": "opened", "empty": "empty", "calls": [ "filter:attention", "open:first", "filter:later", "announce:No deferred work is ready to open.", ], } def test_mobile_queue_launcher_recommends_and_revalidates_cross_queue_continuation(): script = f""" const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))}); const calls = []; let counts = {{attention: 2, today: 1, later: 4, draft: 3}}; const launcher = createLauncher({{ openToday: () => calls.push('today'), selectFilter: name => calls.push('filter:' + name), firstAction: name => (counts[name] || 0) ? {{click() {{ calls.push('open:' + name); }}}} : null, announce: message => calls.push('announce:' + message), getCounts: () => counts, openFindWork: () => calls.push('find'), }}); const first = launcher.recommend(); counts = {{attention: 0, today: 0, later: 4, draft: 3}}; const opened = launcher.continueWork(); counts = {{attention: 0, today: 0, later: 0, draft: 0}}; const fallback = launcher.recommend(); launcher.continueWork(); process.stdout.write(JSON.stringify({{first, opened, fallback, calls}})); """ result = subprocess.run(["node", "-e", script], capture_output=True, text=True) assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == { "first": {"name": "attention", "count": 2, "label": "Start Attention (2)"}, "opened": "opened", "fallback": {"name": "find", "count": 0, "label": "Find Work"}, "calls": ["filter:later", "open:later", "find"], } @pytest.mark.anyio async def test_dashboard_renders_accessible_mobile_queue_completion_handoff(): html = await dashboard() assert 'id="mobile-queue-heading"' in html assert 'data-mobile-queue="find"' in html assert "mobileQueueLauncher.recommend()" in html assert "showMobileQueueCompletion('Updates')" in html assert "row.setAttribute('data-recommended', 'true')" in html assert "row.focus()" in html @pytest.mark.anyio async def test_dashboard_renders_and_wires_mobile_queue_switcher(): html = await dashboard() assert 'data-mobile-task="queues"' in html assert 'id="mobile-queue-sheet"' in html assert 'aria-labelledby="mobile-queue-heading"' in html assert 'data-mobile-queue="today"' in html assert 'data-mobile-queue="attention"' in html assert 'data-mobile-queue="update"' in html assert 'UpdatesUnread conversations' in html assert 'data-mobile-queue="later"' in html assert 'data-mobile-queue="draft"' in html assert 'data-mobile-queue="recaps"' in html assert 'RecapsHistory' in html assert 'id="mobile-queue-count"' in html assert 'mobileTaskDock.updateQueues(counts)' in html assert '' in html assert "const mobileQueueLauncher = createMobileQueueLauncher({" in html assert "firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit')" in html assert "name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name)" in html assert '.mobile-queue-sheet' in html assert '.my-work-actions { display:none;' in html assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html assert '.mobile-task-dock[data-attention="true"]' not in html @pytest.mark.anyio async def test_dashboard_wires_mobile_work_dock_into_today_session_lifecycle(): html = await dashboard() assert 'id="mobile-work-label">Work' in html assert '' in html assert "const mobileWorkEntry = createMobileWorkEntry({" in html assert "isTodayActive: () => workSession.checkpointed()" in html assert "isTodayResumable: () => workSession.resumable()" in html assert "getTodayCount: () => todayMyWork.length" in html assert "getEligibleCount: () => activeMyWork.length" in html assert "continueToday: continueTodaySession" in html assert "resumeToday: resumeTodaySession" in html assert "startToday: startTodaySession" in html assert "planToday: () => openPlanToday(mobileTaskButtons.work)" in html assert "work: () => mobileWorkEntry.open()" in html assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html assert "mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention)" in html assert "workSession.reopen(item)" in html assert "runTodayTransition('continue')" in html @pytest.mark.anyio async def test_dashboard_renders_and_wires_phone_safe_task_dock(): html = await dashboard() assert '