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
@@ -7,7 +7,7 @@ admin-table presentation cannot leak into another workstation page.
from __future__ import annotations
from collections.abc import Iterable, Mapping, Sequence
from collections.abc import Callable, Iterable, Mapping, Sequence
from math import ceil
from typing import Any
@@ -75,12 +75,9 @@ TEXT = QColor("#1A1C1F")
SECONDARY = QColor("#606163")
PLACEHOLDER = QColor("#8E8F90")
_INVALID_INDEX = QModelIndex()
# The last two columns are the frozen right-hand pane. They were 120 + 410,
# which pinned 532 px of the table. Measured against what they actually hold:
# the widest video cell is "暂无通话" at 56 px, and the action cell - two inline
# buttons plus 更多, all two-character labels - reports a 145 px sizeHint. So
# 265 px of the action column was permanently blank on every row while the data
# columns to its left were horizontally scrolling.
# The last two columns form the compact frozen pane. Video status and ending
# time occupy separate lines; actions keep two inline links plus 更多, with
# the remaining actions in its menu so clinical columns retain more space.
_TABLE_COLUMN_WIDTHS = (48, 70, 60, 100, 175, 88, 120, 100, 72, 110, 96, 160)
_BLUE_COLUMN_WIDTHS = (48, 70, 82, 102, 244, 90, 84, 90, 88, 122, 92, 158)
_BLUE_WARNING = QColor("#A9691D")
@@ -1609,6 +1606,7 @@ class DiagnosisTableHost(QFrame):
self,
*,
action_policy: Mapping[str, bool] | None = None,
video_entry_allowed: Callable[[Any], bool] | None = None,
parent: QWidget | None = None,
tech_blue: bool = False,
) -> None:
@@ -1618,6 +1616,7 @@ class DiagnosisTableHost(QFrame):
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.setMinimumHeight(0)
self.action_policy = dict(action_policy or {})
self.video_entry_allowed = video_entry_allowed
self.force_open_prescription = False
if tech_blue:
self.LEFT_WIDTHS = _BLUE_COLUMN_WIDTHS[:10]
@@ -1633,6 +1632,9 @@ class DiagnosisTableHost(QFrame):
self._fixed_height_timer.timeout.connect(self._sync_fixed_height)
self.main.setObjectName("DiagnosisMainTable")
self.fixed.setObjectName("DiagnosisFixedTable")
# Index widgets supply their own margins. Inherited item padding would
# shrink a 92px video cell to 76px and clip its wrapped status text.
self.fixed.setStyleSheet("QTableView#DiagnosisFixedTable::item { padding: 0; }")
for view in (self.main, self.fixed):
view.setModel(self.model)
view.setSelectionModel(self.selection)
@@ -1786,11 +1788,11 @@ class DiagnosisTableHost(QFrame):
host.hovered_row.connect(self._set_hover_row)
layout = QHBoxLayout(host)
layout.setContentsMargins(3, 2, 3, 2)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
video_capable = self.action_policy.get("video_call", False)
entry_allowed = self.video_entry_allowed is None or self.video_entry_allowed(record)
call_state = video_call_state(record)
is_text = appointment_type_value(record) == "text"
if video_capable and _appointment_active(record) and (is_text or call_state == "live"):
if video_capable and entry_allowed and _appointment_active(record) and (is_text or call_state == "live"):
button = QToolButton(host)
button.setText("图文沟通" if is_text else "进入视频问诊")
button.setProperty("rowLink", "primary")
@@ -1803,10 +1805,17 @@ class DiagnosisTableHost(QFrame):
button.clicked.connect(
lambda _checked=False, item=record: self.video_requested.emit(item)
)
layout.addWidget(button)
layout.addWidget(button, 0, Qt.AlignmentFlag.AlignCenter)
else:
status = "—"
if video_capable:
if not entry_allowed:
# Match the admin's public status without turning an active
# doctor's room into a receiver entry for an unrelated user.
if _appointment_active(record) and can_appointment_video(appointment_type_value(record)):
status = {"live": "通话中", "pending_room": "接通中", "none": "—"}.get(
call_state, first_value(record, "video_call_hint.label", default="—")
)
elif video_capable:
if call_state == "pending_room":
status = "等待接通"
elif _appointment_active(record):
@@ -1816,10 +1825,17 @@ class DiagnosisTableHost(QFrame):
_appointment_status(record),
"未挂号" if not _has_appointment(record) else "—",
)
label = QLabel(display_text(status), host)
status_text = display_text(status)
label = QLabel(status_text.replace(" · ", "\n", 1), host)
label.setProperty("fixedMuted", True)
label.setTextFormat(Qt.TextFormat.PlainText)
label.setToolTip(status_text)
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
label.setWordWrap(True)
# Fill the cell instead of centering the label at its sizeHint:
# its wrapped height can grow when the available width changes.
label.setMinimumWidth(0)
label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding)
layout.addWidget(label)
return host
@@ -11,6 +11,7 @@ from copy import deepcopy
from datetime import datetime
from html import escape
from threading import Event
from time import monotonic
from typing import Any
from PySide6.QtCore import QObject, QRunnable, QSize, Qt, QThreadPool, QTimer, Signal, Slot
@@ -2338,8 +2339,14 @@ class _RichMessage(QTextBrowser):
self.document().contentsChanged.connect(self._fit)
def set_payload(self, raw: Any) -> None:
render_chat_payload(self, raw, role=self._role)
QTimer.singleShot(0, self._fit)
# Markdown parsing and block formatting both emit contentsChanged. Fit
# once after the complete update instead of laying out each mutation.
self._fitting = True
try:
render_chat_payload(self, raw, role=self._role)
finally:
self._fitting = False
self._fit()
def resizeEvent(self, event) -> None: # type: ignore[override]
super().resizeEvent(event)
@@ -3177,6 +3184,8 @@ class _AiStreamWorker(QRunnable):
if self.is_cancelled():
return
self.signals.event.emit(event)
if isinstance(event, Mapping) and event.get("event") == "done":
return
except Exception as error: # UI boundary: render transport/domain errors in place.
if not self.is_cancelled():
self.signals.error.emit(error)
@@ -3517,6 +3526,7 @@ class AiConsultDialog(QDialog):
self._detail: Mapping[str, Any] = {}
self._workspace_sections: dict[str, dict[str, Any]] = {}
self._generation = 0
self._session_closed = False
self._image_generation = 0
self._image_preview: QWidget | None = None
# 字典翻译表:内置种子先兜底,加载诊单时再并入服务端实时字典。
@@ -3528,8 +3538,12 @@ class AiConsultDialog(QDialog):
self._stream_worker: _AiStreamWorker | None = None
self._stream_bubble: _ChatBubble | None = None
self._stream_text = ""
self._stream_prompt = ""
self._pending_chunks: list[str] = []
self._stream_meta: dict[str, Any] = {}
self._stream_started_at: float | None = None
self._stream_first_delta_at: float | None = None
self._stream_ended_at: float | None = None
self._patient_ai_context: str = ""
self._patient_ai_context_labels: list[str] = []
self._patient_ai_context_bubble: _ChatBubble | None = None
@@ -3546,6 +3560,9 @@ class AiConsultDialog(QDialog):
self._stream_timeout_timer = QTimer(self)
self._stream_timeout_timer.setSingleShot(True)
self._stream_timeout_timer.timeout.connect(self._stream_timed_out)
self._stream_elapsed_timer = QTimer(self)
self._stream_elapsed_timer.setInterval(1000)
self._stream_elapsed_timer.timeout.connect(self._update_stream_elapsed)
self.setObjectName("AiConsultDialog")
self.setWindowTitle("问诊详情")
# QDialog 默认只带关闭按钮,医生无法把这个信息密度很高的窗口放大到整屏。
@@ -3562,6 +3579,7 @@ class AiConsultDialog(QDialog):
self.setStyleSheet(AI_CONSULT_QSS)
mark_business_dialog(self, "AiConsultDialog")
self._build()
self.finished.connect(self._end_session)
def keyPressEvent(self, event: QKeyEvent) -> None: # type: ignore[override]
# QLineEdit emits returnPressed before the key reaches QDialog. The
@@ -3585,6 +3603,7 @@ class AiConsultDialog(QDialog):
source_title: str = "问诊列表",
) -> None:
self._cancel_stream()
self._session_closed = False
self.diagnosis_id = diagnosis_id
self.patient_id = _as_int(patient_id)
self._seed = dict(seed) if isinstance(seed, Mapping) else {}
@@ -3792,7 +3811,7 @@ class AiConsultDialog(QDialog):
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
button.clicked.connect(
lambda _checked=False, text=prompt: self._ask(text)
lambda _checked=False, text=prompt: self._ask(text, replace_active=True)
)
chips.addWidget(button, 1)
more = QPushButton("更多")
@@ -3803,7 +3822,7 @@ class AiConsultDialog(QDialog):
for label, prompt in QUICK_PROMPTS[3:]:
action = more_menu.addAction(label)
action.triggered.connect(
lambda _checked=False, text=prompt: self._ask(text)
lambda _checked=False, text=prompt: self._ask(text, replace_active=True)
)
more.setMenu(more_menu)
chips.addWidget(more, 1)
@@ -3996,7 +4015,7 @@ class AiConsultDialog(QDialog):
for index, (kind, label, prompt) in enumerate(TOOL_ITEMS):
card = _ClickCard()
card.setObjectName("AiConsultToolCard")
card.clicked.connect(lambda text=prompt: self._ask(text))
card.clicked.connect(lambda text=prompt: self._ask(text, replace_active=True))
inner = QVBoxLayout(card)
inner.setContentsMargins(8, 10, 8, 10)
inner.setSpacing(6)
@@ -5976,11 +5995,14 @@ class AiConsultDialog(QDialog):
def _use_suggestion(self, question: str) -> None:
self.input.setText(question)
self._ask(f"请针对该问题给出问诊话术与判断要点:{question}")
self._ask(f"请针对该问题给出问诊话术与判断要点:{question}", replace_active=True)
def _ask(self, prompt: str) -> None:
def _ask(self, prompt: str, *, replace_active: bool = False) -> None:
text = prompt.strip()
if self._asking or not text:
if not text:
return
if self._asking and (not replace_active or text == self._stream_prompt):
show_toast(self, "AI 正在生成,请稍候或点击其他快捷工具切换。", "info")
return
if self.diagnosis_id <= 0:
show_toast(self, "缺少诊单编号,无法发起 AI 对话。", "warning")
@@ -5997,8 +6019,12 @@ class AiConsultDialog(QDialog):
"warning",
)
return
self._cancel_stream()
self._cancel_stream(replaced=self._asking)
self._stream_started_at = monotonic()
self._stream_prompt = text
self._follow_chat = True
self.tabs.setCurrentIndex(0)
self._set_chat_filter("all")
self.input.clear()
self._append_bubble(
"doctor", text, time_text=datetime.now().strftime("%H:%M")
@@ -6019,6 +6045,8 @@ class AiConsultDialog(QDialog):
self._pending_chunks.clear()
self._stream_meta.clear()
self._asking = True
self._update_stream_elapsed()
self._stream_elapsed_timer.start()
self.send_button.setEnabled(False)
generation = self._generation
stream_generation = self._stream_generation
@@ -6047,26 +6075,65 @@ class AiConsultDialog(QDialog):
def _stream_is_current(self, generation: int, stream_generation: int) -> bool:
return generation == self._generation and stream_generation == self._stream_generation
def _update_stream_elapsed(self) -> None:
if self._stream_bubble is None:
return
model = display_text(
first_value(self._stream_meta, "model_label", "model_key"), ""
)
if self._stream_started_at is None:
self._stream_bubble.set_time_text(_join_meta(model, datetime.now().strftime("%H:%M")))
return
now = self._stream_ended_at if self._stream_ended_at is not None else monotonic()
elapsed = max(0.0, now - self._stream_started_at)
if self._stream_ended_at is None:
status = (
f"生成中 · 已用时 {int(elapsed)} 秒"
if self._stream_first_delta_at is not None else f"已等待 {int(elapsed)} 秒"
)
self._stream_bubble.set_time_text(_join_meta(model, status))
return
first_delta = (
f"首字 {max(0.0, self._stream_first_delta_at - self._stream_started_at):.1f} 秒"
if self._stream_first_delta_at is not None else ""
)
self._stream_bubble.set_time_text(
_join_meta(model, first_delta, f"耗时 {elapsed:.1f} 秒", datetime.now().strftime("%H:%M"))
)
def _stop_stream_clock(self) -> None:
if self._stream_ended_at is None:
self._stream_ended_at = monotonic()
self._stream_elapsed_timer.stop()
self._update_stream_elapsed()
def _stream_event(
self,
generation: int,
stream_generation: int,
event: Any,
) -> None:
if not self._stream_is_current(generation, stream_generation):
if not self._stream_is_current(generation, stream_generation) or self._stream_ended_at is not None:
return
payload = dict(event) if isinstance(event, Mapping) else {}
kind = str(payload.get("event") or "").lower()
if kind == "start":
self._stream_meta.update(payload)
self._update_stream_elapsed()
if self._stream_bubble is not None and not self._stream_text and not self._pending_chunks:
self._stream_bubble.set_payload(AI_STREAM_WAITING_TEXT)
return
if kind == "delta":
chunk = payload.get("text")
if isinstance(chunk, str) and chunk:
if self._stream_first_delta_at is None:
self._stream_first_delta_at = monotonic()
self._update_stream_elapsed()
self._pending_chunks.append(chunk)
if not self._flush_timer.isActive():
if not self._stream_text:
# The first received text replaces the waiting state now.
self._flush_stream_chunks()
elif not self._flush_timer.isActive():
self._flush_timer.start()
return
if kind != "done":
@@ -6074,6 +6141,7 @@ class AiConsultDialog(QDialog):
self._stream_meta.update(payload)
self._stream_completed = True
self._stream_timeout_timer.stop()
self._stop_stream_clock()
if not self._stream_text and not self._pending_chunks:
answer = first_value(payload, "answer", "content", default="")
if answer not in (None, ""):
@@ -6083,17 +6151,15 @@ class AiConsultDialog(QDialog):
if not self._stream_text:
self._stream_text = "未返回分析内容。"
self._stream_bubble.set_payload(self._stream_text)
model = display_text(
first_value(self._stream_meta, "model_label", "model_key"),
"",
)
self._stream_bubble.set_time_text(
_join_meta(model, datetime.now().strftime("%H:%M"))
)
self._stream_bubble.set_provenance(self._stream_meta)
self._stream_bubble.finalize_clinical_analysis()
# A terminal event completes the UI request even if transport cleanup
# has not returned yet. The next click must not depend on finished.
self._asking = False
self.send_button.setEnabled(True)
def _flush_stream_chunks(self) -> None:
self._flush_timer.stop()
if not self._pending_chunks or self._stream_bubble is None:
return
self._stream_text += "".join(self._pending_chunks)
@@ -6108,9 +6174,10 @@ class AiConsultDialog(QDialog):
stream_generation: int,
error: Exception,
) -> None:
if not self._stream_is_current(generation, stream_generation):
if not self._stream_is_current(generation, stream_generation) or self._stream_ended_at is not None:
return
self._stream_timeout_timer.stop()
self._stop_stream_clock()
self._flush_stream_chunks()
message = friendly_error(error)
if self._stream_text:
@@ -6119,7 +6186,8 @@ class AiConsultDialog(QDialog):
self._stream_text = message
if self._stream_bubble is not None:
self._stream_bubble.set_payload(self._stream_text)
self._stream_bubble.set_time_text(datetime.now().strftime("%H:%M"))
self._asking = False
self.send_button.setEnabled(True)
def _stream_finished(
self,
@@ -6130,6 +6198,8 @@ class AiConsultDialog(QDialog):
if not self._stream_is_current(generation, stream_generation):
return
self._stream_timeout_timer.stop()
self._flush_stream_chunks()
self._stop_stream_clock()
if self._stream_worker is worker:
self._stream_worker = None
# 连接结束却既没有 done 也没有报错时(服务端静默断开),
@@ -6141,7 +6211,6 @@ class AiConsultDialog(QDialog):
):
self._stream_text = AI_STREAM_SILENT_TEXT
self._stream_bubble.set_payload(self._stream_text)
self._stream_bubble.set_time_text(datetime.now().strftime("%H:%M"))
self._asking = False
self.send_button.setEnabled(True)
@@ -6151,34 +6220,53 @@ class AiConsultDialog(QDialog):
self._flush_stream_chunks()
bubble = self._stream_bubble
text = self._stream_text
self._stop_stream_clock()
self._cancel_stream()
if bubble is not None:
bubble.set_payload(
f"{text}\n\n> {AI_STREAM_TIMEOUT_TEXT}" if text else AI_STREAM_TIMEOUT_TEXT
)
bubble.set_time_text(datetime.now().strftime("%H:%M"))
def _cancel_stream(self) -> None:
def _cancel_stream(self, *, replaced: bool = False) -> None:
if replaced and self._asking:
self._flush_stream_chunks()
self._stop_stream_clock()
if self._stream_bubble is not None:
notice = "已停止本次生成,已切换到新的请求。"
self._stream_bubble.set_payload(
f"{self._stream_text}\n\n> {notice}" if self._stream_text else notice
)
self._stream_generation += 1
self._flush_timer.stop()
self._stream_timeout_timer.stop()
self._stream_elapsed_timer.stop()
if self._stream_worker is not None:
self._stream_worker.cancel()
self._stream_worker = None
self._stream_bubble = None
self._pending_chunks.clear()
self._stream_text = ""
self._stream_prompt = ""
self._stream_completed = False
self._stream_meta.clear()
self._stream_started_at = None
self._stream_first_delta_at = None
self._stream_ended_at = None
self._asking = False
self.send_button.setEnabled(True)
def closeEvent(self, event: Any) -> None: # type: ignore[override]
def _end_session(self) -> None:
if self._session_closed:
return
self._session_closed = True
self._generation += 1
self._image_generation += 1
_abort_pending_thumbnails(self)
self._close_image_preview()
self._cancel_stream()
def closeEvent(self, event: Any) -> None: # type: ignore[override]
self._end_session()
super().closeEvent(event)
def _open_full_record(self) -> None:
@@ -38,6 +38,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from ...core import UserProfile
from ...core.appointment_modes import appointment_type_value, can_appointment_video
from .. import icons
from ..consultations_style import consultations_stylesheet
@@ -1493,6 +1494,7 @@ class ConsultationsPage(QWidget):
table_wrap_layout.setSpacing(0)
self.table_host = DiagnosisTableHost(
tech_blue=True,
video_entry_allowed=self._can_enter_list_call,
action_policy={
"view": _canonical_allowed(permissions, "tcm.diagnosis/readonlyDetail"),
"edit": _canonical_allowed(permissions, "tcm.diagnosis/edit"),
@@ -3053,8 +3055,11 @@ class ConsultationsPage(QWidget):
_as_int(payload.get(key), 0) > 0
for key in ("appointment_id", "patient_id", "diagnosis_id")
)
entry_allowed = has_record and self._can_enter_list_call(record)
self.video_button.setVisible(entry_allowed)
self.call_toolbar_button.setVisible(entry_allowed)
self.video_button.setEnabled(
has_record
entry_allowed
and is_video_available(record)
and (appointment_type_value(record) == "text" or video_call_is_live(record))
and valid_ids
@@ -3496,6 +3501,28 @@ class ConsultationsPage(QWidget):
self._checked_changed(len(self.table_host.selected_records()))
self._selection_changed()
def _can_enter_list_call(self, record: Any) -> bool:
if not self._native_video_capable or record is None:
return False
# Text appointments retain their IM entry; this restriction concerns
# receiving an existing video call from the diagnosis list.
if appointment_type_value(record) == "text":
return True
if not can_appointment_video(appointment_type_value(record)):
return False
roles = UserProfile.from_dict({
"role_ids": first_value(self.current_user, "role_ids", "role_id", "roles", default=[])
}).role_ids
if 1 in roles:
return False
user_id = _as_int(first_value(self.current_user, "id", "user_id", default=0))
assistant_id = _as_int(first_value(record, "assistant_id", "assistant", default=0))
return bool(
user_id > 0
and assistant_id == user_id
and _canonical_allowed(self.permissions, "tcm.diagnosis/watchCall")
)
def _request_video(self) -> None:
record = self.table.current_data()
if not self._native_video_capable:
@@ -3503,6 +3530,9 @@ class ConsultationsPage(QWidget):
return
if record is None or not is_video_available(record):
self.banner.show_message("仅当前“已预约”的挂号可进入视频问诊。", "warning")
return
if not self._can_enter_list_call(record):
self.banner.show_message("仅本诊单指派且具有旁观权限的医助可进入视频会话。", "warning")
return
if appointment_type_value(record) != "text" and not video_call_is_live(record):
self.banner.show_message("医生尚未发起视频会话,请等待会话开始。", "warning")
@@ -321,6 +321,11 @@ QWidget#ReceptionPage QLabel#ReceptionPatientMeta {
font-size: 13px;
font-weight: 400;
}
QWidget#ReceptionPage QLabel#ReceptionCurrentAssistant {
color: #273244;
font-size: 13px;
font-weight: 400;
}
QWidget#ReceptionPage QLabel#StatusBadge {
font-size: 13px;
font-weight: 400;
@@ -4394,8 +4399,17 @@ class ReceptionPage(QWidget):
actions.setContentsMargins(0, 0, 0, 0)
actions.setSpacing(14)
actions.addStretch(1)
patient_head.addLayout(actions)
patient_head.setAlignment(actions, Qt.AlignmentFlag.AlignBottom)
action_group = QVBoxLayout()
action_group.setContentsMargins(0, 0, 0, 0)
action_group.setSpacing(6)
self.current_assistant_label = QLabel("当前医助:—", hero)
self.current_assistant_label.setObjectName("ReceptionCurrentAssistant")
self.current_assistant_label.setTextFormat(Qt.TextFormat.PlainText)
self.current_assistant_label.setWordWrap(True)
action_group.addWidget(self.current_assistant_label)
action_group.addLayout(actions)
patient_head.addLayout(action_group)
patient_head.setAlignment(action_group, Qt.AlignmentFlag.AlignBottom)
self.complete_button = _ReceptionCompleteButton(hero)
self.complete_button.clicked.connect(self._complete_appointment)
@@ -7885,9 +7899,14 @@ class ReceptionPage(QWidget):
)
)
)
self.appointment_labels["assistant"].setText(
display_text(first_value(appointment, "assistant_name"))
)
# reception normalises these fields from the diagnosis's current
# assistant, matching the recipient of the notification action.
assistant_name = display_text(first_value(appointment, "assistant_name"), "")
assistant_id = _as_int(first_value(appointment, "assistant_id"), None)
missing_name = "姓名暂缺" if assistant_id else "未分配" if assistant_id == 0 else "—"
self.current_assistant_label.setText(f"当前医助:{assistant_name or missing_name}")
self.current_assistant_label.setToolTip(self.current_assistant_label.text())
self.appointment_labels["assistant"].setText(assistant_name or "—")
self.appointment_labels["type"].setText(
appointment_type_description(appointment_type_value(appointment))
)
@@ -8821,6 +8840,8 @@ class ReceptionPage(QWidget):
)
self.patient_meta_label.setText("正在核对患者、诊单与预约信息…" if seed else "—")
self.patient_meta_label.setToolTip("")
self.current_assistant_label.setText("当前医助:正在加载…" if seed else "当前医助:—")
self.current_assistant_label.setToolTip("")
self.detail_status.set_status("—", "neutral")
for label in getattr(self, "appointment_labels", {}).values():
label.setText("—")
@@ -8886,6 +8907,8 @@ class ReceptionPage(QWidget):
return
self.patient_meta_label.setText(message)
self.patient_meta_label.setToolTip("")
self.current_assistant_label.setText("当前医助:—")
self.current_assistant_label.setToolTip("")
self._set_case_placeholder(message)
self.prescription_hint.setText("处方:患者详情加载失败")
self.prescription_hint.setProperty("casePrescriptionState", "unknown")
+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)