179 lines
6.8 KiB
Python
179 lines
6.8 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tests.dashboard_bundle import dashboard
|
|
|
|
|
|
GESTURE = Path(__file__).resolve().parents[1] / "frontend" / "update-triage-gesture.js"
|
|
TRANSACTION = Path(__file__).resolve().parents[1] / "frontend" / "update-decision-transaction.js"
|
|
|
|
|
|
def run_gesture(script):
|
|
source = f"const createGesture = require({json.dumps(str(GESTURE))});\n" + script
|
|
result = subprocess.run(["node", "-e", source], capture_output=True, text=True)
|
|
assert result.returncode == 0, result.stderr
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def run_transaction(script):
|
|
source = f"const createTransaction = require({json.dumps(str(TRANSACTION))});\n" + script
|
|
result = subprocess.run(["node", "-e", source], capture_output=True, text=True)
|
|
assert result.returncode == 0, result.stderr
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def test_update_decision_transaction_locks_every_action_until_success():
|
|
result = run_transaction(r"""
|
|
const controls = ['keep', 'read', 'reply', 'more'].map(name => ({name, disabled:false, focus(){}}));
|
|
const status = {textContent:'Ready'};
|
|
let resolveAction;
|
|
let calls = 0;
|
|
const transaction = createTransaction({controls, status});
|
|
const pending = transaction.run('Marking read…', () => new Promise(resolve => {
|
|
calls += 1;
|
|
resolveAction = resolve;
|
|
}));
|
|
const locked = controls.every(control => control.disabled) && transaction.busy();
|
|
const rejected = transaction.run('Keeping unread…', () => { calls += 1; });
|
|
resolveAction(true);
|
|
Promise.all([pending, rejected]).then(([completed, second]) => process.stdout.write(JSON.stringify({
|
|
locked, pendingStatus: 'Marking read…', calls, completed, second,
|
|
unlocked: controls.every(control => !control.disabled) && !transaction.busy(),
|
|
finalStatus: status.textContent,
|
|
})));
|
|
""")
|
|
|
|
assert result == {
|
|
"locked": True,
|
|
"pendingStatus": "Marking read…",
|
|
"calls": 1,
|
|
"completed": True,
|
|
"second": False,
|
|
"unlocked": True,
|
|
"finalStatus": "Marking read…",
|
|
}
|
|
|
|
|
|
def test_update_decision_transaction_restores_focus_and_announces_failure():
|
|
result = run_transaction(r"""
|
|
let focused = 0;
|
|
let focusedWhileEnabled = false;
|
|
const origin = {disabled:false, focus(){ focused += 1; focusedWhileEnabled = !this.disabled; }};
|
|
const other = {disabled:false, focus(){}};
|
|
const status = {textContent:''};
|
|
const transaction = createTransaction({controls:[origin, other], status});
|
|
transaction.run('Marking read…', async () => false, {
|
|
origin,
|
|
failure:'Could not mark update read. Retry.',
|
|
}).then(completed => process.stdout.write(JSON.stringify({
|
|
completed, focused, focusedWhileEnabled, status:status.textContent,
|
|
unlocked:!origin.disabled && !other.disabled && !transaction.busy(),
|
|
})));
|
|
""")
|
|
|
|
assert result == {
|
|
"completed": False,
|
|
"focused": 1,
|
|
"focusedWhileEnabled": True,
|
|
"status": "Could not mark update read. Retry.",
|
|
"unlocked": True,
|
|
}
|
|
|
|
|
|
def test_committed_horizontal_swipes_decide_once_and_lock_while_pending():
|
|
result = run_gesture(r"""
|
|
class Surface {
|
|
constructor() { this.listeners = {}; this.dataset = {}; }
|
|
addEventListener(name, callback) { this.listeners[name] = callback; }
|
|
removeEventListener(name) { delete this.listeners[name]; }
|
|
setPointerCapture() {}
|
|
releasePointerCapture() {}
|
|
}
|
|
const surface = new Surface();
|
|
const calls = [];
|
|
let enabled = true;
|
|
let resolveRead;
|
|
let lockedCalls;
|
|
const gesture = createGesture({
|
|
surface,
|
|
enabled: () => enabled,
|
|
keepUnread: () => calls.push('keep'),
|
|
markRead: () => new Promise(resolve => { calls.push('read'); resolveRead = resolve; }),
|
|
viewportWidth: () => 390,
|
|
});
|
|
const target = {closest: () => null, parentElement: null, scrollWidth: 100, clientWidth: 100};
|
|
const event = (x, y, id=1) => ({pointerId:id, pointerType:'touch', clientX:x, clientY:y, target, preventDefault(){}});
|
|
surface.listeners.pointerdown(event(180, 200));
|
|
surface.listeners.pointermove(event(250, 205));
|
|
surface.listeners.pointerup(event(270, 206));
|
|
surface.listeners.pointerdown(event(280, 200));
|
|
surface.listeners.pointermove(event(205, 204));
|
|
surface.listeners.pointerup(event(190, 205));
|
|
surface.listeners.pointerdown(event(280, 200));
|
|
surface.listeners.pointermove(event(190, 203));
|
|
surface.listeners.pointerup(event(180, 203));
|
|
Promise.resolve().then(() => {
|
|
lockedCalls = [...calls];
|
|
resolveRead();
|
|
return Promise.resolve();
|
|
}).then(() => process.stdout.write(JSON.stringify({calls, lockedCalls, direction:surface.dataset.triageGesture || null})));
|
|
""")
|
|
|
|
assert result == {
|
|
"calls": ["keep", "read"],
|
|
"lockedCalls": ["keep", "read"],
|
|
"direction": None,
|
|
}
|
|
|
|
|
|
def test_scrolls_short_drags_edges_and_interactive_targets_never_decide():
|
|
result = run_gesture(r"""
|
|
class Surface {
|
|
constructor() { this.listeners = {}; this.dataset = {}; }
|
|
addEventListener(name, callback) { this.listeners[name] = callback; }
|
|
removeEventListener(name) { delete this.listeners[name]; }
|
|
setPointerCapture() {}
|
|
releasePointerCapture() {}
|
|
}
|
|
const surface = new Surface();
|
|
const calls = [];
|
|
createGesture({surface, enabled:()=>true, keepUnread:()=>calls.push('keep'), markRead:()=>calls.push('read'), viewportWidth:()=>390});
|
|
const plain = {closest: () => null, parentElement: null, scrollWidth:100, clientWidth:100};
|
|
const interactive = {closest: selector => selector.includes('button') ? {} : null, parentElement:null, scrollWidth:100, clientWidth:100};
|
|
const scroller = {closest: () => null, parentElement:null, scrollWidth:400, clientWidth:100};
|
|
const event = (x,y,target=plain,id=1) => ({pointerId:id,pointerType:'touch',clientX:x,clientY:y,target,preventDefault(){}});
|
|
function drag(fromX, fromY, toX, toY, target=plain) {
|
|
surface.listeners.pointerdown(event(fromX,fromY,target));
|
|
surface.listeners.pointermove(event(toX,toY,target));
|
|
surface.listeners.pointerup(event(toX,toY,target));
|
|
}
|
|
drag(180,100,205,104);
|
|
drag(180,100,220,190);
|
|
drag(8,100,120,104);
|
|
drag(180,100,280,104,interactive);
|
|
drag(180,100,280,104,scroller);
|
|
surface.listeners.pointerdown(event(180,100));
|
|
surface.listeners.pointercancel(event(280,104));
|
|
process.stdout.write(JSON.stringify({calls, direction:surface.dataset.triageGesture || null}));
|
|
""")
|
|
|
|
assert result == {"calls": [], "direction": None}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_dashboard_wires_accessible_phone_only_update_swipe_triage():
|
|
html = await dashboard()
|
|
|
|
assert '<script src="static/update-triage-gesture.js"></script>' in html
|
|
assert '<script src="static/update-decision-transaction.js"></script>' in html
|
|
assert 'id="update-gesture-status"' in html
|
|
assert "createUpdateTriageGesture({" in html
|
|
assert "createUpdateDecisionActions({" in html
|
|
assert "keepUnread: keepUpdateUnread" in html
|
|
assert "markRead: markUpdateRead" in html
|
|
assert "data-triage-gesture" in html
|
|
assert "prefers-reduced-motion: reduce" in html
|