264 lines
11 KiB
Python
264 lines
11 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tests.dashboard_bundle import dashboard
|
|
|
|
|
|
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-first-task.js"
|
|
|
|
|
|
def run_node(body: str) -> dict:
|
|
script = f"""
|
|
const createFirstTask = require({json.dumps(str(CONTROLLER))});
|
|
class Element {{
|
|
constructor() {{ this.listeners = {{}}; this.disabled = false; this.hidden = false; this.open = false; this.textContent = ''; this.focused = false; }}
|
|
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
|
|
click() {{ this.listeners.click?.({{preventDefault() {{}}}}); }}
|
|
showModal() {{ this.open = true; }}
|
|
close() {{ this.open = false; this.listeners.close?.(); }}
|
|
focus() {{ this.focused = true; }}
|
|
}}
|
|
{body}
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
assert result.returncode == 0, result.stderr
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def test_first_task_activation_is_account_bound_and_completes_only_after_a_today_outcome():
|
|
result = run_node(
|
|
"""
|
|
const values = new Map();
|
|
const storage = {getItem:key => values.get(key) || null, setItem:(key,value) => values.set(key,value)};
|
|
const sheet = new Element();
|
|
const coach = new Element(); coach.hidden = true;
|
|
const receipt = new Element(); receipt.hidden = true;
|
|
let login = 'Timmy';
|
|
let hasWork = false;
|
|
let todayActive = false;
|
|
let hideReceipt;
|
|
const controller = createFirstTask({
|
|
storage, getLogin:() => login, hasWork:() => hasWork, isTodayActive:() => todayActive,
|
|
isOnline:() => true, mediaQuery:{matches:true}, sheet, title:new Element(), coach, receipt,
|
|
findButton:new Element(), createButton:new Element(), setupButton:new Element(),
|
|
closeButton:new Element(), status:new Element(), onFind() {}, onCreate() {}, onSetup() {},
|
|
setTimer:callback => { hideReceipt = callback; return {unref() {}}; },
|
|
});
|
|
const before = [controller.required(), controller.open(), sheet.open];
|
|
hasWork = true;
|
|
const awaitingStart = controller.refresh();
|
|
const stillRequired = controller.required();
|
|
todayActive = true;
|
|
const coaching = controller.refresh();
|
|
const afterStart = {required:controller.required(), sheetOpen:sheet.open, coachHidden:coach.hidden, stored:values.get('stackchain.first-task.v1:timmy')};
|
|
const completed = controller.completeOutcome();
|
|
const afterOutcome = {required:controller.required(), coachHidden:coach.hidden, receiptHidden:receipt.hidden, stored:values.get('stackchain.first-task.v1:timmy')};
|
|
hideReceipt();
|
|
const receiptHiddenAfterTimeout = receipt.hidden;
|
|
login = 'alexander'; hasWork = false; todayActive = false;
|
|
const alexanderRequired = controller.required();
|
|
process.stdout.write(JSON.stringify({before, awaitingStart, stillRequired, coaching, afterStart, completed, afterOutcome, receiptHiddenAfterTimeout, alexanderRequired}));
|
|
"""
|
|
)
|
|
|
|
assert result == {
|
|
"before": [True, True, True],
|
|
"awaitingStart": "awaiting-start",
|
|
"stillRequired": True,
|
|
"coaching": "coaching",
|
|
"afterStart": {
|
|
"required": False,
|
|
"sheetOpen": False,
|
|
"coachHidden": False,
|
|
"stored": "coaching",
|
|
},
|
|
"completed": True,
|
|
"afterOutcome": {
|
|
"required": False,
|
|
"coachHidden": True,
|
|
"receiptHidden": False,
|
|
"stored": "complete",
|
|
},
|
|
"receiptHiddenAfterTimeout": True,
|
|
"alexanderRequired": True,
|
|
}
|
|
|
|
|
|
def test_first_task_activation_resumes_with_finish_starting_guidance():
|
|
result = run_node(
|
|
"""
|
|
const sheet = new Element();
|
|
const title = new Element();
|
|
const findButton = new Element();
|
|
const createButton = new Element();
|
|
const status = new Element();
|
|
const controller = createFirstTask({
|
|
storage:{getItem:() => null, setItem() {}}, getLogin:() => 'timmy', hasWork:() => true,
|
|
isTodayActive:() => false, isOnline:() => true, mediaQuery:{matches:true}, sheet, title,
|
|
findButton, createButton, setupButton:new Element(), closeButton:new Element(), status,
|
|
onFind() {}, onCreate() {}, onSetup() {},
|
|
});
|
|
controller.open();
|
|
process.stdout.write(JSON.stringify({
|
|
required:controller.required(), title:title.textContent, status:status.textContent,
|
|
find:findButton.textContent, create:createButton.textContent,
|
|
}));
|
|
"""
|
|
)
|
|
|
|
assert result == {
|
|
"required": True,
|
|
"title": "Finish starting your first task",
|
|
"status": "Your task is ready. Start it from Find or create and start another task.",
|
|
"find": "Find & start",
|
|
"create": "Create & start",
|
|
}
|
|
|
|
|
|
def test_first_task_adopts_remote_completion_without_crossing_accounts():
|
|
result = run_node(
|
|
"""
|
|
const values = new Map(); let login='timmy'; let todayActive=false;
|
|
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
|
|
const controller=createFirstTask({
|
|
storage,getLogin:()=>login,hasWork:()=>false,isTodayActive:()=>todayActive,isOnline:()=>true,
|
|
mediaQuery:{matches:true},sheet:new Element(),title:new Element(),coach:new Element(),receipt:new Element(),
|
|
findButton:new Element(),createButton:new Element(),setupButton:new Element(),closeButton:new Element(),
|
|
status:new Element(),
|
|
});
|
|
const adopted=controller.adoptRemote('complete');
|
|
const timmy={required:controller.required(),stored:values.get('stackchain.first-task.v1:timmy')};
|
|
login='alexander'; const alexanderRequired=controller.required();
|
|
todayActive=true; controller.refresh(); controller.completeOutcome();
|
|
process.stdout.write(JSON.stringify({adopted,timmy,alexanderRequired,
|
|
alexander:values.get('stackchain.first-task.v1:alexander')}));
|
|
"""
|
|
)
|
|
|
|
assert result == {
|
|
"adopted": True,
|
|
"timmy": {"required": False, "stored": "complete"},
|
|
"alexanderRequired": True,
|
|
"alexander": "complete",
|
|
}
|
|
|
|
|
|
def test_first_task_activation_routes_existing_flows_and_keeps_create_available_offline():
|
|
result = run_node(
|
|
"""
|
|
const calls = [];
|
|
const sheet = new Element();
|
|
const findButton = new Element();
|
|
const createButton = new Element();
|
|
const setupButton = new Element();
|
|
const closeButton = new Element();
|
|
const status = new Element();
|
|
let online = false;
|
|
const controller = createFirstTask({
|
|
storage:{getItem:() => null, setItem() {}}, getLogin:() => 'timmy', hasWork:() => false,
|
|
isOnline:() => online, mediaQuery:{matches:true}, sheet, findButton, createButton,
|
|
setupButton, closeButton, status,
|
|
onFind:() => calls.push('find'), onCreate:() => calls.push('create'), onSetup:() => calls.push('setup'),
|
|
});
|
|
controller.start();
|
|
controller.open();
|
|
const offline = {findDisabled:findButton.disabled, createDisabled:createButton.disabled, status:status.textContent, createFocused:createButton.focused};
|
|
findButton.click();
|
|
createButton.click();
|
|
controller.open();
|
|
setupButton.click();
|
|
controller.open();
|
|
online = true;
|
|
controller.render();
|
|
findButton.click();
|
|
process.stdout.write(JSON.stringify({offline, calls, sheetOpen:sheet.open}));
|
|
"""
|
|
)
|
|
|
|
assert result == {
|
|
"offline": {
|
|
"findDisabled": True,
|
|
"createDisabled": False,
|
|
"status": "You are offline. Create a task now and it will stay in Drafts until you reconnect.",
|
|
"createFocused": True,
|
|
},
|
|
"calls": ["create", "setup", "find"],
|
|
"sheetOpen": False,
|
|
}
|
|
|
|
|
|
def test_first_task_coaching_reveals_its_mobile_today_host():
|
|
result = run_node(
|
|
"""
|
|
const values = new Map([['stackchain.first-task.v1:timmy', 'coaching']]);
|
|
const host = new Element(); host.hidden = true;
|
|
const coach = new Element(); coach.hidden = true; coach.closest = () => host;
|
|
const controller = createFirstTask({
|
|
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)},
|
|
getLogin:()=> 'timmy',hasWork:()=>true,isTodayActive:()=>true,isOnline:()=>true,
|
|
mediaQuery:{matches:true},sheet:new Element(),title:new Element(),coach,receipt:new Element(),
|
|
findButton:new Element(),createButton:new Element(),setupButton:new Element(),closeButton:new Element(),status:new Element(),
|
|
});
|
|
controller.refresh();
|
|
process.stdout.write(JSON.stringify({coachHidden:coach.hidden,hostHidden:host.hidden}));
|
|
"""
|
|
)
|
|
|
|
assert result == {"coachHidden": False, "hostHidden": False}
|
|
|
|
|
|
def test_first_task_exposes_completion_operation_before_showing_local_success():
|
|
result = run_node(
|
|
"""
|
|
const values = new Map([['stackchain.first-task.v1:timmy', 'coaching']]);
|
|
let dispatched = 0;
|
|
const controller = createFirstTask({
|
|
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)},
|
|
getLogin:()=> 'timmy',hasWork:()=>true,isTodayActive:()=>true,isOnline:()=>true,
|
|
mediaQuery:{matches:true},sheet:new Element(),title:new Element(),coach:new Element(),receipt:new Element(),
|
|
findButton:new Element(),createButton:new Element(),setupButton:new Element(),closeButton:new Element(),status:new Element(),
|
|
eventTarget:{dispatchEvent:()=>{dispatched += 1}}, setTimer:()=>({unref(){}}),
|
|
});
|
|
const before = globalThis.stackchainFirstTaskCompleting();
|
|
const globalProbe = globalThis.stackchainFirstTaskCompleting();
|
|
const completed = controller.completeOutcome();
|
|
process.stdout.write(JSON.stringify({before,globalProbe,completed,stored:values.get('stackchain.first-task.v1:timmy'),dispatched}));
|
|
"""
|
|
)
|
|
|
|
assert result == {
|
|
"before": True,
|
|
"globalProbe": True,
|
|
"completed": True,
|
|
"stored": "complete",
|
|
"dispatched": 1,
|
|
}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_dashboard_renders_and_wires_phone_safe_first_task_activation():
|
|
html = await dashboard()
|
|
|
|
assert '<dialog class="mobile-first-task" id="mobile-first-task" aria-labelledby="mobile-first-task-title">' in html
|
|
assert '<h2 id="mobile-first-task-title">Start your first task</h2>' in html
|
|
assert 'id="mobile-first-task-find" type="button">Find & start</button>' in html
|
|
assert 'id="mobile-first-task-create" type="button">Create & start</button>' in html
|
|
assert 'id="mobile-first-task-setup" type="button">Make this phone work-ready</button>' in html
|
|
assert '<script src="static/mobile-first-task.js"></script>' in html
|
|
assert "const mobileFirstTask = createMobileFirstTask({" in html
|
|
assert "isTodayActive: () => workSession.checkpointed()" in html
|
|
|
|
assert "shouldActivate: () => mobileFirstTask.required()" in html
|
|
assert "openActivation: () => mobileFirstTask.open()" in html
|
|
assert "mobileFirstTask.refresh()" in html
|
|
assert 'data-mobile-first-task-coach hidden' in html
|
|
assert 'Complete your first task' in html
|
|
assert 'id="mobile-first-task-receipt" role="status" aria-live="polite" hidden' in html
|
|
assert "advance: () => (mobileFirstTask.completeOutcome(), runTodayTransition('complete'))" in html
|
|
assert '.mobile-first-task { box-sizing:border-box; width:100%;' in html
|
|
assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html
|
|
assert '.mobile-first-task-actions button { min-height:48px;' in html
|