更新
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
"""Permission-scoped department loading and diagnosis HTTP filter contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import QApplication, QWidget
|
||||
|
||||
from doctor_workstation.core.errors import ApiBusinessError, ApiProtocolError
|
||||
from doctor_workstation.services.repository import RemoteDoctorRepository
|
||||
from doctor_workstation.ui.pages import consultations as module
|
||||
|
||||
TREE = [
|
||||
{"id": 10, "name": "医助部", "children": [
|
||||
{"id": "11", "name": "一组", "children": [
|
||||
{"id": 12, "name": "专病组", "children": []},
|
||||
]},
|
||||
]},
|
||||
{"id": 20, "name": "另一部门", "children": []},
|
||||
]
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, departments: Any = TREE) -> None:
|
||||
self.departments = departments
|
||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
|
||||
self.calls.append((endpoint, dict(params or {})))
|
||||
if endpoint == "dept.dept/all":
|
||||
if isinstance(self.departments, Exception):
|
||||
raise self.departments
|
||||
return self.departments
|
||||
return {"lists": [], "count": 0}
|
||||
|
||||
|
||||
class DetailStub(QWidget):
|
||||
saved = Signal()
|
||||
|
||||
def __init__(self, _repository: Any, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(module, "DiagnosisDialog", DetailStub)
|
||||
# Test only this page's controls; loading a global stylesheet is unrelated.
|
||||
client = Client()
|
||||
result = module.ConsultationsPage(
|
||||
RemoteDoctorRepository(client),
|
||||
current_user={"department_id": 99, "department_name": "个人所属部门"},
|
||||
)
|
||||
yield result, client
|
||||
result.poll_timer.stop()
|
||||
result.close()
|
||||
result.deleteLater()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def immediate(function: Any, *, on_success: Any = None, on_error: Any = None,
|
||||
on_finished: Any = None, **_kwargs: Any) -> None:
|
||||
try:
|
||||
value = function()
|
||||
except Exception as error:
|
||||
if on_error:
|
||||
on_error(error)
|
||||
else:
|
||||
if on_success:
|
||||
on_success(value)
|
||||
finally:
|
||||
if on_finished:
|
||||
on_finished()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [TREE, {"lists": TREE}, {"tree": TREE}, {"data": {"lists": TREE}}])
|
||||
def test_repository_scoped_tree_shapes(payload: Any) -> None:
|
||||
client = Client(payload)
|
||||
assert RemoteDoctorRepository(client).list_departments(apply_data_scope=True) == TREE
|
||||
assert client.calls == [("dept.dept/all", {"apply_data_scope": 1})]
|
||||
|
||||
|
||||
def test_repository_malformed_is_not_a_permission_empty_result() -> None:
|
||||
with pytest.raises(ApiProtocolError):
|
||||
RemoteDoctorRepository(Client({"unexpected": 1})).list_departments(apply_data_scope=True)
|
||||
|
||||
|
||||
def test_repository_maps_legacy_department_key_and_preserves_canonical() -> None:
|
||||
client = Client()
|
||||
repository = RemoteDoctorRepository(client)
|
||||
repository.list_consultations(department_id=12)
|
||||
assert client.calls[-1][1]["assistant_dept_id"] == 12
|
||||
assert "department_id" not in client.calls[-1][1]
|
||||
repository.list_consultations(department_id=12, assistant_dept_id=20)
|
||||
assert client.calls[-1][1]["assistant_dept_id"] == 20
|
||||
|
||||
|
||||
def test_nested_options_search_and_selected_id_reach_list_and_counts(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
widget, client = page
|
||||
monkeypatch.setattr(module, "run_async", immediate)
|
||||
widget._load_departments()
|
||||
combo = widget.department_combo
|
||||
assert combo.count() == 5
|
||||
assert combo.itemText(3) == "医助部 / 一组 / 专病组"
|
||||
assert combo.findData(99) == -1
|
||||
assert combo.isEditable()
|
||||
combo.completer().setCompletionPrefix("专病")
|
||||
assert combo.completer().completionCount() == 1
|
||||
combo.setCurrentIndex(combo.findData(12))
|
||||
widget._search()
|
||||
calls = [params for endpoint, params in client.calls if endpoint == "tcm.diagnosis/lists"]
|
||||
assert calls
|
||||
assert all(params["assistant_dept_id"] == 12 for params in calls)
|
||||
assert all("department_id" not in params for params in calls)
|
||||
assert calls[-1]["page_size"] == 15
|
||||
|
||||
|
||||
def test_unselected_search_text_never_becomes_an_id_or_silently_searches_all(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
widget, client = page
|
||||
monkeypatch.setattr(module, "run_async", immediate)
|
||||
widget._load_departments()
|
||||
widget.department_combo.setEditText("不存在的部门")
|
||||
widget._search()
|
||||
assert not any(endpoint == "tcm.diagnosis/lists" for endpoint, _ in client.calls)
|
||||
widget.department_combo.setEditText("")
|
||||
widget._search()
|
||||
assert widget._shared_filters()["assistant_dept_id"] == ""
|
||||
|
||||
|
||||
def test_permission_empty_has_no_profile_fallback_and_can_retry(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
widget, client = page
|
||||
monkeypatch.setattr(module, "run_async", immediate)
|
||||
client.departments = []
|
||||
widget._load_departments()
|
||||
assert widget.department_combo.count() == 1
|
||||
assert widget.department_combo.currentData() == ""
|
||||
assert widget.department_combo.currentText() == "暂无可选部门"
|
||||
assert not widget.department_combo.isEnabled()
|
||||
assert not widget.department_retry_button.isHidden()
|
||||
client.departments = TREE
|
||||
widget.department_retry_button.click()
|
||||
assert widget.department_combo.isEnabled()
|
||||
assert widget.department_combo.findData(12) > 0
|
||||
assert all(params == {"apply_data_scope": 1} for _, params in client.calls)
|
||||
|
||||
|
||||
def test_permission_error_stays_scoped_and_retry_recovers(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
widget, client = page
|
||||
monkeypatch.setattr(module, "run_async", immediate)
|
||||
client.departments = ApiBusinessError("无权限访问部门")
|
||||
widget._load_departments()
|
||||
assert not widget._departments_loaded
|
||||
assert not widget._departments_loading
|
||||
assert widget.department_combo.currentText() == "部门加载失败"
|
||||
assert "无权限" in widget.department_combo.toolTip()
|
||||
assert not widget.department_retry_button.isHidden()
|
||||
client.departments = TREE
|
||||
widget.department_retry_button.click()
|
||||
assert widget._departments_loaded
|
||||
assert widget.department_retry_button.isHidden()
|
||||
assert all(params == {"apply_data_scope": 1} for _, params in client.calls)
|
||||
|
||||
|
||||
def test_inflight_dedup_and_stale_success_or_failure_cannot_override_selection(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
widget, _client = page
|
||||
jobs: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(module, "run_async", lambda function, **callbacks: jobs.append(callbacks))
|
||||
widget._load_departments()
|
||||
widget._load_departments()
|
||||
assert len(jobs) == 1
|
||||
widget._load_departments(force=True)
|
||||
assert len(jobs) == 2
|
||||
jobs[1]["on_success"]([(12, "当前部门")])
|
||||
widget.department_combo.setCurrentIndex(1)
|
||||
jobs[0]["on_success"]([(99, "旧部门")])
|
||||
jobs[0]["on_error"](RuntimeError("旧请求失败"))
|
||||
assert widget.department_combo.currentData() == 12
|
||||
assert widget.department_combo.findData(99) == -1
|
||||
assert widget._departments_loaded
|
||||
assert widget.department_combo.isEnabled()
|
||||
|
||||
|
||||
def test_refresh_of_options_preserves_selected_id(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
widget, _client = page
|
||||
monkeypatch.setattr(module, "run_async", immediate)
|
||||
widget._load_departments()
|
||||
widget.department_combo.setCurrentIndex(widget.department_combo.findData(12))
|
||||
widget._load_departments(force=True)
|
||||
assert widget.department_combo.currentData() == 12
|
||||
|
||||
|
||||
def test_legacy_repository_is_not_called_without_scope(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
widget, _client = page
|
||||
monkeypatch.setattr(module, "run_async", immediate)
|
||||
calls: list[bool] = []
|
||||
|
||||
class OldRepository:
|
||||
def list_departments(self):
|
||||
calls.append(True)
|
||||
return TREE
|
||||
|
||||
widget.repository = OldRepository()
|
||||
widget._load_departments()
|
||||
assert calls == []
|
||||
assert not widget._departments_loaded
|
||||
assert not widget.department_retry_button.isHidden()
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Critical-path request counts and late metadata safety, without global Qt styling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.services.repository import RemoteDoctorRepository
|
||||
from doctor_workstation.ui.dialogs import diagnosis as module
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, delay: float = 0) -> None:
|
||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||
self.delay = delay
|
||||
|
||||
def get(self, endpoint: str, params: dict[str, Any]) -> Any:
|
||||
self.calls.append((endpoint, params))
|
||||
time.sleep(self.delay)
|
||||
if endpoint == "config/dict":
|
||||
return {
|
||||
key: [{"name": "口干", "value": "dry"}, {"name": "口苦", "value": "bitter"}]
|
||||
for key in params["type"].split(",")
|
||||
}
|
||||
if endpoint.endswith("Detail") or endpoint.endswith("/detail"):
|
||||
return {
|
||||
"id": params["id"], "patient_id": params["id"] + 1000,
|
||||
"patient_name": f"Patient {params['id']}", "appetite": ["dry"],
|
||||
"diagnosis_type": "first_visit", "chief_complaint": "Original",
|
||||
}
|
||||
return {"lists": [], "count": 0}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def queued(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
|
||||
jobs: list[dict[str, Any]] = []
|
||||
|
||||
def enqueue(function: Any, **callbacks: Any) -> object:
|
||||
jobs.append({"function": function, **callbacks})
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(module, "run_async", enqueue)
|
||||
return jobs
|
||||
|
||||
|
||||
def complete(job: dict[str, Any]) -> None:
|
||||
job["on_success"](job["function"]())
|
||||
if job.get("on_finished"):
|
||||
job["on_finished"]()
|
||||
|
||||
|
||||
def dialog_for(client: Client) -> module.DiagnosisDialog:
|
||||
return module.DiagnosisDialog(
|
||||
RemoteDoctorRepository(client), permissions=PermissionSet(["*"])
|
||||
)
|
||||
|
||||
|
||||
def test_detail_unlocks_before_single_batch_and_preserves_draft(
|
||||
application: QApplication, queued: list[dict[str, Any]]
|
||||
) -> None:
|
||||
client = Client()
|
||||
dialog = dialog_for(client)
|
||||
try:
|
||||
dialog.open_for(501, editable=True, seed={"patient_name": "Unverified"})
|
||||
assert dialog.isVisible()
|
||||
assert dialog.drawer_loading.isVisibleTo(dialog)
|
||||
assert dialog.edit_fields["patient_name"].isReadOnly()
|
||||
assert not dialog.save_button.isEnabled()
|
||||
assert len(queued) == 1
|
||||
complete(queued[0])
|
||||
assert client.calls == [("tcm.diagnosis/detail", {"id": 501})]
|
||||
assert dialog._authoritative_detail_loaded
|
||||
assert dialog.save_button.isEnabled()
|
||||
assert not dialog.drawer_loading.isVisibleTo(dialog)
|
||||
assert len(queued) == 2
|
||||
dialog.edit_fields["chief_complaint"].setPlainText("Unsaved draft")
|
||||
dialog.edit_fields["appetite"].setPlainText("bitter")
|
||||
complete(queued[1])
|
||||
assert len(client.calls) == 2
|
||||
assert client.calls[1][0] == "config/dict"
|
||||
assert len(client.calls[1][1]["type"].split(",")) == 16
|
||||
assert dialog.edit_fields["appetite"].toPlainText() == "bitter"
|
||||
assert dialog.edit_fields["chief_complaint"].toPlainText() == "Unsaved draft"
|
||||
assert dialog._loaded_tabs == set()
|
||||
assert not any("getCallRecords" in endpoint for endpoint, _params in client.calls)
|
||||
dialog._ensure_tab_loaded("basic")
|
||||
dialog.refresh_permissions()
|
||||
assert len(queued) == 2
|
||||
assert len(client.calls) == 2
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("close", [False, True])
|
||||
def test_late_detail_and_dictionary_callbacks_cannot_touch_another_opening(
|
||||
application: QApplication, queued: list[dict[str, Any]], close: bool
|
||||
) -> None:
|
||||
dialog = dialog_for(Client())
|
||||
try:
|
||||
dialog.open_for(501, editable=True)
|
||||
detail_job = queued[0]
|
||||
complete(detail_job)
|
||||
metadata_job = queued[1]
|
||||
if close:
|
||||
dialog.close()
|
||||
else:
|
||||
dialog.open_for(502, editable=True)
|
||||
complete(queued[2])
|
||||
dialog.edit_fields["chief_complaint"].setPlainText("Keep current draft")
|
||||
before = dialog.edit_fields["patient_name"].toPlainText()
|
||||
complete(detail_job)
|
||||
complete(metadata_job)
|
||||
assert dialog.edit_fields["patient_name"].toPlainText() == before
|
||||
assert dialog.edit_fields["chief_complaint"].toPlainText() == "Keep current draft"
|
||||
if not close:
|
||||
assert before == "Patient 502"
|
||||
assert dialog._patient_id == 1502
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_dictionary_failure_does_not_relock_valid_detail_or_erase_choices(
|
||||
application: QApplication, queued: list[dict[str, Any]]
|
||||
) -> None:
|
||||
dialog = dialog_for(Client())
|
||||
try:
|
||||
dialog.open_for(501, editable=True)
|
||||
complete(queued[0])
|
||||
queued[1]["on_error"](RuntimeError("Delayed metadata unavailable"))
|
||||
assert dialog._authoritative_detail_loaded
|
||||
assert dialog.save_button.isEnabled()
|
||||
assert dialog.edit_fields["appetite"].toPlainText() == "dry"
|
||||
assert not dialog.drawer_loading.isVisibleTo(dialog)
|
||||
dialog._retry_current()
|
||||
assert len(queued) == 3
|
||||
complete(queued[2])
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_inactive_basic_tab_defers_dictionary_request_until_selected(
|
||||
application: QApplication, queued: list[dict[str, Any]]
|
||||
) -> None:
|
||||
client = Client()
|
||||
dialog = dialog_for(client)
|
||||
try:
|
||||
notes_index = next(
|
||||
i for i in range(dialog.tabs.count()) if dialog.tabs.tabBar().tabData(i) == "notes"
|
||||
)
|
||||
dialog.tabs.setCurrentIndex(notes_index)
|
||||
dialog.open_for(501, editable=True)
|
||||
complete(queued[0])
|
||||
assert dialog._current_tab_key() == "notes"
|
||||
assert dialog._dictionary_requested_generation != dialog._generation
|
||||
assert len(client.calls) == 1
|
||||
# The only secondary request is for the active notes tab, not all tabs.
|
||||
assert len(queued) == 2
|
||||
dialog.tabs.setCurrentIndex(0)
|
||||
assert len(queued) == 3
|
||||
complete(queued[2])
|
||||
assert client.calls[-1][0] == "config/dict"
|
||||
dialog.tabs.setCurrentIndex(notes_index)
|
||||
dialog.tabs.setCurrentIndex(0)
|
||||
assert len(queued) == 3
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("retry_from_banner", [False, True])
|
||||
def test_dictionary_error_survives_other_tab_success_and_retries_correct_request(
|
||||
application: QApplication, queued: list[dict[str, Any]], retry_from_banner: bool,
|
||||
) -> None:
|
||||
client = Client()
|
||||
dialog = dialog_for(client)
|
||||
try:
|
||||
dialog.open_for(501, editable=True)
|
||||
complete(queued[0])
|
||||
metadata_job = queued[1]
|
||||
notes_index = next(i for i in range(dialog.tabs.count())
|
||||
if dialog.tabs.tabBar().tabData(i) == "notes")
|
||||
dialog.tabs.setCurrentIndex(notes_index)
|
||||
notes_job = queued[2]
|
||||
metadata_job["on_error"](RuntimeError("Metadata unavailable"))
|
||||
complete(notes_job)
|
||||
assert dialog.drawer_banner.label.text() == dialog._dictionary_error_message
|
||||
assert dialog.drawer_banner.label.text().startswith("病历选项加载失败")
|
||||
dialog.edit_fields["chief_complaint"].setPlainText("Keep draft across retry")
|
||||
if retry_from_banner:
|
||||
dialog.drawer_banner.action_requested.emit()
|
||||
else:
|
||||
dialog.tabs.setCurrentIndex(0)
|
||||
assert len(queued) == 4
|
||||
complete(queued[3])
|
||||
assert client.calls[-1][0] == "config/dict"
|
||||
assert not dialog._dictionary_error_message
|
||||
assert dialog.edit_fields["chief_complaint"].toPlainText() == "Keep draft across retry"
|
||||
assert dialog._authoritative_detail_loaded
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_controlled_latency_removes_sixteen_round_trips_from_detail_gate(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
client = Client(delay=0.02)
|
||||
dialog = dialog_for(client)
|
||||
try:
|
||||
types = sorted({"diagnosis_type", *(row[0] for row in module._CHOICE_DICTIONARIES.values())})
|
||||
started = time.perf_counter()
|
||||
dialog.repository.get_diagnosis_detail(501)
|
||||
for key in types:
|
||||
dialog.repository.get_dictionary(key)
|
||||
old_time = time.perf_counter() - started
|
||||
old_calls = len(client.calls)
|
||||
client.calls.clear()
|
||||
started = time.perf_counter()
|
||||
detail = dialog._load_bundle(501, "edit")
|
||||
new_time = time.perf_counter() - started
|
||||
assert detail["detail"]["id"] == 501
|
||||
assert len(client.calls) == 1
|
||||
dialog._load_dictionary_choices()
|
||||
assert len(client.calls) == 2
|
||||
assert old_calls == 17
|
||||
assert new_time < old_time / 4
|
||||
print(f"controlled 20 ms RTT: old gate={old_time:.3f}s/17 calls; "
|
||||
f"new gate={new_time:.3f}s/1 call; total new=2 calls")
|
||||
finally:
|
||||
dialog.close()
|
||||
@@ -356,8 +356,11 @@ def test_notes_render_cover_thumbnail_and_keep_safe_open_and_single_delete(
|
||||
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 == [tongue_url]
|
||||
assert opened == []
|
||||
assert previews == [([tongue_url], 0)]
|
||||
|
||||
remove_buttons = timeline.findChildren(QPushButton, "DiagnosisAttachmentRemove")
|
||||
assert len(remove_buttons) == 2
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QBuffer, QCoreApplication, QEvent, QIODevice, QPoint, Qt
|
||||
from PySide6.QtGui import QColor, QDesktopServices, QFont, QImage, QPainter
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from test_diagnosis_drawer_visual import (
|
||||
ActionRepository,
|
||||
_open_dialog,
|
||||
_select_tab,
|
||||
)
|
||||
from test_diagnosis_drawer_visual import (
|
||||
application as application,
|
||||
)
|
||||
from test_diagnosis_drawer_visual import (
|
||||
immediate_async as immediate_async,
|
||||
)
|
||||
from test_diagnosis_media_thumbnail_visual import (
|
||||
_FakeManager,
|
||||
_hold_remote_load,
|
||||
_png_bytes,
|
||||
)
|
||||
|
||||
from doctor_workstation.ui.diagnosis_drawer import _RemoteImageButton
|
||||
from doctor_workstation.ui.diagnosis_media import ImagePreviewDialog
|
||||
from doctor_workstation.ui.theme import _register_preferred_cjk_fonts
|
||||
|
||||
|
||||
def _synthetic_image() -> bytes:
|
||||
image = QImage(1600, 1200, QImage.Format.Format_ARGB32)
|
||||
image.fill(QColor("#F4E3D8"))
|
||||
painter = QPainter(image)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor("#C37B85"))
|
||||
painter.drawEllipse(440, 160, 720, 900)
|
||||
painter.setPen(QColor("#67434A"))
|
||||
painter.setFont(QFont("Arial", 36))
|
||||
painter.drawText(image.rect(), Qt.AlignmentFlag.AlignCenter, "SYNTHETIC TEST IMAGE")
|
||||
painter.end()
|
||||
buffer = QBuffer()
|
||||
assert buffer.open(QIODevice.OpenModeFlag.WriteOnly)
|
||||
assert image.save(buffer, "PNG")
|
||||
return bytes(buffer.data())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["edit", "readonly"])
|
||||
def test_real_note_thumbnail_click_keeps_drawer_and_previews_all_eight_images(
|
||||
application: QApplication, monkeypatch: pytest.MonkeyPatch, mode: str,
|
||||
) -> None:
|
||||
"""Exercise the wired drawer click, not just the standalone lightbox class."""
|
||||
monkeypatch.setattr(_RemoteImageButton, "load_url", _hold_remote_load)
|
||||
external: list[str] = []
|
||||
monkeypatch.setattr(QDesktopServices, "openUrl", lambda url: external.append(url.toString()) or True)
|
||||
application.setFont(QFont(_register_preferred_cjk_fonts(), 10))
|
||||
payload = _synthetic_image()
|
||||
|
||||
def offline_request(preview: ImagePreviewDialog, target: str) -> None:
|
||||
preview._invalidate_request()
|
||||
preview.apply_payload(payload, preview._generation)
|
||||
|
||||
monkeypatch.setattr(ImagePreviewDialog, "_request", offline_request)
|
||||
dialog = _open_dialog(application, (1440, 900), mode=mode, repository=ActionRepository())
|
||||
urls = [f"https://media.example.invalid/tongue-{index}.jpg" for index in range(8)]
|
||||
if mode == "edit":
|
||||
_select_tab(dialog, "notes")
|
||||
dialog._fill_notes([{"id": 7001, "note_date": "2026-09-08", "content": "合成测试图片", "tongue_images": urls}])
|
||||
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
timeline = dialog.drawer_notes_timeline if mode == "edit" else dialog._notes_timelines[0]
|
||||
application.processEvents()
|
||||
thumbs = timeline.findChildren(_RemoteImageButton, "DiagnosisTongueThumb")
|
||||
assert len(thumbs) == 8
|
||||
for thumb in thumbs:
|
||||
assert thumb._apply_payload(payload, thumb._generation)
|
||||
original_parent = dialog.parentWidget()
|
||||
original_generation = dialog._generation
|
||||
for index in (0, 7, 3):
|
||||
QTest.mouseClick(thumbs[index], Qt.MouseButton.LeftButton, pos=QPoint(24, 32))
|
||||
application.processEvents()
|
||||
assert external == [], "Thumbnail click must not launch the system browser"
|
||||
preview = dialog._image_preview
|
||||
assert isinstance(preview, ImagePreviewDialog)
|
||||
assert preview.isVisible() and preview.parentWidget() is dialog
|
||||
assert dialog.isVisible() and dialog.parentWidget() is original_parent
|
||||
assert dialog._generation == original_generation
|
||||
assert preview.current_source == urls[index]
|
||||
assert preview.sources == urls
|
||||
assert not preview.canvas.pixmap().isNull()
|
||||
fitted_width = preview.canvas.pixmap().width()
|
||||
QTest.mouseClick(preview.zoom_button, Qt.MouseButton.LeftButton)
|
||||
assert preview.canvas.pixmap().width() == 1600 > fitted_width
|
||||
QTest.mouseClick(preview.next_button, Qt.MouseButton.LeftButton)
|
||||
assert preview.current_source == urls[(index + 1) % 8]
|
||||
QTest.mouseClick(preview.previous_button, Qt.MouseButton.LeftButton)
|
||||
assert preview.current_source == urls[index]
|
||||
if mode == "edit" and index == 7:
|
||||
output = Path(__file__).resolve().parents[2] / "artifacts/patient-render-image-fix"
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
assert dialog.grab().save(str(output / "notes-eight-thumbnails.png"))
|
||||
QTest.mouseClick(preview.zoom_button, Qt.MouseButton.LeftButton)
|
||||
application.processEvents()
|
||||
assert preview.grab().save(str(output / "notes-image-preview.png"))
|
||||
if index == 3:
|
||||
QTest.keyClick(preview, Qt.Key.Key_Escape)
|
||||
else:
|
||||
QTest.mouseClick(preview.close_button, Qt.MouseButton.LeftButton)
|
||||
application.processEvents()
|
||||
assert dialog.isVisible() and timeline.isVisible()
|
||||
assert dialog._image_preview is None
|
||||
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
assert external == []
|
||||
QTest.mouseClick(thumbs[4], Qt.MouseButton.LeftButton, pos=QPoint(24, 32))
|
||||
preview = dialog._image_preview
|
||||
QTest.mouseClick(preview.external_button, Qt.MouseButton.LeftButton)
|
||||
assert external == [urls[4]]
|
||||
dialog.reject()
|
||||
application.processEvents()
|
||||
assert dialog._image_preview is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("close_with_escape", [False, True])
|
||||
def test_preview_close_cancels_pending_response(
|
||||
application: QApplication, monkeypatch: pytest.MonkeyPatch, close_with_escape: bool,
|
||||
) -> None:
|
||||
monkeypatch.setattr(ImagePreviewDialog, "_request", lambda self, target: None)
|
||||
preview = ImagePreviewDialog(["https://media.example.invalid/pending.jpg"])
|
||||
manager = _FakeManager(preview)
|
||||
manager.queue(_png_bytes(80, 60))
|
||||
preview._manager = manager
|
||||
monkeypatch.undo()
|
||||
preview.reload_current()
|
||||
reply = manager.replies[-1]
|
||||
generation = preview._generation
|
||||
preview.show()
|
||||
if close_with_escape:
|
||||
QTest.keyClick(preview, Qt.Key.Key_Escape)
|
||||
else:
|
||||
QTest.mouseClick(preview.close_button, Qt.MouseButton.LeftButton)
|
||||
assert reply.aborted
|
||||
assert preview._generation > generation
|
||||
reply.finished.emit()
|
||||
assert preview._pixmap.isNull()
|
||||
application.processEvents()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Regressions for the blank strip between query results and pinned actions."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.ui.diagnosis_index_widgets import DiagnosisTableHost
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
def settle(app):
|
||||
for _ in range(4):
|
||||
app.processEvents()
|
||||
QTest.qWait(15)
|
||||
|
||||
|
||||
def rows(count):
|
||||
return [{"id": i + 1, "patient_name": f"演示患者{i + 1}", "age": 55,
|
||||
"gender": 1, "has_appointment": 0, "diagnosis_confirmed": 1}
|
||||
for i in range(count)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blue", [True, False])
|
||||
def test_filter_results_fill_viewport_through_scrollbar_and_window_changes(application, blue):
|
||||
host = DiagnosisTableHost(tech_blue=blue, action_policy={"view": True, "edit": True})
|
||||
host.resize(1650, 680)
|
||||
host.show()
|
||||
for width, count in ((1650, 70), (1650, 2), (1900, 2), (900, 2),
|
||||
(1900, 0), (1900, 2), (1366, 80), (1650, 2)):
|
||||
host.resize(width, 680)
|
||||
host.set_rows(rows(count))
|
||||
settle(application)
|
||||
main = host.main
|
||||
viewport = main.viewport()
|
||||
base_width = sum(host.LEFT_WIDTHS)
|
||||
assert main.horizontalHeader().length() == max(base_width, viewport.width())
|
||||
assert all(main.columnWidth(i) >= minimum for i, minimum in enumerate(host.LEFT_WIDTHS))
|
||||
assert main.viewport().height() == host.fixed.viewport().height()
|
||||
assert main.verticalScrollBar().maximum() == host.fixed.verticalScrollBar().maximum()
|
||||
if viewport.width() >= base_width:
|
||||
assert main.horizontalScrollBar().maximum() == 0
|
||||
if count:
|
||||
main.selectRow(1)
|
||||
settle(application)
|
||||
row = main.visualRect(host.model.index(1, 9))
|
||||
point = QPoint(viewport.width() - 4, row.bottom() - 5)
|
||||
# A real data cell, rather than the QTableView's blank background,
|
||||
# must occupy the entire strip leading into the pinned actions.
|
||||
assert main.indexAt(point).row() == 1
|
||||
assert main.indexAt(point).column() == 9
|
||||
if blue:
|
||||
capture = viewport.grab()
|
||||
scale = capture.devicePixelRatio()
|
||||
pixel = QPoint(round(point.x() * scale), round(point.y() * scale))
|
||||
assert capture.toImage().pixelColor(pixel).name() == "#eaf2ff"
|
||||
else:
|
||||
assert main.horizontalScrollBar().maximum() > 0
|
||||
main.horizontalScrollBar().setValue(main.horizontalScrollBar().maximum())
|
||||
settle(application)
|
||||
assert main.visualRect(host.model.index(0, 9)).right() < viewport.width()
|
||||
host.close()
|
||||
host.deleteLater()
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Doctor choices remain explicit and transactional across mouse/search/keyboard use."""
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, Qt, QTimer
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.ui.pages.prescriptions import DoctorMultiSelect, _DoctorSelectionDialog
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dialog(application):
|
||||
parent = DoctorMultiSelect()
|
||||
widget = _DoctorSelectionDialog({1: "医生甲", 2: "医生乙", 3: "医生丙"}, {1}, parent)
|
||||
widget.show()
|
||||
application.processEvents()
|
||||
yield widget
|
||||
widget.close()
|
||||
parent.deleteLater()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def item_for(dialog, doctor_id):
|
||||
return next(dialog.listing.item(i) for i in range(dialog.listing.count())
|
||||
if dialog.listing.item(i).data(Qt.ItemDataRole.UserRole) == doctor_id)
|
||||
|
||||
|
||||
def test_row_and_checkbox_toggle_once_and_keyboard(dialog, application):
|
||||
item = item_for(dialog, 2)
|
||||
rect = dialog.listing.visualItemRect(item)
|
||||
QTest.mouseClick(dialog.listing.viewport(), Qt.MouseButton.LeftButton, pos=rect.center())
|
||||
assert dialog.selected_ids() == {1, 2}
|
||||
assert dialog.count_label.text() == "已选 2 位医生"
|
||||
QTest.mouseClick(dialog.listing.viewport(), Qt.MouseButton.LeftButton,
|
||||
pos=QPoint(rect.left() + 20, rect.center().y()))
|
||||
assert dialog.selected_ids() == {1}
|
||||
dialog.listing.setCurrentItem(item)
|
||||
dialog.listing.setFocus()
|
||||
QTest.keyClick(dialog.listing, Qt.Key.Key_Space)
|
||||
assert dialog.selected_ids() == {1, 2}
|
||||
QTest.keyClick(dialog.listing, Qt.Key.Key_Space)
|
||||
assert dialog.selected_ids() == {1}
|
||||
|
||||
|
||||
def test_filter_preserves_checks_and_clear_includes_hidden(dialog):
|
||||
dialog.search.setText("乙")
|
||||
assert item_for(dialog, 1).isHidden()
|
||||
assert not item_for(dialog, 2).isHidden()
|
||||
assert dialog.selected_ids() == {1}
|
||||
dialog.clear_button.click()
|
||||
assert dialog.selected_ids() == set()
|
||||
assert not dialog.clear_button.isEnabled()
|
||||
dialog.search.clear()
|
||||
assert not item_for(dialog, 1).isHidden()
|
||||
|
||||
|
||||
def test_checked_row_blue_and_unchecked_white(dialog, application):
|
||||
application.processEvents()
|
||||
image = dialog.listing.viewport().grab().toImage()
|
||||
scale = image.devicePixelRatio()
|
||||
colors = []
|
||||
for doctor_id in (1, 2):
|
||||
rect = dialog.listing.visualItemRect(item_for(dialog, doctor_id))
|
||||
colors.append(image.pixelColor(int((rect.right() - 70) * scale),
|
||||
int(rect.center().y() * scale)).name())
|
||||
assert colors == ["#eaf2ff", "#ffffff"]
|
||||
|
||||
|
||||
def test_accept_commits_cancel_preserves_and_reopen_restores(application):
|
||||
selector = DoctorMultiSelect()
|
||||
selector.update_options([{"id": 1, "name": "医生甲"}, {"id": 2, "name": "医生乙"}])
|
||||
seen = []
|
||||
|
||||
def choose(accept):
|
||||
modal = application.activeModalWidget()
|
||||
seen.append(modal.selected_ids())
|
||||
item_for(modal, 2).setCheckState(Qt.CheckState.Checked)
|
||||
modal.accept() if accept else modal.reject()
|
||||
|
||||
QTimer.singleShot(0, lambda: choose(False))
|
||||
selector._choose()
|
||||
assert selector.values() == []
|
||||
QTimer.singleShot(0, lambda: choose(True))
|
||||
selector._choose()
|
||||
assert selector.values() == [2]
|
||||
assert selector.button.text() == "医生乙"
|
||||
QTimer.singleShot(0, lambda: choose(False))
|
||||
selector._choose()
|
||||
assert seen == [set(), set(), {2}]
|
||||
assert selector.values() == [2]
|
||||
selector.deleteLater()
|
||||
Reference in New Issue
Block a user