stackchain-dashboard/tests/test_update_triage_gesture.py
timmy 2e0d64907b
All checks were successful
CI / lint (pull_request) Successful in 1m36s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
fix: make update decisions transaction-safe (Closes #771)
2026-08-13 20:58:43 +00:00

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