476 lines
16 KiB
Python
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()
|