Files
zyt/app/tests/test_diagnosis_media_thumbnail_visual.py
2026-09-09 12:18:17 +08:00

476 lines
16 KiB
Python

from __future__ import annotations
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QBuffer, QByteArray, QIODevice, QObject, QSize, Signal
from PySide6.QtGui import QColor, QImage
from PySide6.QtNetwork import QNetworkReply, QNetworkRequest
from PySide6.QtWidgets import QApplication, QPushButton, QWidget
from doctor_workstation.ui.diagnosis_drawer import (
ChatPanel,
DailyRecordPanel,
NotesTimeline,
_RemoteImageButton,
)
from doctor_workstation.ui.diagnosis_media import ImagePreviewDialog
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
def _png_bytes(width: int, height: int, color: str = "#0F766E") -> bytes:
image = QImage(width, height, QImage.Format.Format_ARGB32)
image.fill(QColor(color))
buffer = QBuffer()
assert buffer.open(QIODevice.OpenModeFlag.WriteOnly)
assert image.save(buffer, "PNG")
return bytes(buffer.data())
class _FakeReply(QObject):
finished = Signal()
downloadProgress = Signal(int, int)
def __init__(
self,
payload: bytes,
error: QNetworkReply.NetworkError = QNetworkReply.NetworkError.NoError,
parent: QObject | None = None,
) -> None:
super().__init__(parent)
self.payload = payload
self.network_error = error
self.aborted = False
self.read_all_calls = 0
def abort(self) -> None:
self.aborted = True
def error(self) -> QNetworkReply.NetworkError:
return self.network_error
def readAll(self) -> QByteArray: # noqa: N802 - mirrors QNetworkReply
self.read_all_calls += 1
return QByteArray(self.payload)
class _FakeManager(QObject):
def __init__(self, parent: QObject) -> None:
super().__init__(parent)
self.responses: list[tuple[bytes, QNetworkReply.NetworkError]] = []
self.requests: list[str] = []
self.request_objects: list[object] = []
self.replies: list[_FakeReply] = []
def queue(
self,
payload: bytes,
error: QNetworkReply.NetworkError = QNetworkReply.NetworkError.NoError,
) -> None:
self.responses.append((payload, error))
def get(self, request: object) -> _FakeReply:
payload, error = self.responses.pop(0)
self.requests.append(request.url().toString())
self.request_objects.append(request)
reply = _FakeReply(payload, error, self)
self.replies.append(reply)
return reply
class _RenderOwner(QWidget):
def __init__(self, generation: int) -> None:
super().__init__()
self._image_generation = generation
def _hold_remote_load(self: _RemoteImageButton, source: str) -> None:
"""Offline transport used by panel tests; payload completion stays explicit."""
self._source = str(source).strip()
self._invalidate_request()
self.setToolTip(self._source)
self._show_loading()
def test_remote_image_request_is_thread_owned_and_rejects_stale_results(
application: QApplication,
) -> None:
owner = _RenderOwner(7)
button = _RemoteImageButton(
"",
render_owner=owner,
owner_generation=7,
maximum_size=QSize(64, 64),
fallback_text="舌象\n查看",
cover=True,
object_name="DiagnosisTongueThumb",
parent=owner,
)
manager = _FakeManager(button)
button._manager = manager
manager.queue(_png_bytes(180, 90, "#DC2626"))
manager.queue(_png_bytes(180, 90, "#16A34A"))
button.load_url("https://media.example.invalid/old.png")
old_reply = manager.replies[-1]
assert button.text() == "舌象\n查看"
assert button.property("loadState") == "loading"
button.load_url("https://media.example.invalid/current.png")
current_reply = manager.replies[-1]
assert old_reply.aborted is True
old_reply.finished.emit()
assert button.property("loadState") == "loading"
current_reply.finished.emit()
assert button.property("loadState") == "ready"
assert button._rendered_pixmap.size() == QSize(64, 64)
assert button.text() == ""
assert manager.parent() is button
assert manager.thread() == button.thread() == application.thread()
assert manager.requests == [
"https://media.example.invalid/old.png",
"https://media.example.invalid/current.png",
]
manager.queue(_png_bytes(90, 180, "#2563EB"))
button.load_url("https://media.example.invalid/new-owner.png")
owner._image_generation += 1
manager.replies[-1].finished.emit()
assert button.property("loadState") == "loading"
def test_remote_image_uses_text_only_after_request_or_decode_failure(
application: QApplication,
) -> None:
owner = _RenderOwner(3)
button = _RemoteImageButton(
"",
render_owner=owner,
owner_generation=3,
maximum_size=QSize(240, 200),
fallback_text="查看图片",
cover=False,
object_name="DiagnosisChatImage",
parent=owner,
)
manager = _FakeManager(button)
button._manager = manager
manager.queue(b"not-an-image")
button.load_url("https://media.example.invalid/broken.jpg")
assert button.text() == "查看图片"
assert button.property("loadState") == "loading"
manager.replies[-1].finished.emit()
assert button.property("loadState") == "failed"
assert button.text() == "查看图片"
request_count = len(manager.requests)
button.load_url("file:///C:/private/image.png")
assert len(manager.requests) == request_count
assert button.property("loadState") == "failed"
assert application.thread() == button.thread()
def test_remote_image_enforces_same_origin_redirects_and_aborts_oversize_download(
application: QApplication,
) -> None:
owner = _RenderOwner(5)
button = _RemoteImageButton(
"",
render_owner=owner,
owner_generation=5,
maximum_size=QSize(64, 64),
fallback_text="image unavailable",
cover=True,
object_name="DiagnosisTongueThumb",
parent=owner,
)
manager = _FakeManager(button)
button._manager = manager
manager.queue(b"must-not-be-read")
button.load_url("https://media.example.invalid/oversize.png")
request = manager.request_objects[-1]
assert request.attribute(QNetworkRequest.Attribute.RedirectPolicyAttribute) == (
QNetworkRequest.RedirectPolicy.SameOriginRedirectPolicy
)
reply = manager.replies[-1]
reply.downloadProgress.emit(button._MAX_IMAGE_BYTES, -1)
assert reply.aborted is False
reply.downloadProgress.emit(button._MAX_IMAGE_BYTES + 1, -1)
assert reply.aborted is True
assert reply.property("diagnosisImageOversize") is True
reply.finished.emit()
assert reply.read_all_calls == 0
assert button.property("loadState") == "failed"
assert button.text() == "image unavailable"
assert application.thread() == button.thread()
def _preview_with_offline_transport(
sources: list[str],
*,
index: int = 0,
names: list[str] | None = None,
) -> tuple[ImagePreviewDialog, _FakeManager]:
"""Build a preview window whose downloads are driven by the test, not the network."""
original_request = ImagePreviewDialog._request
ImagePreviewDialog._request = lambda self, target: None # type: ignore[method-assign]
try:
dialog = ImagePreviewDialog(sources, index=index, names=names)
finally:
ImagePreviewDialog._request = original_request # type: ignore[method-assign]
manager = _FakeManager(dialog)
dialog._manager = manager
return dialog, manager
def test_image_preview_pages_the_group_in_app_and_reuses_decoded_images(
application: QApplication,
) -> None:
dialog, manager = _preview_with_offline_transport(
[
"https://media.example.invalid/tongue-1.jpg",
"file:///C:/private/tongue.jpg",
"https://media.example.invalid/tongue-2.jpg",
],
index=2,
names=["舌象附件 1", "本地危险附件", "舌象附件 2"],
)
# file:// 附件既不进入分组,也不会发起任何请求。
assert dialog.sources == [
"https://media.example.invalid/tongue-1.jpg",
"https://media.example.invalid/tongue-2.jpg",
]
assert dialog.current_source == "https://media.example.invalid/tongue-2.jpg"
assert dialog.counter.text() == "第 2 / 2 张"
assert dialog.name_label.text() == "舌象附件 2"
manager.queue(_png_bytes(320, 200, "#DC2626"))
dialog.reload_current()
request = manager.request_objects[-1]
assert request.attribute(QNetworkRequest.Attribute.RedirectPolicyAttribute) == (
QNetworkRequest.RedirectPolicy.SameOriginRedirectPolicy
)
manager.replies[-1].finished.emit()
assert dialog.canvas.text() == ""
assert not dialog.canvas.pixmap().isNull()
manager.queue(_png_bytes(120, 90, "#16A34A"))
dialog.step(1)
assert dialog.current_source == "https://media.example.invalid/tongue-1.jpg"
manager.replies[-1].finished.emit()
assert not dialog.canvas.pixmap().isNull()
dialog.step(1)
assert dialog.current_source == "https://media.example.invalid/tongue-2.jpg"
assert manager.requests == [
"https://media.example.invalid/tongue-2.jpg",
"https://media.example.invalid/tongue-1.jpg",
]
assert not dialog.canvas.pixmap().isNull()
dialog.close()
def test_image_preview_aborts_oversize_and_falls_back_on_undecodable_payload(
application: QApplication,
) -> None:
dialog, manager = _preview_with_offline_transport(
["https://media.example.invalid/tongue.jpg"]
)
manager.queue(b"must-not-be-read")
dialog.reload_current()
reply = manager.replies[-1]
reply.downloadProgress.emit(dialog._MAX_IMAGE_BYTES, -1)
assert reply.aborted is False
reply.downloadProgress.emit(dialog._MAX_IMAGE_BYTES + 1, -1)
assert reply.aborted is True
reply.finished.emit()
assert reply.read_all_calls == 0
assert "12 MB" in dialog.canvas.text()
manager.queue(b"not-an-image")
dialog.reload_current()
manager.replies[-1].finished.emit()
assert dialog.canvas.pixmap().isNull()
assert "无法在工作站内预览" in dialog.canvas.text()
dialog.close()
def test_image_preview_refuses_a_group_without_any_safe_http_source(
application: QApplication,
) -> None:
dialog = ImagePreviewDialog(["file:///C:/private/tongue.jpg", ""])
assert dialog.has_images() is False
assert dialog.sources == []
assert dialog.current_source == ""
assert not dialog.external_button.isEnabled()
assert not dialog.next_button.isEnabled()
assert "HTTP(S)" in dialog.canvas.text()
dialog.close()
def test_notes_render_cover_thumbnail_and_keep_safe_open_and_single_delete(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(_RemoteImageButton, "load_url", _hold_remote_load)
timeline = NotesTimeline(editable=True)
opened: list[str] = []
deleted: list[tuple[int, str, str]] = []
timeline.open_attachment_requested.connect(opened.append)
timeline.delete_attachment_requested.connect(
lambda note_id, kind, path: deleted.append((note_id, kind, path))
)
tongue_url = "https://media.example.invalid/tongue-7001.jpg"
timeline.set_notes(
[
{
"id": 7001,
"note_date": "2026-08-10",
"content": "舌淡红,苔薄白。",
"tongue_images": [tongue_url],
"report_files": [
{
"name": "近期血糖趋势.pdf",
"url": "https://media.example.invalid/report-7001.pdf",
}
],
}
]
)
thumb = timeline.findChild(_RemoteImageButton, "DiagnosisTongueThumb")
assert thumb is not None
assert thumb.text() == "舌象\n查看"
assert thumb.property("loadState") == "loading"
assert thumb._apply_payload(_png_bytes(192, 96), thumb._generation)
assert thumb.property("loadState") == "ready"
assert thumb._rendered_pixmap.size() == QSize(64, 64)
previews: list[tuple[list[str], int]] = []
timeline.preview_images_requested.connect(lambda sources, index: previews.append((sources, index)))
thumb.click()
assert opened == []
assert previews == [([tongue_url], 0)]
remove_buttons = timeline.findChildren(QPushButton, "DiagnosisAttachmentRemove")
assert len(remove_buttons) == 2
tongue_remove = next(button for button in remove_buttons if "舌象" in button.toolTip())
tongue_remove.click()
assert deleted == [(7001, "tongue_images", tongue_url)]
stale_generation = thumb._generation
timeline.set_notes(
[
{
"id": 7002,
"note_date": "2026-08-11",
"content": "复诊舌象。",
"tongue_images": ["https://media.example.invalid/tongue-7002.jpg"],
}
]
)
assert not thumb._apply_payload(_png_bytes(96, 192), stale_generation)
current = next(
item
for item in timeline.findChildren(_RemoteImageButton, "DiagnosisTongueThumb")
if item is not thumb
)
assert current.text() == "舌象\n查看"
assert current.property("loadState") == "loading"
assert not current._apply_payload(b"invalid", current._generation)
assert current.text() == "舌象\n查看"
timeline.close()
application.processEvents()
def test_chat_image_is_previewable_bounded_and_owner_generation_safe(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(_RemoteImageButton, "load_url", _hold_remote_load)
panel = ChatPanel()
opened: list[str] = []
panel.open_attachment_requested.connect(opened.append)
first_url = "https://media.example.invalid/glucose-chart.jpg"
panel.set_messages(
[
{
"msg_id": "chat-image-1",
"msg_type": "image",
"image_url": first_url,
"is_from_doctor": False,
"time": "2026-08-10 08:31",
}
]
)
image = panel.findChild(_RemoteImageButton, "DiagnosisChatImage")
assert image is not None
assert image.text() == "查看图片"
assert image.property("loadState") == "loading"
assert image._apply_payload(_png_bytes(640, 480), image._generation)
assert image._rendered_pixmap.size() == QSize(240, 180)
assert image.width() <= 240 and image.height() <= 200
image.click()
assert opened == [first_url]
stale_generation = image._generation
second_url = "https://media.example.invalid/tall-photo.jpg"
panel.set_messages(
[
{
"msg_id": "chat-image-2",
"msg_type": "image",
"image_url": second_url,
"is_from_doctor": True,
"from_staff_name": "陈医生",
"time": "2026-08-10 08:42",
}
]
)
assert not image._apply_payload(_png_bytes(480, 640), stale_generation)
current = next(
item
for item in panel.findChildren(_RemoteImageButton, "DiagnosisChatImage")
if item is not image
)
assert current._apply_payload(_png_bytes(120, 480), current._generation)
assert current._rendered_pixmap.size() == QSize(50, 200)
assert current.property("loadState") == "ready"
panel.close()
application.processEvents()
def test_daily_todo_has_exact_local_toolbar_and_refresh_signal(
application: QApplication,
) -> None:
panel = DailyRecordPanel()
panel.set_editable(True)
refreshed: list[bool] = []
panel.refresh_requested.connect(lambda: refreshed.append(True))
assert panel.todo_add_button.text() == "+ 新增待办"
assert panel.todo_add_button.parentWidget() is panel.todo_toolbar
assert panel.todo_refresh_button.text() == "刷新"
assert panel.todo_refresh_button.parentWidget() is panel.todo_toolbar
assert panel.todo_toolbar.objectName() == "DiagnosisTodoToolbar"
assert all(button.parentWidget() is panel.todo_toolbar for button in panel.todo_group.buttons())
panel.todo_refresh_button.click()
assert refreshed == [True]
panel.set_loading(True)
assert not panel.todo_refresh_button.isEnabled()
panel.set_loading(False)
assert panel.todo_refresh_button.isEnabled()
panel.close()
application.processEvents()