380 lines
12 KiB
Python
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()
|