新增
This commit is contained in:
@@ -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"),
|
||||
[
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
+1298
-1283
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,
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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 != (
|
||||
"外部改写"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user