Files
hermes-webui/tests/test_issue4814_mermaid_toolbar.py
T

345 lines
13 KiB
Python

"""Behavioral coverage for issue #4814 Mermaid toolbar controls."""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
UI = ROOT / "static" / "ui.js"
NODE = shutil.which("node")
_DRIVER_SRC = r"""
const fs = require('fs');
const ui = fs.readFileSync(process.argv[2], 'utf8');
const helperStart = ui.indexOf('const _MERMAID_VIEWER_MIN_SCALE');
const helperEnd = ui.indexOf('function _openMermaidLightbox');
if (helperStart < 0 || helperEnd < 0) {
throw new Error('could not locate Mermaid viewer helpers');
}
const documentListeners = {};
function makeClassList(node) {
const classes = new Set();
return {
add(...items) { items.forEach((item) => item && classes.add(item)); node.className = [...classes].join(' '); },
remove(...items) { items.forEach((item) => classes.delete(item)); node.className = [...classes].join(' '); },
contains(item) { return classes.has(item); },
toggle(item, force) {
if (force === true) { classes.add(item); node.className = [...classes].join(' '); return true; }
if (force === false) { classes.delete(item); node.className = [...classes].join(' '); return false; }
if (classes.has(item)) { classes.delete(item); node.className = [...classes].join(' '); return false; }
classes.add(item); node.className = [...classes].join(' '); return true;
},
toJSON() { return [...classes]; },
};
}
function makeElement(tagName) {
const node = {
tagName: String(tagName || '').toUpperCase(),
children: [],
parentNode: null,
className: '',
dataset: {},
style: {},
attributes: {},
textContent: '',
innerHTML: '',
clientWidth: 0,
clientHeight: 0,
onclick: null,
onpointerdown: null,
onpointermove: null,
onpointerup: null,
onpointercancel: null,
onpointerleave: null,
onwheel: null,
classList: null,
capturedPointerId: null,
releasedPointerId: null,
appendChild(child) {
if (child.parentNode) child.parentNode.removeChild(child);
this.children.push(child);
child.parentNode = this;
return child;
},
replaceChild(next, prev) {
const idx = this.children.indexOf(prev);
if (idx < 0) throw new Error('replaceChild target missing');
if (next.parentNode) next.parentNode.removeChild(next);
this.children[idx] = next;
next.parentNode = this;
prev.parentNode = null;
return prev;
},
removeChild(child) {
const idx = this.children.indexOf(child);
if (idx >= 0) this.children.splice(idx, 1);
child.parentNode = null;
return child;
},
setAttribute(name, value) {
this.attributes[name] = String(value);
if (name === 'class') this.className = String(value);
if (name === 'aria-label') this.ariaLabel = String(value);
},
getAttribute(name) {
return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : null;
},
removeAttribute(name) {
delete this.attributes[name];
},
getBoundingClientRect() {
return {left: 0, top: 0, width: this.clientWidth, height: this.clientHeight};
},
setPointerCapture(pointerId) {
this.capturedPointerId = pointerId;
},
releasePointerCapture(pointerId) {
this.releasedPointerId = pointerId;
if (this.capturedPointerId === pointerId) this.capturedPointerId = null;
},
cloneNode() {
const copy = makeElement(this.tagName);
copy.className = this.className;
copy.dataset = {...this.dataset};
copy.style = {...this.style};
copy.attributes = {...this.attributes};
copy.textContent = this.textContent;
copy.innerHTML = this.innerHTML;
copy.clientWidth = this.clientWidth;
copy.clientHeight = this.clientHeight;
copy.classList = makeClassList(copy);
for (const cls of String(this.className || '').split(/\s+/).filter(Boolean)) copy.classList.add(cls);
return copy;
},
};
node.classList = makeClassList(node);
return node;
}
function makeSvg(width, height) {
const svg = makeElement('svg');
svg.setAttribute('width', String(width));
svg.setAttribute('height', String(height));
svg.viewBox = {baseVal: {x: 0, y: 0, width, height}};
svg.getBBox = () => ({x: 0, y: 0, width, height});
return svg;
}
const document = {
body: makeElement('body'),
createElement(tagName) {
return makeElement(tagName);
},
addEventListener(type, handler) {
(documentListeners[type] ||= []).push(handler);
},
removeEventListener(type, handler) {
const list = documentListeners[type] || [];
const idx = list.indexOf(handler);
if (idx >= 0) list.splice(idx, 1);
},
};
const window = {innerWidth: 1200, innerHeight: 800};
global.document = document;
global.window = window;
global.requestAnimationFrame = (fn) => fn();
global.cancelAnimationFrame = () => {};
eval(ui.slice(helperStart, helperEnd));
function makeHost(svg) {
const host = makeElement('div');
host.appendChild(svg);
return host;
}
function labelsFromToolbar(viewer) {
return viewer._mermaidViewer.toolbar.children.map((child) => child.getAttribute('aria-label'));
}
function runScenario(payload) {
const svg = makeSvg(payload.width || 480, payload.height || 320);
const host = makeHost(svg);
const viewer = _mountMermaidViewer(svg, payload.options || {});
const state = viewer._mermaidViewer;
state.viewport.clientWidth = payload.viewportWidth || 960;
state.viewport.clientHeight = payload.viewportHeight || 540;
state.viewport.getBoundingClientRect = () => ({left: 0, top: 0, width: state.viewport.clientWidth, height: state.viewport.clientHeight});
if (payload.fitBefore) state.fit();
const result = {
className: viewer.className,
labels: labelsFromToolbar(viewer),
scale: state.scale,
x: state.x,
y: state.y,
canvasWidth: state.canvas.style.width,
canvasHeight: state.canvas.style.height,
viewportWidth: state.viewport.style.width,
viewportHeight: state.viewport.style.height,
};
if (payload.scenario === 'toolbar') {
result.hasInlineFullscreen = result.labels.includes('Fullscreen');
return result;
}
if (payload.scenario === 'zoom') {
state.fit();
const fitScale = state.scale;
state.zoomIn();
const zoomInScale = state.scale;
state.zoomOut();
const zoomOutScale = state.scale;
for (let i = 0; i < 20; i += 1) state.zoomIn();
const maxScale = state.scale;
state.reset();
const reset = {scale: state.scale, x: state.x, y: state.y};
state.fit();
const beforeWheel = {scale: state.scale, x: state.x, y: state.y};
state.viewport.onwheel({deltaY: -120, clientX: 240, clientY: 160, preventDefault() {}});
const afterWheel = {scale: state.scale, x: state.x, y: state.y};
state.fit();
const beforeLineWheel = {scale: state.scale, x: state.x, y: state.y};
state.viewport.onwheel({deltaY: -3, deltaMode: 1, clientX: 240, clientY: 160, preventDefault() {}});
const afterLineWheel = {scale: state.scale, x: state.x, y: state.y};
return {fitScale, zoomInScale, zoomOutScale, maxScale, reset, beforeWheel, afterWheel, beforeLineWheel, afterLineWheel};
}
if (payload.scenario === 'drag') {
const opens = [];
const interactive = _mountMermaidViewer(svg, {
mode: 'inline',
openLightbox() { opens.push('open'); },
});
const interactiveState = interactive._mermaidViewer;
interactiveState.viewport.clientWidth = 960;
interactiveState.viewport.clientHeight = 540;
interactiveState.viewport.getBoundingClientRect = () => ({left: 0, top: 0, width: interactiveState.viewport.clientWidth, height: interactiveState.viewport.clientHeight});
interactiveState.fit();
const start = {x: interactiveState.x, y: interactiveState.y, scale: interactiveState.scale};
interactiveState.viewport.onpointerdown({button: 0, clientX: 100, clientY: 100, pointerId: 7, preventDefault() {}});
interactiveState.viewport.onpointermove({clientX: 160, clientY: 150});
interactiveState.viewport.onpointerup({pointerId: 7, preventDefault() {}});
interactiveState.viewport.onclick({preventDefault() {}, stopPropagation() {}, target: interactiveState.viewport});
const afterDrag = {
x: interactiveState.x,
y: interactiveState.y,
dragged: interactiveState.dragged,
opens: opens.length,
releasedPointerId: interactiveState.viewport.releasedPointerId,
capturedPointerId: interactiveState.viewport.capturedPointerId,
};
interactiveState.dragged = false;
interactiveState.viewport.onclick({preventDefault() {}, stopPropagation() {}, target: interactiveState.viewport});
const afterClick = {opens: opens.length};
return {start, afterDrag, afterClick};
}
if (payload.scenario === 'fullscreen') {
const opens = [];
const inlineViewer = _mountMermaidViewer(svg, {
mode: 'inline',
openLightbox() { opens.push('inline-lightbox'); },
});
const inlineLabels = labelsFromToolbar(inlineViewer);
const fullscreen = inlineViewer._mermaidViewer.toolbar.children.find((btn) => btn.getAttribute('aria-label') === 'Fullscreen');
fullscreen.onclick({preventDefault() {}, stopPropagation() {}});
const lightboxSvg = makeSvg(payload.width || 480, payload.height || 320);
const lightboxViewer = _mountMermaidViewer(lightboxSvg, {mode: 'lightbox'});
const lightboxLabels = labelsFromToolbar(lightboxViewer);
return {
inlineLabels,
lightboxLabels,
opens,
lightboxClassName: lightboxViewer.className,
};
}
throw new Error('unknown scenario: ' + payload.scenario);
}
const payload = JSON.parse(process.argv[3]);
process.stdout.write(JSON.stringify(runScenario(payload)));
"""
def _run_node(driver_path: str, payload: dict) -> dict:
result = subprocess.run(
[NODE, driver_path, str(UI), json.dumps(payload)],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
raise RuntimeError(f"node driver failed:\nSTDOUT={result.stdout}\nSTDERR={result.stderr}")
return json.loads(result.stdout)
@pytest.fixture(scope="module")
def _driver_path(tmp_path_factory):
path = tmp_path_factory.mktemp("mermaid_toolbar_driver") / "driver.js"
path.write_text(_DRIVER_SRC, encoding="utf-8")
return str(path)
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_inline_viewer_mounts_toolbar_and_shell(_driver_path):
result = _run_node(_driver_path, {
"scenario": "toolbar",
"options": {"mode": "inline", "openLightbox": "ignored"},
})
assert result["className"] == "mermaid-viewer mermaid-viewer--inline"
assert result["labels"] == ["Zoom in", "Zoom out", "Reset view", "Fit to screen", "Fullscreen"]
assert result["canvasWidth"] == "480px"
assert result["viewportWidth"] == "100%"
assert result["hasInlineFullscreen"] is True
def test_zoom_fit_reset_and_wheel_update_state(_driver_path):
result = _run_node(_driver_path, {"scenario": "zoom", "options": {"mode": "inline"}})
assert 1.0 < result["fitScale"] < 8.0
assert result["zoomInScale"] > result["fitScale"]
assert result["zoomOutScale"] < result["zoomInScale"]
assert result["maxScale"] <= 8.0
assert result["reset"]["scale"] == 1
assert result["afterWheel"]["scale"] > result["beforeWheel"]["scale"]
assert (result["afterWheel"]["x"], result["afterWheel"]["y"]) != (result["beforeWheel"]["x"], result["beforeWheel"]["y"])
assert result["afterLineWheel"]["scale"] > result["beforeLineWheel"]["scale"] * 1.1
def test_drag_suppresses_accidental_lightbox_open(_driver_path):
result = _run_node(_driver_path, {"scenario": "drag", "options": {"mode": "inline"}})
assert result["start"]["scale"] > 0
assert (result["afterDrag"]["x"], result["afterDrag"]["y"]) != (result["start"]["x"], result["start"]["y"])
assert result["afterDrag"]["opens"] == 0
assert result["afterDrag"]["releasedPointerId"] == 7
assert result["afterDrag"]["capturedPointerId"] is None
assert result["afterClick"]["opens"] == 1
def test_lightbox_mode_uses_same_viewer_helper_without_fullscreen(_driver_path):
result = _run_node(_driver_path, {"scenario": "fullscreen", "options": {"mode": "inline"}})
assert result["inlineLabels"] == ["Zoom in", "Zoom out", "Reset view", "Fit to screen", "Fullscreen"]
assert result["lightboxLabels"] == ["Zoom in", "Zoom out", "Reset view", "Fit to screen"]
assert result["opens"] == ["inline-lightbox"]
assert result["lightboxClassName"] == "mermaid-viewer mermaid-viewer--lightbox"