Files
zyt/app/tests/test_video_screenshot.py
T
2026-10-29 14:01:39 +08:00

380 lines
12 KiB
Python

from __future__ import annotations
import base64
import json
from collections.abc import Mapping
from types import SimpleNamespace
from typing import Any
from unittest.mock import Mock
import pytest
from PySide6.QtCore import QRect, QUrl
from PySide6.QtGui import QColor, QImage, QPainter
from doctor_workstation.video import window
from doctor_workstation.video.screenshot import capture_view_region
from doctor_workstation.video.security import TrustedDocumentPolicy
TRUSTED_URL = "https://video.example.test/companion/index.html?mode=call"
REGION_KEYS = ("x", "y", "width", "height", "viewportWidth", "viewportHeight")
def _region(**changes: Any) -> dict[str, Any]:
return {
"x": 120,
"y": 80,
"width": 480,
"height": 320,
"viewportWidth": 1000,
"viewportHeight": 800,
**changes,
}
class _View:
"""Keep grab coordinates logical, as QWidget does, and its image physical."""
def __init__(
self,
width: int = 1000,
height: int = 800,
*,
zoom: float = 1,
dpr: float = 1,
source: QImage | None = None,
) -> None:
self._width = width
self._height = height
self._zoom = zoom
self.dpr = dpr
self.source = source
self.grabbed: list[QRect] = []
def width(self) -> int:
return self._width
def height(self) -> int:
return self._height
def zoomFactor(self) -> float: # noqa: N802 - Qt API
return self._zoom
def rect(self) -> QRect:
return QRect(0, 0, self._width, self._height)
def grab(self, rect: QRect) -> SimpleNamespace:
self.grabbed.append(QRect(rect))
physical_rect = QRect(
round(rect.x() * self.dpr),
round(rect.y() * self.dpr),
round(rect.width() * self.dpr),
round(rect.height() * self.dpr),
)
if self.source is not None:
image = self.source.copy(physical_rect)
else:
image = QImage(physical_rect.size(), QImage.Format.Format_RGB32)
image.fill(QColor("#143b67"))
image.setDevicePixelRatio(self.dpr)
return SimpleNamespace(toImage=lambda: image)
def _decode_jpeg(data_url: str) -> QImage:
prefix = "data:image/jpeg;base64,"
assert data_url.startswith(prefix)
data = base64.b64decode(data_url[len(prefix) :], validate=True)
assert data.startswith(b"\xff\xd8")
image = QImage.fromData(data, "JPEG")
assert not image.isNull()
return image
@pytest.mark.parametrize("zoom", [0.8, 1, 1.25, 1.5, 2])
def test_css_coordinates_follow_webengine_zoom(zoom: float) -> None:
view = _View(round(1000 * zoom), round(800 * zoom), zoom=zoom)
image = _decode_jpeg(capture_view_region(view, _region()))
expected = QRect(round(120 * zoom), round(80 * zoom), round(480 * zoom), round(320 * zoom))
assert view.grabbed == [expected]
assert image.size() == expected.size()
def test_fractional_edges_round_inward_and_exclude_adjacent_patient_panel() -> None:
source = QImage(1250, 1000, QImage.Format.Format_RGB32)
source.fill(QColor("red"))
painter = QPainter(source)
# Only complete logical pixels inside the fractional video rectangle are safe.
expected = QRect(17, 26, 374, 249)
painter.fillRect(expected, QColor("#143b67"))
painter.end()
view = _View(1250, 1000, zoom=1.25, source=source)
image = _decode_jpeg(
capture_view_region(view, _region(x=13.1, y=20.2, width=300.4, height=200.4))
)
assert view.grabbed == [expected]
for x, y in ((0, 0), (373, 0), (0, 248), (373, 248), (187, 124)):
color = image.pixelColor(x, y)
assert color.blue() > color.red() + 50
@pytest.mark.parametrize(
("changes", "expected"),
[
({"x": -20, "y": -10, "width": 150, "height": 110}, QRect(0, 0, 130, 100)),
({"x": 950, "y": 760, "width": 100, "height": 80}, QRect(950, 760, 50, 40)),
({"x": -50, "y": -50, "width": 1200, "height": 1000}, QRect(0, 0, 1000, 800)),
],
)
def test_capture_is_clipped_to_the_visible_viewport(
changes: dict[str, int], expected: QRect
) -> None:
view = _View()
image = _decode_jpeg(capture_view_region(view, _region(**changes)))
assert view.grabbed == [expected]
assert image.size() == expected.size()
@pytest.mark.parametrize("key", REGION_KEYS)
@pytest.mark.parametrize(
"value",
[float("nan"), float("inf"), float("-inf"), True, False, None, "12"],
ids=["nan", "infinity", "negative-infinity", "true", "false", "null", "string"],
)
def test_invalid_coordinate_values_are_rejected_before_grab(key: str, value: Any) -> None:
view = _View()
with pytest.raises(ValueError):
capture_view_region(view, _region(**{key: value}))
assert view.grabbed == []
@pytest.mark.parametrize("key", REGION_KEYS)
def test_missing_coordinates_are_rejected_before_grab(key: str) -> None:
view = _View()
region = _region()
del region[key]
with pytest.raises(ValueError):
capture_view_region(view, region)
assert view.grabbed == []
@pytest.mark.parametrize("key", ["width", "height", "viewportWidth", "viewportHeight"])
@pytest.mark.parametrize("value", [0, -1])
def test_empty_or_negative_sizes_are_rejected_before_grab(key: str, value: int) -> None:
view = _View()
with pytest.raises(ValueError):
capture_view_region(view, _region(**{key: value}))
assert view.grabbed == []
@pytest.mark.parametrize(
"changes",
[
{"x": 1000},
{"y": 800},
{"x": -480},
{"y": -320},
{"x": 12.2, "width": 0.2},
{"y": 12.2, "height": 0.2},
],
)
def test_offscreen_or_subpixel_empty_regions_are_rejected(changes: dict[str, Any]) -> None:
view = _View()
with pytest.raises(ValueError):
capture_view_region(view, _region(**changes))
assert view.grabbed == []
@pytest.mark.parametrize("width,height,zoom", [(1200, 800, 1), (1000, 900, 1), (1000, 800, 1.25)])
def test_resize_or_zoom_race_rejects_stale_viewport(width: int, height: int, zoom: float) -> None:
view = _View(width, height, zoom=zoom)
with pytest.raises(ValueError, match="尺寸已变化"):
capture_view_region(view, _region())
assert view.grabbed == []
@pytest.mark.parametrize("dpr", [1, 1.25, 2])
def test_dpr_changes_encoded_pixels_without_scaling_grab_coordinates(dpr: float) -> None:
view = _View(1250, 1000, zoom=1.25, dpr=dpr)
image = _decode_jpeg(capture_view_region(view, _region()))
assert view.grabbed == [QRect(150, 100, 600, 400)]
assert (image.width(), image.height()) == (round(600 * dpr), round(400 * dpr))
@pytest.mark.parametrize(
"width,height,dpr,expected",
[
(3000, 1500, 1, (1920, 960)),
(1500, 3000, 1, (960, 1920)),
(1600, 900, 2, (1920, 1080)),
(320, 240, 1, (320, 240)),
],
)
def test_jpeg_longest_edge_is_limited_to_1920_without_upscaling(
width: int, height: int, dpr: float, expected: tuple[int, int]
) -> None:
view = _View(width, height, dpr=dpr)
region = _region(
x=0, y=0, width=width, height=height, viewportWidth=width, viewportHeight=height
)
image = _decode_jpeg(capture_view_region(view, region))
assert (image.width(), image.height()) == expected
assert view.grabbed == [QRect(0, 0, width, height)]
def test_null_grab_and_jpeg_encoding_failure_are_reported() -> None:
view = _View()
view.grab = Mock(return_value=SimpleNamespace(toImage=lambda: QImage()))
with pytest.raises(ValueError, match="截屏失败"):
capture_view_region(view, _region())
image = SimpleNamespace(
isNull=lambda: False, width=lambda: 480, height=lambda: 320, save=lambda *_: False
)
view.grab = Mock(return_value=SimpleNamespace(toImage=lambda: image))
with pytest.raises(ValueError, match="图片生成失败"):
capture_view_region(view, _region())
def _host(view: _View, **changes: Any) -> SimpleNamespace:
return SimpleNamespace(
**{
"_closing": False,
"_released": False,
"_shutdown_requested": False,
"_media_active": True,
"_policy": TrustedDocumentPolicy.from_url(TRUSTED_URL, is_local=False),
"_page": SimpleNamespace(url=lambda: QUrl(TRUSTED_URL)),
"web_view": view,
"lifecycle": SimpleNamespace(save_screenshot=Mock()),
**changes,
}
)
def test_bridge_returns_preview_without_saving_until_explicit_save() -> None:
view = _View()
host = _host(view)
events: list[Mapping[str, Any]] = []
bridge = window._QtVideoBridge(
events.append,
lambda region: window._EmbeddedVideoWindow._capture_screenshot_preview(host, region),
)
result = json.loads(bridge.captureScreenshot(json.dumps(_region())))
assert set(result) == {"dataUrl"}
assert _decode_jpeg(result["dataUrl"]).size() == QRect(0, 0, 480, 320).size()
assert events == []
host.lifecycle.save_screenshot.assert_not_called()
bridge.saveScreenshot(result["dataUrl"])
assert events == [
{"source": "doctor-call", "event": "screenshot", "dataUrl": result["dataUrl"]}
]
@pytest.mark.parametrize("payload", [None, 12, "", "{", "null", "[]", '"region"', " " * 2049])
def test_bridge_rejects_invalid_payloads_without_calling_capture(payload: Any) -> None:
capture = Mock()
notify = Mock()
bridge = window._QtVideoBridge(notify, capture)
result = json.loads(bridge.captureScreenshot(payload))
assert set(result) == {"error"}
assert result["error"]
capture.assert_not_called()
notify.assert_not_called()
@pytest.mark.parametrize(
"value",
[float("nan"), float("inf"), True, 0, -1, 10**400],
ids=["nan", "infinity", "boolean", "empty", "negative", "oversized-integer"],
)
def test_bridge_returns_error_for_invalid_region_values(value: Any) -> None:
view = _View()
notify = Mock()
bridge = window._QtVideoBridge(notify, lambda region: capture_view_region(view, region))
result = json.loads(bridge.captureScreenshot(json.dumps(_region(width=value))))
assert set(result) == {"error"}
assert result["error"]
assert view.grabbed == []
notify.assert_not_called()
@pytest.mark.parametrize("error_type", [ValueError, TypeError, RuntimeError])
def test_bridge_reports_capture_failure_without_exposing_error_details(
error_type: type[Exception],
) -> None:
notify = Mock()
bridge = window._QtVideoBridge(
notify, Mock(side_effect=error_type("private path or patient information"))
)
result = json.loads(bridge.captureScreenshot(json.dumps(_region())))
assert set(result) == {"error"}
assert "private" not in result["error"]
notify.assert_not_called()
@pytest.mark.parametrize(
"changes",
[
{"_closing": True},
{"_released": True},
{"_shutdown_requested": True},
{"_media_active": False},
],
)
def test_host_rejects_capture_when_call_is_inactive_or_closing(changes: dict[str, bool]) -> None:
view = _View()
host = _host(view, **changes)
with pytest.raises(ValueError, match="已关闭"):
window._EmbeddedVideoWindow._capture_screenshot_preview(host, _region())
assert view.grabbed == []
host.lifecycle.save_screenshot.assert_not_called()
@pytest.mark.parametrize(
"url",
[
"about:blank",
"https://untrusted.example.test/companion/index.html?mode=call",
"https://video.example.test/other",
"https://video.example.test/companion/index.html?mode=other",
],
)
def test_host_rejects_capture_after_navigation_away_from_trusted_document(url: str) -> None:
view = _View()
host = _host(view, _page=SimpleNamespace(url=lambda: QUrl(url)))
with pytest.raises(ValueError, match="页面不可用"):
window._EmbeddedVideoWindow._capture_screenshot_preview(host, _region())
assert view.grabbed == []
host.lifecycle.save_screenshot.assert_not_called()