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, ) @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, ) button._manager.deleteLater() 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, ) button._manager.deleteLater() 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, ) button._manager.deleteLater() 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 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) thumb.click() assert opened == [tongue_url] 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()