792 lines
33 KiB
Python
792 lines
33 KiB
Python
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_find():
|
|
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'),
|
|
findWork: () => calls.push('find'),
|
|
}});
|
|
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", "find"],
|
|
"calls": ["continue", "resume", "start", "plan", "find"],
|
|
}
|
|
|
|
|
|
def test_mobile_work_entry_preserves_active_today_then_launches_highest_priority_queue():
|
|
script = f"""
|
|
const createEntry = require({json.dumps(str(ENTRY))});
|
|
const calls = [];
|
|
const state = {{active:true, today:2, resumable:true, eligible:3, next:{{name:'attention'}}}};
|
|
const entry = createEntry({{
|
|
isTodayActive: () => state.active,
|
|
isTodayResumable: () => state.resumable,
|
|
getTodayCount: () => state.today,
|
|
getEligibleCount: () => state.eligible,
|
|
queueLauncher: {{recommend: () => state.next, open: name => calls.push('queue:' + name)}},
|
|
continueToday: () => calls.push('continue'),
|
|
resumeToday: () => calls.push('resume'),
|
|
startToday: () => calls.push('start'),
|
|
planToday: () => calls.push('plan'),
|
|
|
|
findWork: () => calls.push('find'),
|
|
}});
|
|
const modes = [entry.open()];
|
|
state.active = false; modes.push(entry.open());
|
|
state.next = {{name:'today'}}; modes.push(entry.open());
|
|
state.today = 0; state.eligible = 0; state.next = {{name:'update'}}; modes.push(entry.open());
|
|
state.next = {{name:'agenda'}}; modes.push(entry.open());
|
|
state.next = {{name:'later'}}; modes.push(entry.open());
|
|
state.next = {{name:'draft'}}; modes.push(entry.open());
|
|
state.next = {{name:'find'}}; 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", "attention", "resume", "update", "agenda", "later", "draft", "find"],
|
|
"calls": ["continue", "queue:attention", "resume", "queue:update", "queue:agenda", "queue:later", "queue:draft", "find"],
|
|
}
|
|
|
|
|
|
def test_mobile_work_entry_plans_eligible_work_before_find_fallback():
|
|
script = f"""
|
|
const createEntry = require({json.dumps(str(ENTRY))});
|
|
const calls = [];
|
|
const entry = createEntry({{
|
|
isTodayActive: () => false,
|
|
isTodayResumable: () => false,
|
|
getTodayCount: () => 0,
|
|
getEligibleCount: () => 2,
|
|
queueLauncher: {{recommend: () => ({{name:'find'}}), open: () => {{}}}},
|
|
continueToday: () => {{}},
|
|
resumeToday: () => {{}},
|
|
startToday: () => {{}},
|
|
planToday: () => calls.push('plan'),
|
|
findWork: () => calls.push('find'),
|
|
}});
|
|
process.stdout.write(JSON.stringify({{mode:entry.open(), calls}}));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {"mode": "plan", "calls": ["plan"]}
|
|
|
|
|
|
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('find', 0);
|
|
process.stdout.write(JSON.stringify({{
|
|
continuing, resuming, starting, planning,
|
|
finding:{{ 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"},
|
|
"finding": {"text": "Find", "label": "Find 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 deadline = new FakeElement();
|
|
const rows = Object.fromEntries(['today','agenda','attention','update','filed','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, deadlineBadge:deadline,
|
|
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, agenda:5, attention:1, update:5, filed:2, 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();
|
|
const populated = {{
|
|
badge:badge.textContent, badgeHidden:badge.hidden,
|
|
deadline:deadline.textContent, deadlineHidden:deadline.hidden,
|
|
agendaDue:rows.agenda.attributes['data-deadlines'] || null,
|
|
badgeLabel:queues.attributes['aria-label'],
|
|
}};
|
|
dock.updateQueues({{today:0, agenda:0, attention:0, update:7, later:0, draft:0}});
|
|
const updatesOnly = {{
|
|
badge:badge.textContent, badgeHidden:badge.hidden,
|
|
deadlineHidden:deadline.hidden,
|
|
badgeLabel:queues.attributes['aria-label'],
|
|
}};
|
|
dock.updateQueues({{today:0, agenda:0, attention:0, update:0, later:0, draft:0}});
|
|
process.stdout.write(JSON.stringify({{
|
|
opened, closedAfterSelect, closedAfterUtility, selected, utilities,
|
|
badge:badge.textContent, populated, updatesOnly,
|
|
clearedBadgeHidden:badge.hidden,
|
|
clearedDeadlineHidden:deadline.hidden,
|
|
clearedAgendaDue:rows.agenda.attributes['data-deadlines'] || null,
|
|
clearedBadgeLabel: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"],
|
|
"badge": "0 active",
|
|
"populated": {
|
|
"badge": "6 active",
|
|
"badgeHidden": False,
|
|
"badgeLabel": "Queues: Today 2, Agenda 5 due, Attention 1, Updates 5, Filed 2, Later 3, Drafts 4; 6 active queues",
|
|
"deadline": "5 due",
|
|
"deadlineHidden": False,
|
|
"agendaDue": "true",
|
|
},
|
|
"updatesOnly": {
|
|
"badge": "1 active",
|
|
"badgeHidden": False,
|
|
"deadlineHidden": True,
|
|
"badgeLabel": "Queues: Today 0, Agenda 0 due, Attention 0, Updates 7, Filed 0, Later 0, Drafts 0; 1 active queue",
|
|
},
|
|
"clearedBadgeHidden": True,
|
|
"clearedDeadlineHidden": True,
|
|
"clearedAgendaDue": None,
|
|
"clearedBadgeLabel": "Queues: no active queues; no upcoming deadlines",
|
|
"counts": {"today": "0", "agenda": "0", "attention": "0", "update": "0", "filed": "0", "later": "0", "draft": "0", "recaps": "0"},
|
|
"updateLabel": "Updates, 0 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_uses_dedicated_filed_follow_up_flow():
|
|
script = f"""
|
|
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
|
const calls = [];
|
|
const launcher = createLauncher({{
|
|
openFiled: () => {{ calls.push('filed-follow-up'); return 'opened-update'; }},
|
|
selectFilter: name => calls.push('generic-filter:' + name),
|
|
firstAction: () => {{ throw new Error('generic card launch must not run'); }},
|
|
announce: message => calls.push('announce:' + message),
|
|
}});
|
|
const result = launcher.open('filed');
|
|
process.stdout.write(JSON.stringify({{result, calls}}));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"result": "opened-update",
|
|
"calls": ["filed-follow-up"],
|
|
}
|
|
|
|
|
|
def test_mobile_queue_launcher_uses_dedicated_delivery_recovery_flow():
|
|
script = f"""
|
|
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
|
const calls = [];
|
|
const launcher = createLauncher({{
|
|
openDelivery: () => {{ calls.push('delivery-recovery'); return 'opened'; }},
|
|
selectFilter: name => calls.push('generic-filter:' + name),
|
|
firstAction: () => {{ throw new Error('generic card launch must not run'); }},
|
|
}});
|
|
const result = launcher.open('delivery');
|
|
process.stdout.write(JSON.stringify({{result, calls}}));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {"result": "opened", "calls": ["delivery-recovery"]}
|
|
|
|
|
|
def test_mobile_work_continues_into_filed_follow_up_before_later_work():
|
|
script = f"""
|
|
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
|
const calls = [];
|
|
let counts = {{attention:0, today:0, update:0, agenda:0, filed:2, later:4, draft:3}};
|
|
const launcher = createLauncher({{
|
|
getCounts: () => counts,
|
|
openFiled: () => {{ calls.push('filed-follow-up'); return 'opened-filed'; }},
|
|
selectFilter: name => calls.push('filter:' + name),
|
|
firstAction: () => null,
|
|
announce: () => {{}},
|
|
openFindWork: () => calls.push('find'),
|
|
}});
|
|
const recommendation = launcher.recommend();
|
|
const opened = launcher.continueWork();
|
|
process.stdout.write(JSON.stringify({{recommendation, opened, calls}}));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"recommendation": {"name": "filed", "count": 2, "label": "Review Filed (2)"},
|
|
"opened": "opened-filed",
|
|
"calls": ["filed-follow-up"],
|
|
}
|
|
|
|
|
|
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, agenda: 5, later: 4, draft: 3}};
|
|
const launcher = createLauncher({{
|
|
openToday: () => calls.push('today'),
|
|
openAgenda: () => {{ calls.push('agenda'); return 'opened'; }},
|
|
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, agenda: 5, later: 4, draft: 3}};
|
|
const agenda = launcher.recommend();
|
|
const opened = launcher.continueWork();
|
|
counts = {{attention: 0, today: 0, agenda: 0, later: 0, draft: 0}};
|
|
const fallback = launcher.recommend();
|
|
launcher.continueWork();
|
|
process.stdout.write(JSON.stringify({{first, agenda, 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)"},
|
|
"agenda": {"name": "agenda", "count": 5, "label": "Open Agenda (5)"},
|
|
"opened": "opened",
|
|
"fallback": {"name": "find", "count": 0, "label": "Find Work"},
|
|
"calls": ["agenda", "find"],
|
|
}
|
|
|
|
|
|
def test_mobile_queue_launcher_prioritizes_updates_before_agenda_and_resumes_launcher():
|
|
script = f"""
|
|
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
|
const calls = [];
|
|
let counts = {{attention:0, today:0, update:6, agenda:2, later:1, draft:1}};
|
|
const launcher = createLauncher({{
|
|
getCounts: () => counts,
|
|
openUpdates: () => {{ calls.push('updates'); return 'resumed'; }},
|
|
openAgenda: () => calls.push('agenda'),
|
|
selectFilter: name => calls.push('filter:' + name),
|
|
firstAction: () => null,
|
|
announce: () => {{}},
|
|
openFindWork: () => calls.push('find'),
|
|
}});
|
|
const recommendation = launcher.recommend();
|
|
const opened = launcher.continueWork();
|
|
process.stdout.write(JSON.stringify({{recommendation, opened, calls}}));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"recommendation": {"name": "update", "count": 6, "label": "Resume Updates (6)"},
|
|
"opened": "resumed",
|
|
"calls": ["updates"],
|
|
}
|
|
|
|
|
|
def test_mobile_task_dock_labels_each_recommended_work_destination_accessibly():
|
|
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}});
|
|
const labels = {{}};
|
|
for (const mode of ['attention','update','agenda','filed','later','draft']) {{
|
|
dock.updateWork(mode);
|
|
labels[mode] = [workLabel.textContent, work.attributes['aria-label']];
|
|
}}
|
|
process.stdout.write(JSON.stringify(labels));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"attention": ["Attention", "Open Attention"],
|
|
"update": ["Updates", "Resume Updates"],
|
|
"agenda": ["Agenda", "Open Agenda"],
|
|
"filed": ["Filed", "Open Filed"],
|
|
"later": ["Later", "Open Later"],
|
|
"draft": ["Drafts", "Open Drafts"],
|
|
}
|
|
|
|
|
|
@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="agenda"' in html
|
|
assert '<strong>Agenda</strong><small>Upcoming deadlines</small>' in html
|
|
assert 'data-work-filter="agenda"' in html
|
|
assert "const agendaItems = agendaMyWork(activeMyWork);" in html
|
|
assert "counts.agenda = agendaItems.length;" in html
|
|
assert 'data-agenda-group' in html
|
|
assert 'data-mobile-queue="attention"' in html
|
|
assert 'data-mobile-queue="update"' in html
|
|
assert '<strong>Updates</strong><small>Unread conversations</small>' in html
|
|
assert 'data-mobile-queue="filed"' in html
|
|
assert '<strong>Filed</strong><small>Issues you delegated</small>' in html
|
|
assert 'data-mobile-queue-count="filed"' in html
|
|
assert "openFiled: openFiledFollowUp" in html
|
|
assert "filedFollowUpTarget(completedFiledReview.visible(lastMyWork))" in html
|
|
assert 'data-mobile-queue="later"' in html
|
|
assert 'data-mobile-queue="draft"' in html
|
|
assert 'data-mobile-queue="recaps"' in html
|
|
assert '<strong>Recaps</strong><small>History</small>' in html
|
|
assert 'id="mobile-queue-count"' in html
|
|
assert 'id="mobile-deadline-count"' in html
|
|
assert 'mobileTaskDock.updateQueues(counts)' in html
|
|
assert '<script src="static/mobile-queue-launcher.js"></script>' 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-deadline' in html
|
|
assert '[data-deadlines="true"]' 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</span>' in html
|
|
assert '<script src="static/mobile-work-entry.js"></script>' 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_wires_work_dock_and_app_shortcut_to_live_queue_recommendation():
|
|
html = await dashboard()
|
|
|
|
assert "queueLauncher: mobileQueueLauncher" in html
|
|
assert "work: () => mobileWorkEntry.open()" in html
|
|
assert "continueWork: () => mobileWorkEntry.open()" in html
|
|
assert "mobileQueueCounts = counts;" in html
|
|
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
|
html = await dashboard()
|
|
|
|
assert '<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">' in html
|
|
assert html.count('class="mobile-task-action" data-mobile-task=') == 5
|
|
for task in ("work", "find", "new", "search", "queues"):
|
|
assert f'data-mobile-task="{task}"' in html
|
|
assert 'id="mobile-queue-count"' in html
|
|
assert 'data-work-filter="attention"' in html
|
|
assert '.mobile-task-dock { display:none;' in html
|
|
assert 'grid-template-columns:repeat(5,minmax(0,1fr))' in html
|
|
assert 'padding-bottom:env(safe-area-inset-bottom)' in html
|
|
assert '.mobile-task-action { min-width:0; min-height:44px;' in html
|
|
assert '<script src="static/mobile-task-dock.js"></script>' in html
|
|
assert "createMobileTaskDock({" in html
|
|
assert "work: () => mobileWorkEntry.open()" in html
|
|
assert "name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name)" in html
|
|
assert "qs('[data-work-filter=\"' + name + '\"]').click()" in html
|
|
assert "find: () => qs('#find-work').click()" in html
|
|
assert "new: () => qs('#new-issue').click()" in html
|
|
assert "search: () => qs('#open-palette').click()" in html
|
|
assert "queues: () => {}" in html
|
|
assert "mobileTaskDock.updateQueues(counts)" in html
|
|
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html
|
|
assert "mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention)" in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_update_reader_keeps_primary_decisions_within_thumb_reach():
|
|
html = await dashboard()
|
|
|
|
assert 'class="update-decision-bar" aria-label="Update decisions"' in html
|
|
assert 'id="keep-update-unread" type="button" aria-label="Keep unread and open next update"' in html
|
|
assert 'id="mark-update-read-next" type="button" aria-label="Mark read and open next update"' in html
|
|
assert 'id="focus-update-reply" type="button">Reply</button>' in html
|
|
assert 'class="update-more-actions"' in html
|
|
assert "qs('#focus-update-reply').addEventListener('click'" in html
|
|
assert "qs('#update-reply').focus({ preventScroll:true })" in html
|
|
assert '.update-decision-bar { position:fixed;' in html
|
|
assert 'padding-bottom:calc(8px + env(safe-area-inset-bottom))' in html
|
|
assert '.update-decision-bar button, .update-decision-bar summary { min-height:44px;' in html
|
|
assert 'padding-bottom:calc(82px + env(safe-area-inset-bottom))' in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_dashboard_renders_phone_safe_today_session_hud_above_task_dock():
|
|
html = await dashboard()
|
|
|
|
assert 'class="mobile-today-hud" data-mobile-today-hud' in html
|
|
assert 'data-mobile-today-open' in html
|
|
assert 'data-work-session-progress' in html
|
|
assert 'data-work-session-timer-toggle' in html
|
|
assert 'data-work-session-adjust-plan' in html
|
|
assert 'data-mobile-today-complete' in html
|
|
assert 'Done & next' in html
|
|
assert "getItem: identity => [...todayMyWork, ...activeMyWork].find" in html
|
|
assert "onReopen: identity =>" in html
|
|
assert "onComplete: identity =>" in html
|
|
assert "completeTodayItem(item)" in html
|
|
assert "workSession.reopen(item)" in html
|
|
assert '.mobile-today-hud { display:none;' in html
|
|
assert 'grid-template-areas:' in html
|
|
assert '[data-mobile-today-complete] { grid-area:complete;' in html
|
|
assert 'bottom:calc(56px + env(safe-area-inset-bottom))' in html
|
|
assert '.mobile-today-hud button { min-height:44px;' in html
|
|
assert '.mobile-task-action[hidden] { display:none;' in html
|
|
assert 'max-width:100%;' in html
|
|
assert 'overflow:hidden;' in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_attention_pauses_today_and_offers_phone_safe_return():
|
|
html = await dashboard()
|
|
|
|
assert 'class="attention-interruption" id="attention-interruption"' in html
|
|
assert 'id="return-to-today" type="button">Return to Today</button>' in html
|
|
assert "if (name === 'attention' && workSession.checkpointed()) timer.beginAttention();" in html
|
|
assert 'timer.returnFromAttention()' in html
|
|
assert "workSession.reopen(item)" in html
|
|
assert "attentionInterruption.hidden = !pending" in html
|
|
assert '.attention-interruption:not([hidden]) {' in html
|
|
assert '.attention-interruption button { min-height:44px;' in html
|
|
assert 'max-width:100%;' in html
|