This commit is contained in:
Your Name
2026-08-11 17:39:41 +08:00
parent cfe4c82c90
commit 25467b9d91
350 changed files with 201115 additions and 132208 deletions
+21
View File
@@ -134,6 +134,27 @@ def test_get_retries_only_timeouts_then_returns_data() -> None:
assert attempts == 3
def test_get_bytes_downloads_relative_public_image_without_api_token() -> None:
"""Generated QR images bypass the JSON envelope and never leak the API token."""
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(200, headers={"content-type": "image/png"}, content=b"png-data")
with ApiClient(
"https://example.test/root",
token="private-token",
transport=httpx.MockTransport(handler),
) as client:
assert client.get_bytes("/uploads/qrcode.png") == b"png-data"
request = requests[0]
assert str(request.url) == "https://example.test/uploads/qrcode.png"
assert "token" not in request.headers
@pytest.mark.parametrize(
("code", "exception_type"),
[
+1 -1
View File
@@ -559,7 +559,7 @@ def test_keyboard_focus_has_a_visible_state(
assert focus_target.hasFocus()
assert application.focusWidget() is focus_target
assert 'QPushButton[appointmentDate="true"]:focus' in APPOINTMENT_DRAWER_QSS
assert "border-color: #79BBFF;" in APPOINTMENT_DRAWER_QSS
assert "border-color: #0891B2;" in APPOINTMENT_DRAWER_QSS
drawer.close()
host.close()
+339
View File
@@ -0,0 +1,339 @@
"""Parity contracts for the admin appointment list port."""
from __future__ import annotations
import os
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtWidgets import QApplication, QLabel
from doctor_workstation.core.errors import ApiProtocolError
from doctor_workstation.core.models import Appointment, PageResult
from doctor_workstation.core.permissions import PermissionSet
from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.services.repository import RemoteDoctorRepository
from doctor_workstation.ui.pages import appointments as appointments_module
from doctor_workstation.ui.pages.appointments import (
AppointmentsPage,
_diagnosis_id,
_video_patient_id,
prescription_action_label,
)
from doctor_workstation.ui.shell import _match_navigation
class RecordingClient:
def __init__(self) -> None:
self.get_calls: list[tuple[str, dict[str, Any]]] = []
self.post_calls: list[tuple[str, dict[str, Any]]] = []
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
self.get_calls.append((endpoint, dict(params or {})))
if endpoint == "doctor.appointment/detail":
return {"id": int((params or {}).get("id", 0)), "patient_name": "测试"}
if endpoint == "dept.dept/all":
return [{"id": 10, "name": "中医门诊", "children": []}]
return {"lists": [], "count": 0, "extend": {"status_count": {"1": 2}}}
def post(self, endpoint: str, payload: dict[str, Any] | None = None) -> Any:
self.post_calls.append((endpoint, dict(payload or {})))
if endpoint == "tcm.diagnosis/generateMiniProgramQrcode":
return {"qrcode_url": "https://example.test/uploads/video-qr.png"}
return {"ok": True}
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
def test_remote_appointment_list_and_detail_hit_admin_endpoints() -> None:
client = RecordingClient()
repo = RemoteDoctorRepository(client) # type: ignore[arg-type]
repo.list_appointments(
page_no=1,
page_size=15,
status=1,
start_date="2026-08-11",
end_date="2026-08-11",
include_status_counts=1,
diagnosis_confirmed="1",
assistant_dept_id=10,
patient_name="",
)
detail = repo.get_appointment_detail(101)
departments = repo.list_departments()
assert client.get_calls[0][0] == "doctor.appointment/lists"
assert client.get_calls[0][1]["include_status_counts"] == 1
assert client.get_calls[0][1]["assistant_dept_id"] == 10
assert "diag_scope_relax" not in client.get_calls[0][1]
assert client.get_calls[1] == ("doctor.appointment/detail", {"id": 101})
assert client.get_calls[2] == ("dept.dept/all", {})
assert detail["id"] == 101
assert departments[0]["id"] == 10
def test_unlimited_date_relaxes_today_scope_for_pending_status() -> None:
client = RecordingClient()
repo = RemoteDoctorRepository(client) # type: ignore[arg-type]
repo.list_appointments(status=1, diag_scope_relax=1, page_no=1, page_size=15)
params = client.get_calls[0][1]
assert "start_date" not in params
assert "end_date" not in params
assert "diag_scope_relax" not in params
def test_video_qr_uses_doctor_id_without_fake_diagnosis_id() -> None:
client = RecordingClient()
repo = RemoteDoctorRepository(client) # type: ignore[arg-type]
result = repo.generate_video_qrcode(
diagnosis_id=501,
doctor_id=88,
patient_id=301,
share_user_id=9,
)
endpoint, payload = client.post_calls[-1]
assert endpoint == "tcm.diagnosis/generateMiniProgramQrcode"
assert payload == {
"diagnosis_id": 501,
"doctor_id": 88,
"patient_id": 301,
"share_user_id": 9,
"mini_program_path": "pages/login/login",
}
assert result["qrcode_url"].endswith("video-qr.png")
def test_legacy_empty_string_prescription_response_is_scoped_to_not_found() -> None:
class LegacyClient(RecordingClient):
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
if endpoint == "tcm.prescription/getByAppointment":
raise ApiProtocolError("envelope must be an object", data="")
return super().get(endpoint, params)
repo = RemoteDoctorRepository(LegacyClient()) # type: ignore[arg-type]
assert repo.get_prescription_by_appointment(101) is None
class BrokenClient(LegacyClient):
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
raise ApiProtocolError("invalid JSON")
broken = RemoteDoctorRepository(BrokenClient()) # type: ignore[arg-type]
with pytest.raises(ApiProtocolError, match="invalid JSON"):
broken.get_prescription_by_appointment(101)
def test_shell_matches_appointment_list_by_route_not_shared_permission() -> None:
row = {
"name": "挂号列表",
"paths": "/appointments",
"component": "tcm/appointment/list",
"perms": "doctor.appointment/lists",
}
item = _match_navigation(row)
assert item is not None
assert item.key == "appointments"
reception = {
"name": "接诊台",
"paths": "/reception",
"component": "patient/reception/index",
"perms": "doctor.appointment/lists",
}
assert _match_navigation(reception).key == "reception" # type: ignore[union-attr]
def test_diagnosis_and_patient_ids_stay_distinct_for_video() -> None:
row = Appointment.from_dict(
{
"id": 101,
"patient_id": 301,
"diagnosis_id": 501,
"source_patient_id": 301,
"status": 1,
"has_prescription": 0,
}
)
assert _diagnosis_id(row) == 501
assert _video_patient_id(row) == 301
assert prescription_action_label(row) == "开方"
approved = Appointment.from_dict(
{
"id": 102,
"prescription_audit_status": 1,
"prescription_void_status": 0,
"has_prescription": 1,
}
)
assert prescription_action_label(approved) == "查看"
def test_appointments_page_default_query_is_today_pending(
application: QApplication,
) -> None:
repo = DemoDoctorRepository()
page = AppointmentsPage(
repo,
permissions=PermissionSet(
[
"doctor.appointment/lists",
"doctor.appointment/complete",
"doctor.appointment/cancel",
"doctor.appointment/prescription",
"doctor.appointment/addDoctorNote",
"tcm.diagnosis/edit",
"tcm.diagnosis/kaifang",
"tcm.diagnosis/videoQr",
]
),
current_user={"id": 1001, "name": "陈医生", "role_id": 1},
)
page.show()
application.processEvents()
page.refresh()
application.processEvents()
filters = page._query_filters()
assert filters["status"] == 1
assert filters["include_status_counts"] == 1
assert filters["start_date"] == filters["end_date"]
assert "diag_scope_relax" not in filters
assert page.table.rowCount() >= 1
def test_demo_appointment_status_counts_respect_date_scope() -> None:
repo = DemoDoctorRepository()
today = repo._today.isoformat()
result = repo.list_appointments(
page_no=1,
page_size=20,
start_date=today,
end_date=today,
include_status_counts=1,
)
assert isinstance(result, PageResult)
counts = result.extend.get("status_count", {})
assert int(counts.get("1", counts.get(1, 0))) >= 1
assert int(counts.get("3", counts.get(3, 0))) >= 1
def test_appointment_multiline_cells_receive_enough_row_height(
application: QApplication,
) -> None:
page = AppointmentsPage(
DemoDoctorRepository(),
permissions=PermissionSet(["doctor.appointment/lists"]),
current_user={"id": 1001, "role_id": 1},
)
page._loaded(
{
"lists": [
{
"id": 15534,
"patient_name": "张玉英",
"patient_phone": "13800138000",
"gender": 0,
"age": 42,
"height": 162,
"weight": 55,
"doctor_name": "徐国军",
"appointment_date": "2026-08-11",
"appointment_time": "14:30",
"assistant_name": "蒋露露",
"diagnosis_confirmed": 0,
"has_prescription": 0,
"status": 1,
"status_desc": "已预约",
"remark": "",
}
],
"count": 1,
},
page._generation,
False,
)
patient_text = page.table.item(0, 1).text()
appointment_text = page.table.item(0, 3).text()
assert patient_text.count("\n") == 2
assert appointment_text == "2026-08-11\n14:30"
required = 3 * max(16, page.table.fontMetrics().lineSpacing()) + 10
assert page.table.rowHeight(0) >= required
assert page.table.item(0, 1).toolTip() == patient_text
assert page.table.item(0, 3).toolTip() == appointment_text
page.close()
def test_video_qr_dialog_renders_downloaded_image_inside_app(
application: QApplication,
) -> None:
repo = DemoDoctorRepository()
page = AppointmentsPage(
repo,
permissions=PermissionSet(["tcm.diagnosis/videoQr"]),
current_user={"id": 1001, "role_id": 1},
)
url = "https://demo.invalid/qrcode/video/88.png"
dialog = page._build_qr_dialog(
{"patient_name": "鹿立核"},
url,
{"qrcode_url": url, "_image_bytes": repo.download_public_image(url)},
)
image = dialog.findChild(QLabel, "VideoQrImage")
assert image is not None
assert image.pixmap() is not None and not image.pixmap().isNull()
assert dialog.findChild(QLabel, "VideoQrImage").text() == ""
dialog.close()
page.close()
application.processEvents()
def test_prescription_case_snapshot_uses_keyword_diagnosis_id(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
requested: list[int] = []
opened: list[tuple[Any, Any]] = []
class Repository:
def get_diagnosis_detail(self, *, diagnosis_id: int) -> dict[str, Any]:
requested.append(diagnosis_id)
return {"id": diagnosis_id, "patient_name": "测试患者"}
def run_immediately(function: Any, **callbacks: Any) -> object:
try:
callbacks["on_success"](function())
except Exception as error: # pragma: no cover - assertion output is clearer
callbacks["on_error"](error)
finally:
callbacks["on_finished"]()
return object()
monkeypatch.setattr(appointments_module, "run_async", run_immediately)
page = AppointmentsPage(
Repository(),
permissions=PermissionSet(["tcm.diagnosis/kaifang"]),
current_user={"id": 9, "role_id": 1},
)
monkeypatch.setattr(
page,
"_open_prescription_editor",
lambda record, detail: opened.append((record, detail)),
)
page._begin_case_record_load(
{"id": 101, "appointment_id": 101, "diagnosis_id": 501, "patient_id": 501}
)
assert requested == [501]
assert opened and opened[0][1]["id"] == 501
page.close()
application.processEvents()
+5
View File
@@ -35,6 +35,11 @@ def test_config_update_validates_video_mode() -> None:
AppConfig().with_updates(video_mode="unknown")
def test_config_update_normalizes_ssl_boolean_strings() -> None:
assert AppConfig().with_updates(verify_ssl="false").verify_ssl is False
assert AppConfig(verify_ssl=False).with_updates(verify_ssl="true").verify_ssl is True
def test_runtime_directories_can_be_isolated_without_replacing_user_home(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
File diff suppressed because it is too large Load Diff
@@ -113,7 +113,6 @@ def test_remote_detail_actions_use_exact_confirmed_endpoints() -> None:
if endpoint == "tcm.diagnosis/generateMiniProgramQrcode"
]
assert qr_payloads[0] == {
"diagnosis_id": 1001,
"doctor_id": 1001,
"patient_id": 301,
"share_user_id": 1001,
+132 -3
View File
@@ -15,7 +15,6 @@ from PySide6.QtWidgets import (
QFrame,
QLabel,
QPushButton,
QRadioButton,
QScrollArea,
QToolButton,
QWidget,
@@ -187,6 +186,63 @@ class VisualRepository:
{"name": "口干", "value": "口干"},
{"name": "口苦", "value": "口苦"},
],
"water_intake": [
{"name": "偏少", "value": "偏少"},
{"name": "正常", "value": "正常"},
],
"weight_change": [
{"name": "下降", "value": "下降"},
{"name": "上升", "value": "上升"},
],
"fatty_liver_degree": [
{"name": "轻度", "value": "轻度"},
{"name": "中度", "value": "中度"},
],
"diet_condition": [
{"name": "纳差", "value": "纳差"},
{"name": "多食易饥", "value": "多食易饥"},
],
"body_feeling": [
{"name": "乏力", "value": "乏力"},
{"name": "肢体麻木", "value": "肢体麻木"},
],
"sleep_condition": [
{"name": "入睡困难", "value": "入睡困难"},
{"name": "多梦", "value": "多梦"},
{"name": "易醒", "value": "易醒"},
],
"eye_condition": [
{"name": "视物模糊", "value": "视物模糊"},
{"name": "眼干", "value": "眼干"},
],
"head_feeling": [
{"name": "头痛", "value": "头痛"},
{"name": "头晕", "value": "头晕"},
],
"sweat_condition": [
{"name": "自汗", "value": "自汗"},
{"name": "盗汗", "value": "盗汗"},
],
"skin_condition": [
{"name": "皮肤瘙痒", "value": "皮肤瘙痒"},
{"name": "皮肤干燥", "value": "皮肤干燥"},
],
"urine_condition": [
{"name": "尿频", "value": "尿频"},
{"name": "夜尿多", "value": "夜尿多"},
],
"stool_condition": [
{"name": "便秘", "value": "便秘"},
{"name": "便溏", "value": "便溏"},
],
"kidney_condition": [
{"name": "腰酸", "value": "腰酸"},
{"name": "腰痛", "value": "腰痛"},
],
"past_history": [
{"name": "高血压", "value": "高血压"},
{"name": "冠心病", "value": "冠心病"},
],
}
return dictionaries.get(dictionary_type, [])
@@ -661,7 +717,8 @@ def test_semantic_form_controls_keep_desktop_grid_and_canonical_diagnosis_type(
)
assert isinstance(dialog.edit_fields["gender"], DiagnosisChoiceButtons)
assert len(dialog.edit_fields["gender"].findChildren(QRadioButton)) == 2
assert len(dialog.edit_fields["gender"]._buttons) == 2
assert all(button.isCheckable() for button in dialog.edit_fields["gender"]._buttons)
assert isinstance(dialog.edit_fields["local_hospital_diagnosis"], DiagnosisChoiceButtons)
assert isinstance(dialog.edit_fields["age"], DiagnosisNumberEdit)
assert isinstance(dialog.edit_fields["height"], DiagnosisNumberEdit)
@@ -677,8 +734,9 @@ def test_semantic_form_controls_keep_desktop_grid_and_canonical_diagnosis_type(
]
assert diagnosis_type.toPlainText() == "follow_up"
assert "diagnosis_type" in repository.dictionary_calls
# 1024×60% drawer keeps a two-column grid; only phone-narrow widths stack.
assert dialog._form_narrow is False
assert any(len(fields) >= 3 for _layout, fields, _remainder in dialog._form_rows)
assert any(len(fields) >= 2 for _layout, fields, _remainder in dialog._form_rows)
_select_tab(dialog, "notes")
assert not dialog.save_button.isVisibleTo(dialog)
@@ -690,6 +748,77 @@ def test_semantic_form_controls_keep_desktop_grid_and_canonical_diagnosis_type(
dialog.close()
def test_hpi_choice_chips_are_visible_after_dictionary_load(
application: QApplication,
) -> None:
"""Regression: wrapped choice chips must not clip to blank white space."""
dialog = _open_dialog(application, (1024, 640), mode="edit")
diet = dialog.edit_fields["diet_condition"]
sleep = dialog.edit_fields["sleep_condition"]
assert isinstance(diet, DiagnosisChoiceButtons)
assert isinstance(sleep, DiagnosisChoiceButtons)
assert len(diet._buttons) >= 2
assert diet.height() >= 32
assert diet.minimumHeight() >= diet.heightForWidth(max(160, diet.width()))
visible_chips = [button for button in diet._buttons if button.isVisibleTo(dialog)]
assert visible_chips
assert all(button.height() >= 24 for button in visible_chips)
# Chips must sit inside the choice host, not below a clipped viewport.
host_bottom = diet.mapTo(dialog.drawer_panel, diet.rect().bottomLeft()).y()
chip_bottom = visible_chips[0].mapTo(dialog.drawer_panel, visible_chips[0].rect().bottomLeft()).y()
assert chip_bottom <= host_bottom + 2
dialog.close()
application.processEvents()
def test_choice_chips_keep_visible_checked_style_when_readonly(
application: QApplication,
) -> None:
"""Selected chips must stay cyan even when the form is view-only locked."""
dialog = _open_dialog(application, (1024, 640), mode="edit")
diet = dialog.edit_fields["diet_condition"]
assert isinstance(diet, DiagnosisChoiceButtons)
diet.setPlainText("纳差")
selected = next(button for button in diet._buttons if button.isChecked())
application.processEvents()
# Sample the pad (not glyph center) so white text does not hide the fill.
enabled_color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
assert enabled_color.name().lower() == "#cffafe"
diet.setReadOnly(True)
application.processEvents()
disabled_color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
assert selected.isChecked()
assert selected.isEnabled()
assert diet.isReadOnly()
assert disabled_color.name().lower() == "#cffafe"
dialog.close()
application.processEvents()
def test_view_only_drawer_shows_selected_choice_chips(
application: QApplication,
) -> None:
"""Admin openViewOnly keeps the 病历 form with selected chips visible."""
dialog = _open_dialog(application, (1024, 640), mode="viewOnly")
assert dialog.view_stack.currentWidget() is dialog.drawer_overlay
assert dialog._view_only is True
assert not dialog._editable
diet = dialog.edit_fields["diet_condition"]
assert isinstance(diet, DiagnosisChoiceButtons)
diet.setPlainText("纳差")
selected = next(button for button in diet._buttons if button.isChecked())
application.processEvents()
color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
assert color.name().lower() == "#cffafe"
assert diet.isReadOnly()
assert not dialog.save_button.isVisibleTo(dialog)
dialog.close()
application.processEvents()
def test_view_only_uses_ordinary_detail_and_standalone_uses_readonly_detail(
application: QApplication,
) -> None:
File diff suppressed because it is too large Load Diff
+56 -1
View File
@@ -18,7 +18,11 @@ from doctor_workstation.ui.diagnosis_media import (
safe_http_url,
)
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog, OrderDetailDrawer
from doctor_workstation.ui.dialogs.diagnosis import (
DiagnosisDialog,
OrderDetailDrawer,
present_order_detail,
)
@pytest.fixture(scope="module")
@@ -214,6 +218,31 @@ def test_order_detail_is_eighty_percent_readonly_drawer_with_real_sections(
application.processEvents()
def test_present_order_detail_shared_entry_matches_admin_drawer_sections(
application: QApplication,
) -> None:
host = QFrame()
host.resize(1100, 720)
host.show()
application.processEvents()
drawer = present_order_detail(
host,
_rich_order(),
order_id=801,
permissions=["tcm.prescriptionOrder/logs", "tcm.prescriptionOrder/detail"],
exec_=False,
)
application.processEvents()
assert isinstance(drawer, OrderDetailDrawer)
labels = [label.text() for label in drawer.findChildren(QLabel)]
for section in ("金额概览", "处方详情", "收款记录", "履约与收货信息", "物流轨迹", "操作日志"):
assert section in labels
assert "RX-20260811-0801" in " ".join(labels)
drawer.close()
host.close()
application.processEvents()
def test_order_detail_missing_fields_use_explicit_empty_states_without_fake_zero(
application: QApplication,
) -> None:
@@ -287,6 +316,9 @@ def test_recording_preference_inline_height_alternates_and_safe_external_open(
assert player.maximumHeight() == 180
assert player.property("maximumPlaybackHeight") == 180
assert player._source_attached is False
assert player.player is None
assert player.audio_output is None
assert player.video is None
alternate_buttons = cell.findChildren(QPushButton, "DiagnosisRecordingAlternateLink")
assert [button.text() for button in alternate_buttons] == [
"COS HLS 1",
@@ -309,6 +341,29 @@ def test_recording_preference_inline_height_alternates_and_safe_external_open(
application.processEvents()
def test_recording_rows_do_not_eagerly_create_native_players(
application: QApplication,
) -> None:
cells = [
RecordingPlaybackCell(
[f"https://media.example.invalid/replay-{index}.mp4"],
record_id=index,
)
for index in range(40)
]
application.processEvents()
inline_players = [cell.inline_player for cell in cells]
assert all(player is not None for player in inline_players)
assert all(player.player is None for player in inline_players if player is not None)
assert all(player.audio_output is None for player in inline_players if player is not None)
assert all(player.video is None for player in inline_players if player is not None)
for cell in cells:
cell.close()
application.processEvents()
def test_video_table_embeds_player_and_preserves_row_bound_upload(
application: QApplication,
) -> None:
+55
View File
@@ -0,0 +1,55 @@
"""Dictionary payload contracts for diagnosis choice fields."""
from __future__ import annotations
from typing import Any
from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.services.repository import RemoteDoctorRepository, _dictionary_rows
from doctor_workstation.ui.dialogs.diagnosis import _CHOICE_DICTIONARIES, _dictionary_choices
class RecordingClient:
def __init__(self, payload: Any) -> None:
self.payload = payload
self.get_calls: list[tuple[str, dict[str, Any]]] = []
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
self.get_calls.append((endpoint, dict(params or {})))
return self.payload
def test_dictionary_rows_unwrap_admin_type_map() -> None:
rows = _dictionary_rows(
{
"sleep_condition": [
{"name": "入睡困难", "value": "入睡困难"},
{"name": "多梦", "value": "多梦"},
]
},
"sleep_condition",
)
assert [row["value"] for row in rows] == ["入睡困难", "多梦"]
def test_remote_get_dictionary_uses_admin_dict_shape() -> None:
client = RecordingClient(
{
"appetite": [
{"name": "口干", "value": "口干"},
{"name": "口苦", "value": "口苦"},
]
}
)
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
rows = repository.get_dictionary("appetite")
assert client.get_calls == [("config/dict", {"type": "appetite"})]
assert _dictionary_choices(rows, "appetite") == [("口干", "口干"), ("口苦", "口苦")]
def test_demo_exposes_all_diagnosis_choice_dictionaries() -> None:
repository = DemoDoctorRepository()
for dictionary_type, _multiple in _CHOICE_DICTIONARIES.values():
rows = repository.get_dictionary(dictionary_type)
assert rows, dictionary_type
assert _dictionary_choices(rows, dictionary_type)
+3 -3
View File
@@ -51,7 +51,7 @@ def test_complete_appointment_mutates_all_related_views(
repository.complete_appointment(101)
completed = repository.list_appointments(status=3).items
assert [item.id for item in completed] == [101]
assert [item.id for item in completed] == [101, 104]
patient = repository.list_patients(keyword="林晓岚").items[0]
assert patient.appointment_status == 3
assert patient.status_filter == "completed"
@@ -129,8 +129,8 @@ def test_demo_pagination_and_returned_copies(repository: DemoDoctorRepository) -
"""Pagination metadata is stable and callers cannot mutate repository state."""
page = repository.list_appointments(page_no=1, page_size=1)
assert page.total == 3
assert page.pages == 3
assert page.total == 5
assert page.pages == 5
page.items[0].patient_name = "外部改写"
assert repository.list_appointments(page_no=1, page_size=1).items[0].patient_name != (
"外部改写"
+9
View File
@@ -38,6 +38,15 @@ def test_windows_one_click_entrypoints_and_release_pipeline() -> None:
assert "explorer.exe" in read("Build_DoctorWorkstation.bat")
def test_debug_launcher_reuses_an_isolated_persistent_profile() -> None:
debug_script = read("Debug_DoctorWorkstation.bat")
assert "%LOCALAPPDATA%\\ZhenYangTang\\DoctorWorkstation\\Debug" in debug_script
assert "DOCTOR_CONFIG_DIR=%DEBUG_PROFILE%\\config" in debug_script
assert "DOCTOR_LOG_DIR=%DEBUG_PROFILE%\\logs" in debug_script
assert "%RANDOM%" not in debug_script
def test_macos_one_click_entrypoints_and_release_pipeline() -> None:
for name in (
"run_macos.command",
+2
View File
@@ -108,6 +108,7 @@ def test_shell_resolves_dynamic_menu_order_visibility_and_canonical_permissions(
assert _resolve_navigation([], permissions, demo_mode=False) == []
assert [item.key for item, _title in _resolve_navigation([], permissions, demo_mode=True)] == [
"reception",
"appointments",
"prescriptions",
"patients",
"consultations",
@@ -547,6 +548,7 @@ def test_shell_uses_demo_session_menu_and_fits_minimum_window(
assert shell.size().height() == 640
assert {item.key for item in NAVIGATION} == {
"reception",
"appointments",
"prescription_library",
"prescriptions",
"patients",
+62 -1
View File
@@ -19,6 +19,8 @@ from doctor_workstation.ui.dialogs.prescription import (
PrescriptionOrderDialog,
PrescriptionTemplateDialog,
RemoteMedicineComboBox,
build_prescription_clinical_diagnosis,
build_prescription_visit_no,
parse_pasted_herbs,
render_prescription_html,
)
@@ -109,6 +111,22 @@ def test_paste_parser_matches_common_admin_recipe_forms() -> None:
]
def test_prescription_seed_helpers_match_admin_visit_no_and_clinical() -> None:
assert build_prescription_visit_no(diagnosis_id=15534) == "1K00015534"
assert build_prescription_visit_no(appointment_id=401, diagnosis_id=15534) == "1K00000401"
assert build_prescription_visit_no() == ""
assert build_prescription_clinical_diagnosis({"diagnosis_type": "follow_up"}) == "复诊"
assert build_prescription_clinical_diagnosis({"clinical_diagnosis": "follow_up"}) == "复诊"
assert (
build_prescription_clinical_diagnosis(
{"symptoms": "肝郁脾虚", "diagnosis_type": "follow_up"}
)
== "肝郁脾虚"
)
assert build_prescription_clinical_diagnosis({"clinical_diagnosis": "气阴两虚"}) == "气阴两虚"
def test_remote_medicine_selector_rejects_new_free_text(
application: QApplication,
) -> None:
@@ -374,6 +392,11 @@ def test_order_payload_and_a4_print_document(
"dose_unit": "",
"usage_days": 7,
"times_per_day": 2,
"dosage_amount": 5,
"dosage_bag_count": 2,
"dosage_unit": "g",
"usage_way": "温水送服",
"usage_time": "饭后",
"herbs": [
{"name": "黄芪", "dosage": 15, "formula_type": "主方"},
{"name": "酸枣仁", "dosage": 12, "formula_type": "辅方"},
@@ -399,13 +422,51 @@ def test_order_payload_and_a4_print_document(
assert "林晓岚" in rendered
assert "黄芪" in rendered
assert "酸枣仁" in rendered
assert "服药前请核对姓名、电话、医生等信息" in rendered
assert "Rp." in rendered
assert "药房联" in rendered
assert "主方" in rendered
assert "辅方" in rendered
assert "105克" in rendered
assert "84克" in rendered
assert "单剂量:</span> 27克" in rendered
assert "每天2次, 一次2袋, 每袋5g, 温水送服, 饭后" in rendered
viewer = PrescriptionDetailDialog(prescription)
assert "中医处方笺" in viewer.document.toHtml()
document_html = viewer.document.toHtml()
assert "药房联" in document_html
assert "用药 (单剂)" in document_html
viewer.close()
order.close()
application.processEvents()
def test_prescription_detail_can_open_immutable_case_record_tab(
application: QApplication,
) -> None:
prescription = {
"id": 12,
"diagnosis_id": 6,
"patient_name": "林晓岚",
"case_record": {
"patient_name": "林晓岚",
"phone": "13800138000",
"local_hospital_name": "市中医院",
"symptoms": "口渴乏力",
"tongue_coating": "舌红少苔",
},
}
viewer = PrescriptionDetailDialog(prescription, initial_tab="case")
assert viewer.tabs.count() == 2
assert viewer.tabs.tabText(viewer.tabs.currentIndex()) == "详细病历"
assert viewer.case_document is not None
case_html = viewer.case_document.toHtml()
assert "市中医院" in case_html
assert "口渴乏力" in case_html
viewer.close()
application.processEvents()
def test_demo_repository_pages_render_offscreen(
application: QApplication,
immediate_async: None,
+17 -13
View File
@@ -43,6 +43,7 @@ def shell_window(
NavigationItem(key, title, glyph, _ShellPageDouble, (permission,))
for key, title, glyph, permission in (
("reception", "接诊台", "", "doctor.appointment/lists"),
("appointments", "挂号列表", "", "doctor.appointment/lists"),
("prescription_library", "我的处方库", "", "tcm.prescriptionLibrary/lists"),
("prescriptions", "已开处方", "", "tcm.prescription/lists"),
("patients", "我的患者", "", "firstvisit.myPatient/lists"),
@@ -77,19 +78,19 @@ def test_shell_matches_admin_geometry_at_both_acceptance_sizes(
shell_window.resize(width, height)
application.processEvents()
assert shell_window.sidebar.width() == 183
assert shell_window.topbar.height() == 50
assert shell_window.tabs_host.height() == 40
assert shell_window.workspace.width() == width - 183
assert shell_window.stack.width() == width - 183
assert shell_window.stack.height() == height - 90
assert shell_window.sidebar.width() == 208
assert shell_window.topbar.height() == 58
assert shell_window.tabs_host.height() == 42
assert shell_window.workspace.width() == width - 208
assert shell_window.stack.width() == width - 208
assert shell_window.stack.height() == height - 100
assert shell_window.stack.geometry().right() < shell_window.workspace.width()
assert shell_window.stack.geometry().bottom() < shell_window.workspace.height()
image = shell_window.grab().toImage()
assert image.pixelColor(100, 100).name().lower() == "#1d2124"
assert image.pixelColor(200, 10).name().lower() == "#ffffff"
assert image.pixelColor(190, 70).name().lower() == "#ffffff"
assert image.pixelColor(20, 300).name().lower() == "#ffffff"
assert image.pixelColor(500, 10).name().lower() == "#ffffff"
assert image.pixelColor(220, 110).name().lower() == "#f5f7fb"
def test_every_visible_page_navigates_and_visited_tabs_track_active_page(
@@ -100,6 +101,7 @@ def test_every_visible_page_navigates_and_visited_tabs_track_active_page(
for key in (
"reception",
"appointments",
"prescription_library",
"prescriptions",
"patients",
@@ -112,13 +114,15 @@ def test_every_visible_page_navigates_and_visited_tabs_track_active_page(
assert shell_window.visited_tab_keys() == (
"reception",
"appointments",
"prescription_library",
"prescriptions",
"patients",
"consultations",
)
assert changed[-5:] == [
assert changed[-6:] == [
"reception",
"appointments",
"prescription_library",
"prescriptions",
"patients",
@@ -151,12 +155,12 @@ def test_sidebar_collapse_preserves_active_navigation(shell_window: ShellWindow)
assert shell_window.navigate("consultations")
shell_window.toggle_sidebar()
assert shell_window.sidebar.width() == 64
assert shell_window.nav_buttons["consultations"].text() == ""
assert shell_window.sidebar.width() == 72
assert shell_window.nav_buttons["consultations"].text() == ""
assert shell_window.nav_buttons["consultations"].isChecked()
shell_window.toggle_sidebar()
assert shell_window.sidebar.width() == 183
assert shell_window.sidebar.width() == 208
assert shell_window.nav_buttons["consultations"].text().endswith("问诊列表")
assert shell_window.nav_buttons["consultations"].isChecked()
+180
View File
@@ -8,6 +8,7 @@ from PySide6.QtWidgets import QApplication
from doctor_workstation import app as app_module
from doctor_workstation.app import ApplicationController
from doctor_workstation.config import AppConfig
from doctor_workstation.core.errors import AuthenticationExpiredError
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.ui import login as login_module
@@ -16,6 +17,7 @@ from doctor_workstation.ui.login import LoginWindow
from doctor_workstation.ui.pages.consultations import _video_payload
from doctor_workstation.ui.shell import NAVIGATION
from doctor_workstation.ui.widgets import (
friendly_error,
gender_text,
invoke,
set_authentication_expired_handler,
@@ -47,6 +49,7 @@ def test_gender_text_maps_legacy_codes_and_preserves_labels() -> None:
def test_navigation_requires_each_pages_actual_list_capability() -> None:
assert {item.key: item.permissions for item in NAVIGATION} == {
"reception": ("doctor.appointment/lists",),
"appointments": ("doctor.appointment/lists",),
"prescription_library": ("tcm.prescriptionLibrary/lists",),
"prescriptions": ("tcm.prescription/lists",),
"patients": ("firstvisit.myPatient/lists",),
@@ -227,3 +230,180 @@ def test_real_demo_login_reaches_success_without_widget_adapter(
assert not window._loading
window.close()
application.processEvents()
def test_remembered_account_survives_a_new_login_window(tmp_path: Any) -> None:
application = QApplication.instance() or QApplication([])
settings_path = tmp_path / "remember-account.ini"
config = SimpleNamespace(
api_base_url="",
request_timeout=30,
verify_ssl=True,
demo_mode=False,
remembered_account="",
)
first = LoginWindow(
object(),
config=config,
settings=QSettings(str(settings_path), QSettings.Format.IniFormat),
)
first._on_login_success({}, "admin", True)
first.close()
application.processEvents()
restored = LoginWindow(
object(),
config=config,
settings=QSettings(str(settings_path), QSettings.Format.IniFormat),
)
assert restored.account_edit.text() == "admin"
assert restored.remember_check.isChecked()
restored.close()
application.processEvents()
def test_server_settings_panel_keeps_controls_separated_at_minimum_window(
tmp_path: Any,
) -> None:
application = QApplication.instance() or QApplication([])
settings = QSettings(str(tmp_path / "server-panel.ini"), QSettings.Format.IniFormat)
config = SimpleNamespace(
api_base_url="",
request_timeout=30,
demo_mode=False,
remembered_account="",
)
window = LoginWindow(object(), config=config, settings=settings)
window.resize(860, 590)
window.show()
window.server_toggle.setChecked(True)
window._toggle_server_panel(True)
application.processEvents()
assert window.server_panel.isVisible()
assert window.server_panel.height() >= window.server_panel.minimumSizeHint().height()
assert window.server_url_label.geometry().bottom() < window.server_url_edit.geometry().top()
assert window.server_url_edit.geometry().bottom() < window.timeout_label.geometry().top()
assert window.timeout_spin.geometry().right() < window.save_server_button.geometry().left()
assert window.timeout_label.geometry().bottom() < window.allow_self_signed_check.geometry().top()
assert window.allow_self_signed_check.geometry().bottom() < window.server_hint.geometry().top()
window.allow_self_signed_check.setChecked(True)
application.processEvents()
assert window.ssl_warning.isVisible()
assert window.allow_self_signed_check.geometry().bottom() < window.ssl_warning.geometry().top()
assert window.ssl_warning.geometry().bottom() < window.server_hint.geometry().top()
window.close()
application.processEvents()
def test_server_settings_can_persist_self_signed_debug_mode(tmp_path: Any) -> None:
application = QApplication.instance() or QApplication([])
settings = QSettings(str(tmp_path / "self-signed.ini"), QSettings.Format.IniFormat)
config = SimpleNamespace(
api_base_url="https://internal.example.test",
request_timeout=30,
verify_ssl=True,
demo_mode=False,
remembered_account="",
)
window = LoginWindow(object(), config=config, settings=settings)
emitted: list[dict[str, Any]] = []
window.server_settings_changed.connect(emitted.append)
window.allow_self_signed_check.setChecked(True)
window._save_server_settings()
assert emitted[-1]["verify_ssl"] is False
assert settings.value("server/verify_ssl", type=bool) is False
assert "证书校验已关闭" in window.error_banner.label.text()
window.close()
application.processEvents()
def test_login_applies_self_signed_setting_before_authentication(
monkeypatch: Any,
tmp_path: Any,
) -> None:
application = QApplication.instance() or QApplication([])
settings = QSettings(str(tmp_path / "self-signed-login.ini"), QSettings.Format.IniFormat)
config = AppConfig(
api_base_url="https://internal.example.test/adminapi",
demo_mode=False,
verify_ssl=True,
)
calls: list[str] = []
class Repository:
def __init__(self, name: str) -> None:
self.name = name
def login(self, **_payload: Any) -> object:
calls.append(self.name)
return object()
def get_current_user(self) -> object:
return object()
old_repository = Repository("old")
rebuilt_repository = Repository("rebuilt-without-verification")
window = LoginWindow(old_repository, config=config, settings=settings)
def rebuild_on_config_change(updated: AppConfig) -> None:
assert updated.verify_ssl is False
window.repository = rebuilt_repository
window.active_repository = rebuilt_repository
def run_immediately(function: Any, **callbacks: Any) -> object:
try:
callbacks["on_success"](function())
except Exception as error: # pragma: no cover - assertion output is more useful
callbacks["on_error"](error)
finally:
callbacks["on_finished"]()
return object()
monkeypatch.setattr(login_module, "run_async", run_immediately)
window.config_changed.connect(rebuild_on_config_change)
window.account_edit.setText("admin")
window.password_edit.setText("secret")
window.allow_self_signed_check.setChecked(True)
window.submit()
assert calls == ["rebuilt-without-verification"]
assert settings.value("server/verify_ssl", type=bool) is False
window.close()
application.processEvents()
def test_certificate_error_explains_self_signed_server_setting() -> None:
message = friendly_error(
RuntimeError("[SSL: CERTIFICATE_VERIFY_FAILED] self-signed certificate")
)
assert "信任自签名证书" in message
assert "服务器设置" in message
def test_certificate_error_opens_server_settings(tmp_path: Any) -> None:
application = QApplication.instance() or QApplication([])
settings = QSettings(str(tmp_path / "certificate-error.ini"), QSettings.Format.IniFormat)
config = SimpleNamespace(
api_base_url="https://internal.example.test",
request_timeout=30,
verify_ssl=True,
demo_mode=False,
remembered_account="",
)
window = LoginWindow(object(), config=config, settings=settings)
window._on_login_error(
RuntimeError("[SSL: CERTIFICATE_VERIFY_FAILED] self-signed certificate")
)
assert window.server_toggle.isChecked()
assert not window.server_panel.isHidden()
assert "信任自签名证书" in window.error_banner.label.text()
window.close()
application.processEvents()