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 '' in html assert '' 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