Files
hermes-webui/tests/test_issue4814_mermaid_toolbar.py
T
silent-reader-cn 0403f00789 fix: address Greptile review and fix CI test mock for touch handlers
Three fixes in one commit:

1. **CI test mock** — Added addEventListener/removeEventListener to the
   Node.js makeElement mock so viewport.addEventListener() calls in the
   touch handler registration don't crash with 'not a function'.

2. **Two-finger pan during pinch** — Replaced _setScale() call with
   direct transform math. The new formula computes scale and position
   simultaneously:
     ratio = boundedScale / pinchStartScale
     x = currMidX - (startMidX - startPosX) * ratio
   This keeps the diagram under both fingers regardless of midpoint
   drift, enabling simultaneous zoom + pan.

3. **Post-pinch click suppression** — _onTouchEnd now sets dragged=true
   when exiting pinch mode so any synthesized click event is caught
   by the existing dragged guard in _openViewerOnClick instead of
   opening the lightbox.
2026-07-15 23:02:13 +00:00

601 lines
23 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("document.addEventListener('click'");
if (helperStart < 0 || helperEnd < 0) {
throw new Error('could not locate Mermaid viewer helpers');
}
const documentListeners = {};
const windowListeners = {};
let nextTimerId = 1;
const pendingTimers = new Map();
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};
},
querySelectorAll() {
return [];
},
setPointerCapture(pointerId) {
this.capturedPointerId = pointerId;
},
releasePointerCapture(pointerId) {
this.releasedPointerId = pointerId;
if (this.capturedPointerId === pointerId) this.capturedPointerId = null;
},
_listeners: {},
addEventListener(type, handler, _options) {
(this._listeners[type] ||= []).push(handler);
},
removeEventListener(type, handler) {
const list = this._listeners[type] || [];
const idx = list.indexOf(handler);
if (idx >= 0) list.splice(idx, 1);
},
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;
if (this.viewBox) copy.viewBox = {baseVal: {...this.viewBox.baseVal}};
if (this.getBBox) copy.getBBox = this.getBBox;
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,
addEventListener(type, handler) {
(windowListeners[type] ||= []).push(handler);
},
removeEventListener(type, handler) {
const list = windowListeners[type] || [];
const idx = list.indexOf(handler);
if (idx >= 0) list.splice(idx, 1);
},
dispatchEvent(event) {
const type = event && event.type;
if (!type) return false;
const list = windowListeners[type] || [];
for (const handler of list) handler(event);
return true;
},
};
function triggerWindowResize(width, height) {
if (Number.isFinite(width)) window.innerWidth = width;
if (Number.isFinite(height)) window.innerHeight = height;
if (typeof window.dispatchEvent === 'function') {
window.dispatchEvent({type: 'resize'});
}
}
function flushTimers() {
const timers = [...pendingTimers.entries()];
pendingTimers.clear();
for (const [, fn] of timers) fn();
}
global.document = document;
global.window = window;
global.requestAnimationFrame = (fn) => fn();
global.cancelAnimationFrame = () => {};
global.setTimeout = (fn) => {
const id = nextTimerId;
nextTimerId += 1;
pendingTimers.set(id, fn);
return id;
};
global.clearTimeout = (id) => {
pendingTimers.delete(id);
};
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) {
if (Number.isFinite(payload.viewportWidth)) {
window.innerWidth = payload.viewportWidth;
}
if (Number.isFinite(payload.viewportHeight)) {
window.innerHeight = payload.viewportHeight;
}
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 === 'wide-inline') {
return {
scale: state.scale,
viewportWidth: state.viewport.style.width,
viewportHeight: state.viewport.style.height,
};
}
if (payload.scenario === 'wide-lightbox') {
const expectedEnvelopeWidth = Math.round((payload.viewportWidth || 0) * 0.9);
const expectedEnvelopeHeight = Math.round((payload.viewportHeight || 0) * 0.9);
const lightboxSvg = makeSvg(payload.width || 480, payload.height || 320);
const lightbox = _mountMermaidViewer(lightboxSvg, {mode:'lightbox'});
const lightboxState = lightbox._mermaidViewer;
lightboxState.viewport.clientWidth = expectedEnvelopeWidth || 960;
lightboxState.viewport.clientHeight = expectedEnvelopeHeight || 540;
lightboxState.viewport.getBoundingClientRect = () => ({left: 0, top: 0, width: lightboxState.viewport.clientWidth, height: lightboxState.viewport.clientHeight});
const initialScale = lightboxState.scale;
const initialViewportWidth = lightboxState.viewport.style.width;
const initialViewportHeight = lightboxState.viewport.style.height;
lightboxState.fit();
const fitScale = lightboxState.scale;
lightboxState.zoomOut();
const zoomOutScale = lightboxState.scale;
return {
initialScale,
fitScale,
zoomOutScale,
expectedViewportWidth: expectedEnvelopeWidth,
expectedViewportHeight: expectedEnvelopeHeight,
initialViewportWidth,
initialViewportHeight,
viewportWidthAfterFit: lightboxState.viewport.style.width,
viewportHeightAfterFit: lightboxState.viewport.style.height,
lightboxLabels: labelsFromToolbar(lightbox),
mode: lightboxState.mode,
};
}
if (payload.scenario === 'lightbox-resize') {
const expectedEnvelopeWidth = Math.round((payload.viewportWidth || 0) * 0.9);
const expectedEnvelopeHeight = Math.round((payload.viewportHeight || 0) * 0.9);
triggerWindowResize(payload.viewportWidth, payload.viewportHeight);
const lightboxSvg = makeSvg(payload.width || 480, payload.height || 320);
const beforeListenerCount = (windowListeners.resize || []).length;
const lightbox = _openMermaidLightbox(lightboxSvg);
const lightboxState = lightbox && lightbox.children[0] && lightbox.children[0]._mermaidViewer;
if (!lightboxState) throw new Error('unable to access lightbox viewer state');
const afterOpenListenerCount = (windowListeners.resize || []).length;
lightboxState.viewport.clientWidth = expectedEnvelopeWidth || 960;
lightboxState.viewport.clientHeight = expectedEnvelopeHeight || 540;
lightboxState.viewport.getBoundingClientRect = () => ({left: 0, top: 0, width: lightboxState.viewport.clientWidth, height: lightboxState.viewport.clientHeight});
const beforeViewportWidth = lightboxState.viewport.style.width;
const beforeViewportHeight = lightboxState.viewport.style.height;
const beforeScale = lightboxState.scale;
triggerWindowResize(payload.resizedViewportWidth, payload.resizedViewportHeight);
const queuedAfterFirstResize = pendingTimers.size;
const preFlushViewportWidth = lightboxState.viewport.style.width;
const preFlushViewportHeight = lightboxState.viewport.style.height;
triggerWindowResize(payload.resizedViewportWidth + 20, payload.resizedViewportHeight + 20);
const queuedAfterSecondResize = pendingTimers.size;
flushTimers();
const afterViewportWidth = lightboxState.viewport.style.width;
const afterViewportHeight = lightboxState.viewport.style.height;
const afterScale = lightboxState.scale;
lightboxState.zoomIn();
const manualZoomScale = lightboxState.scale;
triggerWindowResize(payload.zoomedViewportWidth, payload.zoomedViewportHeight);
flushTimers();
const afterManualZoomResizeScale = lightboxState.scale;
const afterManualZoomResizeViewportWidth = lightboxState.viewport.style.width;
const afterManualZoomResizeViewportHeight = lightboxState.viewport.style.height;
_closeImgLightbox(lightbox);
const afterCloseListenerCount = (windowListeners.resize || []).length;
return {
beforeListenerCount,
afterOpenListenerCount,
afterCloseListenerCount,
queuedAfterFirstResize,
queuedAfterSecondResize,
beforeViewportWidth,
beforeViewportHeight,
beforeScale,
preFlushViewportWidth,
preFlushViewportHeight,
afterViewportWidth,
afterViewportHeight,
afterScale,
manualZoomScale,
afterManualZoomResizeScale,
afterManualZoomResizeViewportWidth,
afterManualZoomResizeViewportHeight,
expectedViewportWidth: Math.round(((payload.resizedViewportWidth || 0) + 20) * 0.9),
expectedViewportHeight: Math.round(((payload.resizedViewportHeight || 0) + 20) * 0.9),
expectedZoomedViewportWidth: Math.round((payload.zoomedViewportWidth || 0) * 0.9),
expectedZoomedViewportHeight: Math.round((payload.zoomedViewportHeight || 0) * 0.9),
};
}
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)
def _px(value) -> int:
return int(float(str(value).rstrip("px")))
@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_inline_wide_diagram_reads_readable_height_on_mobile(_driver_path):
result = _run_node(_driver_path, {
"scenario": "wide-inline",
"width": 2400,
"height": 320,
"viewportWidth": 360,
"viewportHeight": 640,
"options": {"mode": "inline"},
})
assert _px(result["viewportHeight"]) >= 220
assert result["scale"] > 0.25
assert result["scale"] < 1.0
def test_inline_tall_diagram_respects_viewport_cap_without_overflow_mismatch(_driver_path):
result = _run_node(_driver_path, {
"scenario": "wide-inline",
"width": 400,
"height": 2400,
"viewportWidth": 360,
"viewportHeight": 640,
"options": {"mode": "inline"},
})
assert _px(result["viewportHeight"]) == 448
assert result["scale"] < 0.25
assert _px(result["viewportHeight"]) == round(2400 * result["scale"])
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"
def test_lightbox_wide_diagram_fits_modal_envelope(_driver_path):
result = _run_node(_driver_path, {
"scenario": "wide-lightbox",
"width": 4000,
"height": 320,
"viewportWidth": 360,
"viewportHeight": 640,
"options": {"mode": "inline"},
})
# Wide lightbox now sizes the viewport to the modal envelope first,
# then fits the full diagram into that envelope.
assert result["mode"] == "lightbox"
assert _px(result["initialViewportWidth"]) == round(360 * 0.9)
assert _px(result["initialViewportHeight"]) == round(640 * 0.9)
expectedScale = min(result["expectedViewportWidth"] / 4000, result["expectedViewportHeight"] / 320)
assert abs(result["initialScale"] - expectedScale) < 1e-9
assert abs(result["fitScale"] - expectedScale) < 1e-9
assert abs(result["zoomOutScale"] - expectedScale) < 1e-9
assert _px(result["initialViewportWidth"]) == _px(result["viewportWidthAfterFit"])
assert _px(result["initialViewportHeight"]) == _px(result["viewportHeightAfterFit"])
def test_lightbox_resize_recomputes_viewport_and_scale(_driver_path):
result = _run_node(_driver_path, {
"scenario": "lightbox-resize",
"width": 4000,
"height": 320,
"viewportWidth": 360,
"viewportHeight": 640,
"resizedViewportWidth": 800,
"resizedViewportHeight": 400,
"zoomedViewportWidth": 640,
"zoomedViewportHeight": 500,
"options": {"mode": "inline"},
})
assert _px(result["beforeViewportWidth"]) == round(360 * 0.9)
assert _px(result["beforeViewportHeight"]) == round(640 * 0.9)
assert _px(result["preFlushViewportWidth"]) == _px(result["beforeViewportWidth"])
assert _px(result["preFlushViewportHeight"]) == _px(result["beforeViewportHeight"])
assert result["queuedAfterFirstResize"] == 1
assert result["queuedAfterSecondResize"] == 1
assert _px(result["afterViewportWidth"]) == round((800 + 20) * 0.9)
assert _px(result["afterViewportHeight"]) == round((400 + 20) * 0.9)
expectedScale = min(result["expectedViewportWidth"] / 4000, result["expectedViewportHeight"] / 320)
assert abs(result["afterScale"] - expectedScale) < 1e-9
assert result["afterScale"] != result["beforeScale"]
assert result["manualZoomScale"] > result["afterScale"]
assert abs(result["afterManualZoomResizeScale"] - result["manualZoomScale"]) < 1e-9
assert _px(result["afterManualZoomResizeViewportWidth"]) == round(640 * 0.9)
assert _px(result["afterManualZoomResizeViewportHeight"]) == round(500 * 0.9)
assert result["beforeListenerCount"] == 0
assert result["afterOpenListenerCount"] == 1
assert result["afterCloseListenerCount"] == 0