This commit is contained in:
Your Name
2026-10-17 16:08:24 +08:00
parent c67b3eff69
commit 56f85f7d89
57 changed files with 4170 additions and 57 deletions
+355 -2
View File
@@ -2,13 +2,16 @@ from __future__ import annotations
import json
import os
from threading import Event
from time import monotonic
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import httpx
import pytest
from PySide6.QtCore import QPoint, Qt
from PySide6.QtCore import QPoint, Qt, QThread
from PySide6.QtTest import QTest
from PySide6.QtWidgets import (
QApplication,
@@ -21,7 +24,7 @@ from PySide6.QtWidgets import (
)
from doctor_workstation.core import PermissionSet
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.services import ApiClient, DemoDoctorRepository, RemoteDoctorRepository
from doctor_workstation.ui.dialogs import ai_consult as ai_consult_module
from doctor_workstation.ui.dialogs.ai_consult import (
AiConsultDialog,
@@ -1577,6 +1580,356 @@ def test_answered_stream_replaces_the_pending_placeholder(
dialog.close()
def test_stream_clock_covers_preparation_first_text_and_completion(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
dialog = _silent_dialog(monkeypatch)
now = [100.0]
monkeypatch.setattr(ai_consult_module, "monotonic", lambda: now[0])
dialog._ask("请生成问诊小结")
bubble = dialog._stream_bubble
assert bubble is not None
generation, stream_generation = dialog._generation, dialog._stream_generation
assert "已等待 0 秒" in bubble.stamp.text()
assert dialog._stream_elapsed_timer.isActive()
now[0] = 103.0
dialog._stream_elapsed_timer.timeout.emit()
assert "已等待 3 秒" in bubble.stamp.text()
dialog._stream_event(generation, stream_generation, {"event": "start"})
assert "已等待 3 秒" in bubble.stamp.text()
now[0] = 104.2
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "问诊小结"})
# No timer flush or done event is needed to display the first text.
assert bubble._raw_payload == "问诊小结"
assert "生成中" in bubble.stamp.text()
assert "已用时 4 秒" in bubble.stamp.text()
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": ":请补充舌脉。"})
assert dialog._flush_timer.isActive()
now[0] = 108.7
dialog._stream_event(
generation, stream_generation, {"event": "done", "model_label": "千问"}
)
assert bubble._raw_payload == "问诊小结:请补充舌脉。"
assert "千问" in bubble.stamp.text()
assert "首字 4.2 秒" in bubble.stamp.text()
assert "耗时 8.7 秒" in bubble.stamp.text()
assert not dialog._stream_elapsed_timer.isActive()
assert not dialog._flush_timer.isActive()
stamp = bubble.stamp.text()
now[0] = 130.0
dialog._stream_finished(generation, stream_generation, dialog._stream_worker)
dialog._update_stream_elapsed()
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "迟到内容"})
assert bubble.stamp.text() == stamp
assert bubble._raw_payload == "问诊小结:请补充舌脉。"
assert dialog.send_button.isEnabled()
dialog.close()
@pytest.mark.parametrize("ending", ["error", "timeout", "silent", "switch", "close"])
def test_stream_clock_stops_on_every_exit(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
ending: str,
) -> None:
dialog = _silent_dialog(monkeypatch)
now = [100.0]
monkeypatch.setattr(ai_consult_module, "monotonic", lambda: now[0])
dialog._ask("请总结病情")
bubble = dialog._stream_bubble
assert bubble is not None
now[0] = 112.4
generation, stream_generation = dialog._generation, dialog._stream_generation
worker = dialog._stream_worker
if ending == "error":
dialog._stream_failed(generation, stream_generation, RuntimeError("模型繁忙"))
dialog._stream_finished(generation, stream_generation, worker)
elif ending == "timeout":
dialog._stream_timed_out()
elif ending == "silent":
dialog._stream_finished(generation, stream_generation, worker)
elif ending == "switch":
dialog.open_for(diagnosis_id=502, patient_id=302)
else:
dialog.close()
assert not dialog._stream_elapsed_timer.isActive()
assert not dialog._stream_timeout_timer.isActive()
assert not dialog._asking
assert dialog.send_button.isEnabled()
if ending in {"error", "timeout", "silent"}:
assert "耗时 12.4 秒" in bubble.stamp.text()
stamp = bubble.stamp.text()
now[0] = 140.0
dialog._update_stream_elapsed()
assert bubble.stamp.text() == stamp
if ending == "switch":
dialog._ask("新患者的问题")
assert dialog._stream_bubble is not None
assert "已等待 0 秒" in dialog._stream_bubble.stamp.text()
assert dialog._stream_first_delta_at is None
dialog.close()
def test_http_stream_paints_before_upstream_finishes_on_the_ui_thread(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Exercise HTTP parsing, repository, real worker and Qt delivery together."""
release = Event()
waiting_for_end = Event()
class DelayedStream(httpx.SyncByteStream):
def __iter__(self):
first = (
'event: start\r\ndata: {"model_label":"千问"}\r\n\r\n'
'event: delta\r\ndata: {"text":"先收到的内容"}\r\n\r\n'
).encode()
# A network chunk may end partway through a Chinese character.
split = first.index("先".encode()) + 1
yield first[:split]
yield first[split:]
waiting_for_end.set()
if not release.wait(5):
raise AssertionError("UI did not receive the first streamed text")
yield (
'event: delta\r\ndata: {"text":",然后完成。"}\r\n\r\n'
'event: done\r\ndata: {"model_label":"千问"}\r\n\r\n'
).encode()
client = ApiClient(
"https://example.test",
transport=httpx.MockTransport(
lambda request: httpx.Response(
200, headers={"content-type": "text/event-stream"}, stream=DelayedStream()
)
),
)
dialog = AiConsultDialog(RemoteDoctorRepository(client))
dialog.diagnosis_id = 501
dialog._context_notice_shown = True
delivered_on = []
original_event = dialog._stream_event
def record_event(*args: Any) -> None:
delivered_on.append(QThread.currentThread())
original_event(*args)
monkeypatch.setattr(dialog, "_stream_event", record_event)
dialog.show()
try:
dialog._ask("请生成问诊小结")
deadline = monotonic() + 3
while (not dialog._stream_text or not waiting_for_end.is_set()) and monotonic() < deadline:
QTest.qWait(10)
bubble = dialog._stream_bubble
assert bubble is not None and bubble.body is not None
assert waiting_for_end.is_set()
assert bubble.body.toPlainText() == "先收到的内容"
assert not dialog._stream_completed
assert dialog._asking
assert "生成中" in bubble.stamp.text()
release.set()
deadline = monotonic() + 3
while dialog._asking and monotonic() < deadline:
QTest.qWait(10)
assert dialog._stream_completed
assert not dialog._asking
assert bubble.body.toPlainText() == "先收到的内容,然后完成。"
assert delivered_on and all(thread == application.thread() for thread in delivered_on)
assert not dialog._stream_elapsed_timer.isActive()
finally:
release.set()
dialog.close()
client.close()
@pytest.mark.parametrize("terminal", ["done", "error"])
def test_every_quick_tool_can_start_after_terminal_event_before_worker_exit(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
terminal: str,
) -> None:
dialog = _silent_dialog(monkeypatch)
dialog.show()
application.processEvents()
cards = dialog.findChildren(ai_consult_module._ClickCard, "AiConsultToolCard")
assert len(cards) == len(ai_consult_module.TOOL_ITEMS) == 6
previous = []
for card, (_kind, label, prompt) in zip(cards, ai_consult_module.TOOL_ITEMS, strict=True):
# Exercise the text surface the doctor clicks, including event propagation.
target = next(child for child in card.findChildren(QLabel) if child.text() == label)
QTest.mouseClick(target, Qt.MouseButton.LeftButton)
worker = dialog._stream_worker
assert worker is not None and worker.prompt == prompt
assert dialog._asking
generation, stream_generation = dialog._generation, dialog._stream_generation
for old_generation, old_stream_generation, old_worker in previous:
dialog._stream_finished(old_generation, old_stream_generation, old_worker)
assert dialog._stream_worker is worker
assert dialog._asking
previous.append((generation, stream_generation, worker))
if terminal == "done":
dialog._stream_event(generation, stream_generation, {"event": "done", "answer": "已完成"})
else:
dialog._stream_failed(generation, stream_generation, RuntimeError("模型繁忙"))
# The transport may take longer to close; it must not keep the UI locked.
assert not dialog._asking
assert dialog.send_button.isEnabled()
dialog.close()
def test_clicking_another_quick_tool_switches_the_active_request(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
dialog = _silent_dialog(monkeypatch)
notices = []
monkeypatch.setattr(ai_consult_module, "show_toast", lambda *args: notices.append(args[1]))
dialog.show()
application.processEvents()
cards = dialog.findChildren(ai_consult_module._ClickCard, "AiConsultToolCard")
QTest.mouseClick(cards[0], Qt.MouseButton.LeftButton)
old_worker, old_bubble = dialog._stream_worker, dialog._stream_bubble
old_generation, old_stream_generation = dialog._generation, dialog._stream_generation
assert old_worker is not None and old_bubble is not None
dialog._stream_event(old_generation, old_stream_generation, {"event": "delta", "text": "已收到的分析"})
QTest.mouseClick(cards[1], Qt.MouseButton.LeftButton)
worker, bubble = dialog._stream_worker, dialog._stream_bubble
assert worker is not None and worker is not old_worker
assert worker.prompt == ai_consult_module.TOOL_ITEMS[1][2]
assert old_worker.is_cancelled()
assert "已收到的分析" in old_bubble._raw_payload
assert "已停止" in old_bubble._raw_payload
assert bubble is not None and bubble is not old_bubble
assert bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT
assert "已等待 0 秒" in bubble.stamp.text()
assert dialog._asking
dialog._stream_event(old_generation, old_stream_generation, {"event": "done", "answer": "旧结果"})
dialog._stream_failed(old_generation, old_stream_generation, RuntimeError("旧错误"))
dialog._stream_finished(old_generation, old_stream_generation, old_worker)
assert dialog._stream_worker is worker
assert dialog._asking
assert bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT
# Repeated clicks on the same running tool do not submit duplicate requests.
QTest.mouseClick(cards[1], Qt.MouseButton.LeftButton)
assert dialog._stream_worker is worker
assert not worker.is_cancelled()
assert notices and "正在生成" in notices[-1]
dialog.close()
def test_stream_worker_does_not_wait_for_another_event_after_done() -> None:
continued_after_done = []
class DoneThenIdleRepository:
def stream_diagnosis_ai(self, *args: Any, **kwargs: Any):
yield {"event": "done", "answer": "已完成"}
continued_after_done.append(True)
raise AssertionError("the terminal event must end iteration")
worker = ai_consult_module._AiStreamWorker(
DoneThenIdleRepository(), diagnosis_id=501, prompt="请分析", task="custom"
)
events, errors, finished = [], [], []
worker.signals.event.connect(events.append)
worker.signals.error.connect(errors.append)
worker.signals.finished.connect(lambda: finished.append(True))
worker.run()
assert [event["event"] for event in events] == ["done"]
assert continued_after_done == []
assert errors == []
assert finished == [True]
@pytest.mark.parametrize("entry", ["tool", "chip", "menu", "suggestion"])
def test_quick_actions_switch_requests_and_reveal_the_chat(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
entry: str,
) -> None:
dialog = _silent_dialog(monkeypatch)
dialog.show()
application.processEvents()
dialog._ask("之前的请求")
old_worker = dialog._stream_worker
assert old_worker is not None
dialog.tabs.setCurrentIndex(1)
dialog._set_chat_filter("doctor")
if entry == "tool":
target = dialog.findChildren(ai_consult_module._ClickCard, "AiConsultToolCard")[0]
prompt = ai_consult_module.TOOL_ITEMS[0][2]
QTest.mouseClick(target, Qt.MouseButton.LeftButton)
elif entry == "chip":
target = next(button for button in dialog.findChildren(QPushButton) if button.text() == "总结当前病情")
prompt = ai_consult_module.QUICK_PROMPTS[0][1]
target.click()
elif entry == "menu":
more = next(button for button in dialog.findChildren(QPushButton) if button.text() == "更多")
prompt = ai_consult_module.QUICK_PROMPTS[3][1]
more.menu().actions()[0].trigger()
else:
target = dialog.findChildren(ai_consult_module._ClickCard, "AiConsultSuggestRow")[0]
prompt = f"请针对该问题给出问诊话术与判断要点:{ai_consult_module.SUGGESTIONS[0]}"
QTest.mouseClick(target, Qt.MouseButton.LeftButton)
assert old_worker.is_cancelled()
assert dialog._stream_worker is not None and dialog._stream_worker.prompt == prompt
assert dialog.tabs.currentIndex() == dialog.center_stack.currentIndex() == 0
assert dialog._chat_filter == "all"
assert dialog._stream_bubble is not None and dialog._stream_bubble.isVisible()
dialog.close()
@pytest.mark.parametrize("exit_action", ["back", "side", "escape", "accept", "reject", "close"])
def test_all_dialog_exit_paths_cancel_stream_and_allow_a_fresh_session(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
exit_action: str,
) -> None:
dialog = _silent_dialog(monkeypatch)
dialog.show()
application.processEvents()
dialog._ask("请分析病情")
worker = dialog._stream_worker
assert worker is not None
generation, stream_generation = dialog._generation, dialog._stream_generation
if exit_action == "back":
next(button for button in dialog.findChildren(QPushButton) if button.text() == "返回").click()
elif exit_action == "side":
dialog.findChild(QPushButton, "AiConsultSideClose").click()
elif exit_action == "escape":
QTest.keyClick(dialog, Qt.Key.Key_Escape)
else:
getattr(dialog, exit_action)()
assert not dialog.isVisible()
assert worker.is_cancelled()
assert not dialog._asking
assert not dialog._stream_timeout_timer.isActive()
assert not dialog._stream_elapsed_timer.isActive()
closed_generation = dialog._generation
assert closed_generation > generation
dialog.close()
assert dialog._generation == closed_generation
dialog.open_for(diagnosis_id=502, patient_id=302)
dialog.show()
dialog._ask("新患者的问题")
new_worker, bubble = dialog._stream_worker, dialog._stream_bubble
assert new_worker is not None and not new_worker.is_cancelled()
dialog._stream_event(generation, stream_generation, {"event": "done", "answer": "旧患者结果"})
dialog._stream_finished(generation, stream_generation, worker)
assert dialog._stream_worker is new_worker
assert dialog._asking
assert bubble is not None and bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT
dialog.close()
def test_ai_consult_window_can_be_maximized(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
+99 -6
View File
@@ -7,7 +7,7 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QDate, Signal
from PySide6.QtCore import QDate, QEvent, Signal
from PySide6.QtWidgets import (
QApplication,
QDialog,
@@ -19,7 +19,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from doctor_workstation.core import PermissionSet
from doctor_workstation.core import PermissionSet, UserProfile
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
from doctor_workstation.ui.pages import consultations as consultations_module
from doctor_workstation.ui.pages.consultations import (
@@ -669,7 +669,7 @@ def test_switching_rows_invalidates_prescription_worker_and_clears_busy(
application.processEvents()
def test_native_call_does_not_reuse_video_qr_permission(
def test_list_video_entry_uses_watch_permission_not_video_qr_permission(
application: QApplication,
) -> None:
class VideoRepository:
@@ -686,13 +686,17 @@ def test_native_call_does_not_reuse_video_qr_permission(
pass
live_row = _row(
assistant_id=66,
video_call_hint={
"state": "live",
"label": "视频通话进行中",
"start_time": 1787102100,
}
)
page = ConsultationsPage(VideoRepository(), permissions=PermissionSet([]))
page = ConsultationsPage(
VideoRepository(), permissions=PermissionSet(["tcm.diagnosis/watchCall"]),
current_user={"id": 66, "role_id": 2},
)
emitted: list[dict[str, Any]] = []
page.video_requested.connect(emitted.append)
page.table.set_rows([live_row])
@@ -738,10 +742,13 @@ def test_video_join_action_is_hidden_until_doctor_session_is_live(
def end_call(self, diagnosis_id: int) -> None:
pass
page = ConsultationsPage(VideoRepository(), permissions=PermissionSet([]))
page = ConsultationsPage(
VideoRepository(), permissions=PermissionSet(["tcm.diagnosis/watchCall"]),
current_user={"id": 66, "role_id": 2},
)
emitted: list[dict[str, Any]] = []
page.video_requested.connect(emitted.append)
page.table.set_rows([_row(video_call_hint=hint)])
page.table.set_rows([_row(assistant_id=66, video_call_hint=hint)])
page.table.selectRow(0)
application.processEvents()
@@ -752,6 +759,92 @@ def test_video_join_action_is_hidden_until_doctor_session_is_live(
assert emitted == []
page.close()
application.processEvents()
@pytest.mark.parametrize("user", [
{"id": 66, "role_id": 1},
{"id": 66, "role_id": "1"},
{"id": 66, "role_ids": [1, 2]},
{"id": 66, "role_id": "1,2"},
{"id": 66, "roles": [{"id": 1}]},
{"id": 66, "role_id": 1, "root": True},
UserProfile.from_dict({"id": 66, "role_id": [1, 2]}),
])
@pytest.mark.parametrize("state,status", [
("live", "通话中"), ("pending_room", "接通中"), ("none", "—"),
])
def test_doctor_video_column_is_readonly_even_with_full_permission(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
user: Any,
state: str,
status: str,
) -> None:
from doctor_workstation.services import DemoDoctorRepository
monkeypatch.setattr(consultations_module, "run_async", lambda *_a, **_kw: None)
# Even a mixed-role doctor assigned to this row must not get the receiver entry.
row = _row(assistant_id=66, video_call_hint={"state": state})
page = ConsultationsPage(DemoDoctorRepository(), PermissionSet(["*"]), current_user=user)
emitted = []
page.video_requested.connect(emitted.append)
try:
page.table_host.set_rows([row])
page.table.selectRow(0)
page._selection_changed()
cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 10))
assert cell.findChildren(QToolButton) == []
assert status in [label.text() for label in cell.findChildren(QLabel)]
assert page.call_toolbar_button.isHidden()
assert not page.video_button.isEnabled()
page._row_video(row)
page.video_button.setEnabled(True)
page.video_button.click()
assert emitted == []
finally:
page.close()
page.deleteLater()
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
@pytest.mark.parametrize("user,permissions,assistant_id,allowed", [
({"id": 66, "role_id": 2}, ["tcm.diagnosis/watchCall"], 66, True),
({"id": 66, "role_id": 2}, ["tcm.diagnosis/videoQr"], 66, False),
({"id": 66, "role_id": 2}, [], 66, False),
({"id": 66, "role_id": 2}, ["*"], 77, False),
({"id": 66, "role_id": 0, "root": True}, ["*"], 77, False),
({"id": 66, "role_id": 0, "root": True}, ["*"], 66, True),
({"id": 0, "role_id": 2}, ["*"], 0, False),
(None, ["*"], 66, False),
])
def test_list_video_entry_requires_the_assigned_user_and_watch_permission(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
user: Any,
permissions: list[str],
assistant_id: int,
allowed: bool,
) -> None:
from doctor_workstation.services import DemoDoctorRepository
monkeypatch.setattr(consultations_module, "run_async", lambda *_a, **_kw: None)
row = _row(assistant_id=assistant_id, video_call_hint={"state": "live"})
page = ConsultationsPage(DemoDoctorRepository(), PermissionSet(permissions), current_user=user)
emitted = []
page.video_requested.connect(emitted.append)
try:
page.table_host.set_rows([row])
page.table.selectRow(0)
page._selection_changed()
cell = page.table_host.fixed.indexWidget(page.table_host.model.index(0, 10))
assert bool(cell.findChildren(QToolButton)) is allowed
assert page.video_button.isEnabled() is allowed
page._request_video()
assert bool(emitted) is allowed
finally:
page.close()
page.deleteLater()
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
@pytest.mark.parametrize(
+70
View File
@@ -1121,6 +1121,76 @@ def test_appointment_history_phone_is_fail_closed_without_phone_plain(
dialog.close()
@pytest.mark.parametrize("locked", [False, True])
def test_doctor_can_save_clinical_fields_without_resubmitting_masked_identity(
application: QApplication,
locked: bool,
) -> None:
class MaskedRepository(VisualRepository):
def check_diagnosis_phone(self, payload: dict[str, Any]) -> dict[str, Any]:
raise AssertionError("a masked phone must not be checked as plaintext")
def check_diagnosis_id_card(self, payload: dict[str, Any]) -> dict[str, Any]:
raise AssertionError("a masked identity must not be checked as plaintext")
repository = MaskedRepository(locked=locked)
for source in (repository.detail["diagnosis"], repository.detail["patient"]):
source["phone"] = "156****7862"
source["id_card"] = "1101**********1234"
dialog = _open_dialog(
application, (1024, 640), mode="edit", repository=repository,
permissions=PermissionSet(["tcm.diagnosis/detail", "tcm.diagnosis/edit"]),
)
saved = []
dialog.saved.connect(lambda: saved.append(True))
try:
assert dialog.edit_fields["phone"].isReadOnly()
assert dialog.edit_fields["phone"].toPlainText() == "156****7862"
assert dialog.edit_fields["id_card"].isReadOnly()
dialog.edit_fields["chief_complaint"].setPlainText("口渴较前改善")
dialog._save()
assert saved == [True]
assert len(repository.updates) == 1
payload = repository.updates[0]
assert payload["chief_complaint"] == "口渴较前改善"
assert "phone" not in payload and "id_card" not in payload
assert "156****7862" not in str(payload)
assert "1101**********1234" not in str(payload)
finally:
dialog.close()
def test_plain_phone_permission_keeps_validation_and_duplicate_checks(
application: QApplication,
) -> None:
class PlainRepository(VisualRepository):
def __init__(self) -> None:
super().__init__()
self.phone_checks = []
def check_diagnosis_phone(self, payload: dict[str, Any]) -> dict[str, Any]:
self.phone_checks.append(dict(payload))
return {"exists": False}
repository = PlainRepository()
dialog = _open_dialog(
application, (1024, 640), mode="edit", repository=repository,
permissions=PermissionSet(["tcm.diagnosis/edit", "tcm.diagnosis/phonePlain"]),
)
try:
assert not dialog.edit_fields["phone"].isReadOnly()
dialog.edit_fields["phone"].setPlainText("156****7862")
dialog._save()
assert not repository.phone_checks
assert not repository.updates
dialog.edit_fields["phone"].setPlainText("15600007862")
dialog._save()
assert repository.phone_checks == [{"id": 501, "phone": "15600007862"}]
assert repository.updates[-1]["phone"] == "15600007862"
finally:
dialog.close()
def test_order_detail_action_requires_exact_permission(application: QApplication) -> None:
repository = VisualRepository()
dialog = _open_dialog(
@@ -0,0 +1,96 @@
from __future__ import annotations
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QEvent
from PySide6.QtGui import QFont
from PySide6.QtWidgets import QApplication, QLabel, QToolButton, QVBoxLayout, QWidget
from doctor_workstation.ui.diagnosis_index_widgets import DiagnosisTableHost
from doctor_workstation.ui.reception_style import body_family
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.mark.parametrize("tech_blue", [False, True])
@pytest.mark.parametrize("size", [(1024, 640), (1710, 920)])
@pytest.mark.parametrize("with_channel", [False, True])
def test_public_video_status_fits_frozen_cell_with_inherited_item_padding(
application: QApplication, tech_blue: bool, size: tuple[int, int],
with_channel: bool,
) -> None:
root = QWidget()
font = QFont(body_family())
font.setPixelSize(13)
root.setFont(font)
# Match GLOBAL_QSS without changing the application's theme between tests.
root.setStyleSheet("QTableView::item { padding: 6px 8px; border-bottom: 1px solid #E1E9F4; }")
layout = QVBoxLayout(root)
host = DiagnosisTableHost(
parent=root, tech_blue=tech_blue,
action_policy={"video_call": True, "view": True, "edit": True},
video_entry_allowed=lambda _record: False,
)
layout.addWidget(host)
statuses = [
("ended", "视频已结束 · 10-17 14:19"),
("ended", "视频已结束 · 10-17 14:27"),
("ended", "视频已结束"),
("live", "通话中"),
("pending_room", "接通中"),
("missed", "上次通话未接听"),
("cancelled", "上次通话已取消"),
("none", "—"),
]
rows = [
{
"id": index + 1, "patient_id": index + 100, "patient_name": "测试患者",
"gender": 1, "age": 50, "has_appointment": 1,
"appointment_id": index + 200, "appointment_status": 1,
"appointment_type": "video",
"latest_appointment_channel_text": "自媒体4H" if with_channel else "",
"appointments": [{"id": index + 200, "status": 1, "doctor_name": "测试医生",
"time_text": "2026-10-17 14:15", "appointment_type": "video"}],
"video_call_hint": {"state": state, "label": text},
}
for index, (state, text) in enumerate(statuses * 4)
]
try:
host.set_rows(rows)
root.resize(*size)
root.show()
for _ in range(4):
application.processEvents()
for index, (_state, text) in enumerate(statuses):
cell = host.fixed.indexWidget(host.model.index(index, 10))
assert not cell.findChildren(QToolButton)
label = cell.findChild(QLabel)
assert label is not None
assert label.height() >= label.heightForWidth(label.width())
assert cell.rect().contains(label.geometry())
assert abs(label.geometry().center().y() - cell.rect().center().y()) <= 1
assert label.toolTip() == text
if " · " in text:
assert label.text() == text.replace(" · ", "\n")
assert label.fontMetrics().horizontalAdvance("10-17 14:19") <= label.width()
assert cell.width() >= host.fixed.columnWidth(10) - 2
host.main.horizontalScrollBar().setValue(host.main.horizontalScrollBar().maximum())
for fraction in (2, 3):
host.main.verticalScrollBar().setValue(host.main.verticalScrollBar().maximum() // fraction)
application.processEvents()
assert host.fixed.verticalScrollBar().value() == host.main.verticalScrollBar().value()
row = host.main.indexAt(host.main.viewport().rect().center()).row()
assert row >= 0
assert host.main.visualRect(host.model.index(row, 0)).top() == host.fixed.visualRect(
host.model.index(row, 10)
).top()
finally:
root.close()
root.deleteLater()
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
+8 -3
View File
@@ -7,7 +7,7 @@ import pytest
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QToolButton
from doctor_workstation.core import PermissionSet
from doctor_workstation.core import PermissionSet, UserProfile
from doctor_workstation.core.appointment_modes import (
appointment_type_description,
appointment_type_value,
@@ -76,13 +76,18 @@ def test_appointment_text_chat_carries_exact_ids_and_blocks_video_qr(application
page.deleteLater()
def test_consultation_text_entry_does_not_wait_for_live_video(application, monkeypatch):
@pytest.mark.parametrize("current_user", [
None,
{"id": 66, "role_id": 1},
UserProfile.from_dict({"id": 66, "role_id": [1, 2]}),
])
def test_consultation_text_entry_does_not_wait_for_live_video(application, monkeypatch, current_user):
monkeypatch.setattr(consultations, "run_async", lambda *_a, **_kw: None)
row = {"id": 501, "patient_id": 901, "patient_name": "图文患者", "has_appointment": 1,
"appointment_status": 1, "latest_appointment_id": 71,
"appointments": [{"id": 71, "status": 1, "appointment_type": "text"}],
"video_call_hint": {"state": "none"}}
page = consultations.ConsultationsPage(DemoDoctorRepository(), PermissionSet(["*"]))
page = consultations.ConsultationsPage(DemoDoctorRepository(), PermissionSet(["*"]), current_user=current_user)
try:
page.table_host.set_rows([row])
page.table.selectRow(0)