mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-13 03:32:21 +02:00
140 lines
5.6 KiB
Python
140 lines
5.6 KiB
Python
"""Pin the DOM-free Escape-dismissal registry in static/js/escMenuStack.js.
|
|
|
|
Driven through `node --input-type=module` so we exercise the real JS without a
|
|
full Vitest/Jest setup (same spirit as test_reply_recipients_js.py). Skips when
|
|
`node` is not installed rather than failing.
|
|
|
|
The module source is inlined into the eval'd module body (rather than imported
|
|
by path) so the test runs identically on Windows and POSIX — the repo has no
|
|
`"type": "module"` in package.json, so a path import of a `.js` file is treated
|
|
as CommonJS by node and rejects the ES `export`s. escMenuStack.js has no
|
|
imports of its own, so inlining is exact.
|
|
|
|
Background: ad-hoc dropdowns/popups (document-library card menus, chat context
|
|
popups, cookbook serve menus, calendar event menus, compare pane menus) live
|
|
outside the .modal system, so the global Escape arbiter in ui.js couldn't see
|
|
them. They register a dismiss callback here while open; the arbiter calls
|
|
dismissTopMenu() to close the most-recently-opened one. These tests lock in the
|
|
LIFO contract and the "exactly one menu per Escape, never get stuck" guarantees
|
|
the arbiter relies on.
|
|
"""
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_REPO = Path(__file__).resolve().parent.parent
|
|
_HELPER = _REPO / "static" / "js" / "escMenuStack.js"
|
|
_HAS_NODE = shutil.which("node") is not None
|
|
_SRC = _HELPER.read_text(encoding="utf-8") if _HELPER.exists() else ""
|
|
|
|
|
|
def _run(body: str) -> str:
|
|
"""Run `body` as a module with the registry's functions already in scope."""
|
|
js = _SRC + "\n" + body
|
|
proc = subprocess.run(
|
|
["node", "--input-type=module"],
|
|
input=js, capture_output=True, text=True, encoding="utf-8",
|
|
cwd=str(_REPO), timeout=30,
|
|
)
|
|
assert proc.returncode == 0, proc.stderr
|
|
return proc.stdout.strip()
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_empty_stack_dismiss_is_noop():
|
|
# Nothing open: returns false so the arbiter can fall through to modals.
|
|
body = "console.log(JSON.stringify([dismissTopMenu(), _openMenuCount()]));"
|
|
assert json.loads(_run(body)) == [False, 0]
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_dismiss_is_lifo_and_closes_exactly_one():
|
|
body = """
|
|
const order = [];
|
|
registerMenuDismiss(() => order.push('A'));
|
|
registerMenuDismiss(() => order.push('B'));
|
|
const r1 = dismissTopMenu(); // closes B (most recent)
|
|
const r2 = dismissTopMenu(); // closes A
|
|
const r3 = dismissTopMenu(); // nothing left
|
|
console.log(JSON.stringify({ order, r1, r2, r3, left: _openMenuCount() }));
|
|
"""
|
|
out = json.loads(_run(body))
|
|
assert out["order"] == ["B", "A"] # LIFO
|
|
assert [out["r1"], out["r2"], out["r3"]] == [True, True, False]
|
|
assert out["left"] == 0
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_unregister_removes_entry_without_firing():
|
|
body = """
|
|
let fired = false;
|
|
const unreg = registerMenuDismiss(() => { fired = true; });
|
|
unreg(); // menu closed itself via outside-click
|
|
const r = dismissTopMenu(); // Escape should now find nothing
|
|
console.log(JSON.stringify({ fired, r, left: _openMenuCount() }));
|
|
"""
|
|
# Unregistering must not invoke the callback and must leave the stack empty.
|
|
assert json.loads(_run(body)) == {"fired": False, "r": False, "left": 0}
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_unregister_targets_correct_entry_when_interleaved():
|
|
body = """
|
|
const order = [];
|
|
const unregA = registerMenuDismiss(() => order.push('A'));
|
|
registerMenuDismiss(() => order.push('B'));
|
|
unregA(); // remove the older entry, keep B
|
|
dismissTopMenu(); // should fire B, not A
|
|
console.log(JSON.stringify({ order, left: _openMenuCount() }));
|
|
"""
|
|
out = json.loads(_run(body))
|
|
assert out["order"] == ["B"]
|
|
assert out["left"] == 0
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_throwing_dismiss_still_pops_and_reports_handled():
|
|
body = """
|
|
registerMenuDismiss(() => { throw new Error('boom'); });
|
|
const r = dismissTopMenu(); // must swallow the error...
|
|
console.log(JSON.stringify({ r, left: _openMenuCount() }));
|
|
"""
|
|
# A misbehaving menu must not wedge the stack or crash the arbiter.
|
|
assert json.loads(_run(body)) == {"r": True, "left": 0}
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_non_function_registration_is_ignored():
|
|
body = """
|
|
const unreg = registerMenuDismiss(null);
|
|
console.log(JSON.stringify({ left: _openMenuCount(), unregType: typeof unreg }));
|
|
"""
|
|
# Bad input must not enter the stack, and must still return a callable.
|
|
assert json.loads(_run(body)) == {"left": 0, "unregType": "function"}
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_escape_layers_skip_inactive_cards_and_keep_lifo_order():
|
|
body = """
|
|
const order = [];
|
|
let cardOpen = true;
|
|
registerEscapeLayer(() => order.push('menu'));
|
|
registerEscapeLayer(() => { order.push('card'); cardOpen = false; }, () => cardOpen);
|
|
registerEscapeLayer(() => order.push('stale'), () => false);
|
|
const r1 = dismissTopEscapeLayer(); // stale entry is ignored
|
|
const r2 = dismissTopEscapeLayer(); // expanded card
|
|
const r3 = dismissTopEscapeLayer(); // menu
|
|
console.log(JSON.stringify({ order, cardOpen, r1, r2, r3, left: _openMenuCount() }));
|
|
"""
|
|
assert json.loads(_run(body)) == {
|
|
"order": ["card", "menu"],
|
|
"cardOpen": False,
|
|
"r1": True,
|
|
"r2": True,
|
|
"r3": False,
|
|
"left": 0,
|
|
}
|