更新
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Bundled Noto Sans SC
|
||||
|
||||
`NotoSansSC-VF.ttf` is the unmodified Google Fonts distribution of
|
||||
`NotoSansSC[wght].ttf`, stored under a filename without brackets for simpler
|
||||
resource lookup. No font bytes were changed or subsetted locally.
|
||||
|
||||
- Qt family: `Noto Sans SC`
|
||||
- Font version: `Version 2.004-H2;hotconv 1.0.118;makeotfexe 2.5.65603`
|
||||
- Variable axis: `wght`, 100–900; named instances at every 100, including 600.
|
||||
- Size: 17,772,300 bytes.
|
||||
- SHA-256: `a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da`
|
||||
- Official repository revision: `google/fonts@5e35378e6bda803962ee6fd257e444a7d459660d`.
|
||||
- [Pinned font source](https://github.com/google/fonts/blob/5e35378e6bda803962ee6fd257e444a7d459660d/ofl/notosanssc/NotoSansSC%5Bwght%5D.ttf).
|
||||
- [Pinned license source](https://github.com/google/fonts/blob/5e35378e6bda803962ee6fd257e444a7d459660d/ofl/notosanssc/OFL.txt).
|
||||
|
||||
The font is distributed under the SIL Open Font License 1.1. Retain
|
||||
`OFL-NotoSansSC.txt`, including its copyright notice, when redistributing the
|
||||
font with the application. The license applies to the font, independently of
|
||||
the application's license.
|
||||
|
||||
Load this local resource through `QFontDatabase.addApplicationFont` after
|
||||
creating `QApplication`, then use the returned family name. The application
|
||||
must not fetch fonts at runtime. The PyInstaller spec already copies the
|
||||
entire `resources` directory, including this directory and its license.
|
||||
|
||||
Google Fonts supplies explicit Regular (400), Medium (500), and SemiBold (600)
|
||||
instances. The Noto CJK upstream 2.004 file lacks a named 600 instance and Qt
|
||||
may select Medium for a plain `font-weight: 600` request; this distribution
|
||||
preserves distinct results with the application's normal QSS font weights.
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Render the clinical reading surfaces with demo data and production fonts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtCore import Qt, QThreadPool
|
||||
from PySide6.QtGui import QFontInfo, QGuiApplication, QPalette
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui import ShellWindow, apply_theme
|
||||
|
||||
|
||||
def _settle(app: QApplication) -> None:
|
||||
for _ in range(4):
|
||||
QThreadPool.globalInstance().waitForDone(3000)
|
||||
app.processEvents()
|
||||
QTest.qWait(100)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, default=Path("artifacts/ui_comfort"))
|
||||
parser.add_argument("--width", type=int, default=1536)
|
||||
parser.add_argument("--height", type=int, default=912)
|
||||
args = parser.parse_args()
|
||||
args.output.mkdir(parents=True, exist_ok=True)
|
||||
QGuiApplication.setHighDpiScaleFactorRoundingPolicy(
|
||||
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
|
||||
)
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
repo = DemoDoctorRepository()
|
||||
session = repo.login(repo.DEMO_ACCOUNT, repo.DEMO_PASSWORD)
|
||||
shell = ShellWindow(
|
||||
repo, {"session": session, "demo_mode": True}, permissions=session.permissions
|
||||
)
|
||||
shell.resize(args.width, args.height)
|
||||
shell.show()
|
||||
try:
|
||||
shell.navigate("reception")
|
||||
_settle(app)
|
||||
page = shell.pages["reception"]
|
||||
page._set_queue_filter(None)
|
||||
_settle(app)
|
||||
# A synthetic multiline case tests paragraph rhythm without capturing
|
||||
# a live patient or connecting to a production service.
|
||||
page.case_labels["present"].setText(
|
||||
"患者自述近期口干,睡眠较浅,日常饮食与作息较规律。\n"
|
||||
"近一周已记录空腹血糖,复诊时携带记录与既往检查报告。\n"
|
||||
"问诊记录包含当前不适、变化时间、生活习惯与既往用药,供医生核对。"
|
||||
)
|
||||
app.processEvents()
|
||||
if not shell.grab().save(str(args.output / "reception.png")):
|
||||
raise RuntimeError("Could not save reception preview")
|
||||
daily = next(
|
||||
index
|
||||
for index in range(page.detail_tabs.count())
|
||||
if page.detail_tabs.tabText(index) == "日常记录"
|
||||
)
|
||||
page.detail_tabs.setCurrentIndex(daily)
|
||||
_settle(app)
|
||||
if not shell.grab().save(str(args.output / "daily_records.png")):
|
||||
raise RuntimeError("Could not save daily-record preview")
|
||||
metrics = {
|
||||
"family": QFontInfo(app.font()).family(),
|
||||
"pixel_size": app.font().pixelSize(),
|
||||
"font_strategy": app.font().styleStrategy().value,
|
||||
"font_hinting": app.font().hintingPreference().name,
|
||||
"text_color": app.palette().color(QPalette.ColorRole.Text).name(),
|
||||
"device_pixel_ratio": shell.devicePixelRatioF(),
|
||||
"window": [shell.width(), shell.height()],
|
||||
"daily_table_font": QFontInfo(page.daily_panel.matrix.font()).family(),
|
||||
"daily_table_size": page.daily_panel.matrix.font().pixelSize(),
|
||||
}
|
||||
(args.output / "render.json").write_text(
|
||||
json.dumps(metrics, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
print(args.output)
|
||||
finally:
|
||||
_settle(app)
|
||||
shell.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Page-scoped palette and compact typography for the approved appointment list."""
|
||||
|
||||
from string import Template
|
||||
|
||||
from .reception_style import body_family, heading_family
|
||||
|
||||
|
||||
def appointments_stylesheet() -> str:
|
||||
return Template(_QSS).substitute(body=body_family(), heading=heading_family())
|
||||
|
||||
|
||||
_QSS = """
|
||||
#AppointmentsPage { background: #F3F7FD; color: #273244; }
|
||||
#AppointmentsPage QLabel, #AppointmentsPage QPushButton,
|
||||
#AppointmentsPage QLineEdit, #AppointmentsPage QComboBox,
|
||||
#AppointmentsPage QTabBar, #AppointmentsPage QTableWidget {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||
}
|
||||
#AppointmentsPage QWidget#PageHeader QLabel[role="pageTitle"] {
|
||||
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
|
||||
}
|
||||
#AppointmentsPage QWidget#PageHeader QLabel[role="muted"],
|
||||
#AppointmentsPage QWidget#PageHeader QLabel[role="breadcrumb"],
|
||||
#AppointmentsPage QWidget#PageHeader QLabel[role="breadcrumbSeparator"],
|
||||
#AppointmentsPage QWidget#PageHeader QLabel[role="breadcrumbCurrent"] {
|
||||
color: #5D6B80; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#AppointmentsPage QFrame#AppointmentFilterPanel,
|
||||
#AppointmentsPage QFrame#AppointmentMainCard {
|
||||
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||
}
|
||||
#AppointmentsPage QPushButton {
|
||||
min-height: 30px; padding: 0 12px; border: 1px solid #DBE5F2;
|
||||
border-radius: 6px; background: #FFFFFF;
|
||||
}
|
||||
#AppointmentsPage QPushButton:hover { color: #1555B6; background: #F2F7FF; border-color: #B6CDEE; }
|
||||
#AppointmentsPage QPushButton:pressed { background: #DCEAFF; }
|
||||
#AppointmentsPage QPushButton:focus { border-color: #75A5F0; }
|
||||
#AppointmentsPage QPushButton[variant="primary"] {
|
||||
background: #1769E8; color: #FFFFFF; border-color: #1769E8;
|
||||
}
|
||||
#AppointmentsPage QPushButton[variant="primary"]:hover { background: #155BCC; }
|
||||
#AppointmentsPage QPushButton[variant="primary"]:pressed { background: #124EA9; }
|
||||
#AppointmentsPage QPushButton[variant="danger"] {
|
||||
color: #B84652; background: #FFFFFF; border-color: #EFC8CE;
|
||||
}
|
||||
#AppointmentsPage QPushButton[variant="danger"]:hover { background: #FFF0F2; }
|
||||
#AppointmentsPage QPushButton[variant="ghost"] { background: transparent; border-color: transparent; }
|
||||
#AppointmentsPage QPushButton:disabled {
|
||||
color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2;
|
||||
}
|
||||
#AppointmentsPage QLineEdit, #AppointmentsPage QComboBox {
|
||||
min-height: 32px; padding: 0 10px; color: #273244; background: #FFFFFF;
|
||||
border: 1px solid #DBE5F2; border-radius: 6px; selection-background-color: #DCEAFF;
|
||||
}
|
||||
#AppointmentsPage QLineEdit:focus, #AppointmentsPage QComboBox:focus { border-color: #75A5F0; }
|
||||
#AppointmentsPage QLineEdit QToolButton {
|
||||
min-width: 0; min-height: 0; padding: 0; border: 0; background: transparent;
|
||||
}
|
||||
#AppointmentsPage QLineEdit QToolButton:focus { background: #EAF2FF; }
|
||||
#AppointmentsPage QPushButton#AppointmentSearchButton {
|
||||
min-height: 39px; max-height: 39px; min-width: 52px;
|
||||
}
|
||||
#AppointmentsPage QLineEdit#AppointmentPatientSearch { min-height: 39px; max-height: 39px; }
|
||||
#AppointmentsPage QPushButton[appointmentStat="true"] {
|
||||
min-height: 34px; max-height: 34px; padding: 0 10px; background: #F4F7FC;
|
||||
border-color: transparent; color: #5D6B80; font-size: 13px;
|
||||
}
|
||||
#AppointmentsPage QPushButton[appointmentStat="true"]:hover { color: #1555B6; background: #EAF2FF; }
|
||||
#AppointmentsPage QPushButton[appointmentStat="true"]:checked {
|
||||
color: #FFFFFF; background: #1769E8; border-color: #1769E8;
|
||||
}
|
||||
#AppointmentsPage QPushButton[appointmentStatKind="warning"][hasPending="true"]:!checked {
|
||||
color: #9C681F; background: #FFF6E7; border-color: #EEDCBF;
|
||||
}
|
||||
#AppointmentsPage QLabel#FilterRowLabel { color: #5D6B80; font-size: 13px; }
|
||||
#AppointmentsPage QLabel#FilterDivider { color: #DBE5F2; padding: 0 10px; }
|
||||
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab {
|
||||
min-width: 52px; min-height: 32px; padding: 0 12px; color: #5D6B80;
|
||||
background: transparent; border: 0; border-bottom: 2px solid transparent; font-size: 13px;
|
||||
}
|
||||
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab:hover { color: #1555B6; background: #F7FAFE; }
|
||||
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab:selected {
|
||||
color: #1769E8; background: transparent; border-bottom-color: #1769E8;
|
||||
}
|
||||
#AppointmentsPage QPushButton[filterChoice="true"] {
|
||||
min-height: 30px; max-height: 30px; padding: 0 12px; color: #5D6B80;
|
||||
background: transparent; border-color: transparent; font-size: 13px;
|
||||
}
|
||||
#AppointmentsPage QPushButton[filterChoice="true"]:checked { color: #1555B6; background: #EAF2FF; }
|
||||
#AppointmentsPage QFrame#AppointmentToolbar { background: transparent; border: 0; }
|
||||
#AppointmentsPage QPushButton[compactAction="true"] {
|
||||
min-height: 36px; max-height: 36px; padding: 0 17px; font-size: 13px;
|
||||
}
|
||||
#AppointmentsPage QFrame#AppointmentToolbar QPushButton[variant="secondary"]:enabled {
|
||||
color: #1769E8; border-color: #ADC8F0;
|
||||
}
|
||||
#AppointmentsPage QTableWidget#AppointmentTable {
|
||||
background: #FFFFFF; alternate-background-color: #FFFFFF; border: 0; border-radius: 0;
|
||||
gridline-color: #E6EDF6; selection-background-color: #EAF2FF; selection-color: #273244;
|
||||
}
|
||||
#AppointmentsPage QTableWidget#AppointmentTable::item { padding: 0; border: 0; border-bottom: 1px solid #E6EDF6; }
|
||||
#AppointmentsPage QTableWidget#AppointmentTable::item:selected { background: #EAF2FF; color: #273244; }
|
||||
#AppointmentsPage QTableWidget#AppointmentTable QHeaderView::section {
|
||||
min-height: 40px; padding: 0; background: #F5F8FD; color: #5D6B80;
|
||||
border: 0; border-top: 1px solid #E1E9F4; border-bottom: 1px solid #E1E9F4;
|
||||
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#AppointmentsPage QWidget[appointmentSelectionHost="true"] {
|
||||
background: transparent; border-left: 3px solid transparent;
|
||||
}
|
||||
#AppointmentsPage QWidget[appointmentSelectionHost="true"][selected="true"] { border-left-color: #1769E8; }
|
||||
#AppointmentsPage QCheckBox[appointmentSelector="true"]::indicator {
|
||||
width: 16px; height: 16px; background: #FFFFFF; border: 1px solid #C8D5E6; border-radius: 3px;
|
||||
}
|
||||
#AppointmentsPage QCheckBox[appointmentSelector="true"]::indicator:checked { background: #1769E8; border-color: #1769E8; }
|
||||
#AppointmentsPage QWidget[appointmentInfoHost="true"],
|
||||
#AppointmentsPage QWidget[appointmentImHost="true"] { background: transparent; }
|
||||
#AppointmentsPage QLabel[tableAppointmentStatus="true"] {
|
||||
min-height: 20px; max-height: 20px; padding: 0 6px; color: #1555B6;
|
||||
background: #EAF2FF; border-radius: 4px; font-size: 13px;
|
||||
}
|
||||
#AppointmentsPage QLabel[tableAppointmentStatusKind="warning"] { color: #9C681F; background: #FFF3DD; }
|
||||
#AppointmentsPage QLabel[tableAppointmentStatusKind="muted"] { color: #66758A; background: #EEF2F7; }
|
||||
#AppointmentsPage QLabel[tableAppointmentMeta="true"] { color: #5D6B80; font-size: 13px; }
|
||||
#AppointmentsPage QPushButton[tableCancelAction="true"] {
|
||||
min-height: 20px; max-height: 20px; padding: 0 5px; color: #B84652;
|
||||
background: #FFF0F2; border: 0; border-radius: 4px; font-size: 13px;
|
||||
}
|
||||
#AppointmentsPage QPushButton[appointmentImAction="true"] {
|
||||
min-width: 62px; min-height: 32px; max-height: 32px; padding: 0 10px;
|
||||
color: #1555B6; background: #EAF2FF; border-color: #C9DCF7; font-size: 13px;
|
||||
}
|
||||
#AppointmentsPage QPushButton[appointmentImAction="true"]:hover { color: #FFFFFF; background: #1769E8; }
|
||||
#AppointmentsPage QPushButton[appointmentImAction="true"]:disabled { color: #8A97A9; background: #F3F6FB; border-color: #DFE7F2; }
|
||||
#AppointmentsPage QWidget#Pager { background: #FFFFFF; border: 0; border-top: 1px solid #E1E9F4; }
|
||||
#AppointmentsPage QWidget#Pager QLabel { color: #5D6B80; font-size: 13px; border: 0; }
|
||||
#AppointmentsPage QWidget#Pager QPushButton {
|
||||
min-width: 30px; max-width: 30px; min-height: 32px; max-height: 32px;
|
||||
padding: 0; border: 1px solid #DBE5F2; background: #FFFFFF; color: #5D6B80; font-size: 13px;
|
||||
}
|
||||
#AppointmentsPage QWidget#Pager QPushButton[active="true"] { color: #FFFFFF; background: #1769E8; border-color: #1769E8; }
|
||||
#AppointmentsPage QWidget#Pager QPushButton:disabled { color: #9AA6B7; background: #F7F9FC; }
|
||||
"""
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Scoped colors and compact typography for the approved consultation list."""
|
||||
|
||||
from string import Template
|
||||
|
||||
from .reception_style import body_family, heading_family
|
||||
|
||||
|
||||
def consultations_stylesheet() -> str:
|
||||
return Template(_QSS).substitute(body=body_family(), heading=heading_family())
|
||||
|
||||
|
||||
_QSS = """
|
||||
#DiagnosisIndex, #DiagnosisIndexContent, #DiagnosisPageScroll {
|
||||
background: #F3F7FD; color: #273244; border: 0;
|
||||
}
|
||||
#DiagnosisIndex QLabel, #DiagnosisIndex QPushButton, #DiagnosisIndex QToolButton,
|
||||
#DiagnosisIndex QLineEdit, #DiagnosisIndex QComboBox, #DiagnosisIndex QDateEdit,
|
||||
#DiagnosisIndex QSpinBox, #DiagnosisIndex QTableView {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||
}
|
||||
#DiagnosisIndex QWidget#PageHeader QLabel[role="pageTitle"] {
|
||||
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
|
||||
}
|
||||
#DiagnosisIndex QLabel[role="muted"], #DiagnosisIndex QLabel[role="breadcrumb"],
|
||||
#DiagnosisIndex QLabel[role="breadcrumbCurrent"], #DiagnosisIndex QLabel[role="breadcrumbSeparator"],
|
||||
#DiagnosisIndex QLabel[filterGroup="true"], #DiagnosisIndex QLabel[pagerMuted="true"] {
|
||||
color: #5D6B80; font-size: 13px;
|
||||
}
|
||||
#DiagnosisIndex QFrame#DiagnosisFilterCard, #DiagnosisIndex QFrame#DiagnosisListCard {
|
||||
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||
}
|
||||
#DiagnosisIndex QFrame#DiagnosisStatusCard, #DiagnosisIndex QFrame#DiagnosisQuickFilters,
|
||||
#DiagnosisIndex QFrame#DiagnosisListToolbar { background: transparent; border: 0; }
|
||||
#DiagnosisIndex QFrame#DiagnosisAdvancedFilters {
|
||||
background: transparent; border: 0; border-top: 1px solid #E6EDF6; border-radius: 0;
|
||||
}
|
||||
#DiagnosisIndex QPushButton {
|
||||
min-height: 34px; padding: 0 13px; border: 1px solid #DBE5F2;
|
||||
border-radius: 6px; background: #FFFFFF;
|
||||
}
|
||||
#DiagnosisIndex QPushButton:hover { color: #1555B6; background: #F2F7FF; border-color: #B6CDEE; }
|
||||
#DiagnosisIndex QPushButton:pressed { background: #DCEAFF; }
|
||||
#DiagnosisIndex QPushButton:focus { border-color: #75A5F0; }
|
||||
#DiagnosisIndex QPushButton[variant="primary"] { color: #FFFFFF; background: #1769E8; border-color: #1769E8; }
|
||||
#DiagnosisIndex QPushButton[variant="primary"]:hover { background: #155BCC; }
|
||||
#DiagnosisIndex QPushButton[consultationTool="true"] { color: #1555B6; border-color: #C2D5EF; }
|
||||
#DiagnosisIndex QPushButton[consultationDanger="true"] { color: #BE4B58; border-color: #EBCDD2; }
|
||||
#DiagnosisIndex QPushButton:disabled, #DiagnosisIndex QPushButton[consultationDanger="true"]:disabled {
|
||||
color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2;
|
||||
}
|
||||
#DiagnosisIndex QLineEdit, #DiagnosisIndex QComboBox, #DiagnosisIndex QDateEdit, #DiagnosisIndex QSpinBox {
|
||||
min-height: 32px; padding: 0 10px; color: #273244; background: #FFFFFF;
|
||||
border: 1px solid #DBE5F2; border-radius: 6px; selection-background-color: #DCEAFF;
|
||||
font-size: 13px;
|
||||
}
|
||||
#DiagnosisIndex QLineEdit:focus, #DiagnosisIndex QComboBox:focus,
|
||||
#DiagnosisIndex QDateEdit:focus, #DiagnosisIndex QSpinBox:focus { border-color: #75A5F0; }
|
||||
#DiagnosisIndex QLineEdit QToolButton {
|
||||
min-width: 0; min-height: 0; padding: 0; border: 0; background: transparent;
|
||||
}
|
||||
#DiagnosisIndex QComboBox::drop-down, #DiagnosisIndex QDateEdit::drop-down {
|
||||
width: 22px; border: 0; background: transparent;
|
||||
}
|
||||
#DiagnosisIndex QComboBox QAbstractItemView {
|
||||
color: #273244; background: #FFFFFF; border: 1px solid #DBE5F2;
|
||||
selection-background-color: #EAF2FF; selection-color: #1555B6; outline: 0;
|
||||
}
|
||||
#DiagnosisIndex QWidget#DiagnosisStatusSearch QLineEdit,
|
||||
#DiagnosisIndex QWidget#DiagnosisStatusSearch QPushButton { min-height: 39px; max-height: 39px; }
|
||||
#DiagnosisIndex QToolButton { min-width: 0; min-height: 0; border: 0; padding: 0; background: transparent; }
|
||||
#DiagnosisIndex QToolButton[diagnosisChip="true"] {
|
||||
min-height: 32px; max-height: 32px; padding: 0 14px; border: 1px solid transparent;
|
||||
border-radius: 5px; color: #5D6B80; background: transparent; font-size: 13px;
|
||||
}
|
||||
#DiagnosisIndex QToolButton[diagnosisChip="true"]:hover { background: #F2F7FF; color: #1555B6; }
|
||||
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked { background: #1769E8; color: #FFFFFF; }
|
||||
#DiagnosisIndex QToolButton[dateChoice="true"] { padding: 0 17px; border-color: #E1E9F4; }
|
||||
#DiagnosisIndex QToolButton[dateChoice="true"]:checked { border-color: #1769E8; }
|
||||
#DiagnosisIndex QToolButton[statusTab="true"] {
|
||||
min-height: 44px; max-height: 44px; padding: 0 20px; border: 0;
|
||||
border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
|
||||
}
|
||||
#DiagnosisIndex QToolButton[statusTab="true"]:checked {
|
||||
color: #1769E8; background: transparent; border-bottom-color: #1769E8;
|
||||
}
|
||||
#DiagnosisIndex QToolButton#DiagnosisMoreFilter {
|
||||
min-height: 32px; padding: 0 6px; color: #5D6B80; font-size: 13px;
|
||||
}
|
||||
#DiagnosisIndex QToolButton#DiagnosisMoreFilter:hover { color: #1769E8; background: #F2F7FF; }
|
||||
#DiagnosisIndex QFrame#DiagnosisFilterDivider { min-width: 1px; max-width: 1px; min-height: 20px; background: #E1E9F4; border: 0; }
|
||||
#DiagnosisIndex QFrame#DiagnosisDateRange {
|
||||
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 6px;
|
||||
}
|
||||
#DiagnosisIndex QDateEdit[diagnosisRangePart="true"] { min-height: 30px; padding: 0 4px; border: 0; }
|
||||
#DiagnosisIndex QTableView {
|
||||
background: #FFFFFF; alternate-background-color: #FFFFFF; border: 0; border-radius: 0;
|
||||
gridline-color: #E6EDF6; selection-background-color: #EAF2FF; selection-color: #273244;
|
||||
}
|
||||
#DiagnosisIndex QTableView QHeaderView::section {
|
||||
min-height: 41px; padding: 0; background: #F5F8FD; color: #5D6B80;
|
||||
border: 0; border-bottom: 1px solid #E1E9F4;
|
||||
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#DiagnosisIndex QToolButton[rowLink], #DiagnosisIndex QToolButton[appointmentCancel="true"] {
|
||||
color: #1769E8; min-height: 26px; padding: 0 4px; border: 0; background: transparent; font-size: 13px;
|
||||
}
|
||||
#DiagnosisIndex QToolButton[appointmentCancel="true"] { color: #BE4B58; }
|
||||
#DiagnosisIndex QToolButton[rowLink]:hover { color: #1555B6; background: #DCEAFF; border-radius: 4px; }
|
||||
#DiagnosisIndex QToolButton[rowLink="muted"], #DiagnosisIndex QLabel[fixedMuted="true"] {
|
||||
color: #5D6B80; font-size: 13px;
|
||||
}
|
||||
#DiagnosisIndex QToolButton#DiagnosisRowMore { padding-right: 18px; }
|
||||
#DiagnosisIndex QWidget#DiagnosisFixedCell { background: transparent; }
|
||||
#DiagnosisIndex QLabel#DiagnosisTableEmpty { color: #5D6B80; background: #FFFFFF; }
|
||||
#DiagnosisIndex QLabel#DiagnosisTableEmpty[stateKind="error"] { color: #BE4B58; }
|
||||
#DiagnosisIndex QTableView#DiagnosisFixedTable { border-left: 1px solid #E1E9F4; }
|
||||
#DiagnosisIndex QWidget#DiagnosisPager { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||
#DiagnosisIndex QToolButton[pagerButton="true"] {
|
||||
min-width: 30px; max-width: 30px; min-height: 30px; max-height: 30px;
|
||||
border: 1px solid #DBE5F2; border-radius: 5px; color: #5D6B80; background: #FFFFFF;
|
||||
}
|
||||
#DiagnosisIndex QToolButton[pagerButton="true"][active="true"] { color: #FFFFFF; background: #1769E8; border-color: #1769E8; }
|
||||
#DiagnosisIndex QToolButton[pagerButton="true"]:disabled { color: #A4B0C0; background: #F6F8FC; }
|
||||
#DiagnosisIndex QComboBox#DiagnosisPageSize { min-width: 95px; min-height: 30px; }
|
||||
#DiagnosisIndex QSpinBox#DiagnosisPageJumper { min-height: 30px; padding: 0 8px; }
|
||||
#DiagnosisIndex QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||
#DiagnosisIndex QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||
#DiagnosisIndex QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||
#DiagnosisIndex QScrollBar::add-line, #DiagnosisIndex QScrollBar::sub-line { width: 0; height: 0; }
|
||||
#DiagnosisIndex QScrollBar::add-page, #DiagnosisIndex QScrollBar::sub-page { background: transparent; }
|
||||
"""
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Compact, presentation-only disclosure for page search and overview regions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from PySide6.QtCore import QObject, QSize, Qt, Signal
|
||||
from PySide6.QtWidgets import QApplication, QPushButton, QWidget
|
||||
|
||||
from . import icons
|
||||
|
||||
|
||||
class FilterDisclosure(QObject):
|
||||
"""Keep query values and loading state intact while reclaiming list space.
|
||||
|
||||
Targets should be region containers, not individual permission-controlled
|
||||
controls. Showing a container preserves its children's explicit visibility.
|
||||
"""
|
||||
|
||||
expanded_changed = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget,
|
||||
targets: Sequence[QWidget],
|
||||
*,
|
||||
expanded: bool = False,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._targets = tuple(targets)
|
||||
self._expanded = bool(expanded)
|
||||
self.button = QPushButton(parent)
|
||||
self.button.setObjectName("FilterDisclosureButton")
|
||||
self.button.setCheckable(True)
|
||||
self.button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.button.setFixedHeight(32)
|
||||
self.button.setIconSize(QSize(14, 14))
|
||||
self.button.setStyleSheet("""
|
||||
QPushButton#FilterDisclosureButton {
|
||||
color: #1769E8; background: #FFFFFF; border: 1px solid #DBE5F2;
|
||||
border-radius: 6px; padding: 0 11px; min-height: 30px; max-height: 30px;
|
||||
min-width: 92px; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
QPushButton#FilterDisclosureButton:hover { background: #F3F7FD; border-color: #ADC8F2; }
|
||||
QPushButton#FilterDisclosureButton:checked { background: #EAF2FF; border-color: #ADC8F2; }
|
||||
QPushButton#FilterDisclosureButton:focus { border-color: #1769E8; }
|
||||
QPushButton#FilterDisclosureButton:disabled { color: #8B97A8; border-color: #E3E9F1; }
|
||||
""")
|
||||
self.button.toggled.connect(self.set_expanded)
|
||||
self._apply()
|
||||
|
||||
@property
|
||||
def expanded(self) -> bool:
|
||||
return self._expanded
|
||||
|
||||
def set_expanded(self, expanded: bool) -> None:
|
||||
expanded = bool(expanded)
|
||||
changed = expanded != self._expanded
|
||||
self._expanded = expanded
|
||||
self._apply()
|
||||
if changed:
|
||||
self.expanded_changed.emit(expanded)
|
||||
|
||||
def _apply(self) -> None:
|
||||
# Keep keyboard focus on a visible control when folding a focused form.
|
||||
focused = QApplication.focusWidget()
|
||||
if not self._expanded and focused is not None and any(
|
||||
target is focused or target.isAncestorOf(focused) for target in self._targets
|
||||
):
|
||||
self.button.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
for target in self._targets:
|
||||
target.setVisible(self._expanded)
|
||||
blocked = self.button.blockSignals(True)
|
||||
self.button.setChecked(self._expanded)
|
||||
self.button.blockSignals(blocked)
|
||||
label = "收起筛选" if self._expanded else "展开筛选"
|
||||
self.button.setText(label)
|
||||
self.button.setAccessibleName(label)
|
||||
self.button.setAccessibleDescription("显示或收起检索条件和统计信息;收起保留当前筛选条件")
|
||||
self.button.setToolTip("收起保留当前筛选条件" if self._expanded else "展开检索条件和统计信息,当前筛选条件保持不变")
|
||||
self.button.setIcon(icons.icon("chevron_up" if self._expanded else "chevron_down", "#1769E8", 14))
|
||||
@@ -0,0 +1,794 @@
|
||||
"""Single source of truth for every line icon in the workstation.
|
||||
|
||||
Before this module the application drew its icons from nine independent
|
||||
painters (``ui/shell.py`` had two, ``ui/login.py`` two,
|
||||
``ui/pages/reception.py`` four, and ``ui/pages/prescriptions.py``,
|
||||
``ui/pages/patients.py`` and ``ui/diagnosis_index_widgets.py`` one each). They disagreed on everything that
|
||||
makes an icon set read as one family:
|
||||
|
||||
* seven stroke weights - 1.4, 1.5, 1.55, 1.6, 1.7, 2.0 and ``size / 11.5`` px;
|
||||
* four design grids - geometry authored against 14, 16, 18 and 24 px boxes, so
|
||||
the same glyph asked for at another size came out off-centre or clipped;
|
||||
* mixed fills and strokes inside one row of icons (a stroked ``search`` beside a
|
||||
solid ``down`` triangle);
|
||||
* integer ``QRect`` coordinates in the menu painter, which put a 1.6 px stroke
|
||||
across a pixel boundary and rendered visibly softer than its neighbours;
|
||||
* six near-identical indigos and two near-identical reds picked per call site
|
||||
instead of from the palette.
|
||||
|
||||
Everything here is authored once on a 24-unit grid with a 20-unit optical safe
|
||||
area, stroked with one weight formula, and scaled to the requested size by the
|
||||
painter transform. Glyphs are pure stroke unless a filled counter is part of
|
||||
the mark (a list bullet, the dot on an "i"), which keeps the whole set at a
|
||||
single apparent weight.
|
||||
|
||||
Icons are cached as well. List pages build one icon per action button per row,
|
||||
so the previous code re-ran a ``QPainter`` for every visible row on every
|
||||
refresh; the cache turns that into one paint per (kind, colour, size).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Callable
|
||||
from functools import lru_cache
|
||||
|
||||
from PySide6.QtCore import QPointF, QRectF, Qt
|
||||
from PySide6.QtGui import QColor, QIcon, QPainter, QPainterPath, QPen, QPixmap
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from .theme import COLORS, crisp_pixmap
|
||||
|
||||
#: Every glyph is drawn inside this box. Nothing is authored against the pixel
|
||||
#: size the caller asks for, which is what keeps a 14 px and a 24 px request
|
||||
#: optically identical instead of merely proportional.
|
||||
GRID = 24.0
|
||||
|
||||
#: Ideal stroke at the reference grid. ``2 / 24`` is the Feather/Lucide ratio;
|
||||
#: the clamp keeps the line from vanishing at 12 px or turning into a slab at
|
||||
#: 36 px, which is the range the shell actually asks for.
|
||||
_STROKE_RATIO = 2.0 / GRID
|
||||
_STROKE_MIN_PX = 1.25
|
||||
_STROKE_MAX_PX = 2.25
|
||||
|
||||
|
||||
def stroke_px(size: float) -> float:
|
||||
"""Return the on-screen stroke width used for an icon of ``size`` px."""
|
||||
|
||||
return max(_STROKE_MIN_PX, min(_STROKE_MAX_PX, size * _STROKE_RATIO))
|
||||
|
||||
|
||||
# --- Semantic colour roles ------------------------------------------------
|
||||
# Call sites name a role instead of a hex value. The seven painters replaced
|
||||
# here between them hardcoded #5265F6, #5761F4, #5469F0, #5E69F6, #5365F5,
|
||||
# #4965F5 and #6675F5 for what was always meant to be one accent.
|
||||
ROLES = {
|
||||
"default": COLORS["muted"],
|
||||
"muted": COLORS["muted"],
|
||||
"soft": COLORS["text_soft"],
|
||||
"strong": COLORS["text"],
|
||||
"accent": COLORS["indigo"],
|
||||
"on_accent": "#FFFFFF",
|
||||
"success": COLORS["success"],
|
||||
"warning": COLORS["warning"],
|
||||
"danger": COLORS["danger"],
|
||||
"info": COLORS["info"],
|
||||
"disabled": COLORS["disabled_text"],
|
||||
"inverse": "#FFFFFF",
|
||||
}
|
||||
|
||||
|
||||
def resolve_color(color: str) -> str:
|
||||
"""Accept either a semantic role name or a literal colour string."""
|
||||
|
||||
return ROLES.get(color, color)
|
||||
|
||||
|
||||
_GLYPHS: dict[str, Callable[[QPainter, float], None]] = {}
|
||||
|
||||
_Glyph = Callable[[QPainter, float], None]
|
||||
|
||||
|
||||
def _glyph(*names: str) -> Callable[[_Glyph], _Glyph]:
|
||||
def register(fn: _Glyph) -> _Glyph:
|
||||
for name in names:
|
||||
_GLYPHS[name] = fn
|
||||
return fn
|
||||
|
||||
return register
|
||||
|
||||
|
||||
def _line(p: QPainter, x1: float, y1: float, x2: float, y2: float) -> None:
|
||||
p.drawLine(QPointF(x1, y1), QPointF(x2, y2))
|
||||
|
||||
|
||||
def _polyline(p: QPainter, *points: tuple[float, float]) -> None:
|
||||
path = QPainterPath(QPointF(*points[0]))
|
||||
for point in points[1:]:
|
||||
path.lineTo(QPointF(*point))
|
||||
p.drawPath(path)
|
||||
|
||||
|
||||
def _circle(p: QPainter, cx: float, cy: float, r: float) -> None:
|
||||
p.drawEllipse(QPointF(cx, cy), r, r)
|
||||
|
||||
|
||||
def _dot(p: QPainter, cx: float, cy: float, r: float) -> None:
|
||||
"""Filled counter - used only where the mark itself is solid."""
|
||||
|
||||
pen = p.pen()
|
||||
p.setPen(Qt.PenStyle.NoPen)
|
||||
p.setBrush(pen.color())
|
||||
p.drawEllipse(QPointF(cx, cy), r, r)
|
||||
p.setBrush(Qt.BrushStyle.NoBrush)
|
||||
p.setPen(pen)
|
||||
|
||||
|
||||
def _page(p: QPainter, *, fold: bool = True) -> None:
|
||||
"""Shared document silhouette so every file-like glyph has one outline."""
|
||||
|
||||
path = QPainterPath(QPointF(14.0, 2.5))
|
||||
path.lineTo(QPointF(6.5, 2.5))
|
||||
path.quadTo(QPointF(5.0, 2.5), QPointF(5.0, 4.0))
|
||||
path.lineTo(QPointF(5.0, 20.0))
|
||||
path.quadTo(QPointF(5.0, 21.5), QPointF(6.5, 21.5))
|
||||
path.lineTo(QPointF(17.5, 21.5))
|
||||
path.quadTo(QPointF(19.0, 21.5), QPointF(19.0, 20.0))
|
||||
path.lineTo(QPointF(19.0, 7.5))
|
||||
path.closeSubpath()
|
||||
p.drawPath(path)
|
||||
if fold:
|
||||
_polyline(p, (14.0, 2.5), (14.0, 7.5), (19.0, 7.5))
|
||||
|
||||
|
||||
def _sparkle(p: QPainter, cx: float, cy: float, r: float) -> None:
|
||||
"""Four-point concave star - the one AI mark used across the product."""
|
||||
|
||||
path = QPainterPath(QPointF(cx, cy - r))
|
||||
path.quadTo(QPointF(cx, cy), QPointF(cx + r, cy))
|
||||
path.quadTo(QPointF(cx, cy), QPointF(cx, cy + r))
|
||||
path.quadTo(QPointF(cx, cy), QPointF(cx - r, cy))
|
||||
path.quadTo(QPointF(cx, cy), QPointF(cx, cy - r))
|
||||
path.closeSubpath()
|
||||
p.drawPath(path)
|
||||
|
||||
|
||||
def _panel(p: QPainter) -> None:
|
||||
p.drawRoundedRect(QRectF(2.5, 4.0, 19.0, 16.0), 3.0, 3.0)
|
||||
_line(p, 9.5, 4.0, 9.5, 20.0)
|
||||
|
||||
|
||||
# --- Navigation -----------------------------------------------------------
|
||||
|
||||
|
||||
@_glyph("reception", "workbench", "monitor")
|
||||
def _reception(p: QPainter, w: float) -> None:
|
||||
p.drawRoundedRect(QRectF(2.5, 3.5, 19.0, 13.5), 3.0, 3.0)
|
||||
_polyline(p, (6.0, 10.5), (8.8, 10.5), (10.6, 7.5), (13.4, 13.5), (15.2, 10.5), (18.0, 10.5))
|
||||
_line(p, 12.0, 17.0, 12.0, 20.5)
|
||||
_line(p, 8.0, 20.5, 16.0, 20.5)
|
||||
|
||||
|
||||
@_glyph("appointments", "calendar")
|
||||
def _calendar(p: QPainter, w: float) -> None:
|
||||
p.drawRoundedRect(QRectF(3.0, 5.0, 18.0, 16.5), 3.0, 3.0)
|
||||
_line(p, 3.0, 10.0, 21.0, 10.0)
|
||||
_line(p, 8.0, 2.75, 8.0, 7.0)
|
||||
_line(p, 16.0, 2.75, 16.0, 7.0)
|
||||
|
||||
|
||||
@_glyph("prescription_library", "library", "layers")
|
||||
def _layers(p: QPainter, w: float) -> None:
|
||||
path = QPainterPath(QPointF(12.0, 2.5))
|
||||
path.lineTo(QPointF(21.0, 7.0))
|
||||
path.lineTo(QPointF(12.0, 11.5))
|
||||
path.lineTo(QPointF(3.0, 7.0))
|
||||
path.closeSubpath()
|
||||
p.drawPath(path)
|
||||
_polyline(p, (3.0, 12.0), (12.0, 16.5), (21.0, 12.0))
|
||||
_polyline(p, (3.0, 16.75), (12.0, 21.25), (21.0, 16.75))
|
||||
|
||||
|
||||
@_glyph("prescriptions", "file_check")
|
||||
def _file_check(p: QPainter, w: float) -> None:
|
||||
_page(p)
|
||||
_polyline(p, (8.5, 15.0), (10.9, 17.4), (15.5, 12.0))
|
||||
|
||||
|
||||
@_glyph("patients", "users")
|
||||
def _users(p: QPainter, w: float) -> None:
|
||||
_circle(p, 9.0, 8.0, 3.5)
|
||||
path = QPainterPath(QPointF(2.5, 20.5))
|
||||
path.quadTo(QPointF(2.5, 14.5), QPointF(9.0, 14.5))
|
||||
path.quadTo(QPointF(15.5, 14.5), QPointF(15.5, 20.5))
|
||||
p.drawPath(path)
|
||||
_circle(p, 17.6, 8.0, 2.8)
|
||||
tail = QPainterPath(QPointF(17.0, 13.9))
|
||||
tail.quadTo(QPointF(21.5, 14.6), QPointF(21.5, 20.5))
|
||||
p.drawPath(tail)
|
||||
|
||||
|
||||
@_glyph("consultations", "consult", "message", "other")
|
||||
def _message(p: QPainter, w: float) -> None:
|
||||
path = QPainterPath(QPointF(6.5, 3.5))
|
||||
path.lineTo(QPointF(17.5, 3.5))
|
||||
path.quadTo(QPointF(20.5, 3.5), QPointF(20.5, 6.5))
|
||||
path.lineTo(QPointF(20.5, 13.5))
|
||||
path.quadTo(QPointF(20.5, 16.5), QPointF(17.5, 16.5))
|
||||
path.lineTo(QPointF(11.5, 16.5))
|
||||
path.lineTo(QPointF(7.0, 20.5))
|
||||
path.lineTo(QPointF(7.0, 16.5))
|
||||
path.quadTo(QPointF(3.5, 16.5), QPointF(3.5, 13.5))
|
||||
path.lineTo(QPointF(3.5, 6.5))
|
||||
path.quadTo(QPointF(3.5, 3.5), QPointF(6.5, 3.5))
|
||||
p.drawPath(path)
|
||||
_line(p, 7.75, 8.25, 16.25, 8.25)
|
||||
_line(p, 7.75, 11.75, 13.0, 11.75)
|
||||
|
||||
|
||||
# --- Shell chrome ---------------------------------------------------------
|
||||
|
||||
|
||||
@_glyph("fold", "panel_close")
|
||||
def _fold(p: QPainter, w: float) -> None:
|
||||
_panel(p)
|
||||
_polyline(p, (17.0, 9.0), (14.0, 12.0), (17.0, 15.0))
|
||||
|
||||
|
||||
@_glyph("expand", "panel_open")
|
||||
def _expand(p: QPainter, w: float) -> None:
|
||||
_panel(p)
|
||||
_polyline(p, (14.0, 9.0), (17.0, 12.0), (14.0, 15.0))
|
||||
|
||||
|
||||
@_glyph("search")
|
||||
def _search(p: QPainter, w: float) -> None:
|
||||
_circle(p, 10.5, 10.5, 6.25)
|
||||
_line(p, 15.15, 15.15, 19.75, 19.75)
|
||||
|
||||
|
||||
@_glyph("refresh", "rotate")
|
||||
def _refresh(p: QPainter, w: float) -> None:
|
||||
# The arc terminates exactly on the arrow corner so the mark reads as one
|
||||
# continuous stroke. The painters replaced here left a detached triangle
|
||||
# (shell) or two stray lines that never formed a head at all (prescriptions).
|
||||
radius = math.hypot(8.5, 3.5)
|
||||
start = math.degrees(math.atan2(3.5, 8.5))
|
||||
p.drawArc(
|
||||
QRectF(12.0 - radius, 12.0 - radius, radius * 2, radius * 2),
|
||||
round(start * 16),
|
||||
round((360.0 - start) * 16),
|
||||
)
|
||||
_polyline(p, (20.5, 3.5), (20.5, 8.5), (15.5, 8.5))
|
||||
|
||||
|
||||
@_glyph("ai", "spark", "sparkle", "assistant")
|
||||
def _ai(p: QPainter, w: float) -> None:
|
||||
_sparkle(p, 10.2, 11.8, 7.2)
|
||||
_sparkle(p, 18.0, 6.0, 3.2)
|
||||
|
||||
|
||||
@_glyph("fullscreen", "expand-corners", "maximize")
|
||||
def _fullscreen(p: QPainter, w: float) -> None:
|
||||
_polyline(p, (9.0, 3.5), (3.5, 3.5), (3.5, 9.0))
|
||||
_polyline(p, (15.0, 3.5), (20.5, 3.5), (20.5, 9.0))
|
||||
_polyline(p, (3.5, 15.0), (3.5, 20.5), (9.0, 20.5))
|
||||
_polyline(p, (20.5, 15.0), (20.5, 20.5), (15.0, 20.5))
|
||||
|
||||
|
||||
@_glyph("minimize")
|
||||
def _minimize(p: QPainter, w: float) -> None:
|
||||
_line(p, 5.0, 12.0, 19.0, 12.0)
|
||||
|
||||
|
||||
@_glyph("close", "cross")
|
||||
def _close(p: QPainter, w: float) -> None:
|
||||
_line(p, 5.75, 5.75, 18.25, 18.25)
|
||||
_line(p, 18.25, 5.75, 5.75, 18.25)
|
||||
|
||||
|
||||
@_glyph("down", "chevron_down")
|
||||
def _down(p: QPainter, w: float) -> None:
|
||||
_polyline(p, (5.5, 9.0), (12.0, 15.5), (18.5, 9.0))
|
||||
|
||||
|
||||
@_glyph("up", "chevron_up")
|
||||
def _up(p: QPainter, w: float) -> None:
|
||||
_polyline(p, (5.5, 15.0), (12.0, 8.5), (18.5, 15.0))
|
||||
|
||||
|
||||
@_glyph("left", "chevron_left")
|
||||
def _left(p: QPainter, w: float) -> None:
|
||||
_polyline(p, (15.0, 5.5), (8.5, 12.0), (15.0, 18.5))
|
||||
|
||||
|
||||
@_glyph("right", "chevron_right")
|
||||
def _right(p: QPainter, w: float) -> None:
|
||||
_polyline(p, (9.0, 5.5), (15.5, 12.0), (9.0, 18.5))
|
||||
|
||||
|
||||
@_glyph("notification", "bell")
|
||||
def _bell(p: QPainter, w: float) -> None:
|
||||
path = QPainterPath(QPointF(6.75, 17.5))
|
||||
path.lineTo(QPointF(6.75, 10.75))
|
||||
path.arcTo(QRectF(6.75, 5.0, 10.5, 11.5), 180.0, -180.0)
|
||||
path.lineTo(QPointF(17.25, 17.5))
|
||||
p.drawPath(path)
|
||||
_line(p, 4.5, 17.5, 19.5, 17.5)
|
||||
p.drawArc(QRectF(10.0, 17.4, 4.0, 3.6), 180 * 16, 180 * 16)
|
||||
|
||||
|
||||
@_glyph("settings", "sliders")
|
||||
def _settings(p: QPainter, w: float) -> None:
|
||||
_line(p, 3.5, 8.5, 20.5, 8.5)
|
||||
_line(p, 3.5, 15.5, 20.5, 15.5)
|
||||
_circle(p, 9.0, 8.5, 2.4)
|
||||
_circle(p, 15.0, 15.5, 2.4)
|
||||
|
||||
|
||||
# --- Row and toolbar actions ---------------------------------------------
|
||||
|
||||
|
||||
@_glyph("eye", "view")
|
||||
def _eye(p: QPainter, w: float) -> None:
|
||||
path = QPainterPath(QPointF(2.5, 12.0))
|
||||
path.quadTo(QPointF(12.0, 2.5), QPointF(21.5, 12.0))
|
||||
path.quadTo(QPointF(12.0, 21.5), QPointF(2.5, 12.0))
|
||||
p.drawPath(path)
|
||||
_circle(p, 12.0, 12.0, 3.0)
|
||||
|
||||
|
||||
@_glyph("pencil", "edit")
|
||||
def _pencil(p: QPainter, w: float) -> None:
|
||||
path = QPainterPath(QPointF(16.25, 3.0))
|
||||
path.lineTo(QPointF(20.75, 7.5))
|
||||
path.lineTo(QPointF(8.5, 19.75))
|
||||
path.lineTo(QPointF(3.0, 21.0))
|
||||
path.lineTo(QPointF(4.25, 15.5))
|
||||
path.closeSubpath()
|
||||
p.drawPath(path)
|
||||
_line(p, 13.0, 6.25, 17.5, 10.75)
|
||||
|
||||
|
||||
@_glyph("trash", "delete")
|
||||
def _trash(p: QPainter, w: float) -> None:
|
||||
_line(p, 3.5, 6.25, 20.5, 6.25)
|
||||
_polyline(p, (9.0, 6.25), (9.0, 3.5), (15.0, 3.5), (15.0, 6.25))
|
||||
path = QPainterPath(QPointF(5.75, 6.25))
|
||||
path.lineTo(QPointF(6.6, 19.4))
|
||||
path.quadTo(QPointF(6.7, 20.5), QPointF(7.8, 20.5))
|
||||
path.lineTo(QPointF(16.2, 20.5))
|
||||
path.quadTo(QPointF(17.3, 20.5), QPointF(17.4, 19.4))
|
||||
path.lineTo(QPointF(18.25, 6.25))
|
||||
p.drawPath(path)
|
||||
_line(p, 10.0, 10.0, 10.0, 16.75)
|
||||
_line(p, 14.0, 10.0, 14.0, 16.75)
|
||||
|
||||
|
||||
@_glyph("plus", "add")
|
||||
def _plus(p: QPainter, w: float) -> None:
|
||||
_line(p, 12.0, 4.75, 12.0, 19.25)
|
||||
_line(p, 4.75, 12.0, 19.25, 12.0)
|
||||
|
||||
|
||||
@_glyph("check")
|
||||
def _check(p: QPainter, w: float) -> None:
|
||||
_polyline(p, (4.75, 12.5), (9.75, 17.5), (19.25, 7.0))
|
||||
|
||||
|
||||
@_glyph("check_circle", "health")
|
||||
def _check_circle(p: QPainter, w: float) -> None:
|
||||
_circle(p, 12.0, 12.0, 8.75)
|
||||
_polyline(p, (7.75, 12.25), (10.75, 15.25), (16.25, 9.0))
|
||||
|
||||
|
||||
@_glyph("checkbox")
|
||||
def _checkbox(p: QPainter, w: float) -> None:
|
||||
p.drawRoundedRect(QRectF(3.75, 3.75, 16.5, 16.5), 3.5, 3.5)
|
||||
|
||||
|
||||
@_glyph("lock")
|
||||
def _lock(p: QPainter, w: float) -> None:
|
||||
p.drawRoundedRect(QRectF(4.5, 10.5, 15.0, 10.0), 2.75, 2.75)
|
||||
p.drawArc(QRectF(8.0, 3.75, 8.0, 13.5), 0, 180 * 16)
|
||||
_dot(p, 12.0, 15.5, 1.35)
|
||||
|
||||
|
||||
@_glyph("document", "file")
|
||||
def _document(p: QPainter, w: float) -> None:
|
||||
_page(p)
|
||||
_line(p, 8.5, 12.75, 15.5, 12.75)
|
||||
_line(p, 8.5, 16.75, 13.25, 16.75)
|
||||
|
||||
|
||||
@_glyph("report")
|
||||
def _report(p: QPainter, w: float) -> None:
|
||||
_page(p)
|
||||
_line(p, 8.75, 17.75, 8.75, 14.25)
|
||||
_line(p, 12.0, 17.75, 12.0, 10.5)
|
||||
_line(p, 15.25, 17.75, 15.25, 12.75)
|
||||
|
||||
|
||||
@_glyph("list")
|
||||
def _list(p: QPainter, w: float) -> None:
|
||||
for y in (6.5, 12.0, 17.5):
|
||||
_dot(p, 4.5, y, 1.2)
|
||||
_line(p, 8.75, y, 19.5, y)
|
||||
|
||||
|
||||
@_glyph("user", "person")
|
||||
def _user(p: QPainter, w: float) -> None:
|
||||
_circle(p, 12.0, 8.0, 4.0)
|
||||
path = QPainterPath(QPointF(4.25, 20.5))
|
||||
path.quadTo(QPointF(4.25, 14.5), QPointF(12.0, 14.5))
|
||||
path.quadTo(QPointF(19.75, 14.5), QPointF(19.75, 20.5))
|
||||
p.drawPath(path)
|
||||
|
||||
|
||||
@_glyph("remove", "minus_circle")
|
||||
def _remove(p: QPainter, w: float) -> None:
|
||||
_circle(p, 12.0, 12.0, 8.75)
|
||||
_line(p, 8.0, 12.0, 16.0, 12.0)
|
||||
|
||||
|
||||
@_glyph("stop", "close_circle")
|
||||
def _stop(p: QPainter, w: float) -> None:
|
||||
_circle(p, 12.0, 12.0, 8.75)
|
||||
_line(p, 9.0, 9.0, 15.0, 15.0)
|
||||
_line(p, 15.0, 9.0, 9.0, 15.0)
|
||||
|
||||
|
||||
@_glyph("info")
|
||||
def _info(p: QPainter, w: float) -> None:
|
||||
_circle(p, 12.0, 12.0, 8.75)
|
||||
_line(p, 12.0, 11.25, 12.0, 16.5)
|
||||
_dot(p, 12.0, 7.75, 1.0)
|
||||
|
||||
|
||||
@_glyph("picture", "image")
|
||||
def _picture(p: QPainter, w: float) -> None:
|
||||
p.drawRoundedRect(QRectF(3.0, 4.5, 18.0, 15.0), 3.0, 3.0)
|
||||
_circle(p, 7.75, 8.75, 1.6)
|
||||
_polyline(p, (3.5, 17.75), (9.75, 12.25), (13.25, 15.5), (15.75, 13.25), (20.5, 17.75))
|
||||
|
||||
|
||||
@_glyph("meds", "pill")
|
||||
def _meds(p: QPainter, w: float) -> None:
|
||||
p.save()
|
||||
p.translate(12.0, 12.0)
|
||||
p.rotate(-45.0)
|
||||
p.drawRoundedRect(QRectF(-9.25, -4.5, 18.5, 9.0), 4.5, 4.5)
|
||||
_line(p, 0.0, -4.5, 0.0, 4.5)
|
||||
p.restore()
|
||||
|
||||
|
||||
@_glyph("daily", "clipboard")
|
||||
def _clipboard(p: QPainter, w: float) -> None:
|
||||
path = QPainterPath(QPointF(8.5, 4.5))
|
||||
path.lineTo(QPointF(6.75, 4.5))
|
||||
path.quadTo(QPointF(4.25, 4.5), QPointF(4.25, 7.0))
|
||||
path.lineTo(QPointF(4.25, 19.0))
|
||||
path.quadTo(QPointF(4.25, 21.5), QPointF(6.75, 21.5))
|
||||
path.lineTo(QPointF(17.25, 21.5))
|
||||
path.quadTo(QPointF(19.75, 21.5), QPointF(19.75, 19.0))
|
||||
path.lineTo(QPointF(19.75, 7.0))
|
||||
path.quadTo(QPointF(19.75, 4.5), QPointF(17.25, 4.5))
|
||||
path.lineTo(QPointF(15.5, 4.5))
|
||||
p.drawPath(path)
|
||||
p.drawRoundedRect(QRectF(8.5, 2.5, 7.0, 4.0), 1.5, 1.5)
|
||||
_line(p, 8.0, 12.0, 16.0, 12.0)
|
||||
_line(p, 8.0, 16.25, 13.5, 16.25)
|
||||
|
||||
|
||||
@_glyph("followup", "calendar_clock")
|
||||
def _followup(p: QPainter, w: float) -> None:
|
||||
path = QPainterPath(QPointF(13.0, 20.0))
|
||||
path.lineTo(QPointF(5.5, 20.0))
|
||||
path.quadTo(QPointF(3.0, 20.0), QPointF(3.0, 17.5))
|
||||
path.lineTo(QPointF(3.0, 7.5))
|
||||
path.quadTo(QPointF(3.0, 5.0), QPointF(5.5, 5.0))
|
||||
path.lineTo(QPointF(14.5, 5.0))
|
||||
path.quadTo(QPointF(17.0, 5.0), QPointF(17.0, 7.5))
|
||||
path.lineTo(QPointF(17.0, 9.0))
|
||||
p.drawPath(path)
|
||||
_line(p, 3.0, 9.5, 17.0, 9.5)
|
||||
_line(p, 7.0, 2.75, 7.0, 6.75)
|
||||
_line(p, 13.0, 2.75, 13.0, 6.75)
|
||||
_circle(p, 16.75, 16.75, 4.5)
|
||||
_polyline(p, (16.75, 14.25), (16.75, 16.75), (18.9, 16.75))
|
||||
|
||||
|
||||
@_glyph("brand", "logo")
|
||||
def _brand(p: QPainter, w: float) -> None:
|
||||
_circle(p, 12.0, 12.0, 8.75)
|
||||
_polyline(p, (7.0, 12.0), (10.0, 12.0), (11.5, 8.5), (13.5, 15.5), (15.0, 12.0), (17.0, 12.0))
|
||||
|
||||
|
||||
# --- AI consultation ------------------------------------------------------
|
||||
|
||||
|
||||
@_glyph("chart", "analytics")
|
||||
def _analytics(p: QPainter, w: float) -> None:
|
||||
_polyline(p, (3.5, 3.0), (3.5, 20.5), (21.0, 20.5))
|
||||
_polyline(p, (7.0, 16.5), (10.5, 8.5), (14.0, 13.0), (20.0, 5.5))
|
||||
|
||||
|
||||
@_glyph("trend")
|
||||
def _trend(p: QPainter, w: float) -> None:
|
||||
_polyline(p, (3.0, 18.0), (8.5, 9.5), (12.5, 13.5), (21.0, 5.5))
|
||||
_polyline(p, (15.5, 5.5), (21.0, 5.5), (21.0, 11.0))
|
||||
|
||||
|
||||
@_glyph("alert", "warning")
|
||||
def _alert(p: QPainter, w: float) -> None:
|
||||
path = QPainterPath(QPointF(12.0, 3.0))
|
||||
path.lineTo(QPointF(21.5, 20.0))
|
||||
path.lineTo(QPointF(2.5, 20.0))
|
||||
path.closeSubpath()
|
||||
p.drawPath(path)
|
||||
_line(p, 12.0, 9.5, 12.0, 14.5)
|
||||
_dot(p, 12.0, 17.4, 1.05)
|
||||
|
||||
|
||||
@_glyph("mic", "microphone")
|
||||
def _mic(p: QPainter, w: float) -> None:
|
||||
p.drawRoundedRect(QRectF(8.5, 2.5, 7.0, 12.0), 3.5, 3.5)
|
||||
p.drawArc(QRectF(5.0, 6.0, 14.0, 14.0), 0, -180 * 16)
|
||||
_line(p, 12.0, 17.5, 12.0, 21.0)
|
||||
_line(p, 8.25, 21.0, 15.75, 21.0)
|
||||
|
||||
|
||||
@_glyph("send")
|
||||
def _send(p: QPainter, w: float) -> None:
|
||||
path = QPainterPath(QPointF(21.0, 3.0))
|
||||
path.lineTo(QPointF(2.5, 10.5))
|
||||
path.lineTo(QPointF(10.25, 13.75))
|
||||
path.lineTo(QPointF(13.5, 21.5))
|
||||
path.closeSubpath()
|
||||
p.drawPath(path)
|
||||
_line(p, 10.25, 13.75, 21.0, 3.0)
|
||||
|
||||
|
||||
@_glyph("qr", "qrcode")
|
||||
def _qr(p: QPainter, w: float) -> None:
|
||||
"""Three finder squares plus a few modules - the shape people scan for."""
|
||||
|
||||
for x, y in ((3.0, 3.0), (14.0, 3.0), (3.0, 14.0)):
|
||||
p.drawRoundedRect(QRectF(x, y, 7.0, 7.0), 1.5, 1.5)
|
||||
_dot(p, x + 3.5, y + 3.5, 1.15)
|
||||
_line(p, 14.5, 14.5, 14.5, 17.0)
|
||||
_line(p, 18.0, 14.5, 21.0, 14.5)
|
||||
_line(p, 17.5, 18.0, 17.5, 21.0)
|
||||
_dot(p, 20.75, 20.75, 1.15)
|
||||
|
||||
|
||||
@_glyph("video", "call")
|
||||
def _video(p: QPainter, w: float) -> None:
|
||||
p.drawRoundedRect(QRectF(2.5, 6.0, 13.5, 12.0), 3.0, 3.0)
|
||||
path = QPainterPath(QPointF(16.0, 10.5))
|
||||
path.lineTo(QPointF(21.5, 7.25))
|
||||
path.lineTo(QPointF(21.5, 16.75))
|
||||
path.lineTo(QPointF(16.0, 13.5))
|
||||
path.closeSubpath()
|
||||
p.drawPath(path)
|
||||
|
||||
|
||||
# --- Clinical measures ----------------------------------------------------
|
||||
# The vital-sign tiles and the lifestyle row used to carry two more bespoke
|
||||
# painters (22 px / 1.35 px stroke and 16 px / 1.3 px stroke). Beyond the extra
|
||||
# weights, two of their glyphs were simply wrong: "weight" read as a padlock and
|
||||
# "BMI" as the Venus symbol.
|
||||
|
||||
|
||||
@_glyph("height")
|
||||
def _height(p: QPainter, w: float) -> None:
|
||||
_line(p, 6.0, 3.5, 18.0, 3.5)
|
||||
_line(p, 6.0, 20.5, 18.0, 20.5)
|
||||
_line(p, 12.0, 5.75, 12.0, 18.25)
|
||||
_polyline(p, (9.5, 8.25), (12.0, 5.75), (14.5, 8.25))
|
||||
_polyline(p, (9.5, 15.75), (12.0, 18.25), (14.5, 15.75))
|
||||
|
||||
|
||||
@_glyph("weight", "scale")
|
||||
def _weight(p: QPainter, w: float) -> None:
|
||||
p.drawRoundedRect(QRectF(3.0, 5.0, 18.0, 14.5), 3.5, 3.5)
|
||||
p.drawArc(QRectF(7.0, 10.0, 10.0, 10.0), 25 * 16, 130 * 16)
|
||||
_line(p, 12.0, 15.0, 9.9, 10.9)
|
||||
|
||||
|
||||
@_glyph("bmi", "body")
|
||||
def _bmi(p: QPainter, w: float) -> None:
|
||||
_circle(p, 12.0, 5.0, 2.75)
|
||||
_line(p, 12.0, 7.75, 12.0, 15.0)
|
||||
_line(p, 7.25, 11.0, 16.75, 11.0)
|
||||
_polyline(p, (8.5, 20.75), (12.0, 15.0), (15.5, 20.75))
|
||||
|
||||
|
||||
@_glyph("blood_pressure", "gauge", "bp")
|
||||
def _blood_pressure(p: QPainter, w: float) -> None:
|
||||
p.drawArc(QRectF(3.0, 5.5, 18.0, 18.0), 0, 180 * 16)
|
||||
_line(p, 3.0, 14.5, 21.0, 14.5)
|
||||
_line(p, 12.0, 14.5, 16.4, 9.4)
|
||||
_dot(p, 12.0, 14.5, 1.15)
|
||||
|
||||
|
||||
@_glyph("pulse", "heart")
|
||||
def _pulse(p: QPainter, w: float) -> None:
|
||||
heart = QPainterPath(QPointF(12.0, 20.25))
|
||||
heart.cubicTo(QPointF(3.2, 13.6), QPointF(2.2, 9.6), QPointF(4.7, 6.7))
|
||||
heart.cubicTo(QPointF(7.2, 4.0), QPointF(10.5, 4.8), QPointF(12.0, 7.7))
|
||||
heart.cubicTo(QPointF(13.5, 4.8), QPointF(16.8, 4.0), QPointF(19.3, 6.7))
|
||||
heart.cubicTo(QPointF(21.8, 9.6), QPointF(20.8, 13.6), QPointF(12.0, 20.25))
|
||||
heart.closeSubpath()
|
||||
p.drawPath(heart)
|
||||
_polyline(
|
||||
p, (5.6, 12.4), (9.0, 12.4), (10.6, 9.7), (13.2, 15.1), (14.7, 12.4), (18.4, 12.4)
|
||||
)
|
||||
|
||||
|
||||
@_glyph("smoke", "cigarette")
|
||||
def _smoke(p: QPainter, w: float) -> None:
|
||||
p.drawRoundedRect(QRectF(2.5, 14.0, 14.0, 5.0), 1.75, 1.75)
|
||||
_line(p, 13.0, 14.0, 13.0, 19.0)
|
||||
curl = QPainterPath(QPointF(19.0, 12.0))
|
||||
curl.quadTo(QPointF(21.5, 9.5), QPointF(19.0, 7.5))
|
||||
curl.quadTo(QPointF(16.5, 5.5), QPointF(19.0, 3.5))
|
||||
p.drawPath(curl)
|
||||
|
||||
|
||||
@_glyph("drink", "glass")
|
||||
def _drink(p: QPainter, w: float) -> None:
|
||||
bowl = QPainterPath(QPointF(6.5, 3.5))
|
||||
bowl.lineTo(QPointF(17.5, 3.5))
|
||||
bowl.lineTo(QPointF(13.75, 12.5))
|
||||
bowl.lineTo(QPointF(10.25, 12.5))
|
||||
bowl.closeSubpath()
|
||||
p.drawPath(bowl)
|
||||
_line(p, 7.75, 7.5, 16.25, 7.5)
|
||||
_line(p, 12.0, 12.5, 12.0, 20.0)
|
||||
_line(p, 8.0, 20.0, 16.0, 20.0)
|
||||
|
||||
|
||||
@_glyph("exercise", "run")
|
||||
def _exercise(p: QPainter, w: float) -> None:
|
||||
_circle(p, 15.75, 4.75, 2.5)
|
||||
_polyline(p, (14.5, 9.0), (9.75, 12.5), (6.0, 20.5))
|
||||
_polyline(p, (14.5, 9.0), (18.75, 12.75), (21.0, 10.75))
|
||||
_polyline(p, (11.75, 11.0), (15.25, 16.0), (13.25, 20.75))
|
||||
|
||||
|
||||
# --- Painting -------------------------------------------------------------
|
||||
|
||||
|
||||
def _device_ratio() -> float:
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
return 1.0
|
||||
screen = app.primaryScreen()
|
||||
if screen is None:
|
||||
return 1.0
|
||||
return max(1.0, float(screen.devicePixelRatio()))
|
||||
|
||||
|
||||
def _render(kind: str, color: str, size: int) -> QPixmap:
|
||||
canvas = crisp_pixmap(size)
|
||||
draw = _GLYPHS.get(kind)
|
||||
if draw is None:
|
||||
return canvas
|
||||
painter = QPainter(canvas)
|
||||
try:
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
# Inset the grid by half a stroke on every edge. Without it a glyph that
|
||||
# legitimately reaches grid unit 24 loses the outer half of its line to
|
||||
# the pixmap boundary at the smaller sizes - which is exactly how the old
|
||||
# painters lost the shell star's companion dot and flattened the top of
|
||||
# the calendar. Insetting here means every glyph can use the full grid.
|
||||
weight = stroke_px(size)
|
||||
scale = (size - weight) / GRID
|
||||
painter.translate(weight / 2.0, weight / 2.0)
|
||||
painter.scale(scale, scale)
|
||||
# The pen width is expressed on the design grid, so the on-screen weight
|
||||
# stays the same fraction of the box at every size the shell asks for.
|
||||
width = weight / scale
|
||||
pen = QPen(QColor(color), width)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
draw(painter, width)
|
||||
finally:
|
||||
painter.end()
|
||||
return canvas
|
||||
|
||||
|
||||
@lru_cache(maxsize=1024)
|
||||
def _cached_pixmap(kind: str, color: str, size: int, ratio: float) -> QPixmap:
|
||||
del ratio # part of the cache key only; crisp_pixmap reads it back itself
|
||||
return _render(kind, color, size)
|
||||
|
||||
|
||||
def pixmap(kind: str, color: str = "default", size: int = 18) -> QPixmap:
|
||||
"""Return a cached, device-pixel-correct pixmap for ``kind``."""
|
||||
|
||||
return _cached_pixmap(kind, resolve_color(color), int(size), _device_ratio())
|
||||
|
||||
|
||||
@lru_cache(maxsize=1024)
|
||||
def _cached_icon(kind: str, color: str, size: int, ratio: float) -> QIcon:
|
||||
result = QIcon(_cached_pixmap(kind, color, size, ratio))
|
||||
result.addPixmap(
|
||||
_cached_pixmap(kind, ROLES["disabled"], size, ratio),
|
||||
QIcon.Mode.Disabled,
|
||||
QIcon.State.Off,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def icon(kind: str, color: str = "default", size: int = 18) -> QIcon:
|
||||
"""Return a cached icon with a matching disabled variant already attached.
|
||||
|
||||
``color`` accepts a role name from :data:`ROLES` or a literal colour.
|
||||
"""
|
||||
|
||||
return _cached_icon(kind, resolve_color(color), int(size), _device_ratio())
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def _cached_state_icon(
|
||||
kind: str,
|
||||
size: int,
|
||||
normal: str,
|
||||
active: str,
|
||||
checked: str,
|
||||
disabled: str,
|
||||
ratio: float,
|
||||
) -> QIcon:
|
||||
result = QIcon()
|
||||
result.addPixmap(_cached_pixmap(kind, normal, size, ratio), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
result.addPixmap(_cached_pixmap(kind, checked, size, ratio), QIcon.Mode.Normal, QIcon.State.On)
|
||||
result.addPixmap(_cached_pixmap(kind, active, size, ratio), QIcon.Mode.Active, QIcon.State.Off)
|
||||
result.addPixmap(_cached_pixmap(kind, checked, size, ratio), QIcon.Mode.Active, QIcon.State.On)
|
||||
result.addPixmap(
|
||||
_cached_pixmap(kind, disabled, size, ratio), QIcon.Mode.Disabled, QIcon.State.Off
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def state_icon(
|
||||
kind: str,
|
||||
*,
|
||||
size: int = 18,
|
||||
normal: str = "muted",
|
||||
active: str = "strong",
|
||||
checked: str = "inverse",
|
||||
disabled: str = "disabled",
|
||||
) -> QIcon:
|
||||
"""Return an icon carrying its own hover / selected / disabled colours.
|
||||
|
||||
Qt only tints an icon when a widget asks it to, so a single-pixmap icon on a
|
||||
selected navigation row keeps its resting grey and reads as switched off.
|
||||
"""
|
||||
|
||||
return _cached_state_icon(
|
||||
kind,
|
||||
int(size),
|
||||
resolve_color(normal),
|
||||
resolve_color(active),
|
||||
resolve_color(checked),
|
||||
resolve_color(disabled),
|
||||
_device_ratio(),
|
||||
)
|
||||
|
||||
|
||||
def available_kinds() -> tuple[str, ...]:
|
||||
"""Every glyph name this module answers to, aliases included."""
|
||||
|
||||
return tuple(sorted(_GLYPHS))
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""Drop cached pixmaps - used when the display scale factor changes."""
|
||||
|
||||
_cached_pixmap.cache_clear()
|
||||
_cached_icon.cache_clear()
|
||||
_cached_state_icon.cache_clear()
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Incremental server lists with a compact status footer and stable view state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QEvent, QSignalBlocker, Qt, QTimer
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractScrollArea,
|
||||
QCheckBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QTableWidget,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .widgets import first_value, get_value, page_items, page_total, run_async
|
||||
|
||||
|
||||
def record_key(row: Any) -> str:
|
||||
value = first_value(
|
||||
row, "id", "prescription_id", "appointment_id", "diagnosis_id", "order_id", "patient_id"
|
||||
)
|
||||
return str(value) if value is not None else repr(row)
|
||||
|
||||
|
||||
class ListSnapshot:
|
||||
"""Keep repository metadata available while replacing only the list payload."""
|
||||
|
||||
def __init__(self, rows: list[Any], total: int, source: Any) -> None:
|
||||
self.items = rows
|
||||
self.total = total
|
||||
self.source = source
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return get_value(self.source, name, None)
|
||||
|
||||
|
||||
class InfiniteList(QWidget):
|
||||
"""Bind to a scrolling view; fetch pages only as the visible list needs them.
|
||||
|
||||
Reloads use captured query arguments. Refreshing the same query rebuilds the
|
||||
loaded prefix atomically, so polling neither drops appended rows nor mixes
|
||||
an updated first page with an old tail. Failed appends retain the prior page
|
||||
and can be retried explicitly without an automatic request loop.
|
||||
"""
|
||||
|
||||
def __init__(self, page_size: int = 20, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("InfiniteList")
|
||||
self.setFixedHeight(24)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(12, 0, 12, 0)
|
||||
self.summary_label = QLabel("", self)
|
||||
self.summary_label.setStyleSheet(
|
||||
"color: #5D6B80; font-size: 12px; background: transparent;"
|
||||
)
|
||||
layout.addWidget(self.summary_label)
|
||||
layout.addStretch(1)
|
||||
self.retry_button = QPushButton("加载失败,点击重试", self)
|
||||
self.retry_button.setFlat(True)
|
||||
self.retry_button.setStyleSheet(
|
||||
"color: #1769E8; font-size: 12px; padding: 0 4px; border: none; background: transparent; min-height: 20px; max-height: 20px; min-width: 0;"
|
||||
)
|
||||
self.retry_button.hide()
|
||||
self.retry_button.clicked.connect(self.retry)
|
||||
layout.addWidget(self.retry_button)
|
||||
self.page_size = page_size
|
||||
self.page = 0
|
||||
self.total = 0
|
||||
self.rows: list[Any] = []
|
||||
self.loading = False
|
||||
self.has_more = False
|
||||
self._generation = 0
|
||||
self._query_key: Any = object()
|
||||
self._view: QAbstractScrollArea | None = None
|
||||
self._views: list[QAbstractScrollArea] = []
|
||||
self._error = False
|
||||
self._configured = False
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setSingleShot(True)
|
||||
self._timer.timeout.connect(self._maybe_load_more)
|
||||
|
||||
def bind(self, view: QAbstractScrollArea) -> None:
|
||||
if view in self._views:
|
||||
return
|
||||
self._views.append(view)
|
||||
self._view = view
|
||||
view.verticalScrollBar().valueChanged.connect(self._schedule_check)
|
||||
view.verticalScrollBar().rangeChanged.connect(self._schedule_check)
|
||||
view.viewport().installEventFilter(self)
|
||||
|
||||
def eventFilter(self, watched: Any, event: Any) -> bool:
|
||||
if event.type() in (QEvent.Type.Show, QEvent.Type.Resize):
|
||||
self._schedule_check()
|
||||
return super().eventFilter(watched, event)
|
||||
|
||||
def _schedule_check(self, *_: Any) -> None:
|
||||
self._timer.start(30)
|
||||
|
||||
def _maybe_load_more(self) -> None:
|
||||
view = next((v for v in self._views if v.isVisible()), None)
|
||||
if view is None or self.loading or self._error or not self.has_more:
|
||||
return
|
||||
bar = view.verticalScrollBar()
|
||||
# pageStep respects both per-item and per-pixel Qt scrolling modes.
|
||||
if bar.maximum() - bar.value() <= max(1, bar.pageStep() // 4):
|
||||
self.load_more()
|
||||
|
||||
def invalidate(self) -> None:
|
||||
"""Disarm callbacks when a reusable dialog switches to another record."""
|
||||
self._generation += 1
|
||||
self._configured = False
|
||||
self.loading = self.has_more = self._error = False
|
||||
self.rows, self.page, self.total = [], 0, 0
|
||||
self._timer.stop()
|
||||
self.retry_button.hide()
|
||||
self._status()
|
||||
|
||||
reset = invalidate
|
||||
|
||||
def reload(
|
||||
self,
|
||||
fetch: Callable[[int], Any],
|
||||
apply: Callable[[Any], None],
|
||||
on_error: Callable[[Exception], None],
|
||||
*,
|
||||
runner: Callable[..., Any] = run_async,
|
||||
query_key: Any = None,
|
||||
on_finished: Callable[[], None] | None = None,
|
||||
) -> None:
|
||||
same_query = self._configured and query_key == self._query_key
|
||||
if same_query and self.loading:
|
||||
# Polling must not restart a slow prefix refresh indefinitely. The
|
||||
# caller may have advanced its own generation, so use its latest
|
||||
# render/error closures while the captured request finishes.
|
||||
self._apply, self._on_error = apply, on_error
|
||||
self._on_finished = on_finished
|
||||
return
|
||||
self._generation += 1
|
||||
self._query_key = query_key
|
||||
self._configured = True
|
||||
self._fetch, self._apply, self._on_error = fetch, apply, on_error
|
||||
self._runner, self._on_finished = runner, on_finished
|
||||
self._target = max(1, self.page) if same_query else 1
|
||||
self._reset_view = not same_query
|
||||
if not same_query:
|
||||
self.rows, self.page, self.total = [], 0, 0
|
||||
self.has_more = False
|
||||
self._render(ListSnapshot([], 0, None), preserve=False)
|
||||
self._begin(1, [], refresh=True)
|
||||
|
||||
def load_more(self) -> None:
|
||||
if not self._configured or self.loading or self._error or not self.has_more:
|
||||
return
|
||||
self._reset_view = False
|
||||
self._target = self.page + 1
|
||||
self._begin(self.page + 1, list(self.rows), refresh=False)
|
||||
|
||||
def retry(self) -> None:
|
||||
if self.loading or not self._error:
|
||||
return
|
||||
self._begin(self._failed_page, list(self._failed_rows), refresh=self._failed_refresh)
|
||||
|
||||
def _begin(self, page: int, rows: list[Any], *, refresh: bool) -> None:
|
||||
self.loading = True
|
||||
self._error = False
|
||||
self.retry_button.hide()
|
||||
self.summary_label.setText(
|
||||
f"已加载 {len(self.rows)} 条 · 正在加载…" if self.rows else "正在加载…"
|
||||
)
|
||||
generation = self._generation
|
||||
fetch = self._fetch
|
||||
self._runner(
|
||||
lambda: fetch(page),
|
||||
on_success=lambda result: self._received(result, generation, page, rows, refresh),
|
||||
on_error=lambda error: self._failed(error, generation, page, rows, refresh),
|
||||
on_finished=lambda: None,
|
||||
)
|
||||
|
||||
def _received(
|
||||
self, result: Any, generation: int, page: int, prior: list[Any], refresh: bool
|
||||
) -> None:
|
||||
if generation != self._generation:
|
||||
return
|
||||
incoming = page_items(result)
|
||||
merged = {record_key(row): row for row in prior}
|
||||
before = len(merged)
|
||||
for row in incoming:
|
||||
merged[record_key(row)] = row
|
||||
rows = list(merged.values())
|
||||
total = page_total(result, -1)
|
||||
more = (
|
||||
bool(incoming)
|
||||
and len(rows) > before
|
||||
and (len(rows) < total if total >= 0 else len(incoming) >= self.page_size)
|
||||
)
|
||||
# Retain first-page metadata (scope, counts, filter choices) on refresh.
|
||||
if page == 1:
|
||||
self._refresh_source = result
|
||||
source = self._refresh_source
|
||||
if refresh and page < self._target and more:
|
||||
self._begin(page + 1, rows, refresh=True)
|
||||
return
|
||||
self.rows, self.page = rows, page
|
||||
self.total = max(len(rows), total)
|
||||
self.has_more = more
|
||||
self._render(ListSnapshot(rows, self.total, source), preserve=not self._reset_view)
|
||||
self.loading = False
|
||||
self._status()
|
||||
if self._on_finished is not None:
|
||||
self._on_finished()
|
||||
self._schedule_check()
|
||||
|
||||
def _failed(
|
||||
self, error: Exception, generation: int, page: int, rows: list[Any], refresh: bool
|
||||
) -> None:
|
||||
if generation != self._generation:
|
||||
return
|
||||
self.loading = False
|
||||
self._error = True
|
||||
self._failed_page, self._failed_rows, self._failed_refresh = page, rows, refresh
|
||||
self.summary_label.setText(f"已加载 {len(self.rows)} 条" if self.rows else "暂未加载数据")
|
||||
self.retry_button.show()
|
||||
self._on_error(error)
|
||||
if self._on_finished is not None:
|
||||
self._on_finished()
|
||||
|
||||
def _status(self) -> None:
|
||||
if self.has_more:
|
||||
self.summary_label.setText(f"已加载 {len(self.rows)} / {self.total} 条 · 下拉加载更多")
|
||||
else:
|
||||
if self.total > len(self.rows):
|
||||
self.summary_label.setText(
|
||||
f"已加载 {len(self.rows)} / {self.total} 条 · 暂无更多数据"
|
||||
)
|
||||
else:
|
||||
self.summary_label.setText(
|
||||
f"共 {len(self.rows)} 条 · 已全部加载" if self.rows else "暂无数据"
|
||||
)
|
||||
|
||||
def update_state(self, page: int, total: int) -> None:
|
||||
"""Compatibility for existing render callbacks; requests own the state."""
|
||||
del page, total
|
||||
self._status()
|
||||
|
||||
def _render(self, snapshot: ListSnapshot, *, preserve: bool) -> None:
|
||||
view = next((v for v in self._views if v.isVisible()), self._view)
|
||||
if view is None:
|
||||
self._apply(snapshot)
|
||||
return
|
||||
bar = view.verticalScrollBar()
|
||||
scroll = bar.value()
|
||||
horizontal_scroll = view.horizontalScrollBar().value()
|
||||
selected: set[str] = set()
|
||||
checks: dict[tuple[str, int], Qt.CheckState] = {}
|
||||
widget_checks: dict[tuple[str, int, int], bool] = {}
|
||||
if preserve and isinstance(view, QTableWidget):
|
||||
for row in range(view.rowCount()):
|
||||
first = view.item(row, 0)
|
||||
if first is None:
|
||||
continue
|
||||
key = record_key(first.data(Qt.ItemDataRole.UserRole))
|
||||
if first.isSelected():
|
||||
selected.add(key)
|
||||
for column in range(view.columnCount()):
|
||||
item = view.item(row, column)
|
||||
if (
|
||||
item is not None
|
||||
and item.flags() & Qt.ItemFlag.ItemIsUserCheckable
|
||||
and item.data(Qt.ItemDataRole.CheckStateRole) is not None
|
||||
):
|
||||
checks[key, column] = item.checkState()
|
||||
widget = view.cellWidget(row, column)
|
||||
if widget is not None:
|
||||
boxes = (
|
||||
[widget]
|
||||
if isinstance(widget, QCheckBox)
|
||||
else widget.findChildren(QCheckBox)
|
||||
)
|
||||
for index, box in enumerate(boxes):
|
||||
widget_checks[key, column, index] = box.isChecked()
|
||||
blocker = QSignalBlocker(view)
|
||||
try:
|
||||
self._apply(snapshot)
|
||||
if preserve and isinstance(view, QTableWidget):
|
||||
if selected:
|
||||
view.clearSelection()
|
||||
for row in range(view.rowCount()):
|
||||
first = view.item(row, 0)
|
||||
if first is None:
|
||||
continue
|
||||
key = record_key(first.data(Qt.ItemDataRole.UserRole))
|
||||
if key in selected:
|
||||
view.selectRow(row)
|
||||
for column in range(view.columnCount()):
|
||||
item = view.item(row, column)
|
||||
if item is not None and (key, column) in checks:
|
||||
item.setCheckState(checks[key, column])
|
||||
widget = view.cellWidget(row, column)
|
||||
if widget is not None:
|
||||
boxes = (
|
||||
[widget]
|
||||
if isinstance(widget, QCheckBox)
|
||||
else widget.findChildren(QCheckBox)
|
||||
)
|
||||
for index, box in enumerate(boxes):
|
||||
if (key, column, index) in widget_checks:
|
||||
box.setChecked(widget_checks[key, column, index])
|
||||
bar.setValue(min(scroll, bar.maximum()) if preserve else bar.minimum())
|
||||
view.horizontalScrollBar().setValue(horizontal_scroll)
|
||||
finally:
|
||||
blocker.unblock()
|
||||
if isinstance(view, QTableWidget):
|
||||
view.itemSelectionChanged.emit()
|
||||
|
||||
|
||||
__all__ = ["InfiniteList", "ListSnapshot"]
|
||||
@@ -0,0 +1,364 @@
|
||||
"""Motion tokens and helpers.
|
||||
|
||||
The product had two `QGraphicsOpacityEffect` uses and no `QPropertyAnimation`
|
||||
at all, so every state change was an instant cut: pages replaced each other
|
||||
between one frame and the next, drawers appeared fully formed, toasts blinked
|
||||
in and out. Nothing was slow - it just gave the eye no continuity to follow,
|
||||
which is what reads as "not smooth" however fast the code underneath is.
|
||||
|
||||
Everything here is short. A workstation is used all day, so transitions are
|
||||
tuned to be felt rather than watched: 110-260 ms, ease-out on entry, and travel
|
||||
measured in single-digit pixels. Anything longer starts costing the user time.
|
||||
|
||||
Qt stylesheets have no `transition` property, so this is `QPropertyAnimation`
|
||||
throughout. Two rules keep that safe:
|
||||
|
||||
* an animation must be owned, or PySide garbage-collects it mid-flight and the
|
||||
widget freezes half-faded - :func:`_own` parks it on the target;
|
||||
* a `QGraphicsOpacityEffect` forces the whole widget subtree through an
|
||||
offscreen render path, which would make a table scroll badly for the rest of
|
||||
the session - every fade here removes its effect when it finishes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import (
|
||||
QAbstractAnimation,
|
||||
QEasingCurve,
|
||||
QEvent,
|
||||
QObject,
|
||||
QPoint,
|
||||
QPropertyAnimation,
|
||||
Qt,
|
||||
QTimer,
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractScrollArea,
|
||||
QGraphicsOpacityEffect,
|
||||
QStackedWidget,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
#: Durations in milliseconds.
|
||||
FAST = 110 # hover-scale feedback, small fades
|
||||
BASE = 170 # the default: page and panel transitions
|
||||
SLOW = 260 # large travel, e.g. a drawer crossing the workspace
|
||||
|
||||
#: Entering elements decelerate; elements that move between two known places
|
||||
#: ease in and out; large travel gets a longer tail so it never looks linear.
|
||||
EASE_ENTER = QEasingCurve.Type.OutCubic
|
||||
EASE_MOVE = QEasingCurve.Type.InOutCubic
|
||||
EASE_TRAVEL = QEasingCurve.Type.OutQuint
|
||||
|
||||
#: How far an entering surface rises, in device-independent pixels. Kept small
|
||||
#: on purpose: a page that slides a long way reads as a slideshow, not an app.
|
||||
RISE = 8
|
||||
|
||||
|
||||
def reduced_motion() -> bool:
|
||||
"""Whether animation should be skipped entirely.
|
||||
|
||||
Off by default under the offscreen platform so widget grabs in tests and in
|
||||
the packaging smoke checks capture a settled frame rather than a frame from
|
||||
the middle of a fade. ``DOCTOR_MOTION=on`` / ``off`` overrides either way.
|
||||
"""
|
||||
|
||||
override = os.getenv("DOCTOR_MOTION", "").strip().lower()
|
||||
if override in {"off", "0", "false", "none", "reduce"}:
|
||||
return True
|
||||
if override in {"on", "1", "true", "full"}:
|
||||
return False
|
||||
return os.getenv("QT_QPA_PLATFORM", "").strip().lower() == "offscreen"
|
||||
|
||||
|
||||
def _own(target: QWidget, key: str, animation: QPropertyAnimation) -> QPropertyAnimation:
|
||||
"""Park an animation on its target so Python does not collect it early."""
|
||||
|
||||
running: dict[str, QPropertyAnimation] = getattr(target, "_doctor_motion", None) or {}
|
||||
previous = running.get(key)
|
||||
if previous is not None:
|
||||
previous.stop()
|
||||
running[key] = animation
|
||||
target._doctor_motion = running
|
||||
return animation
|
||||
|
||||
|
||||
def animate(
|
||||
target: Any,
|
||||
prop: bytes,
|
||||
start: Any,
|
||||
end: Any,
|
||||
*,
|
||||
duration: int = BASE,
|
||||
easing: QEasingCurve.Type = EASE_ENTER,
|
||||
key: str | None = None,
|
||||
owner: QWidget | None = None,
|
||||
on_finished: Callable[[], None] | None = None,
|
||||
) -> QPropertyAnimation | None:
|
||||
"""Animate one Qt property, or apply the end value outright if motion is off.
|
||||
|
||||
``owner`` keeps the animation alive independently of ``target``. Fades
|
||||
animate a ``QGraphicsOpacityEffect`` that is deleted the moment the fade
|
||||
ends, so parenting the animation to the effect would destroy the animation
|
||||
from inside its own ``finished`` emission.
|
||||
"""
|
||||
|
||||
if reduced_motion():
|
||||
target.setProperty(prop.decode() if isinstance(prop, bytes) else prop, end)
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
return None
|
||||
animation = QPropertyAnimation(target, prop, owner if owner is not None else target)
|
||||
animation.setDuration(duration)
|
||||
animation.setEasingCurve(easing)
|
||||
animation.setStartValue(start)
|
||||
animation.setEndValue(end)
|
||||
if on_finished is not None:
|
||||
animation.finished.connect(on_finished)
|
||||
_own(owner if owner is not None else target, key or prop.decode(), animation)
|
||||
animation.start(QAbstractAnimation.DeletionPolicy.KeepWhenStopped)
|
||||
return animation
|
||||
|
||||
|
||||
def _opacity_effect(widget: QWidget) -> QGraphicsOpacityEffect:
|
||||
effect = widget.graphicsEffect()
|
||||
if not isinstance(effect, QGraphicsOpacityEffect):
|
||||
effect = QGraphicsOpacityEffect(widget)
|
||||
widget.setGraphicsEffect(effect)
|
||||
effect.setEnabled(True)
|
||||
return effect
|
||||
|
||||
|
||||
def _drop_effect(widget: QWidget) -> None:
|
||||
"""Detach the opacity effect once a fade is done.
|
||||
|
||||
Leaving it attached keeps the widget on Qt's offscreen composite path, which
|
||||
is exactly the sort of quiet, permanent frame-rate tax this module exists to
|
||||
avoid introducing.
|
||||
|
||||
The detach is deferred by one event-loop turn on purpose. ``finished`` is
|
||||
emitted from inside the animation, and ``setGraphicsEffect(None)`` deletes
|
||||
the old effect immediately - tearing down the object graph underneath a
|
||||
signal that is still being delivered.
|
||||
"""
|
||||
|
||||
def detach() -> None:
|
||||
try:
|
||||
if isinstance(widget.graphicsEffect(), QGraphicsOpacityEffect):
|
||||
widget.setGraphicsEffect(None)
|
||||
except RuntimeError: # the widget went away while the fade was running
|
||||
pass
|
||||
|
||||
QTimer.singleShot(0, detach)
|
||||
|
||||
|
||||
def fade_in(
|
||||
widget: QWidget,
|
||||
*,
|
||||
duration: int = BASE,
|
||||
start: float = 0.0,
|
||||
easing: QEasingCurve.Type = EASE_ENTER,
|
||||
) -> None:
|
||||
"""Fade a widget up to full opacity, showing it first if needed."""
|
||||
|
||||
if reduced_motion():
|
||||
widget.show()
|
||||
return
|
||||
effect = _opacity_effect(widget)
|
||||
effect.setOpacity(start)
|
||||
widget.show()
|
||||
animate(
|
||||
effect,
|
||||
b"opacity",
|
||||
start,
|
||||
1.0,
|
||||
duration=duration,
|
||||
easing=easing,
|
||||
key="fade",
|
||||
owner=widget,
|
||||
on_finished=lambda: _drop_effect(widget),
|
||||
)
|
||||
|
||||
|
||||
def fade_out(
|
||||
widget: QWidget,
|
||||
*,
|
||||
duration: int = FAST,
|
||||
hide: bool = True,
|
||||
on_finished: Callable[[], None] | None = None,
|
||||
) -> None:
|
||||
"""Fade a widget down, optionally hiding it when the fade completes."""
|
||||
|
||||
if reduced_motion():
|
||||
if hide:
|
||||
widget.hide()
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
return
|
||||
effect = _opacity_effect(widget)
|
||||
|
||||
def done() -> None:
|
||||
if hide:
|
||||
widget.hide()
|
||||
_drop_effect(widget)
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
|
||||
animate(
|
||||
effect,
|
||||
b"opacity",
|
||||
float(effect.opacity()),
|
||||
0.0,
|
||||
duration=duration,
|
||||
easing=EASE_MOVE,
|
||||
key="fade",
|
||||
owner=widget,
|
||||
on_finished=done,
|
||||
)
|
||||
|
||||
|
||||
def enter(widget: QWidget, *, duration: int = BASE, rise: int = RISE) -> None:
|
||||
"""Fade a surface in while it settles upward by a few pixels.
|
||||
|
||||
The rise is what makes a swap read as one surface replacing another rather
|
||||
than as a repaint; keeping it under ten pixels stops it becoming a gesture
|
||||
the user has to wait out.
|
||||
"""
|
||||
|
||||
if reduced_motion():
|
||||
widget.show()
|
||||
return
|
||||
fade_in(widget, duration=duration)
|
||||
if rise:
|
||||
origin = widget.pos()
|
||||
widget.move(origin + QPoint(0, rise))
|
||||
animate(
|
||||
widget,
|
||||
b"pos",
|
||||
widget.pos(),
|
||||
origin,
|
||||
duration=duration,
|
||||
easing=EASE_ENTER,
|
||||
key="enter",
|
||||
)
|
||||
|
||||
|
||||
def switch_stack(stack: QStackedWidget, index: int, *, rise: int = RISE) -> None:
|
||||
"""Change the current page of a stack with a short cross-fade.
|
||||
|
||||
``QStackedWidget`` swaps pages between two frames with nothing in between,
|
||||
which is the single most-seen transition in this product - it happens on
|
||||
every sidebar click and on every list that toggles to its empty state.
|
||||
"""
|
||||
|
||||
if index < 0 or index >= stack.count() or stack.currentIndex() == index:
|
||||
stack.setCurrentIndex(index)
|
||||
return
|
||||
stack.setCurrentIndex(index)
|
||||
page = stack.currentWidget()
|
||||
if page is None or reduced_motion():
|
||||
return
|
||||
enter(page, rise=rise)
|
||||
|
||||
|
||||
# --- Smooth scrolling -----------------------------------------------------
|
||||
|
||||
#: One wheel notch travels this far, and takes this long to get there. Qt's
|
||||
#: default is an instant jump of three lines per notch, which on a long clinical
|
||||
#: record is the single jerkiest thing in the interface.
|
||||
SCROLL_STEP = 120
|
||||
SCROLL_MS = 190
|
||||
|
||||
|
||||
class _SmoothScroller(QObject):
|
||||
"""Animate a scroll area's wheel movement instead of jumping to it."""
|
||||
|
||||
def __init__(self, area: QAbstractScrollArea, *, orientation: Qt.Orientation) -> None:
|
||||
super().__init__(area)
|
||||
self._bar = (
|
||||
area.verticalScrollBar()
|
||||
if orientation is Qt.Orientation.Vertical
|
||||
else area.horizontalScrollBar()
|
||||
)
|
||||
self._target = self._bar.value()
|
||||
self._animation = QPropertyAnimation(self._bar, b"value", self)
|
||||
self._animation.setEasingCurve(EASE_ENTER)
|
||||
self._animation.setDuration(SCROLL_MS)
|
||||
# Keyboard, programmatic and drag movements must not be fought over: when
|
||||
# nothing is animating, the wheel target follows wherever the bar went.
|
||||
self._bar.valueChanged.connect(self._sync_target)
|
||||
area.viewport().installEventFilter(self)
|
||||
|
||||
def _sync_target(self, value: int) -> None:
|
||||
if self._animation.state() != QAbstractAnimation.State.Running:
|
||||
self._target = value
|
||||
|
||||
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802 - Qt API
|
||||
del watched
|
||||
if event.type() is not QEvent.Type.Wheel or reduced_motion():
|
||||
return False
|
||||
delta = event.angleDelta().y() or event.angleDelta().x()
|
||||
if not delta or event.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
||||
return False
|
||||
lower, upper = self._bar.minimum(), self._bar.maximum()
|
||||
# Re-clamp first: the range can shrink underneath a running animation
|
||||
# when the content behind it reloads, which would otherwise leave the
|
||||
# pending target past the end of the new content.
|
||||
self._target = max(lower, min(upper, self._target))
|
||||
target = self._target - round(delta / 120.0 * SCROLL_STEP)
|
||||
target = max(lower, min(upper, target))
|
||||
# At either end, hand the wheel back so an enclosing scroll area still
|
||||
# gets it - swallowing it there is what makes nested panes feel stuck.
|
||||
if target == self._target:
|
||||
return False
|
||||
self._target = target
|
||||
self._animation.stop()
|
||||
self._animation.setStartValue(self._bar.value())
|
||||
self._animation.setEndValue(target)
|
||||
self._animation.start()
|
||||
return True
|
||||
|
||||
|
||||
def install_smooth_scroll(
|
||||
area: QAbstractScrollArea,
|
||||
*,
|
||||
orientation: Qt.Orientation = Qt.Orientation.Vertical,
|
||||
) -> None:
|
||||
"""Give a scroll area eased wheel scrolling."""
|
||||
|
||||
if getattr(area, "_doctor_smooth_scroll", None) is not None:
|
||||
return
|
||||
area._doctor_smooth_scroll = _SmoothScroller(area, orientation=orientation)
|
||||
|
||||
|
||||
def press_feedback(widget: QWidget) -> None:
|
||||
"""Mark a widget so the shared stylesheet can give it a pressed transform.
|
||||
|
||||
Qt has no CSS transitions, so the visual step itself lives in the palette's
|
||||
pressed state; this only tags the widget as one that should get it.
|
||||
"""
|
||||
|
||||
widget.setProperty("motionPress", True)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BASE",
|
||||
"EASE_ENTER",
|
||||
"EASE_MOVE",
|
||||
"EASE_TRAVEL",
|
||||
"FAST",
|
||||
"RISE",
|
||||
"SLOW",
|
||||
"animate",
|
||||
"enter",
|
||||
"fade_in",
|
||||
"fade_out",
|
||||
"install_smooth_scroll",
|
||||
"press_feedback",
|
||||
"reduced_motion",
|
||||
"switch_stack",
|
||||
]
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Scoped surfaces for the approved patient order-management workspace."""
|
||||
|
||||
from string import Template
|
||||
|
||||
from .reception_style import body_family
|
||||
|
||||
|
||||
def patient_orders_stylesheet() -> str:
|
||||
return Template(_ORDERS).substitute(body=body_family())
|
||||
|
||||
|
||||
_ORDERS = """
|
||||
#PatientOrdersWorkspace { background: transparent; color: #273244; }
|
||||
#OrderWorkspaceContent, #OrderWorkspaceScroll { background: transparent; border: 0; }
|
||||
#PatientOrdersWorkspace QLabel, #PatientOrdersWorkspace QPushButton,
|
||||
#PatientOrdersWorkspace QToolButton, #PatientOrdersWorkspace QLineEdit,
|
||||
#PatientOrdersWorkspace QComboBox, #PatientOrdersWorkspace QDateEdit,
|
||||
#PatientOrdersWorkspace QCheckBox, #PatientOrdersWorkspace QTableWidget {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||
}
|
||||
#PatientOrdersWorkspace QLabel[role="muted"] { font-size: 13px; color: #5D6B80; }
|
||||
#PatientOrdersWorkspace QFrame#OrderFilterCard,
|
||||
#PatientOrdersWorkspace QFrame#OrderSummaryStrip,
|
||||
#PatientOrdersWorkspace QFrame#OrderTableCard {
|
||||
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||
}
|
||||
#PatientOrdersWorkspace QPushButton, #PatientOrdersWorkspace QToolButton {
|
||||
min-height: 38px; max-height: 38px; padding: 0 16px; border: 1px solid #DBE5F2;
|
||||
border-radius: 5px; background: #FFFFFF; color: #273244;
|
||||
}
|
||||
#PatientOrdersWorkspace QPushButton:hover, #PatientOrdersWorkspace QToolButton:hover {
|
||||
background: #F2F7FF; border-color: #B6CDEE; color: #1555B6;
|
||||
}
|
||||
#PatientOrdersWorkspace QPushButton:pressed, #PatientOrdersWorkspace QToolButton:pressed { background: #DCEAFF; }
|
||||
#PatientOrdersWorkspace QPushButton:focus, #PatientOrdersWorkspace QToolButton:focus { border-color: #75A5F0; }
|
||||
#PatientOrdersWorkspace QPushButton#OrderSearchButton,
|
||||
#PatientOrdersWorkspace QPushButton#OrderDetailButton {
|
||||
color: #FFFFFF; background: #1769E8; border-color: #1769E8;
|
||||
}
|
||||
#PatientOrdersWorkspace QPushButton#OrderSearchButton:hover,
|
||||
#PatientOrdersWorkspace QPushButton#OrderDetailButton:hover { background: #155BCC; border-color: #155BCC; }
|
||||
#PatientOrdersWorkspace QPushButton:disabled, #PatientOrdersWorkspace QToolButton:disabled {
|
||||
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
|
||||
}
|
||||
#PatientOrdersWorkspace QLineEdit, #PatientOrdersWorkspace QComboBox,
|
||||
#PatientOrdersWorkspace QDateEdit {
|
||||
min-height: 38px; max-height: 38px; padding: 0 10px; font-size: 13px;
|
||||
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 5px;
|
||||
selection-background-color: #DCEAFF; selection-color: #273244;
|
||||
}
|
||||
#PatientOrdersWorkspace QLineEdit:focus, #PatientOrdersWorkspace QComboBox:focus,
|
||||
#PatientOrdersWorkspace QDateEdit:focus { border-color: #75A5F0; }
|
||||
#PatientOrdersWorkspace QLineEdit QToolButton {
|
||||
min-height: 0; max-height: 24px; min-width: 0; border: 0; padding: 0; background: transparent;
|
||||
}
|
||||
#PatientOrdersWorkspace QComboBox { padding-right: 30px; }
|
||||
#PatientOrdersWorkspace QComboBox::drop-down { border: 0; width: 28px; background: transparent; }
|
||||
#PatientOrdersWorkspace QComboBox::down-arrow { image: none; width: 0; height: 0; }
|
||||
#PatientOrdersWorkspace QComboBox QAbstractItemView {
|
||||
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
|
||||
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
|
||||
}
|
||||
#PatientOrdersWorkspace QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
|
||||
#PatientOrdersWorkspace QDateEdit { padding-right: 26px; }
|
||||
#PatientOrdersWorkspace QDateEdit::drop-down { border: 0; width: 24px; background: transparent; }
|
||||
#PatientOrdersWorkspace QDateEdit::down-arrow { image: none; width: 0; height: 0; }
|
||||
#PatientOrdersWorkspace QDateEdit:disabled { color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2; }
|
||||
#PatientOrdersWorkspace QCalendarWidget {
|
||||
font-family: "$body"; font-size: 13px; background: #FFFFFF; color: #273244;
|
||||
}
|
||||
#PatientOrdersWorkspace QCalendarWidget QWidget#qt_calendar_navigationbar { background: #F5F8FD; }
|
||||
#PatientOrdersWorkspace QCalendarWidget QToolButton {
|
||||
min-height: 28px; max-height: 28px; padding: 0 8px; border: 0; background: transparent;
|
||||
font-family: "$body"; font-size: 13px; color: #273244;
|
||||
}
|
||||
#PatientOrdersWorkspace QCalendarWidget QToolButton:hover { background: #EAF2FF; }
|
||||
#PatientOrdersWorkspace QCalendarWidget QAbstractItemView {
|
||||
font-family: "$body"; font-size: 13px; background: #FFFFFF; alternate-background-color: #FFFFFF;
|
||||
color: #273244; selection-background-color: #1769E8; selection-color: #FFFFFF; outline: 0;
|
||||
}
|
||||
#PatientOrdersWorkspace QCheckBox { spacing: 9px; font-size: 13px; }
|
||||
#PatientOrdersWorkspace QCheckBox::indicator {
|
||||
width: 15px; height: 15px; border: 1px solid #C5D5EB; border-radius: 3px; background: #FFFFFF;
|
||||
}
|
||||
#PatientOrdersWorkspace QCheckBox::indicator:checked { background: #1769E8; border-color: #1769E8; }
|
||||
#PatientOrdersWorkspace QCheckBox::indicator:hover { border-color: #75A5F0; }
|
||||
#PatientOrdersWorkspace QCheckBox:focus { color: #1555B6; }
|
||||
#PatientOrdersWorkspace QFrame#OrderMetricCell { background: transparent; border: 0; }
|
||||
#PatientOrdersWorkspace QFrame#OrderMetricDivider { border: 0; background: #DBE5F2; }
|
||||
#PatientOrdersWorkspace QLabel[orderMetricCaption="true"] { color: #5D6B80; font-size: 13px; }
|
||||
#PatientOrdersWorkspace QLabel[orderMetricValue="true"] { color: #273244; font-size: 18px; font-weight: 500; }
|
||||
#PatientOrdersWorkspace QLabel#OrderAmountMetric { color: #1769E8; }
|
||||
#PatientOrdersWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; }
|
||||
#PatientOrdersWorkspace QTableWidget#OrderTable {
|
||||
border: 1px solid #E6EDF6; border-radius: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
|
||||
selection-background-color: #F5F8FF; selection-color: #273244; gridline-color: #E6EDF6;
|
||||
}
|
||||
#PatientOrdersWorkspace QTableWidget#OrderTable::item { padding: 0; border: 0; }
|
||||
#PatientOrdersWorkspace QTableWidget#OrderTable QHeaderView::section {
|
||||
min-height: 44px; padding: 0 16px; background: #F8FAFD; color: #5D6B80;
|
||||
border: 0; border-bottom: 1px solid #E1E9F4;
|
||||
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#PatientOrdersWorkspace QWidget#OrderActionBar { background: #FFFFFF; }
|
||||
#PatientOrdersWorkspace QToolButton#OrderActionButton { padding-right: 28px; }
|
||||
#PatientOrdersWorkspace QToolButton#OrderActionButton::menu-indicator { image: none; width: 0; height: 0; }
|
||||
#PatientOrdersWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||
#PatientOrdersWorkspace QMenu, QMenu#OrderActionMenu {
|
||||
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
|
||||
border: 1px solid #DBE5F2; padding: 5px;
|
||||
}
|
||||
#PatientOrdersWorkspace QMenu::item, QMenu#OrderActionMenu::item { padding: 8px 24px; }
|
||||
#PatientOrdersWorkspace QMenu::item:selected, QMenu#OrderActionMenu::item:selected { color: #1555B6; background: #EAF2FF; }
|
||||
#PatientOrdersWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||
#PatientOrdersWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||
#PatientOrdersWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||
#PatientOrdersWorkspace QScrollBar::add-line, #PatientOrdersWorkspace QScrollBar::sub-line { width: 0; height: 0; }
|
||||
#PatientOrdersWorkspace QScrollBar::add-page, #PatientOrdersWorkspace QScrollBar::sub-page { background: transparent; }
|
||||
"""
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Local typography and surfaces for the approved consultation-progress page."""
|
||||
|
||||
from string import Template
|
||||
|
||||
from .reception_style import body_family
|
||||
|
||||
|
||||
def patient_progress_stylesheet() -> str:
|
||||
return Template(_PROGRESS).substitute(body=body_family())
|
||||
|
||||
|
||||
_PROGRESS = """
|
||||
#PatientProgressWorkspace { background: transparent; color: #273244; }
|
||||
#ProgressWorkspaceContent, #ProgressWorkspaceScroll { background: transparent; border: 0; }
|
||||
#PatientProgressWorkspace QLabel, #PatientProgressWorkspace QPushButton,
|
||||
#PatientProgressWorkspace QTableWidget {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||
}
|
||||
#PatientProgressWorkspace QLabel[role="muted"] { font-size: 13px; color: #5D6B80; }
|
||||
#PatientProgressWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; }
|
||||
#PatientProgressWorkspace QLabel#EmptyStateGlyph {
|
||||
font-size: 24px; color: #1769E8; background: #F2F7FF;
|
||||
border: 1px solid #DBE5F2; border-radius: 22px;
|
||||
}
|
||||
#PatientProgressWorkspace QFrame#ProgressOverviewCard,
|
||||
#PatientProgressWorkspace QFrame#ProgressScheduleCard,
|
||||
#PatientProgressWorkspace QFrame#ProgressQueueCard {
|
||||
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||
}
|
||||
#PatientProgressWorkspace QFrame#ProgressMetricCell { background: transparent; border: 0; }
|
||||
#PatientProgressWorkspace QFrame#ProgressMetricDivider { background: #DBE5F2; border: 0; }
|
||||
#PatientProgressWorkspace QLabel[progressMetricValue="true"] { font-size: 18px; font-weight: 500; color: #273244; }
|
||||
#PatientProgressWorkspace QLabel#ProgressTotalMetric { color: #1769E8; }
|
||||
#PatientProgressWorkspace QSplitter#ProgressSplitter { background: transparent; }
|
||||
#PatientProgressWorkspace QSplitter#ProgressSplitter::handle { background: transparent; }
|
||||
#PatientProgressWorkspace QSplitter#ProgressSplitter::handle:hover { background: #EAF2FF; }
|
||||
#PatientProgressWorkspace QTableWidget#ProgressScheduleTable,
|
||||
#PatientProgressWorkspace QTableWidget#ProgressQueueTable {
|
||||
border: 0; border-radius: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
|
||||
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
|
||||
}
|
||||
#PatientProgressWorkspace QTableWidget::item { border: 0; padding: 0; }
|
||||
#PatientProgressWorkspace QHeaderView::section {
|
||||
min-height: 0; padding: 0 16px; background: #F5F8FD; color: #5D6B80;
|
||||
border: 0; border-bottom: 1px solid #E1E9F4;
|
||||
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#PatientProgressWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||
#PatientProgressWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||
#PatientProgressWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||
#PatientProgressWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||
#PatientProgressWorkspace QScrollBar::add-line, #PatientProgressWorkspace QScrollBar::sub-line { width: 0; height: 0; }
|
||||
#PatientProgressWorkspace QScrollBar::add-page, #PatientProgressWorkspace QScrollBar::sub-page { background: transparent; }
|
||||
"""
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Scoped technology-blue surfaces for the approved patient-list workspace."""
|
||||
|
||||
from string import Template
|
||||
|
||||
from .reception_style import body_family, heading_family
|
||||
|
||||
|
||||
def patients_chrome_stylesheet() -> str:
|
||||
return Template(_CHROME).substitute(body=body_family(), heading=heading_family())
|
||||
|
||||
|
||||
def patient_list_stylesheet() -> str:
|
||||
return Template(_LIST).substitute(body=body_family())
|
||||
|
||||
|
||||
_CHROME = """
|
||||
#PatientsPage { background: #F3F7FD; }
|
||||
#PatientsPage QWidget#PageHeader QLabel {
|
||||
font-family: "$body"; font-size: 13px; font-weight: 400; color: #5D6B80;
|
||||
}
|
||||
#PatientsPage QWidget#PageHeader QLabel[role="pageTitle"] {
|
||||
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
|
||||
}
|
||||
#PatientsPage QPushButton#PatientRefreshButton {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||
min-height: 38px; max-height: 38px; padding: 0 20px;
|
||||
border: 1px solid #DBE5F2; border-radius: 5px; background: #FFFFFF;
|
||||
}
|
||||
#PatientsPage QPushButton#PatientRefreshButton:hover { background: #F2F7FF; border-color: #B6CDEE; }
|
||||
#PatientsPage QTabWidget#PatientWorkspaceTabs::pane { border: 0; background: transparent; top: 0; }
|
||||
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||
min-width: 84px; min-height: 42px; padding: 0 10px; margin-right: 22px;
|
||||
border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
|
||||
}
|
||||
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab:selected {
|
||||
color: #1769E8; border-bottom-color: #1769E8; background: transparent;
|
||||
}
|
||||
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab:hover { color: #1769E8; background: #EAF2FF; }
|
||||
"""
|
||||
|
||||
_LIST = """
|
||||
#PatientListWorkspace { background: transparent; color: #273244; }
|
||||
#PatientWorkspaceContent, #PatientWorkspaceScroll { background: transparent; border: 0; }
|
||||
#PatientListWorkspace QLabel, #PatientListWorkspace QPushButton,
|
||||
#PatientListWorkspace QToolButton, #PatientListWorkspace QDateEdit,
|
||||
#PatientListWorkspace QTableWidget, #PatientSearchToolbar QLineEdit,
|
||||
#PatientSearchToolbar QPushButton {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||
}
|
||||
#PatientListWorkspace QLabel[role="muted"], #PatientListWorkspace QLabel[filterLabel="true"] {
|
||||
color: #5D6B80; font-size: 13px;
|
||||
}
|
||||
#PatientListWorkspace QFrame#PatientFilterCard,
|
||||
#PatientListWorkspace QFrame#PatientSummaryStrip,
|
||||
#PatientListWorkspace QFrame#PatientListCard {
|
||||
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||
}
|
||||
#PatientListWorkspace QPushButton, #PatientSearchToolbar QPushButton {
|
||||
min-height: 38px; max-height: 38px; padding: 0 16px;
|
||||
border: 1px solid #DBE5F2; border-radius: 5px; background: #FFFFFF;
|
||||
}
|
||||
#PatientListWorkspace QPushButton:hover, #PatientSearchToolbar QPushButton:hover {
|
||||
color: #1555B6; background: #F2F7FF; border-color: #B6CDEE;
|
||||
}
|
||||
#PatientListWorkspace QPushButton:pressed, #PatientSearchToolbar QPushButton:pressed { background: #DCEAFF; }
|
||||
#PatientListWorkspace QPushButton:focus, #PatientSearchToolbar QPushButton:focus { border-color: #75A5F0; }
|
||||
#PatientSearchToolbar QPushButton#PatientSearchButton {
|
||||
min-height: 38px; max-height: 38px; color: #FFFFFF; background: #1769E8; border-color: #1769E8;
|
||||
}
|
||||
#PatientSearchToolbar QPushButton#PatientSearchButton:hover { background: #155BCC; }
|
||||
#PatientSearchToolbar QLineEdit {
|
||||
min-height: 38px; max-height: 38px; padding: 0 8px; background: #FFFFFF;
|
||||
border: 1px solid #DBE5F2; border-radius: 5px; selection-background-color: #DCEAFF;
|
||||
}
|
||||
#PatientSearchToolbar QLineEdit:focus { border-color: #75A5F0; }
|
||||
#PatientSearchToolbar QLineEdit QToolButton { border: 0; padding: 0; background: transparent; }
|
||||
#PatientListWorkspace QPushButton[patientStatusChip="true"] {
|
||||
min-height: 44px; max-height: 44px; padding: 0 16px; font-size: 13px;
|
||||
color: #273244; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
|
||||
}
|
||||
#PatientListWorkspace QPushButton[patientStatusChip="true"]:checked {
|
||||
color: #1769E8; border-bottom-color: #1769E8; background: transparent;
|
||||
}
|
||||
#PatientListWorkspace QPushButton[patientStatusChip="true"]:hover { color: #1769E8; background: #F2F7FF; }
|
||||
#PatientListWorkspace QPushButton[patientQuickDate="true"] { padding: 0 12px; font-size: 13px; }
|
||||
#PatientListWorkspace QPushButton[patientQuickDate="true"]:checked,
|
||||
#PatientListWorkspace QPushButton#PatientCustomDateButton:checked {
|
||||
color: #FFFFFF; background: #1769E8; border: 1px solid #1769E8;
|
||||
}
|
||||
#PatientListWorkspace QDateEdit {
|
||||
min-height: 38px; max-height: 38px; padding: 0 8px; font-size: 13px;
|
||||
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 5px;
|
||||
selection-background-color: #DCEAFF;
|
||||
}
|
||||
#PatientListWorkspace QDateEdit:focus { border-color: #75A5F0; }
|
||||
#PatientListWorkspace QDateEdit:disabled {
|
||||
color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2;
|
||||
}
|
||||
#PatientListWorkspace QDateEdit::drop-down { border: 0; width: 20px; background: transparent; }
|
||||
#PatientListWorkspace QDateEdit::down-arrow { image: none; width: 0; height: 0; }
|
||||
#PatientListWorkspace QPushButton[summaryCard="true"] {
|
||||
min-height: 64px; max-height: 64px; padding: 0; background: transparent; border: 0; border-radius: 5px;
|
||||
}
|
||||
#PatientListWorkspace QPushButton[summaryCard="true"]:hover { background: #F2F7FF; }
|
||||
#PatientListWorkspace QFrame#PatientSummaryDivider { border: 0; background: #E1E9F4; }
|
||||
#PatientListWorkspace QWidget#PatientListHeading { background: transparent; }
|
||||
#PatientListWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; color: #273244; }
|
||||
#PatientListWorkspace QTableWidget#PatientTable {
|
||||
background: #FFFFFF; alternate-background-color: #FFFFFF; border: 0; border-radius: 0;
|
||||
selection-background-color: #EAF2FF; selection-color: #273244; gridline-color: #E6EDF6;
|
||||
}
|
||||
#PatientListWorkspace QTableWidget#PatientTable::item { padding: 0; border: 0; }
|
||||
#PatientListWorkspace QTableWidget#PatientTable QHeaderView::section {
|
||||
min-height: 41px; padding: 0 10px; background: #F5F8FD; color: #5D6B80;
|
||||
border: 0; border-top: 1px solid #E6EDF6; border-bottom: 1px solid #E1E9F4;
|
||||
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#PatientListWorkspace QWidget#RowActions, #PatientListWorkspace QWidget#PatientSelectorHost { background: transparent; }
|
||||
#PatientListWorkspace QPushButton[rowAction="true"],
|
||||
#PatientListWorkspace QToolButton#RowActionsMore {
|
||||
min-height: 28px; max-height: 28px; padding: 0 5px; border: 0;
|
||||
border-radius: 4px; background: transparent; color: #1769E8; font-size: 13px;
|
||||
}
|
||||
#PatientListWorkspace QToolButton#RowActionsMore { color: #273244; padding-right: 16px; }
|
||||
#PatientListWorkspace QPushButton[rowAction="true"]:hover,
|
||||
#PatientListWorkspace QToolButton#RowActionsMore:hover { background: #DCEAFF; }
|
||||
#PatientListWorkspace QMenu { background: #FFFFFF; color: #273244; border: 1px solid #DBE5F2; padding: 4px; }
|
||||
#PatientListWorkspace QMenu::item { padding: 7px 22px; font-size: 13px; }
|
||||
#PatientListWorkspace QMenu::item:selected { background: #EAF2FF; color: #1555B6; }
|
||||
#PatientListWorkspace QCheckBox[patientSelector="true"]::indicator {
|
||||
width: 13px; height: 13px; background: #FFFFFF; border: 1px solid #CBDAED; border-radius: 3px;
|
||||
}
|
||||
#PatientListWorkspace QCheckBox[patientSelector="true"]::indicator:checked { background: #1769E8; border-color: #1769E8; }
|
||||
#PatientListWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||
#PatientListWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||
#PatientListWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||
#PatientListWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||
#PatientListWorkspace QScrollBar::add-line, #PatientListWorkspace QScrollBar::sub-line { width: 0; height: 0; }
|
||||
#PatientListWorkspace QScrollBar::add-page, #PatientListWorkspace QScrollBar::sub-page { background: transparent; }
|
||||
"""
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Local technology-blue rendering for the approved prescription library."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from string import Template
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QModelIndex, QRectF, QSize, Qt
|
||||
from PySide6.QtGui import QColor, QFont, QFontMetrics, QIcon, QPainter
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QPushButton,
|
||||
QStyle,
|
||||
QStyledItemDelegate,
|
||||
QToolTip,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .reception_style import body_family, heading_family
|
||||
from .widgets import SortableTable, first_value
|
||||
|
||||
|
||||
def library_stylesheet() -> str:
|
||||
return Template(_LIBRARY).substitute(body=body_family(), heading=heading_family())
|
||||
|
||||
|
||||
def _library_icon(kind: str, color: str = "#1769E8", size: int = 15) -> QIcon:
|
||||
# Defer the page helper lookup so importing this module alone does not
|
||||
# recurse through pages.__init__ and the library page's own style import.
|
||||
from .pages.prescriptions import _painted_icon
|
||||
|
||||
result = QIcon()
|
||||
for mode, tint in (
|
||||
(QIcon.Mode.Normal, color),
|
||||
(QIcon.Mode.Active, color),
|
||||
(QIcon.Mode.Selected, color),
|
||||
(QIcon.Mode.Disabled, "#A4ADBA"),
|
||||
):
|
||||
pixmap = _painted_icon(kind, tint, size).pixmap(QSize(size, size))
|
||||
for state in (QIcon.State.Off, QIcon.State.On):
|
||||
result.addPixmap(pixmap, mode, state)
|
||||
return result
|
||||
|
||||
|
||||
class LibraryComboBox(QComboBox):
|
||||
"""Keep native combo interaction while painting the local caret."""
|
||||
|
||||
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
|
||||
super().paintEvent(event)
|
||||
painter = QPainter(self)
|
||||
try:
|
||||
rect = QRectF(self.width() - 25, (self.height() - 14) / 2, 14, 14)
|
||||
mode = QIcon.Mode.Normal if self.isEnabled() else QIcon.Mode.Disabled
|
||||
_library_icon("chevron_down", "#5D6B80", 14).paint(painter, rect.toRect(), mode=mode)
|
||||
finally:
|
||||
painter.end()
|
||||
|
||||
|
||||
class LibraryTable(SortableTable):
|
||||
"""Repaint existing action widgets and restore selection by template ID."""
|
||||
|
||||
def set_rows(self, rows: Iterable[Any]) -> None:
|
||||
selected_id = first_value(self.current_data(), "id", "template_id", default=None)
|
||||
super().set_rows(rows)
|
||||
# The shared table restores an unsorted row index after enabling sort.
|
||||
# Locate the same source object in the finished visual order instead.
|
||||
if selected_id is not None:
|
||||
for row_index in range(self.rowCount()):
|
||||
item = self.item(row_index, 0)
|
||||
row = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
|
||||
row_id = first_value(row, "id", "template_id", default=None)
|
||||
if row_id is not None and str(row_id) == str(selected_id):
|
||||
self.selectRow(row_index)
|
||||
return
|
||||
self.clearSelection()
|
||||
self.setCurrentCell(-1, -1)
|
||||
|
||||
def setCellWidget(self, row: int, column: int, widget: QWidget | None) -> None: # noqa: N802
|
||||
super().setCellWidget(row, column, widget)
|
||||
if widget is None or column != 9:
|
||||
return
|
||||
widget.setObjectName("PrescriptionLibraryTableCellHost")
|
||||
widget.setAutoFillBackground(False)
|
||||
glyphs = {
|
||||
"查看处方模板": "eye",
|
||||
"AI 解释": "spark",
|
||||
"编辑处方模板": "pencil",
|
||||
"删除处方模板": "trash",
|
||||
}
|
||||
for button in widget.findChildren(QPushButton):
|
||||
if not button.property("rowAction"):
|
||||
continue
|
||||
glyph = glyphs.get(button.accessibleName()) or glyphs.get(button.toolTip())
|
||||
if glyph is not None:
|
||||
color = "#BE4657" if button.property("danger") else "#1769E8"
|
||||
button.setIcon(_library_icon(glyph, color, button.iconSize().width()))
|
||||
|
||||
|
||||
def _library_font(size: int = 14, *, medium: bool = False) -> QFont:
|
||||
font = QFont(heading_family() if medium else body_family())
|
||||
font.setPixelSize(size)
|
||||
font.setWeight(QFont.Weight.Medium if medium else QFont.Weight.Normal)
|
||||
return font
|
||||
|
||||
|
||||
def _elide(text: str, metrics: QFontMetrics, width: int) -> str:
|
||||
return metrics.elidedText(text, Qt.TextElideMode.ElideRight, max(1, width))
|
||||
|
||||
|
||||
def _herb_lines(text: str, metrics: QFontMetrics, width: int) -> list[str]:
|
||||
"""Wrap between complete herb entries; elide only the displayed lines."""
|
||||
entries = text.split("、")
|
||||
first = entries[0]
|
||||
next_index = 1
|
||||
while next_index < min(2, len(entries)):
|
||||
candidate = first + "、" + entries[next_index]
|
||||
if metrics.horizontalAdvance(candidate) > width:
|
||||
break
|
||||
first = candidate
|
||||
next_index += 1
|
||||
lines = [_elide(first, metrics, width)]
|
||||
if next_index < len(entries):
|
||||
lines.append(_elide("、".join(entries[next_index:]), metrics, width))
|
||||
return lines
|
||||
|
||||
|
||||
def _name_lines(text: str, metrics: QFontMetrics, width: int) -> list[str]:
|
||||
if metrics.horizontalAdvance(text) <= width:
|
||||
return [text]
|
||||
split = 1
|
||||
while split < len(text) and metrics.horizontalAdvance(text[: split + 1]) <= width:
|
||||
split += 1
|
||||
return [_elide(text[:split], metrics, width), _elide(text[split:], metrics, width)]
|
||||
|
||||
|
||||
class LibraryItemDelegate(QStyledItemDelegate):
|
||||
"""Paint the ten existing columns without changing display or source roles."""
|
||||
|
||||
def sizeHint(self, option: Any, index: QModelIndex) -> QSize: # noqa: N802
|
||||
size = super().sizeHint(option, index)
|
||||
size.setHeight(78)
|
||||
return size
|
||||
|
||||
def helpEvent(self, event: Any, view: Any, option: Any, index: QModelIndex) -> bool: # noqa: N802
|
||||
if event is not None and index.isValid() and index.column() != 9:
|
||||
text = index.data(Qt.ItemDataRole.ToolTipRole) or index.data(Qt.ItemDataRole.DisplayRole)
|
||||
if text:
|
||||
QToolTip.showText(event.globalPos(), str(text), view)
|
||||
return True
|
||||
return super().helpEvent(event, view, option, index)
|
||||
|
||||
@staticmethod
|
||||
def _paint_lines(
|
||||
painter: QPainter,
|
||||
rect: QRectF,
|
||||
lines: list[str],
|
||||
*,
|
||||
secondary_muted: bool = False,
|
||||
) -> None:
|
||||
line_height = 26
|
||||
top = rect.center().y() - len(lines) * line_height / 2
|
||||
for line_index, line in enumerate(lines):
|
||||
if secondary_muted and line_index:
|
||||
painter.setFont(_library_font(13))
|
||||
painter.setPen(QColor("#5D6B80"))
|
||||
line_rect = QRectF(rect.left(), top + line_index * line_height, rect.width(), line_height)
|
||||
painter.drawText(line_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, line)
|
||||
|
||||
def paint(self, painter: QPainter, option: Any, index: QModelIndex) -> None:
|
||||
painter.save()
|
||||
try:
|
||||
painter.setClipRect(option.rect)
|
||||
selected = bool(option.state & QStyle.StateFlag.State_Selected)
|
||||
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
|
||||
background = "#F2F7FF" if selected else "#F8FAFE" if hovered else "#FFFFFF"
|
||||
painter.fillRect(option.rect, QColor(background))
|
||||
painter.setPen(QColor("#E6EDF6"))
|
||||
painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight())
|
||||
if selected and index.column() == 0:
|
||||
stripe = QRectF(option.rect.left(), option.rect.top(), 3, option.rect.height() - 1)
|
||||
painter.fillRect(stripe, QColor("#1769E8"))
|
||||
if option.state & QStyle.StateFlag.State_HasFocus:
|
||||
painter.setPen(QColor("#75A5F0"))
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawRect(option.rect.adjusted(1, 1, -2, -2))
|
||||
|
||||
column = index.column()
|
||||
if column == 9:
|
||||
return
|
||||
rect = QRectF(option.rect.adjusted(12, 0, -12, -1))
|
||||
if rect.width() <= 0:
|
||||
return
|
||||
value = index.data(Qt.ItemDataRole.DisplayRole)
|
||||
text = "" if value is None else str(value)
|
||||
font = _library_font(medium=column == 1)
|
||||
painter.setFont(font)
|
||||
painter.setPen(QColor("#273244"))
|
||||
metrics = QFontMetrics(font)
|
||||
width = int(rect.width())
|
||||
|
||||
if column == 2:
|
||||
painter.setFont(_library_font(13))
|
||||
metrics = painter.fontMetrics()
|
||||
label = _elide(text, metrics, width - 12)
|
||||
pill_width = min(rect.width(), metrics.horizontalAdvance(label) + 14)
|
||||
pill = QRectF(rect.center().x() - pill_width / 2, rect.center().y() - 12, pill_width, 24)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor("#EAF2FF"))
|
||||
painter.drawRoundedRect(pill, 4, 4)
|
||||
painter.setPen(QColor("#1769E8"))
|
||||
painter.drawText(pill, Qt.AlignmentFlag.AlignCenter, label)
|
||||
elif column == 6:
|
||||
glyph = "users" if text == "所有人可见" else "lock"
|
||||
icon_rect = QRectF(rect.left(), rect.center().y() - 7, 14, 14)
|
||||
_library_icon(glyph, "#5D6B80", 14).paint(painter, icon_rect.toRect())
|
||||
label_rect = rect.adjusted(22, 0, 0, 0)
|
||||
painter.drawText(
|
||||
label_rect,
|
||||
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
|
||||
_elide(text, metrics, int(label_rect.width())),
|
||||
)
|
||||
elif column == 4:
|
||||
self._paint_lines(painter, rect, _herb_lines(text, metrics, width))
|
||||
elif column == 8:
|
||||
parts = text.splitlines() if "\n" in text else text.rsplit(" ", 1)
|
||||
lines = [_elide(parts[0], metrics, width)]
|
||||
if len(parts) > 1:
|
||||
lines.append(_elide(" ".join(parts[1:]), QFontMetrics(_library_font(13)), width))
|
||||
self._paint_lines(painter, rect, lines, secondary_muted=True)
|
||||
elif column == 1:
|
||||
self._paint_lines(painter, rect, _name_lines(text, metrics, width))
|
||||
else:
|
||||
alignment = Qt.AlignmentFlag.AlignCenter if column in (0, 3, 5) else (
|
||||
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
|
||||
)
|
||||
painter.drawText(rect, alignment, _elide(text, metrics, width))
|
||||
finally:
|
||||
painter.restore()
|
||||
|
||||
|
||||
_LIBRARY = """
|
||||
#PrescriptionLibraryPage { background: #F3F7FD; color: #273244; }
|
||||
#PrescriptionLibraryPage QWidget#PrescriptionLibraryContent,
|
||||
#PrescriptionLibraryPage QScrollArea#PrescriptionLibraryScroll,
|
||||
#PrescriptionLibraryPage QScrollArea#PrescriptionLibraryToolbarScroll {
|
||||
background: transparent; border: 0;
|
||||
}
|
||||
#PrescriptionLibraryPage QLabel, #PrescriptionLibraryPage QPushButton,
|
||||
#PrescriptionLibraryPage QLineEdit, #PrescriptionLibraryPage QComboBox,
|
||||
#PrescriptionLibraryPage QTableWidget {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||
}
|
||||
#PrescriptionLibraryPage QLabel[role="pageTitle"] {
|
||||
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
|
||||
}
|
||||
#PrescriptionLibraryPage QLabel[role="muted"] { color: #5D6B80; font-size: 13px; }
|
||||
#PrescriptionLibraryPage QFrame#MetricCard,
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar,
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryTableCard {
|
||||
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||
}
|
||||
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricTitle"] {
|
||||
font-family: "$body"; color: #5D6B80; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricValue"] {
|
||||
font-family: "$heading"; color: #273244; font-size: 18px; font-weight: 600;
|
||||
}
|
||||
#PrescriptionLibraryPage QFrame#MetricCard QLabel[metricIcon="true"] {
|
||||
border: 0; border-radius: 0; background: transparent;
|
||||
}
|
||||
#PrescriptionLibraryPage QPushButton {
|
||||
min-height: 38px; max-height: 38px; padding: 0 16px; border-radius: 5px;
|
||||
border: 1px solid #DBE5F2; background: #FFFFFF; color: #273244;
|
||||
}
|
||||
#PrescriptionLibraryPage QPushButton:hover { background: #F2F7FF; border-color: #B6CDEE; color: #1555B6; }
|
||||
#PrescriptionLibraryPage QPushButton:pressed { background: #DCEAFF; }
|
||||
#PrescriptionLibraryPage QPushButton:focus { border-color: #75A5F0; }
|
||||
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton,
|
||||
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton {
|
||||
background: #1769E8; border-color: #1769E8; color: #FFFFFF;
|
||||
}
|
||||
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:hover,
|
||||
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:hover {
|
||||
background: #155BCC; border-color: #155BCC;
|
||||
}
|
||||
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:pressed,
|
||||
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:pressed {
|
||||
background: #124EA9; border-color: #124EA9;
|
||||
}
|
||||
#PrescriptionLibraryPage QPushButton[variant="danger"] { color: #BE4657; border-color: #E9D8DE; }
|
||||
#PrescriptionLibraryPage QPushButton:disabled,
|
||||
#PrescriptionLibraryPage QPushButton[variant="danger"]:disabled {
|
||||
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
|
||||
}
|
||||
#PrescriptionLibraryPage QLineEdit, #PrescriptionLibraryPage QComboBox {
|
||||
min-height: 38px; max-height: 38px; padding: 0 12px; border: 1px solid #DBE5F2;
|
||||
border-radius: 5px; background: #FFFFFF; color: #273244;
|
||||
selection-background-color: #DCEAFF; selection-color: #273244;
|
||||
}
|
||||
#PrescriptionLibraryPage QLineEdit:focus, #PrescriptionLibraryPage QComboBox:focus { border-color: #75A5F0; }
|
||||
#PrescriptionLibraryPage QLineEdit QToolButton { border: 0; background: transparent; padding: 0; }
|
||||
#PrescriptionLibraryPage QComboBox { padding-right: 30px; font-size: 13px; }
|
||||
#PrescriptionLibraryPage QComboBox::drop-down { width: 28px; border: 0; background: transparent; }
|
||||
#PrescriptionLibraryPage QComboBox::down-arrow { image: none; width: 0; height: 0; }
|
||||
#PrescriptionLibraryPage QComboBox:disabled { color: #97A4B6; background: #F6F8FC; }
|
||||
#PrescriptionLibraryPage QComboBox QAbstractItemView {
|
||||
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
|
||||
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
|
||||
}
|
||||
#PrescriptionLibraryPage QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar { background: transparent; border: 0; }
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton { padding: 0 12px; font-size: 13px; }
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"] {
|
||||
min-width: 80px; min-height: 40px; max-height: 40px; padding: 0 10px; margin-right: 8px;
|
||||
background: transparent; border: 0; border-bottom: 2px solid transparent;
|
||||
border-radius: 0; color: #273244; font-size: 14px;
|
||||
}
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:checked {
|
||||
color: #1769E8; border-bottom-color: #1769E8;
|
||||
}
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:hover {
|
||||
color: #1555B6; background: #F2F7FF;
|
||||
}
|
||||
#PrescriptionLibraryPage QTableWidget {
|
||||
border: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
|
||||
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
|
||||
}
|
||||
#PrescriptionLibraryPage QTableWidget::item { padding: 0; border: 0; }
|
||||
#PrescriptionLibraryPage QHeaderView::section {
|
||||
min-height: 0; padding: 0 12px; background: #F5F8FD; color: #5D6B80;
|
||||
border: 0; border-bottom: 1px solid #DBE5F2;
|
||||
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#PrescriptionLibraryPage QWidget#PrescriptionLibraryTableCellHost { background: transparent; border: 0; }
|
||||
#PrescriptionLibraryPage QPushButton[rowAction="true"] {
|
||||
min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px;
|
||||
padding: 0; border: 1px solid transparent; border-radius: 4px; background: transparent;
|
||||
}
|
||||
#PrescriptionLibraryPage QPushButton[rowAction="true"]:hover { background: #EAF2FF; border-color: #B6CDEE; }
|
||||
#PrescriptionLibraryPage QPushButton[rowAction="true"]:focus { border-color: #75A5F0; }
|
||||
#PrescriptionLibraryPage QPushButton[rowAction="true"][danger="true"]:hover { background: #FFF0F2; border-color: #EAC8D0; }
|
||||
#PrescriptionLibraryPage QPushButton[rowAction="true"]:disabled { background: transparent; border-color: transparent; color: #A4ADBA; }
|
||||
#PrescriptionLibraryPage QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||
#PrescriptionLibraryPage QLabel#EmptyStateGlyph {
|
||||
font-size: 24px; color: #1769E8; background: #F2F7FF;
|
||||
border: 1px solid #DBE5F2; border-radius: 22px;
|
||||
}
|
||||
#PrescriptionLibraryPage QWidget#EmptyState QLabel[role="muted"] { min-width: 300px; }
|
||||
#PrescriptionLibraryPage QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||
#PrescriptionLibraryPage QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||
#PrescriptionLibraryPage QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||
#PrescriptionLibraryPage QScrollBar::add-line, #PrescriptionLibraryPage QScrollBar::sub-line { width: 0; height: 0; }
|
||||
#PrescriptionLibraryPage QScrollBar::add-page, #PrescriptionLibraryPage QScrollBar::sub-page { background: transparent; }
|
||||
"""
|
||||
|
||||
|
||||
__all__ = ["library_stylesheet", "LibraryComboBox", "LibraryTable", "LibraryItemDelegate"]
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Scoped surfaces and real fonts for the approved issued-prescription page."""
|
||||
|
||||
from string import Template
|
||||
|
||||
from .reception_style import body_family, heading_family
|
||||
|
||||
|
||||
def prescriptions_stylesheet() -> str:
|
||||
return Template(_PRESCRIPTIONS).substitute(body=body_family(), heading=heading_family())
|
||||
|
||||
|
||||
_PRESCRIPTIONS = """
|
||||
#PrescriptionsPage { background: #F3F7FD; color: #273244; }
|
||||
#PrescriptionWorkspaceContent, #PrescriptionWorkspaceScroll { background: transparent; border: 0; }
|
||||
#PrescriptionsPage QLabel, #PrescriptionsPage QPushButton, #PrescriptionsPage QLineEdit,
|
||||
#PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit, #PrescriptionsPage QTableWidget {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||
}
|
||||
#PrescriptionsPage QLabel[role="pageTitle"] {
|
||||
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
|
||||
}
|
||||
#PrescriptionsPage QLabel[role="sectionTitle"] {
|
||||
font-family: "$heading"; font-size: 14px; font-weight: 600;
|
||||
}
|
||||
#PrescriptionsPage QLabel[role="muted"], #PrescriptionsPage QLabel#PrescriptionCountBadge {
|
||||
color: #5D6B80; font-size: 13px; background: transparent; border: 0; padding: 0;
|
||||
}
|
||||
#PrescriptionsPage QFrame#PrescriptionFilterBar, #PrescriptionsPage QFrame#PrescriptionTableCard {
|
||||
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||
}
|
||||
#PrescriptionsPage QFrame#PrescriptionToolbar { background: transparent; border: 0; }
|
||||
#PrescriptionsPage QPushButton {
|
||||
min-height: 38px; max-height: 38px; padding: 0 16px; border-radius: 5px;
|
||||
border: 1px solid #DBE5F2; background: #FFFFFF; color: #273244;
|
||||
}
|
||||
#PrescriptionsPage QPushButton:hover { background: #F2F7FF; border-color: #B6CDEE; color: #1555B6; }
|
||||
#PrescriptionsPage QPushButton:pressed { background: #DCEAFF; }
|
||||
#PrescriptionsPage QPushButton:focus { border-color: #75A5F0; }
|
||||
#PrescriptionsPage QPushButton#PrescriptionAddButton, #PrescriptionsPage QPushButton#PrescriptionQueryButton {
|
||||
background: #1769E8; color: #FFFFFF; border-color: #1769E8;
|
||||
}
|
||||
#PrescriptionsPage QPushButton#PrescriptionAddButton:hover, #PrescriptionsPage QPushButton#PrescriptionQueryButton:hover {
|
||||
background: #155BCC; border-color: #155BCC;
|
||||
}
|
||||
#PrescriptionsPage QPushButton#PrescriptionAddButton:pressed, #PrescriptionsPage QPushButton#PrescriptionQueryButton:pressed {
|
||||
background: #124EA9; border-color: #124EA9;
|
||||
}
|
||||
#PrescriptionsPage QPushButton[variant="danger"] { color: #BE4657; background: #FFFFFF; border-color: #E9D8DE; }
|
||||
#PrescriptionsPage QPushButton:disabled, #PrescriptionsPage QPushButton[variant="danger"]:disabled {
|
||||
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
|
||||
}
|
||||
#PrescriptionsPage QLineEdit, #PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit {
|
||||
min-height: 38px; max-height: 38px; padding: 0 12px; border: 1px solid #DBE5F2;
|
||||
border-radius: 5px; background: #FFFFFF; color: #273244;
|
||||
selection-background-color: #DCEAFF; selection-color: #273244;
|
||||
}
|
||||
#PrescriptionsPage QLineEdit:focus, #PrescriptionsPage QComboBox:focus, #PrescriptionsPage QDateTimeEdit:focus { border-color: #75A5F0; }
|
||||
#PrescriptionsPage QLineEdit QToolButton { min-width: 0; min-height: 0; border: 0; background: transparent; padding: 0; }
|
||||
#PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit { padding-right: 30px; font-size: 13px; }
|
||||
#PrescriptionsPage QComboBox::drop-down, #PrescriptionsPage QDateTimeEdit::drop-down { width: 28px; border: 0; background: transparent; }
|
||||
#PrescriptionsPage QComboBox::down-arrow, #PrescriptionsPage QDateTimeEdit::down-arrow { image: none; width: 0; height: 0; }
|
||||
#PrescriptionsPage QDateTimeEdit:disabled { color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2; }
|
||||
#PrescriptionsPage QPushButton#PrescriptionDoctorButton { text-align: left; padding-right: 30px; font-size: 13px; }
|
||||
#PrescriptionsPage QComboBox QAbstractItemView {
|
||||
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
|
||||
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
|
||||
}
|
||||
#PrescriptionsPage QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
|
||||
#PrescriptionsPage QCalendarWidget { font-family: "$body"; font-size: 13px; background: #FFFFFF; color: #273244; }
|
||||
#PrescriptionsPage QCalendarWidget QWidget#qt_calendar_navigationbar { background: #F5F8FD; }
|
||||
#PrescriptionsPage QCalendarWidget QToolButton { min-height: 28px; border: 0; padding: 0 8px; color: #273244; background: transparent; }
|
||||
#PrescriptionsPage QCalendarWidget QAbstractItemView { color: #273244; background: #FFFFFF; selection-background-color: #1769E8; selection-color: #FFFFFF; outline: 0; }
|
||||
#PrescriptionsPage QFrame#PrescriptionToolbar QPushButton { padding: 0 12px; font-size: 13px; }
|
||||
#PrescriptionsPage QTableWidget#PrescriptionTable {
|
||||
border: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
|
||||
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
|
||||
}
|
||||
#PrescriptionsPage QTableWidget#PrescriptionTable::item { border: 0; padding: 0; }
|
||||
#PrescriptionsPage QHeaderView::section {
|
||||
min-height: 0; padding: 0 12px; background: #F5F8FD; color: #5D6B80;
|
||||
border: 0; border-bottom: 1px solid #DBE5F2;
|
||||
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#PrescriptionsPage QWidget#PrescriptionTableCellHost { border: 0; background: transparent; }
|
||||
#PrescriptionsPage QPushButton[rowAction="true"] {
|
||||
min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px;
|
||||
padding: 0; border: 1px solid transparent; border-radius: 4px; background: transparent;
|
||||
}
|
||||
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"] {
|
||||
min-width: 64px; max-width: 64px; color: #1769E8; font-size: 13px; padding: 0;
|
||||
}
|
||||
#PrescriptionsPage QPushButton[rowAction="true"]:hover { background: #EAF2FF; border-color: #B6CDEE; }
|
||||
#PrescriptionsPage QPushButton[rowAction="true"]:focus { border-color: #75A5F0; }
|
||||
#PrescriptionsPage QPushButton[rowAction="true"][danger="true"]:hover { background: #FFF0F2; border-color: #EAC8D0; }
|
||||
#PrescriptionsPage QPushButton[rowAction="true"]:disabled,
|
||||
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"]:disabled {
|
||||
background: transparent; border-color: transparent; color: #A4ADBA;
|
||||
}
|
||||
#PrescriptionsPage QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||
#PrescriptionsPage QLabel#EmptyStateGlyph { font-size: 24px; color: #1769E8; background: #F2F7FF; border: 1px solid #DBE5F2; border-radius: 22px; }
|
||||
#PrescriptionsPage QWidget#EmptyState QLabel[role="muted"] { min-width: 300px; }
|
||||
#PrescriptionsPage QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||
#PrescriptionsPage QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||
#PrescriptionsPage QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||
#PrescriptionsPage QScrollBar::add-line, #PrescriptionsPage QScrollBar::sub-line { width: 0; height: 0; }
|
||||
#PrescriptionsPage QScrollBar::add-page, #PrescriptionsPage QScrollBar::sub-page { background: transparent; }
|
||||
"""
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Palette and real font families for the approved reception page only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtGui import QFontDatabase
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.resources import resource_path
|
||||
|
||||
TECH_BLUE = {
|
||||
"accent": "#1769E8",
|
||||
"accent_hover": "#155BCC",
|
||||
"accent_pressed": "#124EA9",
|
||||
"selection": "#EAF2FF",
|
||||
"selected_text": "#1555B6",
|
||||
"canvas": "#F3F7FD",
|
||||
"sidebar": "#EDF4FF",
|
||||
"surface": "#FFFFFF",
|
||||
"raised": "#F7FAFE",
|
||||
"line": "#DBE5F2",
|
||||
"text": "#273244",
|
||||
"heading": "#202C3F",
|
||||
"muted": "#5D6B80",
|
||||
"focus": "#75A5F0",
|
||||
}
|
||||
|
||||
|
||||
def _families() -> dict[str, str]:
|
||||
"""Resolve after Qt starts; font registration does not change its theme."""
|
||||
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
raise RuntimeError("Reception font families require a QApplication")
|
||||
cached = getattr(app, "_reception_font_families", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
heading = getattr(app, "_doctor_bundled_font_family", None)
|
||||
if not heading:
|
||||
font_path = resource_path("fonts", "NotoSansSC-VF.ttf")
|
||||
if font_path.is_file():
|
||||
font_id = QFontDatabase.addApplicationFont(str(font_path))
|
||||
registered = QFontDatabase.applicationFontFamilies(font_id) if font_id >= 0 else []
|
||||
if registered:
|
||||
heading = registered[0]
|
||||
app._doctor_bundled_font_family = heading
|
||||
|
||||
available = set(QFontDatabase.families())
|
||||
# Qt's offscreen platform does not enumerate Windows fonts automatically.
|
||||
# Register only files already installed on this machine, never substitutes
|
||||
# downloaded or installed into the user's Windows font registry.
|
||||
if sys.platform == "win32":
|
||||
font_dir = (
|
||||
Path(os.environ.get("SYSTEMROOT") or os.environ.get("WINDIR") or "C:/Windows") / "Fonts"
|
||||
)
|
||||
for family, filenames in (
|
||||
("Microsoft YaHei UI", ("msyh.ttc",)),
|
||||
("Segoe UI", ("segoeui.ttf", "seguisb.ttf")),
|
||||
):
|
||||
if family in available:
|
||||
continue
|
||||
for filename in filenames:
|
||||
font_path = font_dir / filename
|
||||
if not font_path.is_file():
|
||||
continue
|
||||
font_id = QFontDatabase.addApplicationFont(str(font_path))
|
||||
if font_id >= 0:
|
||||
available.update(QFontDatabase.applicationFontFamilies(font_id))
|
||||
|
||||
fallback = heading or next(
|
||||
(
|
||||
family
|
||||
for family in ("Noto Sans SC", "Noto Sans CJK SC", "PingFang SC")
|
||||
if family in available
|
||||
),
|
||||
app.font().family(),
|
||||
)
|
||||
resolved = {
|
||||
"body": "Microsoft YaHei UI" if "Microsoft YaHei UI" in available else fallback,
|
||||
"heading": heading or fallback,
|
||||
"number": "Segoe UI" if "Segoe UI" in available else fallback,
|
||||
}
|
||||
app._reception_font_families = resolved
|
||||
return resolved
|
||||
|
||||
|
||||
def body_family() -> str:
|
||||
"""Regular Chinese body copy; YaHei UI has no genuine Medium face."""
|
||||
|
||||
return _families()["body"]
|
||||
|
||||
|
||||
def heading_family() -> str:
|
||||
"""Bundled Noto Sans SC supplies genuine Medium and Semibold faces."""
|
||||
|
||||
return _families()["heading"]
|
||||
|
||||
|
||||
def number_family() -> str:
|
||||
"""Segoe UI supplies Regular and Semibold for numeric labels."""
|
||||
|
||||
return _families()["number"]
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Scrolling, query isolation and refresh contracts for the two clinic queues."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from copy import deepcopy
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QComboBox, QSpinBox, QWidget
|
||||
|
||||
from doctor_workstation.ui.infinite_list import InfiniteList
|
||||
from doctor_workstation.ui.pages import appointments, consultations
|
||||
|
||||
|
||||
class _DiagnosisDialog(QWidget):
|
||||
saved = Signal()
|
||||
|
||||
def __init__(self, _repository: Any, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
|
||||
class _Repository:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
self.fail_page: int | None = None
|
||||
self.revision = 0
|
||||
|
||||
def _list(self, **query: Any) -> dict[str, Any]:
|
||||
self.calls.append(dict(query))
|
||||
page = query["page_no"]
|
||||
if page == self.fail_page:
|
||||
raise RuntimeError("暂时无法加载")
|
||||
second = bool(query.get("keyword") or query.get("patient_name"))
|
||||
offset = 1000 if second else 0
|
||||
total = 4 if second else 34
|
||||
start = (page - 1) * query["page_size"]
|
||||
rows = [
|
||||
{
|
||||
"id": offset + index,
|
||||
"diagnosis_id": offset + index,
|
||||
"source_patient_id": index + 2000,
|
||||
"patient_id": index + 2000,
|
||||
"patient_name": f"患者{offset + index} · {self.revision}",
|
||||
"status": 1,
|
||||
"status_desc": "待接诊",
|
||||
"appointment_id": offset + index,
|
||||
"appointment_status": 1,
|
||||
"appointment_date": date.today().isoformat(),
|
||||
"appointment_time": "09:00-09:30",
|
||||
"patient_phone": "13800001234",
|
||||
"doctor_name": "测试医生",
|
||||
"doctor_id": 30,
|
||||
"diagnosis_confirmed": True,
|
||||
"appointments": [],
|
||||
}
|
||||
for index in range(start + 1, min(total, start + query["page_size"]) + 1)
|
||||
]
|
||||
result = {"lists": rows, "count": total}
|
||||
if page == 1:
|
||||
result["extend"] = {
|
||||
"status_count": {"1": total, "3": 7},
|
||||
"date_counts": {"today": total, "tomorrow": 9},
|
||||
}
|
||||
return result
|
||||
|
||||
list_appointments = _list
|
||||
list_consultations = _list
|
||||
|
||||
|
||||
def _inline(function: Any, *, on_success=None, on_error=None, on_finished=None) -> None:
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
if on_error is not None:
|
||||
on_error(error)
|
||||
else:
|
||||
if on_success is not None:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(3):
|
||||
application.processEvents()
|
||||
QTest.qWait(35)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture(params=["appointments", "consultations"])
|
||||
def queue_page(request: Any, application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
module = appointments if request.param == "appointments" else consultations
|
||||
monkeypatch.setattr(module, "run_async", _inline)
|
||||
monkeypatch.setattr(consultations, "DiagnosisDialog", _DiagnosisDialog)
|
||||
monkeypatch.setattr(consultations.ConsultationsPage, "_load_filter_options", lambda self: None)
|
||||
monkeypatch.setattr(consultations.ConsultationsPage, "_refresh_counts", lambda self: None)
|
||||
monkeypatch.setattr(appointments.AppointmentsPage, "_load_departments", lambda self: None)
|
||||
repository = _Repository()
|
||||
page_class = appointments.AppointmentsPage if module is appointments else consultations.ConsultationsPage
|
||||
page = page_class(repository, permissions={"*"}, current_user={"role_id": 1})
|
||||
page.resize(1280, 800)
|
||||
page.show()
|
||||
page.poll_timer.stop()
|
||||
_settle(application)
|
||||
yield page, repository, module
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
_settle(application)
|
||||
|
||||
|
||||
def _scroll_bottom(page: Any, application: QApplication) -> None:
|
||||
scrollbar = page.table.verticalScrollBar()
|
||||
assert scrollbar.maximum() > 0
|
||||
scrollbar.setValue(scrollbar.maximum())
|
||||
_settle(application)
|
||||
|
||||
|
||||
def test_scroll_appends_and_preserves_selection(queue_page: Any, application: QApplication) -> None:
|
||||
page, repository, _module = queue_page
|
||||
assert page.table.rowCount() == 15
|
||||
page.table.selectRow(6)
|
||||
if hasattr(page, "table_host"):
|
||||
model = page.table_host.model
|
||||
model.setData(model.index(6, 0), Qt.CheckState.Checked, Qt.ItemDataRole.CheckStateRole)
|
||||
_scroll_bottom(page, application)
|
||||
|
||||
assert page.table.rowCount() == 30
|
||||
assert [call["page_no"] for call in repository.calls] == [1, 2]
|
||||
assert page.table.current_data()["id"] == 7
|
||||
assert page.table.verticalScrollBar().value() > 0
|
||||
if hasattr(page, "table_host"):
|
||||
assert [row["id"] for row in page.table_host.selected_records()] == [7]
|
||||
else:
|
||||
assert page._status_counts[3] == 7
|
||||
assert page.date_buttons["tomorrow"].text().endswith(" 9")
|
||||
|
||||
_scroll_bottom(page, application)
|
||||
assert page.table.rowCount() == 34
|
||||
assert len({row["id"] for row in page.pager.rows}) == 34
|
||||
assert not page.pager.has_more
|
||||
assert "已全部加载" in page.pager.summary_label.text()
|
||||
_scroll_bottom(page, application)
|
||||
assert [call["page_no"] for call in repository.calls] == [1, 2, 3]
|
||||
|
||||
|
||||
def test_silent_refresh_keeps_the_loaded_prefix(queue_page: Any, application: QApplication) -> None:
|
||||
page, repository, _module = queue_page
|
||||
_scroll_bottom(page, application)
|
||||
page.table.selectRow(19)
|
||||
old_scroll = page.table.verticalScrollBar().value()
|
||||
repository.calls.clear()
|
||||
repository.revision = 2
|
||||
page.refresh(silent=True)
|
||||
_settle(application)
|
||||
|
||||
assert [call["page_no"] for call in repository.calls] == [1, 2]
|
||||
assert page.table.rowCount() == 30
|
||||
assert page.table.current_data()["id"] == 20
|
||||
assert page.table.current_data()["patient_name"].endswith(" · 2")
|
||||
assert page.table.verticalScrollBar().value() == old_scroll
|
||||
if isinstance(page, appointments.AppointmentsPage):
|
||||
assert page._status_counts[3] == 7
|
||||
assert page.date_buttons["tomorrow"].text().endswith(" 9")
|
||||
|
||||
|
||||
def test_filter_change_supersedes_pending_append(
|
||||
queue_page: Any, application: QApplication, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
page, _repository, module = queue_page
|
||||
jobs: list[tuple[Any, dict[str, Any]]] = []
|
||||
|
||||
def deferred(function: Any, **callbacks: Any) -> None:
|
||||
jobs.append((function, callbacks))
|
||||
|
||||
monkeypatch.setattr(module, "run_async", deferred)
|
||||
# Reconfigure the shared controller to use the deferred runner, then finish
|
||||
# that refresh before simulating a slow next page.
|
||||
page.refresh(silent=True)
|
||||
function, callbacks = jobs.pop()
|
||||
callbacks["on_success"](function())
|
||||
page.pager.load_more()
|
||||
append_function, append_callbacks = jobs.pop()
|
||||
stale_result = deepcopy(append_function())
|
||||
|
||||
search = page.patient_input if module is appointments else page.keyword_edit
|
||||
search.setText("第二组")
|
||||
page.refresh(silent=True)
|
||||
assert len(jobs) == 1
|
||||
function, callbacks = jobs.pop()
|
||||
assert function()["lists"][0]["id"] == 1001
|
||||
callbacks["on_success"](function())
|
||||
append_callbacks["on_success"](stale_result)
|
||||
_settle(application)
|
||||
|
||||
assert page.table.rowCount() == 4
|
||||
assert [row["id"] for row in page.pager.rows] == [1001, 1002, 1003, 1004]
|
||||
assert page.pager.page == 1
|
||||
assert not page.pager.loading
|
||||
assert page.table.verticalScrollBar().value() == 0
|
||||
|
||||
|
||||
def test_failed_append_retries_without_losing_rows(queue_page: Any, application: QApplication) -> None:
|
||||
page, repository, _module = queue_page
|
||||
repository.fail_page = 2
|
||||
_scroll_bottom(page, application)
|
||||
assert page.table.rowCount() == 15
|
||||
assert page.pager.page == 1
|
||||
assert page.pager.retry_button.isVisible()
|
||||
calls = len(repository.calls)
|
||||
_settle(application)
|
||||
assert len(repository.calls) == calls
|
||||
|
||||
repository.fail_page = None
|
||||
page.pager.retry_button.click()
|
||||
_settle(application)
|
||||
assert page.table.rowCount() == 30
|
||||
assert page.pager.page == 2
|
||||
assert page.pager.retry_button.isHidden()
|
||||
|
||||
|
||||
def test_list_footer_is_compact_without_page_controls(queue_page: Any, application: QApplication) -> None:
|
||||
page, _repository, _module = queue_page
|
||||
for height in (768, 960):
|
||||
page.resize(1280, height)
|
||||
_settle(application)
|
||||
assert isinstance(page.pager, InfiniteList)
|
||||
assert page.pager.height() == 24
|
||||
assert not page.pager.findChildren(QComboBox)
|
||||
assert not page.pager.findChildren(QSpinBox)
|
||||
content = page.page_scroll.widget() if hasattr(page, "page_scroll") else page
|
||||
assert content.layout().contentsMargins().bottom() == 8
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Native Qt interaction and layout checks for the approved appointment surface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from copy import deepcopy
|
||||
from datetime import date
|
||||
from itertools import combinations
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, QRect, QSize, Qt
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QTabBar,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from doctor_workstation.ui import shell as shell_module
|
||||
from doctor_workstation.ui.pages import appointments as appointments_module
|
||||
from doctor_workstation.ui.pages.appointments import AppointmentsPage
|
||||
from doctor_workstation.ui.shell import NavigationItem, ShellWindow
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
class _Repository:
|
||||
def __init__(self) -> None:
|
||||
self.rows = [
|
||||
{
|
||||
"id": identifier,
|
||||
"patient_name": f"测试患者{identifier}",
|
||||
"patient_phone": "13800001234",
|
||||
"gender": 1,
|
||||
"age": 36,
|
||||
"status": 1,
|
||||
"status_desc": "待接诊",
|
||||
"appointment_date": date.today().isoformat(),
|
||||
"appointment_time": "09:00-09:30",
|
||||
"doctor_name": "测试医生",
|
||||
"doctor_id": 21,
|
||||
"diagnosis_id": identifier + 100,
|
||||
"source_patient_id": identifier + 200,
|
||||
"diagnosis_confirmed": True,
|
||||
"channel_name": "测试渠道",
|
||||
}
|
||||
for identifier in (401, 402, 403)
|
||||
]
|
||||
self.queries: list[dict[str, Any]] = []
|
||||
|
||||
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
|
||||
self.queries.append(kwargs)
|
||||
return {
|
||||
"lists": deepcopy(self.rows),
|
||||
"count": len(self.rows),
|
||||
"extend": {"status_count": {"1": len(self.rows)}},
|
||||
}
|
||||
|
||||
def list_departments(self) -> list[dict[str, Any]]:
|
||||
return [{"id": 10, "name": "测试部门", "children": []}]
|
||||
|
||||
|
||||
class _QuietPage(QWidget):
|
||||
def __init__(self, _repository: Any, *, parent: QWidget, **_kwargs: Any) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(3):
|
||||
application.processEvents()
|
||||
QTest.qWait(5)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
apply_theme(application)
|
||||
return application
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def window_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
def run_inline(function: Any, *, on_success=None, on_error=None, on_finished=None):
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
if on_error:
|
||||
on_error(error)
|
||||
raise
|
||||
else:
|
||||
if on_success:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished:
|
||||
on_finished()
|
||||
|
||||
def reject_network(*_args: Any, **_kwargs: Any):
|
||||
pytest.fail("The appointment visual tests must stay offline")
|
||||
|
||||
monkeypatch.setattr(socket.socket, "connect", reject_network)
|
||||
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
|
||||
monkeypatch.setattr(socket, "create_connection", reject_network)
|
||||
monkeypatch.setattr(appointments_module, "run_async", run_inline)
|
||||
monkeypatch.setattr(shell_module.motion, "reduced_motion", lambda: True)
|
||||
navigation = [
|
||||
NavigationItem("appointments", "挂号列表", "号", AppointmentsPage, ("doctor.appointment/lists",)),
|
||||
NavigationItem("reception", "接诊台", "◎", _QuietPage, ("doctor.appointment/lists",)),
|
||||
NavigationItem("patients", "我的患者", "患", _QuietPage, ("firstvisit.myPatient/lists",)),
|
||||
NavigationItem("prescriptions", "已开处方", "笺", _QuietPage, ("tcm.prescription/lists",)),
|
||||
NavigationItem("legacy_reference", "参考页", "参", _QuietPage, ()),
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
shell_module, "_resolve_navigation",
|
||||
lambda *_args, **_kwargs: [(item, item.title) for item in navigation],
|
||||
)
|
||||
windows = []
|
||||
|
||||
def create(*, admin: bool = False, width: int = 1536, height: int = 960):
|
||||
repository = _Repository()
|
||||
shell = ShellWindow(
|
||||
repository,
|
||||
{"user": {"name": "测试医生", "role_id": 3 if admin else 1}, "demo_mode": True},
|
||||
permissions={"*"},
|
||||
)
|
||||
windows.append(shell)
|
||||
shell.resize(width, height)
|
||||
shell.show()
|
||||
_settle(application)
|
||||
page = shell.pages["appointments"]
|
||||
page.poll_timer.stop()
|
||||
return shell, page, repository
|
||||
|
||||
yield create
|
||||
for shell in windows:
|
||||
shell.close()
|
||||
shell.deleteLater()
|
||||
_settle(application)
|
||||
|
||||
|
||||
def _selector(page: AppointmentsPage, row: int) -> QCheckBox:
|
||||
host = page.table.cellWidget(row, 0)
|
||||
assert host is not None
|
||||
selector = host.findChild(QCheckBox)
|
||||
assert selector is not None
|
||||
return selector
|
||||
|
||||
|
||||
def _assert_selection_matches(page: AppointmentsPage) -> None:
|
||||
table = page.table
|
||||
selected = {index.row() for index in table.selectionModel().selectedRows()}
|
||||
assert selected == {table.currentRow()}
|
||||
assert sum(_selector(page, row).isChecked() for row in range(table.rowCount())) == 1
|
||||
for row in range(table.rowCount()):
|
||||
assert _selector(page, row).isChecked() == (row in selected)
|
||||
assert table.cellWidget(row, 0).property("selected") == (row in selected)
|
||||
|
||||
|
||||
def test_real_selector_tracks_initial_row_click_repeat_click_and_refresh(
|
||||
application: QApplication, window_factory
|
||||
) -> None:
|
||||
shell, page, repository = window_factory()
|
||||
assert page.table.rowCount() == 3
|
||||
_assert_selection_matches(page)
|
||||
checkbox = _selector(page, 1)
|
||||
QTest.mouseClick(checkbox, Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
assert page.table.currentRow() == 1
|
||||
_assert_selection_matches(page)
|
||||
|
||||
# Clicking the selected checkbox cannot leave the selected patient unchecked.
|
||||
QTest.mouseClick(checkbox, Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
_assert_selection_matches(page)
|
||||
assert page.table.currentRow() == 1
|
||||
|
||||
target = page.table.item(2, 2)
|
||||
QTest.mouseClick(
|
||||
page.table.viewport(), Qt.MouseButton.LeftButton,
|
||||
pos=page.table.visualItemRect(target).center(),
|
||||
)
|
||||
_settle(application)
|
||||
assert page.table.currentRow() == 2
|
||||
_assert_selection_matches(page)
|
||||
selected_id = page.table.current_data()["id"]
|
||||
|
||||
shell.refresh_button.click()
|
||||
_settle(application)
|
||||
assert page.table.current_data()["id"] == selected_id
|
||||
_assert_selection_matches(page)
|
||||
|
||||
# Changed server data rebuilds the cells, unlike unchanged polling.
|
||||
repository.rows[0]["channel_name"] = "更新后的测试渠道"
|
||||
shell.refresh_button.click()
|
||||
_settle(application)
|
||||
assert page.table.current_data()["id"] == selected_id
|
||||
_assert_selection_matches(page)
|
||||
|
||||
|
||||
def test_selector_selects_its_visible_patient_after_sorting(
|
||||
application: QApplication, window_factory
|
||||
) -> None:
|
||||
_shell, page, _repository = window_factory()
|
||||
# A user can change sorting after cell widgets have already been installed.
|
||||
first_id = page.table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"]
|
||||
header = page.table.horizontalHeader()
|
||||
header_position = QPoint(
|
||||
header.sectionViewportPosition(1) + header.sectionSize(1) // 2,
|
||||
header.height() // 2,
|
||||
)
|
||||
for _ in range(2):
|
||||
QTest.mouseClick(header.viewport(), Qt.MouseButton.LeftButton, pos=header_position)
|
||||
_settle(application)
|
||||
if page.table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"] != first_id:
|
||||
break
|
||||
expected_id = page.table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"]
|
||||
assert expected_id != first_id
|
||||
QTest.mouseClick(_selector(page, 0), Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
assert page.table.current_data()["id"] == expected_id
|
||||
_assert_selection_matches(page)
|
||||
QTest.mouseClick(_selector(page, 0), Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
assert page.table.current_data()["id"] == expected_id
|
||||
_assert_selection_matches(page)
|
||||
|
||||
|
||||
def _rect(widget: QWidget, ancestor: QWidget) -> QRect:
|
||||
return QRect(widget.mapTo(ancestor, QPoint()), widget.size())
|
||||
|
||||
|
||||
def _assert_filter_layout(page: AppointmentsPage) -> None:
|
||||
types = (QPushButton, QComboBox, QLineEdit, QTabBar, QLabel)
|
||||
controls = [widget for widget in page.filter_panel.findChildren(QWidget)
|
||||
if isinstance(widget, types) and widget.isVisible()]
|
||||
panel = page.filter_panel.rect()
|
||||
for widget in controls:
|
||||
assert panel.contains(_rect(widget, page.filter_panel)), widget.objectName()
|
||||
for first, second in combinations(controls, 2):
|
||||
if first.isAncestorOf(second) or second.isAncestorOf(first):
|
||||
continue
|
||||
overlap = _rect(first, page).intersected(_rect(second, page))
|
||||
assert overlap.isEmpty(), (first.objectName(), second.objectName(), overlap)
|
||||
assert not _rect(page.filter_panel, page).intersects(_rect(page.table_card, page))
|
||||
assert page.table.viewport().height() > 80
|
||||
|
||||
|
||||
@pytest.mark.parametrize("admin", [False, True], ids=["doctor", "admin"])
|
||||
@pytest.mark.parametrize(("width", "height"), [(1536, 960), (1366, 768), (1024, 640)])
|
||||
def test_more_filters_fit_exact_window_and_preserve_all_columns(
|
||||
application: QApplication, window_factory, admin: bool, width: int, height: int
|
||||
) -> None:
|
||||
shell, page, _repository = window_factory(admin=admin, width=width, height=height)
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
_settle(application)
|
||||
for expanded in (False, True, False):
|
||||
if page.more_filters_button.isChecked() != expanded:
|
||||
QTest.mouseClick(page.more_filters_button, Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
assert shell.size() == QSize(width, height)
|
||||
assert page.advanced_filters.isVisible() == expanded
|
||||
assert page.dept_filter.isVisible() == expanded
|
||||
assert page.doctor_input.isVisible() == (expanded and admin)
|
||||
assert page.custom_date_button.isVisible() == expanded
|
||||
assert page.reset_filter_button.isVisible() == expanded
|
||||
_assert_filter_layout(page)
|
||||
assert page.table.columnCount() == 11
|
||||
assert all(not page.table.isColumnHidden(column) for column in range(11))
|
||||
assert [page.table.horizontalHeaderItem(column).text() for column in range(11)] == [
|
||||
"", "ID", "患者", "性别 / 年龄", "挂号信息", "确认", "复诊", "助理", "开方", "未服务天数", "IM 问诊",
|
||||
]
|
||||
if width == 1024:
|
||||
assert page.table.horizontalScrollBar().maximum() > 0
|
||||
page.table.horizontalScrollBar().setValue(page.table.horizontalScrollBar().maximum())
|
||||
_settle(application)
|
||||
right = page.table.columnViewportPosition(10) + page.table.columnWidth(10)
|
||||
assert right <= page.table.viewport().width()
|
||||
|
||||
|
||||
def test_page_typography_remains_compact_and_chrome_restores(
|
||||
application: QApplication, window_factory
|
||||
) -> None:
|
||||
shell, page, _repository = window_factory()
|
||||
original_qss = application.styleSheet()
|
||||
assert page.header.title_label.font().pixelSize() == 20
|
||||
assert page.patient_input.font().pixelSize() == 14
|
||||
assert page.table.font().pixelSize() == 14
|
||||
assert page.table.item(0, 2).font().pixelSize() == 14
|
||||
assert page.header.subtitle_label.font().pixelSize() == 13
|
||||
assert page.more_filters_button.font().pixelSize() == 13
|
||||
assert shell.sidebar.width() == 208
|
||||
assert shell.topbar.height() == 76
|
||||
assert shell.navigate("reception")
|
||||
_settle(application)
|
||||
assert shell.sidebar.width() == 208
|
||||
assert shell.topbar.height() == 76
|
||||
assert shell.navigate("patients")
|
||||
_settle(application)
|
||||
assert shell.sidebar.width() == 208
|
||||
assert shell.topbar.height() == 76
|
||||
assert shell.navigate("prescriptions")
|
||||
_settle(application)
|
||||
assert shell.sidebar.width() == 208
|
||||
assert shell.topbar.height() == 76
|
||||
assert shell.navigate("legacy_reference")
|
||||
_settle(application)
|
||||
assert shell.sidebar.width() == 190
|
||||
assert shell.topbar.height() == 62
|
||||
assert shell.workspace.pos() == QPoint(203, 13)
|
||||
assert shell.fold_button.isVisible()
|
||||
assert not shell.menu_sidebar_action.isVisible()
|
||||
for widget, stylesheet in shell._legacy_chrome_styles.items():
|
||||
assert widget.styleSheet() == stylesheet
|
||||
assert shell.navigate("appointments")
|
||||
_settle(application)
|
||||
assert shell.sidebar.width() == 208
|
||||
assert shell.topbar.height() == 76
|
||||
assert page.header.title_label.font().pixelSize() == 20
|
||||
assert application.styleSheet() == original_qss
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Native Qt coverage for presentation-only filter folding on both queues."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, QRect, Qt, Signal
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QPushButton, QWidget
|
||||
|
||||
from doctor_workstation.ui import shell as shell_module
|
||||
from doctor_workstation.ui.pages import appointments, consultations
|
||||
from doctor_workstation.ui.shell import NavigationItem, ShellWindow
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
class _Dialog(QWidget):
|
||||
saved = Signal()
|
||||
|
||||
def __init__(self, _repository: Any, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
|
||||
class _Repository:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
def _list(self, **query: Any) -> dict[str, Any]:
|
||||
self.calls.append(query)
|
||||
rows = [{
|
||||
"id": index, "diagnosis_id": index, "patient_id": index + 2000,
|
||||
"source_patient_id": index + 2000, "patient_name": f"演示患者{index:02d}",
|
||||
"patient_phone": "13800001234", "gender": 2, "age": 38,
|
||||
"status": 1, "status_desc": "待接诊", "doctor_name": "演示医生",
|
||||
"doctor_id": 30, "assistant_name": "演示医助", "has_appointment": True,
|
||||
"appointment_id": index + 1000, "appointment_status": 1,
|
||||
"appointment_date": date.today().isoformat(), "appointment_time": "09:00-09:30",
|
||||
"diagnosis_confirmed": True, "has_prescription": False,
|
||||
"appointments": [{"id": index + 1000, "status": 1,
|
||||
"doctor_name": "演示医生", "time_text": "09:00-09:30"}],
|
||||
} for index in range(1, 16)]
|
||||
return {"lists": rows, "count": 15, "extend": {"status_count": {"1": 15}}}
|
||||
|
||||
list_appointments = _list
|
||||
list_consultations = _list
|
||||
|
||||
|
||||
def _inline(function: Any, *, on_success=None, on_error=None, on_finished=None) -> None:
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
if on_error is not None:
|
||||
on_error(error)
|
||||
else:
|
||||
if on_success is not None:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
QTest.qWait(10)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def list_window(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(appointments, "run_async", _inline)
|
||||
monkeypatch.setattr(consultations, "run_async", _inline)
|
||||
monkeypatch.setattr(consultations, "DiagnosisDialog", _Dialog)
|
||||
monkeypatch.setattr(consultations.ConsultationsPage, "_load_filter_options", lambda self: None)
|
||||
monkeypatch.setattr(consultations.ConsultationsPage, "_refresh_counts", lambda self: None)
|
||||
monkeypatch.setattr(appointments.AppointmentsPage, "_load_departments", lambda self: None)
|
||||
monkeypatch.setattr(shell_module.motion, "reduced_motion", lambda: True)
|
||||
navigation = [
|
||||
NavigationItem("appointments", "挂号列表", "号", appointments.AppointmentsPage,
|
||||
("doctor.appointment/lists",)),
|
||||
NavigationItem("consultations", "问诊列表", "询", consultations.ConsultationsPage,
|
||||
("tcm.diagnosis/lists",)),
|
||||
]
|
||||
monkeypatch.setattr(shell_module, "_resolve_navigation",
|
||||
lambda *_args, **_kwargs: [(item, item.title) for item in navigation])
|
||||
repository = _Repository()
|
||||
window = ShellWindow(repository, {"user": {"name": "演示医生", "role_id": 1},
|
||||
"demo_mode": True}, permissions={"*"})
|
||||
yield window, repository
|
||||
window.close()
|
||||
window.deleteLater()
|
||||
_settle(application)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["appointments", "consultations"])
|
||||
@pytest.mark.parametrize(("width", "height"), [(1366, 768), (1536, 960)])
|
||||
def test_folding_reclaims_rows_and_retains_query_refresh_and_navigation_state(
|
||||
application: QApplication, list_window: Any, kind: str, width: int, height: int,
|
||||
) -> None:
|
||||
window, repository = list_window
|
||||
window.resize(width, height)
|
||||
window.show()
|
||||
assert window.navigate(kind)
|
||||
_settle(application)
|
||||
page = window.pages[kind]
|
||||
page.poll_timer.stop()
|
||||
panel = page.filter_panel if kind == "appointments" else page.filters_card
|
||||
header = page.header if kind == "appointments" else page.page_header
|
||||
search = page.patient_input if kind == "appointments" else page.patient_name_edit
|
||||
nested = page.more_filters_button if kind == "appointments" else page.more_filter_button
|
||||
action = page.toolbar_edit_button if kind == "appointments" else page.add_button
|
||||
disclosure = page.filter_disclosure
|
||||
filters = page._query_filters if kind == "appointments" else page._filters
|
||||
assert not disclosure.expanded
|
||||
assert panel.isHidden() and not search.isVisible()
|
||||
assert header.height() <= 44
|
||||
assert not header.subtitle_label.isVisible()
|
||||
assert disclosure.button.isVisible() and action.isVisible()
|
||||
assert 30 <= disclosure.button.height() <= 34
|
||||
assert window.refresh_button.isVisible()
|
||||
assert any(button.isVisible() and button.text() == "刷新"
|
||||
for button in page.findChildren(QPushButton))
|
||||
collapsed_height = page.table.viewport().height()
|
||||
capture_dir = os.environ.get("COLLAPSIBLE_LIST_SCREENSHOTS")
|
||||
if capture_dir:
|
||||
Path(capture_dir).mkdir(parents=True, exist_ok=True)
|
||||
assert window.grab().save(str(Path(capture_dir) / f"{kind}-{width}-collapsed.png"))
|
||||
|
||||
calls_before = len(repository.calls)
|
||||
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
assert len(repository.calls) == calls_before
|
||||
assert panel.isVisible() and search.isVisible()
|
||||
assert collapsed_height >= page.table.viewport().height() + 100
|
||||
assert panel.rect().contains(QRect(search.mapTo(panel, QPoint()), search.size()))
|
||||
assert search.parentWidget().rect().contains(search.geometry())
|
||||
assert not page.advanced_filters.isVisible()
|
||||
if capture_dir:
|
||||
assert window.grab().save(str(Path(capture_dir) / f"{kind}-{width}-expanded.png"))
|
||||
(Path(capture_dir) / f"{kind}-{width}-metrics.json").write_text(
|
||||
json.dumps({"window": [width, height], "header_height": header.height(),
|
||||
"toggle_height": disclosure.button.height(),
|
||||
"collapsed_viewport_height": collapsed_height,
|
||||
"expanded_viewport_height": page.table.viewport().height(),
|
||||
"viewport_gain": collapsed_height - page.table.viewport().height()},
|
||||
indent=2), encoding="utf-8",
|
||||
)
|
||||
|
||||
search.setText("演示患者")
|
||||
query_button = (page.findChild(QPushButton, "AppointmentSearchButton")
|
||||
if kind == "appointments" else page.search_button)
|
||||
QTest.mouseClick(query_button, Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
assert len(repository.calls) > calls_before
|
||||
assert repository.calls[-1]["patient_name"] == "演示患者"
|
||||
QTest.mouseClick(nested, Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
assert page.advanced_filters.isVisible()
|
||||
query = dict(filters())
|
||||
calls_before = len(repository.calls)
|
||||
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
assert len(repository.calls) == calls_before
|
||||
assert filters() == query
|
||||
assert search.text() == "演示患者"
|
||||
assert panel.isHidden()
|
||||
assert page.table.viewport().height() == collapsed_height
|
||||
|
||||
window.resize(width - 50, height - 20)
|
||||
window.refresh_button.click()
|
||||
_settle(application)
|
||||
assert not disclosure.expanded and panel.isHidden()
|
||||
assert filters() == query
|
||||
other = "consultations" if kind == "appointments" else "appointments"
|
||||
assert window.navigate(other)
|
||||
assert window.navigate(kind)
|
||||
_settle(application)
|
||||
assert not disclosure.expanded and panel.isHidden()
|
||||
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
assert page.advanced_filters.isVisible() and nested.isChecked()
|
||||
window.refresh_button.click()
|
||||
_settle(application)
|
||||
assert disclosure.expanded and panel.isVisible()
|
||||
assert filters() == query
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Page disclosures reclaim list space without changing queries or tab state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QDate, QPoint, QRect, Qt, QTimer
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.pages import patients as patients_module
|
||||
from doctor_workstation.ui.pages.patients import PatientsPage
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
class Repository:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
def _result(self, kind: str, query: dict[str, Any]) -> dict[str, Any]:
|
||||
self.calls.append((kind, deepcopy(query)))
|
||||
return {
|
||||
"lists": [{
|
||||
"id": 101 + index, "diagnosis_id": 501 + index,
|
||||
"patient_id": 301 + index, "patient_name": f"患者 {index + 1}",
|
||||
"order_no": f"TEST-20260907-{index + 1}", "queue_no": index + 1,
|
||||
"appointment_status": 1, "queue_status": "waiting",
|
||||
"queue_status_text": "等待中", "doctor_name": "测试医生",
|
||||
} for index in range(4)],
|
||||
"count": 4,
|
||||
"extend": {
|
||||
"scope": {"label": "本人患者"}, "schedule_mode": "ownership",
|
||||
"summary": {"waiting": 4, "today": 4, "orders": 4},
|
||||
},
|
||||
}
|
||||
|
||||
def list_patients(self, **query: Any) -> dict[str, Any]:
|
||||
return self._result("patients", query)
|
||||
|
||||
def patient_orders(self, **query: Any) -> dict[str, Any]:
|
||||
return self._result("orders", query)
|
||||
|
||||
def patient_progress(self, **query: Any) -> dict[str, Any]:
|
||||
return self._result("progress", query)
|
||||
|
||||
|
||||
def settle(application: QApplication) -> None:
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
apply_theme(application)
|
||||
return application
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page_factory(application, monkeypatch):
|
||||
def immediate(function, *, on_success=None, on_error=None, on_finished=None):
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
if on_error:
|
||||
on_error(error)
|
||||
raise
|
||||
else:
|
||||
if on_success:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished:
|
||||
on_finished()
|
||||
|
||||
def reject_network(*_args, **_kwargs):
|
||||
pytest.fail("Disclosure tests must use local fixture data")
|
||||
|
||||
monkeypatch.setattr(patients_module, "run_async", immediate)
|
||||
monkeypatch.setattr(socket.socket, "connect", reject_network)
|
||||
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
|
||||
monkeypatch.setattr(socket, "create_connection", reject_network)
|
||||
opened = []
|
||||
|
||||
def create(width=1328, height=884):
|
||||
repository = Repository()
|
||||
page = PatientsPage(repository, PermissionSet(["*"]))
|
||||
opened.append(page)
|
||||
page.resize(width, height)
|
||||
page.show()
|
||||
settle(application)
|
||||
return page, repository
|
||||
|
||||
yield create
|
||||
for page in opened:
|
||||
for timer in page.findChildren(QTimer):
|
||||
timer.stop()
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
settle(application)
|
||||
|
||||
|
||||
def regions(page, index):
|
||||
return (
|
||||
(page.patient_workspace.search_toolbar, page.patient_workspace.filter_card,
|
||||
page.patient_workspace.summary_strip),
|
||||
(page.order_workspace.filter_card, page.order_workspace.summary_strip),
|
||||
(page.progress_workspace.overview_card, page.progress_workspace.schedule_card),
|
||||
)[index]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("index", [0, 1, 2], ids=["patients", "orders", "progress"])
|
||||
def test_tabs_default_collapsed_and_keyboard_expansion_gives_space_to_lists(
|
||||
application, page_factory, index
|
||||
):
|
||||
page, repository = page_factory()
|
||||
page.tabs.setCurrentIndex(index)
|
||||
settle(application)
|
||||
workspace = page.tabs.currentWidget()
|
||||
table = workspace.queue_table if index == 2 else workspace.table
|
||||
disclosure = page.filter_disclosure
|
||||
assert not disclosure.expanded
|
||||
assert all(widget.isHidden() for widget in regions(page, index))
|
||||
assert sum(item.button.isVisible() for item in page.filter_disclosures) == 1
|
||||
assert disclosure.button.text() == disclosure.button.accessibleName() == "展开筛选"
|
||||
assert disclosure.button.height() == 32
|
||||
assert page.header.height() <= 48
|
||||
assert not page.header.subtitle_label.isVisible()
|
||||
assert page.refresh_button.isVisible() and page.tabs.tabBar().isVisible()
|
||||
collapsed_height = table.viewport().height()
|
||||
before = deepcopy(repository.calls)
|
||||
disclosure.button.setFocus()
|
||||
QTest.keyClick(disclosure.button, Qt.Key.Key_Space)
|
||||
settle(application)
|
||||
assert disclosure.expanded and disclosure.button.text() == "收起筛选"
|
||||
assert all(widget.isVisible() for widget in regions(page, index))
|
||||
assert collapsed_height >= table.viewport().height() + 100
|
||||
assert repository.calls == before
|
||||
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
|
||||
settle(application)
|
||||
assert table.viewport().height() == collapsed_height
|
||||
assert table.isVisible() and workspace.pager.isVisible()
|
||||
assert repository.calls == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("index", [0, 1], ids=["patient-filters", "order-filters"])
|
||||
def test_search_values_survive_collapse_refresh_and_tab_switch(
|
||||
application, page_factory, index
|
||||
):
|
||||
page, repository = page_factory()
|
||||
page.tabs.setCurrentIndex(index)
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
settle(application)
|
||||
workspace = page.tabs.currentWidget()
|
||||
workspace.keyword_edit.setText(" 林青 ")
|
||||
if index == 0:
|
||||
QTest.mouseClick(workspace.custom_date_button, Qt.MouseButton.LeftButton)
|
||||
else:
|
||||
workspace.use_dates.setChecked(True)
|
||||
workspace.rx_audit.setCurrentIndex(2)
|
||||
workspace.start_date.setDate(QDate(2026, 9, 1))
|
||||
workspace.end_date.setDate(QDate(2026, 9, 7))
|
||||
QTest.mouseClick(workspace.search_button, Qt.MouseButton.LeftButton)
|
||||
settle(application)
|
||||
query = deepcopy(repository.calls[-1])
|
||||
before = deepcopy(repository.calls)
|
||||
page.filter_disclosure.set_expanded(False)
|
||||
settle(application)
|
||||
assert repository.calls == before
|
||||
assert workspace.keyword_edit.text() == " 林青 "
|
||||
assert workspace.start_date.date() == QDate(2026, 9, 1)
|
||||
assert workspace.end_date.date() == QDate(2026, 9, 7)
|
||||
QTest.mouseClick(page.refresh_button, Qt.MouseButton.LeftButton)
|
||||
settle(application)
|
||||
assert repository.calls[-1] == query
|
||||
assert not page.filter_disclosure.expanded
|
||||
page.tabs.setCurrentIndex(2)
|
||||
assert not page.filter_disclosure.expanded
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
page.tabs.setCurrentIndex(index)
|
||||
settle(application)
|
||||
assert not page.filter_disclosure.expanded
|
||||
assert repository.calls[-1] == query
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
settle(application)
|
||||
assert workspace.keyword_edit.isVisible() and workspace.start_date.isEnabled()
|
||||
assert (workspace.custom_date_button.isChecked() if index == 0 else
|
||||
workspace.rx_audit.currentIndex() == 2 and workspace.use_dates.isChecked())
|
||||
page.tabs.setCurrentIndex(2)
|
||||
assert page.filter_disclosure.expanded
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(1328, 884), (1158, 692), (816, 564)])
|
||||
def test_resize_keeps_collapsed_regions_hidden_and_queue_reachable(
|
||||
application, page_factory, width, height
|
||||
):
|
||||
page, _repository = page_factory()
|
||||
page.resize(width, height)
|
||||
for index in range(3):
|
||||
page.tabs.setCurrentIndex(index)
|
||||
settle(application)
|
||||
workspace = page.tabs.currentWidget()
|
||||
assert all(widget.isHidden() for widget in regions(page, index))
|
||||
for widget in (page.filter_disclosure.button, page.refresh_button):
|
||||
assert page.rect().contains(QRect(widget.mapTo(page, QPoint()), widget.size()))
|
||||
assert workspace.scroll.verticalScrollBar().maximum() == 0
|
||||
assert workspace.pager.isVisibleTo(page)
|
||||
if index == 2:
|
||||
assert workspace.splitter.minimumHeight() == 222
|
||||
assert workspace.queue_card.isVisible()
|
||||
assert workspace.queue_card.height() == workspace.splitter.height()
|
||||
page.hide()
|
||||
page.show()
|
||||
settle(application)
|
||||
assert not page.filter_disclosure.expanded
|
||||
assert all(widget.isHidden() for widget in regions(page, 2))
|
||||
|
||||
|
||||
def test_collapsed_refresh_coalesces_identical_in_flight_queries(
|
||||
application, page_factory, monkeypatch
|
||||
):
|
||||
page, _repository = page_factory()
|
||||
pending = []
|
||||
monkeypatch.setattr(
|
||||
patients_module, "run_async",
|
||||
lambda function, **callbacks: pending.append((function, callbacks)),
|
||||
)
|
||||
page.refresh()
|
||||
page.refresh()
|
||||
assert len(pending) == 1
|
||||
function, callbacks = pending[0]
|
||||
callbacks["on_success"](function())
|
||||
settle(application)
|
||||
assert page.patient_workspace.table.rowCount() == 4
|
||||
assert not page.filter_disclosure.expanded
|
||||
assert not page.patient_workspace.search_toolbar.isVisible()
|
||||
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QDate, QPoint, Qt
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.pages import reception as reception_module
|
||||
from doctor_workstation.ui.pages.reception import ReceptionPage
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
def settle(application):
|
||||
for _ in range(8):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page(application, monkeypatch):
|
||||
monkeypatch.setattr(reception_module, "run_async", lambda *_args, **_kwargs: None)
|
||||
widget = ReceptionPage(object(), PermissionSet(["*"]))
|
||||
widget.resize(1280, 800)
|
||||
widget.show()
|
||||
widget.poll_timer.stop()
|
||||
widget.detail_stack.setCurrentIndex(1)
|
||||
settle(application)
|
||||
yield widget
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
settle(application)
|
||||
|
||||
|
||||
def test_default_fold_reclaims_queue_space_and_keeps_clinical_workspace(page, application):
|
||||
assert not page.filter_disclosure.expanded
|
||||
assert page.filter_disclosure.button.text() == "展开筛选"
|
||||
assert page.refresh_button.isVisible()
|
||||
assert page.queue_filter_summary.isVisible()
|
||||
assert not page.queue_filter_panel.isVisible()
|
||||
assert not page.queue_date_button.isVisible()
|
||||
assert not page.search_edit.isVisible()
|
||||
assert all(not button.isVisible() for button in page._queue_filter_buttons.values())
|
||||
assert page.detail_tabs.isVisible()
|
||||
assert page.patient_name_label.isVisible()
|
||||
assert page.clinical_info_group.isVisible()
|
||||
clinical_geometry = page.detail_stack.geometry()
|
||||
collapsed_height = page.queue_stack.height()
|
||||
|
||||
QTest.mouseClick(page.filter_disclosure.button, Qt.MouseButton.LeftButton)
|
||||
settle(application)
|
||||
assert page.filter_disclosure.expanded
|
||||
assert page.filter_disclosure.button.text() == "收起筛选"
|
||||
assert page.queue_date_button.isVisible()
|
||||
assert page.search_edit.isVisible()
|
||||
assert all(button.isVisible() for button in page._queue_filter_buttons.values())
|
||||
assert collapsed_height - page.queue_stack.height() >= 64
|
||||
assert page.detail_stack.geometry() == clinical_geometry
|
||||
|
||||
|
||||
def test_toggle_keeps_queries_patient_draft_loaded_pages_and_resize_state(page, application, monkeypatch):
|
||||
requests = []
|
||||
monkeypatch.setattr(page, "_request_queue_page", lambda query, **options: requests.append((query, options)))
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
page.queue_date_button._pick_date(QDate(2026, 8, 3))
|
||||
page._queue_filter_buttons[None].click()
|
||||
page.search_edit.setText(" 折叠测试患者 ")
|
||||
QTest.keyClick(page.search_edit, Qt.Key.Key_Return)
|
||||
assert requests[-1][0]["patient_name"] == "折叠测试患者"
|
||||
assert requests[-1][0]["status"] is None
|
||||
assert requests[-1][0]["start_date"] == requests[-1][0]["end_date"] == "2026-08-03"
|
||||
assert page.queue_filter_summary.text() == "2026-08-03 · 全部状态 · 姓名已筛选"
|
||||
assert "折叠测试患者" in page.queue_filter_summary.toolTip()
|
||||
|
||||
page._selected_appointment_id = 51
|
||||
page._queue_records = [{"id": index} for index in range(1, 31)]
|
||||
page._queue_page = 2
|
||||
page._queue_total = 40
|
||||
page.note_edit.setPlainText("尚未保存的接诊备注")
|
||||
page.detail_tabs.setCurrentIndex(4)
|
||||
query_snapshot = dict(page._queue_query)
|
||||
request_count = len(requests)
|
||||
loading = page._queue_loading
|
||||
|
||||
for expanded in (False, True, False):
|
||||
page.filter_disclosure.set_expanded(expanded)
|
||||
page.resize(816 if expanded else 1280, 800)
|
||||
settle(application)
|
||||
assert page.filter_disclosure.expanded is expanded
|
||||
assert page.queue_filter_panel.isVisible() is expanded
|
||||
assert len(requests) == request_count
|
||||
assert page._selected_appointment_id == 51
|
||||
assert page._queue_page == 2
|
||||
assert len(page._queue_records) == 30
|
||||
assert page._queue_loading is loading
|
||||
assert page._queue_query == query_snapshot
|
||||
assert page.search_edit.text() == " 折叠测试患者 "
|
||||
assert page.note_edit.toPlainText() == "尚未保存的接诊备注"
|
||||
assert page.detail_tabs.currentIndex() == 4
|
||||
|
||||
page.refresh(workspace_refresh=True)
|
||||
assert not page.filter_disclosure.expanded
|
||||
assert requests[-1][0]["patient_name"] == "折叠测试患者"
|
||||
assert requests[-1][0]["status"] is None
|
||||
assert requests[-1][0]["start_date"] == "2026-08-03"
|
||||
assert requests[-1][0]["page_size"] == 30
|
||||
assert page._selected_appointment_id == 51
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width", [816, 1280])
|
||||
def test_expanded_queue_controls_fit_at_supported_page_widths(page, application, width):
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
page.resize(width, 800)
|
||||
page._update_queue_filter_counts({"extend": {"status_count": {"1": 23, "2": 11, "3": 42}}}, [])
|
||||
settle(application)
|
||||
panel = page.queue_panel
|
||||
controls = [
|
||||
page.refresh_button,
|
||||
page.filter_disclosure.button,
|
||||
page.queue_date_button,
|
||||
page.search_edit,
|
||||
*page._queue_filter_buttons.values(),
|
||||
]
|
||||
for widget in controls:
|
||||
position = widget.mapTo(panel, QPoint())
|
||||
assert position.x() >= 0
|
||||
assert position.x() + widget.width() <= panel.width()
|
||||
assert widget.width() >= widget.minimumSizeHint().width()
|
||||
assert page.queue_date_button.geometry().right() < page.search_edit.geometry().left()
|
||||
assert page.filter_disclosure.button.height() == page.search_edit.height() == 32
|
||||
@@ -0,0 +1,270 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, Qt
|
||||
from PySide6.QtGui import QFont
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QToolButton, QVBoxLayout, QWidget
|
||||
|
||||
from doctor_workstation.ui.diagnosis_index_widgets import (
|
||||
DiagnosisTableHost,
|
||||
_blue_appointment_layout,
|
||||
_blue_appointment_text,
|
||||
_blue_font,
|
||||
)
|
||||
from doctor_workstation.ui.reception_style import body_family, heading_family
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(12):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def _row(identifier: int = 501) -> dict:
|
||||
return {
|
||||
"id": identifier, "diagnosis_id": identifier, "patient_id": identifier + 1000,
|
||||
"patient_name": "林晓岚", "gender": 2, "age": 46, "doctor_name": "陈医生(演示)",
|
||||
"appointment_id": identifier + 2000, "has_appointment": 1, "appointment_status": 1,
|
||||
"appointment_date": "2026-09-05", "appointment_time": "09:00-09:30",
|
||||
"appointments": [
|
||||
{"id": identifier + 2000, "status": 1, "appointment_date": "2026-09-05"},
|
||||
{"id": identifier + 1900, "status": 3, "appointment_date": "2026-08-06"},
|
||||
],
|
||||
"diagnosis_confirmed": 1, "assistant_name": "周医助", "has_prescription": 0,
|
||||
"unserved_days": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_display_fallback_requires_exact_current_appointment_id(application: QApplication) -> None:
|
||||
row = _row()
|
||||
before = copy.deepcopy(row)
|
||||
assert _blue_appointment_text(row, row["appointments"][0]) == (
|
||||
"陈医生(演示)", "2026-09-05 09:00-09:30",
|
||||
)
|
||||
assert _blue_appointment_text(row, row["appointments"][1]) == (
|
||||
"—", "2026-08-06 时间 —",
|
||||
)
|
||||
nested = {"id": row["appointment_id"], "doctor_name": "原医生",
|
||||
"appointment_date": "2026-09-07", "time_text": "2026-09-07 11:00-11:30"}
|
||||
assert _blue_appointment_text(row, nested) == ("原医生", "2026-09-07 11:00-11:30")
|
||||
assert _blue_appointment_text(row, {}) == ("—", "时间 —")
|
||||
for missing_id in (0, "", None):
|
||||
assert _blue_appointment_text({**row, "appointment_id": missing_id}, {}) == ("—", "时间 —")
|
||||
assert row == before
|
||||
legacy = DiagnosisTableHost()
|
||||
blue = DiagnosisTableHost(tech_blue=True)
|
||||
for host in (legacy, blue):
|
||||
host.set_rows([row])
|
||||
assert "陈医生" not in legacy.model.index(0, 4).data()
|
||||
assert blue.model.index(0, 4).data().splitlines() == [
|
||||
"陈医生(演示) · 2026-09-05 09:00-09:30", "— · 2026-08-06 时间 —",
|
||||
]
|
||||
assert blue.model.index(0, 9).data() == "—"
|
||||
blue.set_rows([{**row, "appointments": [], "appointment_id": None}])
|
||||
assert blue.model.index(0, 4).data() == "— · 时间 —"
|
||||
blue.set_rows([{**row, "appointments": []}])
|
||||
assert blue.model.index(0, 4).data() == "陈医生(演示) · 2026-09-05 09:00-09:30"
|
||||
legacy.close()
|
||||
blue.close()
|
||||
|
||||
|
||||
def test_blue_metrics_are_opt_in_and_expand_for_complete_content(application: QApplication) -> None:
|
||||
blue = DiagnosisTableHost(tech_blue=True)
|
||||
legacy = DiagnosisTableHost()
|
||||
row = _row()
|
||||
single = _row(502)
|
||||
single["appointments"] = single["appointments"][:1]
|
||||
long = copy.deepcopy(single)
|
||||
long["id"] = long["diagnosis_id"] = 503
|
||||
long["appointments"][0]["doctor_name"] = "超长完整医生姓名以及完整门诊部门需要换行" * 2
|
||||
long["latest_appointment_channel_text"] = "完整保留的挂号渠道说明" * 4
|
||||
long["has_prescription"] = 1
|
||||
long["followup_time_text"] = "2026-10-15 09:00-09:30"
|
||||
long["followup_doctor_name"] = "复诊医生姓名"
|
||||
long["followup_rx_voided"] = True
|
||||
blue.set_rows([row, single, long])
|
||||
legacy.set_rows([row])
|
||||
assert blue.model.columnCount() == 12
|
||||
assert [blue.main.columnWidth(i) for i in range(10)] == [48, 70, 82, 102, 244, 90, 84, 90, 88, 122]
|
||||
assert blue.main.horizontalHeader().height() == 41
|
||||
assert legacy.main.horizontalHeader().height() == 39
|
||||
assert blue.main.rowHeight(0) == 108
|
||||
assert blue.main.rowHeight(1) == 72
|
||||
assert blue.main.rowHeight(2) > 110
|
||||
assert legacy.main.rowHeight(0) == 96
|
||||
for index in range(3):
|
||||
assert blue.main.rowHeight(index) == blue.fixed.rowHeight(index)
|
||||
assert _blue_font(14).family() == body_family()
|
||||
assert _blue_font(14, medium=True).family() == heading_family()
|
||||
assert _blue_font(14, medium=True).weight() == QFont.Weight.Medium
|
||||
assert _blue_font(13).pixelSize() == 13
|
||||
blue.close()
|
||||
legacy.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tech_blue", [False, True])
|
||||
def test_geometry_has_no_minimum_height_feedback_and_tracks_both_scroll_axes(
|
||||
application: QApplication, tech_blue: bool,
|
||||
) -> None:
|
||||
window = QWidget()
|
||||
layout = QVBoxLayout(window)
|
||||
layout.setContentsMargins(7, 7, 7, 7)
|
||||
host = DiagnosisTableHost(tech_blue=tech_blue)
|
||||
layout.addWidget(host, 1)
|
||||
footer = QLabel("始终可达的分页")
|
||||
footer.setFixedHeight(42)
|
||||
layout.addWidget(footer)
|
||||
host.set_rows([_row(i) for i in range(40)])
|
||||
window.resize(1280, 640)
|
||||
window.show()
|
||||
_settle(application)
|
||||
minimum = window.minimumSizeHint().height()
|
||||
for width, height in ((780, 480), (1440, 780), (800, 540), (1280, 640)) * 3:
|
||||
window.resize(width, height)
|
||||
_settle(application)
|
||||
# The previous implementation wrote the available height into the
|
||||
# fixed child's minimum and enlarged every ancestor on each relayout.
|
||||
assert window.size().height() == height
|
||||
assert window.minimumSizeHint().height() == minimum
|
||||
assert host.fixed.minimumHeight() == 0
|
||||
assert host.main.viewport().height() == host.fixed.viewport().height()
|
||||
assert host.main.viewport().mapTo(host, QPoint()).y() == host.fixed.viewport().mapTo(host, QPoint()).y()
|
||||
main_scroll, fixed_scroll = host.main.verticalScrollBar(), host.fixed.verticalScrollBar()
|
||||
assert main_scroll.maximum() == fixed_scroll.maximum()
|
||||
main_scroll.setValue(main_scroll.maximum() // 2)
|
||||
_settle(application)
|
||||
assert main_scroll.value() == fixed_scroll.value()
|
||||
fixed_scroll.setValue(fixed_scroll.maximum())
|
||||
_settle(application)
|
||||
assert main_scroll.value() == fixed_scroll.value()
|
||||
frozen_x = host.fixed.x()
|
||||
host.main.horizontalScrollBar().setValue(host.main.horizontalScrollBar().maximum())
|
||||
_settle(application)
|
||||
assert host.fixed.x() == frozen_x
|
||||
assert host.fixed.geometry().right() <= host.rect().right()
|
||||
assert footer.mapTo(window, QPoint()).y() + footer.height() <= window.height()
|
||||
window.close()
|
||||
_settle(application)
|
||||
|
||||
|
||||
def test_checkbox_toggle_and_select_all_do_not_change_current_patient(application: QApplication) -> None:
|
||||
host = DiagnosisTableHost(tech_blue=True)
|
||||
host.resize(1300, 420)
|
||||
host.set_rows([_row(501), _row(502)])
|
||||
host.show()
|
||||
_settle(application)
|
||||
host.main.selectRow(1)
|
||||
box = host.main.visualRect(host.model.index(1, 0)).center()
|
||||
QTest.mouseClick(host.main.viewport(), Qt.MouseButton.LeftButton, pos=box)
|
||||
assert [row["id"] for row in host.selected_records()] == [502]
|
||||
QTest.mouseClick(host.main.viewport(), Qt.MouseButton.LeftButton, pos=box)
|
||||
assert host.selected_records() == []
|
||||
assert host.main.currentRow() == 1
|
||||
header = host.main.horizontalHeader()
|
||||
QTest.mouseClick(header.viewport(), Qt.MouseButton.LeftButton, pos=QPoint(20, 20))
|
||||
assert {row["id"] for row in host.selected_records()} == {501, 502}
|
||||
assert host.main.currentRow() == 1
|
||||
host.main.selectRow(0)
|
||||
assert {row["id"] for row in host.selected_records()} == {501, 502}
|
||||
QTest.mouseClick(header.viewport(), Qt.MouseButton.LeftButton, pos=QPoint(20, 20))
|
||||
assert host.selected_records() == []
|
||||
host.close()
|
||||
|
||||
|
||||
def test_exact_appointment_cancel_hit_targets_and_video_gates(application: QApplication) -> None:
|
||||
host = DiagnosisTableHost(tech_blue=True, action_policy={
|
||||
"view": True, "edit": True, "appointment_cancel": True, "video_call": True,
|
||||
})
|
||||
row = _row()
|
||||
# Make the second entry cancellable and the first doctor wrap: hit regions
|
||||
# must follow measured content, not a legacy fixed 42-pixel multiplier.
|
||||
row["appointments"][0]["doctor_name"] = "医生与门诊信息完整保留" * 3
|
||||
row["appointments"][1]["status"] = 4
|
||||
host.set_rows([row])
|
||||
host.resize(1300, 500)
|
||||
host.show()
|
||||
_settle(application)
|
||||
received = []
|
||||
host.appointment_cancel_requested.connect(lambda record, identifier: received.append((record["id"], identifier)))
|
||||
overlay = host.main.indexWidget(host.model.index(0, 4))
|
||||
buttons = overlay.findChildren(QToolButton)
|
||||
assert len(buttons) == 2
|
||||
entries, total = _blue_appointment_layout(row, overlay.width())
|
||||
for position, button in enumerate(buttons):
|
||||
assert button.y() == max(9, (overlay.height() - total) // 2) + entries[position][0]
|
||||
assert button.geometry().bottom() < overlay.height()
|
||||
button.click()
|
||||
assert received == [(501, 2501), (501, 2401)]
|
||||
more = host.fixed.indexWidget(host.model.index(0, 11)).findChild(QToolButton, "DiagnosisRowMore")
|
||||
assert "取消挂号" not in [action.text() for action in more.menu().actions()]
|
||||
assert not host.fixed.indexWidget(host.model.index(0, 10)).findChildren(QToolButton)
|
||||
row["video_call_hint"] = {"state": "live"}
|
||||
host.set_rows([row])
|
||||
video = host.fixed.indexWidget(host.model.index(0, 10)).findChild(QToolButton)
|
||||
assert video is not None and video.isEnabled()
|
||||
row["patient_id"] = 0
|
||||
host.set_rows([row])
|
||||
video = host.fixed.indexWidget(host.model.index(0, 10)).findChild(QToolButton)
|
||||
assert video is not None and not video.isEnabled()
|
||||
host.action_policy["appointment_cancel"] = False
|
||||
host.set_rows([row])
|
||||
assert host.main.indexWidget(host.model.index(0, 4)) is None
|
||||
host.close()
|
||||
|
||||
|
||||
def test_real_shell_navigation_has_no_fixed_height_stack_overflow(tmp_path: Path) -> None:
|
||||
script = tmp_path / "shell_navigation.py"
|
||||
script.write_text('''
|
||||
import socket
|
||||
from unittest.mock import patch
|
||||
from PySide6.QtCore import Qt, QThreadPool
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui import ShellWindow, apply_theme
|
||||
app = QApplication([])
|
||||
apply_theme(app)
|
||||
def settle():
|
||||
for _ in range(12):
|
||||
QThreadPool.globalInstance().waitForDone(1000)
|
||||
app.processEvents()
|
||||
with patch.object(socket.socket, "connect", side_effect=RuntimeError("offline test")):
|
||||
repo = DemoDoctorRepository()
|
||||
session = repo.login(repo.DEMO_ACCOUNT, repo.DEMO_PASSWORD)
|
||||
shell = ShellWindow(repo, {"session": session, "demo_mode": True}, permissions=session.permissions)
|
||||
shell.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
|
||||
shell.resize(1536, 960)
|
||||
shell.show()
|
||||
settle()
|
||||
for route in ("consultations", "appointments", "consultations"):
|
||||
shell.navigate(route)
|
||||
settle()
|
||||
host = shell.pages["consultations"].table_host
|
||||
for width, height in ((1024, 640), (1536, 960), (1366, 768)):
|
||||
shell.resize(width, height)
|
||||
settle()
|
||||
assert host.main.viewport().height() == host.fixed.viewport().height()
|
||||
assert host.fixed.minimumHeight() == 0
|
||||
assert shell.height() == height
|
||||
shell.close()
|
||||
settle()
|
||||
print("native navigation geometry stable")
|
||||
''', encoding="utf-8")
|
||||
env = {**os.environ, "QT_QPA_PLATFORM": "offscreen", "DOCTOR_SMOKE_TEST": "1",
|
||||
"PYTHONPATH": str(Path(__file__).resolve().parents[1] / "src")}
|
||||
result = subprocess.run([sys.executable, str(script)], capture_output=True, text=True,
|
||||
encoding="utf-8", errors="replace", env=env, timeout=90)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "native navigation geometry stable" in result.stdout
|
||||
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint
|
||||
from PySide6.QtGui import QPalette
|
||||
from PySide6.QtWidgets import QApplication, QDialog, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui import apply_theme
|
||||
from doctor_workstation.ui.appointment_drawer import APPOINTMENT_DRAWER_QSS, _SlotCard
|
||||
from doctor_workstation.ui.diagnosis_drawer import DIAGNOSIS_QSS, DiagnosisTabWidget
|
||||
from doctor_workstation.ui.dialogs import prescription_ai as ai_module
|
||||
from doctor_workstation.ui.dialogs.prescription import PrescriptionEditorDialog
|
||||
from doctor_workstation.ui.dialogs.prescription_ai import PrescriptionAiReportDialog
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
def settle(application: QApplication) -> None:
|
||||
for _ in range(10):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width", [600, 760, 860])
|
||||
def test_prescription_form_keeps_date_gender_and_controls_readable(
|
||||
application: QApplication, width: int
|
||||
) -> None:
|
||||
host = QWidget()
|
||||
host.resize(width, 640)
|
||||
host.show()
|
||||
editor = PrescriptionEditorDialog(
|
||||
SimpleNamespace(list_medicines=lambda **kwargs: {"lists": [], "count": 0}),
|
||||
{"diagnosis_id": 501, "patient_name": "林晓岚", "prescription_type": "浓缩水丸"},
|
||||
parent=host,
|
||||
)
|
||||
editor.show()
|
||||
settle(application)
|
||||
try:
|
||||
display = editor.date_edit._display
|
||||
assert display.width() >= display.fontMetrics().horizontalAdvance(display.text()) + 15
|
||||
for button in (editor.gender_male, editor.gender_female):
|
||||
assert button.width() >= button.sizeHint().width()
|
||||
controls = [editor.patient_name, editor.age, editor.date_edit, editor.prescription_type, editor.times_per_day]
|
||||
assert len({control.height() for control in controls}) == 1
|
||||
assert editor.body_content.width() == editor.body_scroll.viewport().width()
|
||||
assert editor.body_scroll.horizontalScrollBar().maximum() == 0
|
||||
footer_position = editor.footer.mapToGlobal(QPoint())
|
||||
editor.body_scroll.verticalScrollBar().setValue(editor.body_scroll.verticalScrollBar().maximum())
|
||||
settle(application)
|
||||
assert editor.footer.mapToGlobal(QPoint()) == footer_position
|
||||
assert editor.save_button.visibleRegion().boundingRect() == editor.save_button.rect()
|
||||
# Changing prescription type keeps its conditional fields reachable after reflow.
|
||||
editor.prescription_type.setCurrentText("饮片")
|
||||
settle(application)
|
||||
assert not editor._main_decoction_field.isHidden()
|
||||
assert editor._main_bag_field.isHidden()
|
||||
if width == 860:
|
||||
for resized_width in (600, 860):
|
||||
editor.resize(resized_width, 640)
|
||||
settle(application)
|
||||
assert display.width() >= display.fontMetrics().horizontalAdvance(display.text()) + 15
|
||||
assert editor._main_decoction_field.width() >= editor.need_decoction.minimumSizeHint().width() + 104
|
||||
finally:
|
||||
editor.close()
|
||||
host.close()
|
||||
|
||||
|
||||
def test_diagnosis_overflow_rail_leaves_selected_tab_underline_visible(application: QApplication) -> None:
|
||||
tabs = DiagnosisTabWidget()
|
||||
tabs.setObjectName("DiagnosisDrawerTabs")
|
||||
tabs.setStyleSheet(DIAGNOSIS_QSS)
|
||||
for title in ("病历", "医生备注", "日常记录", "处方", "业务订单", "视频录制回放", "聊天", "指派医助记录", "挂号记录"):
|
||||
tabs.addTab(QWidget(), title)
|
||||
tabs.resize(614, 480)
|
||||
tabs.show()
|
||||
settle(application)
|
||||
try:
|
||||
selected = tabs.tabBar().tabRect(tabs.currentIndex())
|
||||
selected.moveTopLeft(tabs.tabBar().pos() + selected.topLeft())
|
||||
assert tabs.tab_scrollbar.isVisible()
|
||||
assert not selected.intersects(tabs.tab_scrollbar.geometry())
|
||||
tabs.tab_scrollbar.setValue(tabs.count() - 1)
|
||||
assert tabs.currentIndex() == tabs.count() - 1
|
||||
finally:
|
||||
tabs.close()
|
||||
|
||||
|
||||
def test_appointment_slot_labels_follow_checked_and_disabled_states(application: QApplication) -> None:
|
||||
dialog = QDialog()
|
||||
dialog.setObjectName("AppointmentDrawerOverlay")
|
||||
dialog.setStyleSheet(APPOINTMENT_DRAWER_QSS)
|
||||
layout = QVBoxLayout(dialog)
|
||||
card = _SlotCard("09:00-09:30", "可约")
|
||||
card.setProperty("appointmentSlot", True)
|
||||
card.setProperty("availability", "available")
|
||||
card.setCheckable(True)
|
||||
layout.addWidget(card)
|
||||
dialog.show()
|
||||
settle(application)
|
||||
try:
|
||||
for checked, expected in ((True, "#ffffff"), (False, "#1a1c1f")):
|
||||
card.setChecked(checked)
|
||||
settle(application)
|
||||
assert card.time_label.palette().color(QPalette.ColorRole.WindowText).name() == expected
|
||||
card.setChecked(True)
|
||||
card.setEnabled(False)
|
||||
settle(application)
|
||||
assert card.time_label.palette().color(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText).name() == "#8e8f90"
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_ai_report_columns_keep_headings_at_the_same_text_baseline(
|
||||
application: QApplication, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
def immediate(function, **kwargs):
|
||||
kwargs["on_success"](function())
|
||||
if kwargs.get("on_finished"):
|
||||
kwargs["on_finished"]()
|
||||
|
||||
monkeypatch.setattr(ai_module, "run_async", immediate)
|
||||
report = {"summary": "复核参考", "possible_symptoms": ["乏力", "纳差", "睡眠不安"], "main_indications": "脾气不足", "efficacy": ["健脾", "益气", "养阴"], "suitable_people": ["需由医师辨证确认"]}
|
||||
repository = SimpleNamespace(list_prescription_template_ai_reports=lambda template_id: {"prescription_id": template_id, "reports": [{"report_id": 1, "model_key": "qwen", "report": report}]})
|
||||
dialog = PrescriptionAiReportDialog(repository, PermissionSet(["*"]))
|
||||
dialog.open_for({"id": 1, "prescription_name": "测试方", "herbs": []})
|
||||
dialog.show()
|
||||
settle(application)
|
||||
try:
|
||||
labels = {label.text(): label for label in dialog.findChildren(QLabel) if label.objectName() == "PrescriptionAiSectionTitle"}
|
||||
for left, right in (("可能症状与证候", "主治方向"), ("主要功效", "可能适用人群")):
|
||||
# QLabel vertically centers text: compare the centers, not just widget origins.
|
||||
centers = [labels[text].mapTo(dialog, labels[text].rect().center()).y() for text in (left, right)]
|
||||
assert abs(centers[0] - centers[1]) <= 1
|
||||
finally:
|
||||
dialog.close()
|
||||
@@ -0,0 +1,229 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QPushButton
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.dialogs import ai_consult_picker, diagnosis, prescription
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def run(function: Any, *, on_success: Any, on_error: Any, on_finished: Any = None) -> None:
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
on_error(error)
|
||||
else:
|
||||
on_success(result)
|
||||
if on_finished:
|
||||
on_finished()
|
||||
|
||||
for module in (ai_consult_picker, diagnosis, prescription):
|
||||
monkeypatch.setattr(module, "run_async", run)
|
||||
|
||||
|
||||
class ListRepository:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
self.fail_page = 0
|
||||
|
||||
def _list(self, **filters: Any) -> dict[str, Any]:
|
||||
self.calls.append(filters)
|
||||
page, size = filters["page_no"], filters["page_size"]
|
||||
if page == self.fail_page:
|
||||
raise RuntimeError("暂时无法加载")
|
||||
start = (page - 1) * size + 1
|
||||
return {
|
||||
"lists": [
|
||||
{
|
||||
"id": number,
|
||||
"diagnosis_id": number,
|
||||
"patient_id": 1000 + number,
|
||||
"patient_name": f"患者{number}",
|
||||
"prescription_id": 77,
|
||||
"prescription_name": f"处方{number}",
|
||||
"herbs": [{"name": "白术", "dosage": 10}],
|
||||
"order_no": f"ORDER{number}",
|
||||
}
|
||||
for number in range(start, min(start + size, 46))
|
||||
],
|
||||
"count": 45,
|
||||
}
|
||||
|
||||
list_prescription_templates = _list
|
||||
list_prescription_orders = _list
|
||||
list_ai_patient_options = _list
|
||||
|
||||
|
||||
def scroll_to_bottom(table: Any) -> None:
|
||||
scrollbar = table.verticalScrollBar()
|
||||
scrollbar.setValue(scrollbar.maximum())
|
||||
QTest.qWait(70)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["template", "order", "ai"])
|
||||
def test_scroll_appends_and_retains_selected_business_identity(
|
||||
application: QApplication, immediate_async: None, kind: str
|
||||
) -> None:
|
||||
repository = ListRepository()
|
||||
if kind == "template":
|
||||
dialog = prescription.TemplateImportDialog(repository, 42)
|
||||
controller = dialog.infinite_list
|
||||
elif kind == "order":
|
||||
dialog = prescription.PrescriptionOrderListDialog(repository, prescription_id=77)
|
||||
controller = dialog.infinite_list
|
||||
else:
|
||||
dialog = ai_consult_picker.AiConsultTargetDialog(repository, PermissionSet(["*"]))
|
||||
controller = dialog.pager
|
||||
dialog.resize(980, 520)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
try:
|
||||
page_size = controller.page_size
|
||||
assert dialog.table.rowCount() == page_size
|
||||
assert not any(
|
||||
button.text() in {"上一页", "下一页"} for button in dialog.findChildren(QPushButton)
|
||||
)
|
||||
dialog.table.selectRow(2)
|
||||
scroll_to_bottom(dialog.table)
|
||||
assert [call["page_no"] for call in repository.calls] == [1, 2]
|
||||
assert dialog.table.rowCount() == page_size * 2
|
||||
assert dialog.table.currentRow() == 2
|
||||
assert all(
|
||||
dialog.table.item(row, column).data(Qt.ItemDataRole.CheckStateRole) is None
|
||||
for row in range(dialog.table.rowCount())
|
||||
for column in range(dialog.table.columnCount())
|
||||
)
|
||||
repository.fail_page = 3
|
||||
scroll_to_bottom(dialog.table)
|
||||
assert controller.retry_button.isVisible()
|
||||
assert dialog.table.rowCount() == page_size * 2
|
||||
repository.fail_page = 0
|
||||
controller.retry_button.click()
|
||||
assert dialog.table.rowCount() == 45
|
||||
assert not controller.has_more
|
||||
assert dialog.table.currentRow() == 2
|
||||
if kind == "template":
|
||||
dialog.accept()
|
||||
assert dialog.selected_template()["id"] == 3
|
||||
elif kind == "ai":
|
||||
dialog.accept()
|
||||
assert dialog.selected_target().diagnosis_id == 3
|
||||
assert dialog.selected_target().patient_id == 1003
|
||||
else:
|
||||
assert dialog.table.item(2, 0).data(Qt.ItemDataRole.UserRole)["id"] == 3
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_template_query_supersedes_pending_load_and_keeps_creator_scope(
|
||||
application: QApplication, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
pending: list[tuple[Any, dict[str, Any]]] = []
|
||||
monkeypatch.setattr(
|
||||
prescription, "run_async", lambda function, **callbacks: pending.append((function, callbacks))
|
||||
)
|
||||
repository = ListRepository()
|
||||
dialog = prescription.TemplateImportDialog(repository, 42)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
try:
|
||||
dialog.name_edit.setText("白术")
|
||||
dialog.formula_combo.setCurrentIndex(1)
|
||||
dialog.search()
|
||||
assert len(pending) == 2
|
||||
latest_function, latest_callbacks = pending[1]
|
||||
latest_callbacks["on_success"](latest_function())
|
||||
old_function, old_callbacks = pending[0]
|
||||
old_callbacks["on_success"](old_function())
|
||||
old_callbacks["on_error"](RuntimeError("过期错误"))
|
||||
assert dialog.table.rowCount() == 15
|
||||
assert not dialog.banner.isVisible()
|
||||
assert repository.calls[0] == {
|
||||
"page_no": 1,
|
||||
"page_size": 15,
|
||||
"prescription_name": "白术",
|
||||
"formula_type": "主方",
|
||||
"prescribing_creator_id": 42,
|
||||
}
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_ai_picker_continues_when_first_page_has_no_valid_diagnosis_targets(
|
||||
application: QApplication, immediate_async: None
|
||||
) -> None:
|
||||
class Repository(ListRepository):
|
||||
def list_ai_patient_options(self, **filters: Any) -> dict[str, Any]:
|
||||
result = self._list(**filters)
|
||||
if filters["page_no"] == 1:
|
||||
for row in result["lists"]:
|
||||
row["id"] = -row["id"]
|
||||
row["diagnosis_id"] = row["id"]
|
||||
return result
|
||||
|
||||
repository = Repository()
|
||||
dialog = ai_consult_picker.AiConsultTargetDialog(repository, PermissionSet(["*"]))
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
try:
|
||||
QTest.qWait(100)
|
||||
assert [call["page_no"] for call in repository.calls] == [1, 2]
|
||||
assert dialog.table.rowCount() == 20
|
||||
assert dialog.table.item(0, 3).text() == "21"
|
||||
assert dialog.table.isVisible()
|
||||
assert not dialog.empty_state.isVisible()
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_diagnosis_readonly_orders_scroll_and_switching_patient_resets_scope(
|
||||
application: QApplication, immediate_async: None
|
||||
) -> None:
|
||||
repository = ListRepository()
|
||||
dialog = diagnosis.DiagnosisDialog(
|
||||
repository, permissions=PermissionSet(["tcm.diagnosis/patientOrders"])
|
||||
)
|
||||
dialog.open_for(
|
||||
501, authoritative_detail={"id": 501, "patient_id": 301, "patient_name": "患者一"}
|
||||
)
|
||||
application.processEvents()
|
||||
try:
|
||||
table = dialog._table_registry["orders"][0]
|
||||
dialog.readonly_scroll.ensureWidgetVisible(table)
|
||||
assert table.rowCount() == 10
|
||||
assert dialog.orders_list.parentWidget() is dialog._readonly_sections["orders"]
|
||||
table.selectRow(2)
|
||||
scroll_to_bottom(table)
|
||||
assert table.rowCount() == 20
|
||||
assert table.currentRow() == 2
|
||||
assert table.item(2, 0).data(Qt.ItemDataRole.CheckStateRole) is None
|
||||
assert repository.calls[-1] == {
|
||||
"page_no": 2,
|
||||
"page_size": 10,
|
||||
"patient_id": 301,
|
||||
"context_diagnosis_id": 501,
|
||||
"scene": "diagnosis_edit",
|
||||
}
|
||||
dialog.open_for(
|
||||
502, authoritative_detail={"id": 502, "patient_id": 302, "patient_name": "患者二"}
|
||||
)
|
||||
assert table.rowCount() == 10
|
||||
assert repository.calls[-1]["page_no"] == 1
|
||||
assert repository.calls[-1]["patient_id"] == 302
|
||||
assert repository.calls[-1]["context_diagnosis_id"] == 502
|
||||
finally:
|
||||
dialog.close()
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Search disclosure preserves queries, permissions, focus and usable list space."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, Qt, QTimer
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QLineEdit, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui.filter_disclosure import FilterDisclosure
|
||||
from doctor_workstation.ui.pages import prescription_library, prescriptions
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
def settle(app):
|
||||
for _ in range(3):
|
||||
app.processEvents()
|
||||
QTest.qWait(35)
|
||||
|
||||
|
||||
def immediate(function, *, on_success=None, on_error=None, on_finished=None):
|
||||
try:
|
||||
value = function()
|
||||
if on_success:
|
||||
on_success(value)
|
||||
except Exception as error:
|
||||
if on_error:
|
||||
on_error(error)
|
||||
raise
|
||||
finally:
|
||||
if on_finished:
|
||||
on_finished()
|
||||
|
||||
|
||||
def test_keyboard_fold_preserves_hidden_permission_controls_and_focus(application):
|
||||
host = QWidget()
|
||||
layout = QVBoxLayout(host)
|
||||
panel = QWidget(host)
|
||||
form = QVBoxLayout(panel)
|
||||
field = QLineEdit("保留查询", panel)
|
||||
forbidden = QPushButton("无权限操作", panel)
|
||||
forbidden.hide()
|
||||
form.addWidget(field)
|
||||
form.addWidget(forbidden)
|
||||
disclosure = FilterDisclosure(host, [panel])
|
||||
layout.addWidget(disclosure.button)
|
||||
layout.addWidget(panel)
|
||||
host.show()
|
||||
settle(application)
|
||||
assert not panel.isVisible()
|
||||
assert disclosure.button.height() == 32
|
||||
disclosure.button.setFocus()
|
||||
QTest.keyClick(disclosure.button, Qt.Key.Key_Space)
|
||||
settle(application)
|
||||
assert panel.isVisible() and not forbidden.isVisible()
|
||||
field.setFocus()
|
||||
disclosure.set_expanded(False)
|
||||
assert QApplication.focusWidget() is disclosure.button
|
||||
disclosure.set_expanded(True)
|
||||
assert field.text() == "保留查询" and forbidden.isHidden()
|
||||
host.close()
|
||||
host.deleteLater()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["issued", "library"])
|
||||
@pytest.mark.parametrize("size", [(1328, 860), (1158, 690), (816, 620)])
|
||||
def test_compact_default_expands_for_query_and_retains_values(application, monkeypatch, kind, size):
|
||||
module = prescriptions if kind == "issued" else prescription_library
|
||||
monkeypatch.setattr(module, "run_async", immediate)
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||
page_type = module.PrescriptionsPage if kind == "issued" else module.PrescriptionLibraryPage
|
||||
page = page_type(repository, {"*"}, session.user)
|
||||
page.resize(*size)
|
||||
page.show()
|
||||
settle(application)
|
||||
disclosure = page.filter_disclosure
|
||||
assert not disclosure.expanded and page.filter_card.isHidden()
|
||||
assert page.header.height() == 44
|
||||
assert page.header.breadcrumb_label.isHidden() and page.header.subtitle_label.isHidden()
|
||||
assert disclosure.button.isVisible()
|
||||
assert disclosure.button.height() == 32
|
||||
assert page.header.rect().contains(disclosure.button.mapTo(page.header, QPoint(0, 0)))
|
||||
compact_height = page.table.height()
|
||||
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
|
||||
settle(application)
|
||||
assert disclosure.expanded and page.filter_card.isVisible()
|
||||
assert compact_height - page.table.height() >= 100
|
||||
field = page.sn_filter if kind == "issued" else page.name_filter
|
||||
assert field.isVisible() and page.query_button.isVisible() and page.reset_button.isVisible()
|
||||
field.setText("不存在的查询")
|
||||
# Folding must not issue a request or clear an unfinished query.
|
||||
generation = page._generation
|
||||
disclosure.set_expanded(False)
|
||||
settle(application)
|
||||
assert page._generation == generation
|
||||
assert field.text() == "不存在的查询"
|
||||
disclosure.set_expanded(True)
|
||||
QTest.keyClick(field, Qt.Key.Key_Return)
|
||||
settle(application)
|
||||
assert page._generation > generation
|
||||
assert disclosure.expanded
|
||||
disclosure.set_expanded(False)
|
||||
page.refresh()
|
||||
page.resize(size[0] + 10, size[1])
|
||||
settle(application)
|
||||
assert not disclosure.expanded and not page.filter_card.isVisible()
|
||||
disclosure.set_expanded(True)
|
||||
page.reset_button.click()
|
||||
settle(application)
|
||||
assert field.text() == "" and page.table.rowCount() == 2
|
||||
for timer in page.findChildren(QTimer):
|
||||
timer.stop()
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Contract for the shared icon system.
|
||||
|
||||
Before ``ui/icons.py`` existed the product drew its glyphs from ten independent
|
||||
painters that disagreed on stroke weight, design grid and palette. These tests
|
||||
lock in the properties that keep the set reading as one family, so a new glyph
|
||||
cannot quietly reintroduce a one-off weight or a clipped mark.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtGui import QColor, QImage
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.ui import icons
|
||||
from doctor_workstation.ui.theme import COLORS
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def _rendered(kind: str, size: int) -> QImage:
|
||||
return icons.pixmap(kind, "strong", size).toImage()
|
||||
|
||||
|
||||
def _ink_bounds(image: QImage) -> tuple[int, int, int, int, int]:
|
||||
"""Return ``(left, top, right, bottom, count)`` of visibly painted pixels."""
|
||||
|
||||
left, top = image.width(), image.height()
|
||||
right = bottom = -1
|
||||
count = 0
|
||||
for y in range(image.height()):
|
||||
for x in range(image.width()):
|
||||
if image.pixelColor(x, y).alpha() > 24:
|
||||
count += 1
|
||||
left, top = min(left, x), min(top, y)
|
||||
right, bottom = max(right, x), max(bottom, y)
|
||||
return left, top, right, bottom, count
|
||||
|
||||
|
||||
def test_every_glyph_paints_inside_its_box_at_every_shipped_size(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""No glyph may touch the edge of its pixmap.
|
||||
|
||||
Clipping is what the old painters did whenever a call site asked for a size
|
||||
other than the one the geometry was authored against - the shell's ``ai``
|
||||
star lost its companion dot, and the prescription icons were drawn with
|
||||
16 px geometry inside 14 px and 15 px boxes.
|
||||
"""
|
||||
|
||||
for kind in icons.available_kinds():
|
||||
for size in (14, 16, 18, 20, 24):
|
||||
image = _rendered(kind, size)
|
||||
device = image.width() # honours the device pixel ratio
|
||||
left, top, right, bottom, count = _ink_bounds(image)
|
||||
|
||||
assert count > 0, f"{kind}@{size} painted nothing"
|
||||
assert left > 0 and top > 0, f"{kind}@{size} is clipped at the top/left"
|
||||
assert right < device - 1 and bottom < device - 1, (
|
||||
f"{kind}@{size} is clipped at the bottom/right"
|
||||
)
|
||||
|
||||
|
||||
def test_glyphs_fill_a_consistent_share_of_the_optical_box(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""Every mark lives in the same safe area, so none reads over- or undersized.
|
||||
|
||||
The set this replaces mixed glyphs that spanned the full 18 px box with a
|
||||
``close`` cross that spanned only 7 px, which is why the title bar controls
|
||||
never looked like siblings.
|
||||
"""
|
||||
|
||||
for kind in icons.available_kinds():
|
||||
image = _rendered(kind, 24)
|
||||
device = image.width()
|
||||
left, top, right, bottom, _count = _ink_bounds(image)
|
||||
extent = max(right - left, bottom - top) / device
|
||||
|
||||
assert 0.5 <= extent <= 0.95, f"{kind} fills {extent:.2f} of its box"
|
||||
|
||||
|
||||
def test_stroke_weight_is_one_formula_across_the_shipped_size_range() -> None:
|
||||
assert icons.stroke_px(24) == pytest.approx(2.0)
|
||||
assert icons.stroke_px(18) == pytest.approx(1.5)
|
||||
assert icons.stroke_px(16) == pytest.approx(4.0 / 3.0)
|
||||
# Clamped so a small icon stays visible and a large one does not turn slab.
|
||||
assert icons.stroke_px(8) == pytest.approx(1.25)
|
||||
assert icons.stroke_px(64) == pytest.approx(2.25)
|
||||
|
||||
|
||||
def test_icons_are_cached_so_list_rows_do_not_repaint_them(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""A list page builds one icon per action button per row on every refresh.
|
||||
|
||||
Without the cache that is a fresh ``QPainter`` run per button; the pages in
|
||||
this product ask for the same handful of (kind, colour, size) triples over
|
||||
and over, so the cache turns a per-row cost into a per-process one.
|
||||
"""
|
||||
|
||||
icons.clear_cache()
|
||||
for _ in range(50):
|
||||
icons.icon("eye", "accent", 15)
|
||||
icons.icon("trash", "danger", 15)
|
||||
|
||||
# Two glyphs, each painted once in its requested colour and once disabled.
|
||||
assert icons._cached_pixmap.cache_info().misses == 4
|
||||
# The remaining 98 calls are answered from the icon cache without painting.
|
||||
assert icons._cached_icon.cache_info().hits == 98
|
||||
|
||||
|
||||
def test_colour_roles_resolve_to_the_palette_not_to_per_call_site_hexes() -> None:
|
||||
assert icons.resolve_color("accent") == COLORS["indigo"]
|
||||
assert icons.resolve_color("danger") == COLORS["danger"]
|
||||
assert icons.resolve_color("muted") == COLORS["muted"]
|
||||
# A literal colour still passes through for the few bespoke tints that remain.
|
||||
assert icons.resolve_color("#8268E8") == "#8268E8"
|
||||
|
||||
|
||||
def test_state_icon_carries_its_own_selected_and_disabled_pixmaps(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""Navigation rows invert on selection, so the icon has to invert with them."""
|
||||
|
||||
icon = icons.state_icon("patients", size=18, normal="muted", checked="inverse")
|
||||
from PySide6.QtGui import QIcon
|
||||
|
||||
off = icon.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.Off).toImage()
|
||||
on = icon.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.On).toImage()
|
||||
|
||||
assert off != on
|
||||
assert _dominant_ink(on) == QColor("#FFFFFF")
|
||||
|
||||
|
||||
def _dominant_ink(image: QImage) -> QColor:
|
||||
for y in range(image.height()):
|
||||
for x in range(image.width()):
|
||||
colour = image.pixelColor(x, y)
|
||||
if colour.alpha() > 240:
|
||||
colour.setAlpha(255)
|
||||
return colour
|
||||
raise AssertionError("no opaque pixel found")
|
||||
|
||||
|
||||
def test_navigation_glyphs_are_visually_distinct(application: QApplication) -> None:
|
||||
"""Six sidebar entries need six silhouettes.
|
||||
|
||||
The reception detail tabs previously shared three near-identical
|
||||
"document with lines" marks, which made them unreadable at 16 px.
|
||||
"""
|
||||
|
||||
renders = {
|
||||
kind: _rendered(kind, 24).constBits().tobytes()
|
||||
for kind in (
|
||||
"reception",
|
||||
"appointments",
|
||||
"prescription_library",
|
||||
"prescriptions",
|
||||
"patients",
|
||||
"consultations",
|
||||
"report",
|
||||
"meds",
|
||||
"daily",
|
||||
"followup",
|
||||
)
|
||||
}
|
||||
|
||||
assert len(set(renders.values())) == len(renders)
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Incremental requests retain business rows and survive query changes/errors."""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget
|
||||
|
||||
from doctor_workstation.ui.infinite_list import InfiniteList
|
||||
from doctor_workstation.ui.widgets import get_value, page_items
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
class Requests:
|
||||
def __init__(self):
|
||||
self.pending = []
|
||||
|
||||
def __call__(self, function, **callbacks):
|
||||
self.pending.append((function, callbacks))
|
||||
|
||||
def finish(self, index=0, error=None):
|
||||
fn, cb = self.pending.pop(index)
|
||||
if error:
|
||||
cb["on_error"](error)
|
||||
else:
|
||||
cb["on_success"](fn())
|
||||
cb["on_finished"]()
|
||||
|
||||
|
||||
def setup(app, size=2):
|
||||
host = QWidget()
|
||||
layout = QVBoxLayout(host)
|
||||
table = QTableWidget(0, 2)
|
||||
footer = InfiniteList(size)
|
||||
footer.bind(table)
|
||||
layout.addWidget(table)
|
||||
layout.addWidget(footer)
|
||||
host.resize(420, 220)
|
||||
seen = []
|
||||
errors = []
|
||||
jobs = Requests()
|
||||
|
||||
def apply(result):
|
||||
rows = page_items(result)
|
||||
seen.append(result)
|
||||
table.setRowCount(len(rows))
|
||||
for i, row in enumerate(rows):
|
||||
for col in range(2):
|
||||
item = QTableWidgetItem(str(row["id"]))
|
||||
item.setData(Qt.ItemDataRole.UserRole, row)
|
||||
if col == 1:
|
||||
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||
item.setCheckState(Qt.CheckState.Unchecked)
|
||||
table.setItem(i, col, item)
|
||||
|
||||
return host, table, footer, seen, errors, jobs, apply
|
||||
|
||||
|
||||
def test_append_deduplicates_and_preserves_selection_checks_metadata(app):
|
||||
host, table, f, seen, errors, jobs, apply = setup(app)
|
||||
pages = {
|
||||
1: {"items": [{"id": 1}, {"id": 2}], "total": 4, "extend": {"scope": "mine"}},
|
||||
2: {"items": [{"id": 2}, {"id": 3}], "total": 4},
|
||||
3: {"items": [{"id": 4}], "total": 4},
|
||||
}
|
||||
f.reload(lambda p: pages[p], apply, errors.append, runner=jobs, query_key="a")
|
||||
jobs.finish()
|
||||
table.selectRow(1)
|
||||
table.item(1, 1).setCheckState(Qt.CheckState.Checked)
|
||||
f.load_more()
|
||||
f.load_more()
|
||||
assert len(jobs.pending) == 1
|
||||
jobs.finish()
|
||||
assert [r["id"] for r in f.rows] == [1, 2, 3]
|
||||
assert table.currentRow() == 1 and table.item(1, 1).checkState() == Qt.CheckState.Checked
|
||||
assert get_value(seen[-1], "extend.scope") == "mine"
|
||||
f.load_more()
|
||||
jobs.finish()
|
||||
assert not f.has_more
|
||||
assert table.rowCount() == 4 and not errors
|
||||
host.close()
|
||||
|
||||
|
||||
def test_changed_query_discards_old_success_and_error(app):
|
||||
host, t, f, seen, errors, jobs, apply = setup(app)
|
||||
f.reload(
|
||||
lambda p: {"items": [{"id": 1}], "total": 1},
|
||||
apply,
|
||||
errors.append,
|
||||
runner=jobs,
|
||||
query_key="a",
|
||||
)
|
||||
f.reload(
|
||||
lambda p: {"items": [{"id": 2}], "total": 1},
|
||||
apply,
|
||||
errors.append,
|
||||
runner=jobs,
|
||||
query_key="b",
|
||||
)
|
||||
jobs.finish(1)
|
||||
jobs.finish(0, error=RuntimeError("stale"))
|
||||
assert f.rows == [{"id": 2}] and not errors and not f.loading
|
||||
f.invalidate()
|
||||
assert not f.has_more
|
||||
host.close()
|
||||
|
||||
|
||||
def test_append_failure_retry_and_empty_end_are_bounded(app):
|
||||
host, t, f, seen, errors, jobs, apply = setup(app)
|
||||
calls = []
|
||||
|
||||
def fetch(p):
|
||||
calls.append(p)
|
||||
return {"items": [{"id": 1}, {"id": 2}] if p == 1 else [], "total": 8}
|
||||
|
||||
f.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||
jobs.finish()
|
||||
f.load_more()
|
||||
jobs.finish(error=RuntimeError("offline"))
|
||||
assert t.rowCount() == 2 and f.page == 1 and not f.retry_button.isHidden()
|
||||
f.load_more()
|
||||
assert not jobs.pending
|
||||
f.retry()
|
||||
jobs.finish()
|
||||
assert f.page == 2 and not f.has_more
|
||||
assert calls == [1, 2] and len(errors) == 1
|
||||
host.close()
|
||||
|
||||
|
||||
def test_refresh_retains_loaded_prefix_until_complete(app):
|
||||
host, t, f, seen, errors, jobs, apply = setup(app)
|
||||
calls = []
|
||||
|
||||
def fetch(p):
|
||||
calls.append(p)
|
||||
return {"items": [{"id": p * 2 - 1}, {"id": p * 2}], "total": 6}
|
||||
|
||||
f.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||
jobs.finish()
|
||||
f.load_more()
|
||||
jobs.finish()
|
||||
assert t.rowCount() == 4
|
||||
f.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||
jobs.finish()
|
||||
assert t.rowCount() == 4 and f.loading
|
||||
jobs.finish()
|
||||
assert t.rowCount() == 4 and f.page == 2
|
||||
assert calls == [1, 2, 1, 2]
|
||||
host.close()
|
||||
|
||||
|
||||
def test_scroll_autofill_then_load_at_bottom(app):
|
||||
host, t, f, seen, errors, jobs, apply = setup(app, size=12)
|
||||
|
||||
def fetch(p):
|
||||
return {"items": [{"id": i} for i in range((p - 1) * 12, p * 12)], "total": 36}
|
||||
|
||||
host.show()
|
||||
app.processEvents()
|
||||
f.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||
jobs.finish()
|
||||
app.processEvents()
|
||||
QTest.qWait(60)
|
||||
assert not jobs.pending
|
||||
t.verticalScrollBar().setValue(t.verticalScrollBar().maximum())
|
||||
QTest.qWait(60)
|
||||
assert len(jobs.pending) == 1
|
||||
old = t.verticalScrollBar().value()
|
||||
jobs.finish()
|
||||
assert t.verticalScrollBar().value() == old and t.rowCount() == 24
|
||||
host.close()
|
||||
|
||||
|
||||
def test_poll_during_slow_request_keeps_work_and_updates_consumer_callbacks(app):
|
||||
host, table, footer, seen, errors, jobs, apply = setup(app)
|
||||
completed = []
|
||||
|
||||
def fetch(page):
|
||||
return {"items": [{"id": page * 2 - 1}, {"id": page * 2}], "total": 6}
|
||||
|
||||
footer.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||
footer.reload(
|
||||
fetch,
|
||||
apply,
|
||||
errors.append,
|
||||
runner=jobs,
|
||||
query_key="a",
|
||||
on_finished=lambda: completed.append("latest"),
|
||||
)
|
||||
assert len(jobs.pending) == 1
|
||||
jobs.finish()
|
||||
assert completed == ["latest"] and table.rowCount() == 2
|
||||
footer.load_more()
|
||||
footer.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||
assert len(jobs.pending) == 1
|
||||
jobs.finish()
|
||||
assert table.rowCount() == 4 and footer.page == 2
|
||||
footer.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||
jobs.finish() # Prefix page 2 now pending.
|
||||
footer.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||
assert len(jobs.pending) == 1
|
||||
jobs.finish()
|
||||
assert footer.page == 2 and not footer.loading and table.rowCount() == 4
|
||||
host.close()
|
||||
@@ -0,0 +1,156 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, QTimer
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QPushButton
|
||||
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui import apply_theme
|
||||
from doctor_workstation.ui.pages import (
|
||||
appointments,
|
||||
consultations,
|
||||
patients,
|
||||
prescription_library,
|
||||
prescriptions,
|
||||
)
|
||||
from doctor_workstation.ui.widgets import PageHeader
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
def immediate(function: Any, *args: Any, on_success=None, on_error=None,
|
||||
on_finished=None, **kwargs: Any) -> object:
|
||||
try:
|
||||
result = function(*args, **kwargs)
|
||||
except Exception as error:
|
||||
if on_error:
|
||||
on_error(error)
|
||||
else:
|
||||
if on_success:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished:
|
||||
on_finished()
|
||||
return object()
|
||||
|
||||
modules = (appointments, consultations, patients, prescription_library, prescriptions)
|
||||
for module in modules:
|
||||
monkeypatch.setattr(module, "run_async", immediate)
|
||||
opened = []
|
||||
|
||||
def create(kind: str, width: int = 1040):
|
||||
repo = DemoDoctorRepository()
|
||||
session = repo.login(repo.DEMO_ACCOUNT, repo.DEMO_PASSWORD)
|
||||
classes = {
|
||||
"appointments": appointments.AppointmentsPage,
|
||||
"patients": patients.PatientsPage,
|
||||
"consultations": consultations.ConsultationsPage,
|
||||
"prescriptions": prescriptions.PrescriptionsPage,
|
||||
"prescription_library": prescription_library.PrescriptionLibraryPage,
|
||||
}
|
||||
page = classes[kind](repo, permissions=session.permissions, current_user=session.user)
|
||||
opened.append(page)
|
||||
page.resize(width, 700)
|
||||
page.show()
|
||||
page.refresh()
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
return page
|
||||
|
||||
yield create
|
||||
for page in opened:
|
||||
for timer in page.findChildren(QTimer):
|
||||
timer.stop()
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["appointments", "patients", "consultations", "prescriptions", "prescription_library"])
|
||||
def test_list_header_fits_the_real_bundled_font(page_factory, kind: str) -> None:
|
||||
page = page_factory(kind)
|
||||
header = page.findChild(PageHeader)
|
||||
assert header is not None
|
||||
for label in header.findChildren(QLabel):
|
||||
if label.isVisible() and label.text() and label.property("role"):
|
||||
assert label.height() >= label.fontMetrics().height(), (
|
||||
kind, label.text(), label.height(), label.fontMetrics().height()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["patients", "prescription_library"])
|
||||
@pytest.mark.parametrize("width", [1040, 1200])
|
||||
def test_row_actions_are_fully_reachable_at_each_width(page_factory, kind, width):
|
||||
page = page_factory(kind, width)
|
||||
table = page.patient_workspace.table if kind == "patients" else page.table
|
||||
assert table.rowCount() > 0
|
||||
assert table.horizontalHeader().visualIndex(9) == 9
|
||||
actions = table.cellWidget(0, 9)
|
||||
assert actions is not None
|
||||
assert table.horizontalScrollBar().value() == 0
|
||||
# Both approved layouts put actions after the nine data columns. Preserve
|
||||
# full-size controls and the existing reachability assertion after scrolling.
|
||||
table.horizontalScrollBar().setValue(table.horizontalScrollBar().maximum())
|
||||
QApplication.processEvents()
|
||||
assert actions.geometry().left() >= 0
|
||||
assert actions.geometry().right() < table.viewport().width()
|
||||
assert actions.height() >= actions.minimumSizeHint().height()
|
||||
for child in actions.findChildren(QPushButton):
|
||||
assert actions.rect().contains(child.mapTo(actions, QPoint()))
|
||||
assert actions.rect().contains(child.mapTo(actions, child.rect().bottomRight()))
|
||||
|
||||
|
||||
def test_appointment_detail_lines_and_cancel_fit_without_overlapping(page_factory) -> None:
|
||||
page = page_factory("appointments")
|
||||
table = page.table
|
||||
assert table.rowCount() > 0
|
||||
host = table.cellWidget(0, 4)
|
||||
assert host.width() >= host.minimumSizeHint().width()
|
||||
assert host.height() >= host.minimumSizeHint().height()
|
||||
for label in host.findChildren(QLabel):
|
||||
assert label.height() >= label.fontMetrics().height()
|
||||
assert host.rect().contains(label.geometry())
|
||||
cancel = next(button for button in host.findChildren(QPushButton) if button.text() == "取消")
|
||||
for label in host.findChildren(QLabel):
|
||||
assert not label.geometry().intersects(cancel.geometry())
|
||||
# Underlying text is still available to sorting while a delegate paints only
|
||||
# the selection surface beneath the actual controls.
|
||||
assert table.item(0, 4).text()
|
||||
assert isinstance(table.itemDelegateForColumn(4), appointments._AppointmentInfoDelegate)
|
||||
assert table.horizontalScrollBar().maximum() == 0
|
||||
search = page.findChild(QPushButton, "AppointmentSearchButton")
|
||||
assert search.height() == page.patient_input.height()
|
||||
assert all(button.height() == search.height() for button in page.date_buttons.values() if button.isVisible())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["patients", "prescriptions", "prescription_library", "consultations"])
|
||||
def test_filter_actions_align_with_their_inputs(page_factory, kind: str) -> None:
|
||||
page = page_factory(kind)
|
||||
if kind == "patients":
|
||||
controls = page.patient_workspace
|
||||
widgets = (controls.keyword_edit, controls.search_button, controls.reset_button)
|
||||
statuses = tuple(controls.status_buttons.values())
|
||||
assert len({widget.height() for widget in statuses}) == 1
|
||||
assert len({widget.mapTo(controls.status_host, QPoint()).y() for widget in statuses}) == 1
|
||||
for widget in statuses:
|
||||
assert controls.status_host.rect().contains(widget.geometry())
|
||||
assert widget.height() >= widget.fontMetrics().height()
|
||||
elif kind == "prescriptions":
|
||||
widgets = (page.sn_filter, page.query_button, page.reset_button, page.doctor_filter.button)
|
||||
elif kind == "prescription_library":
|
||||
widgets = (page.name_filter, page.query_button, page.reset_button)
|
||||
else:
|
||||
widgets = (page.search_button, page.reset_button, page.custom_date_edit)
|
||||
assert len({widget.height() for widget in widgets}) == 1
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Contract for the shared motion system.
|
||||
|
||||
The product previously had no `QPropertyAnimation` at all, so these tests exist
|
||||
to keep the two things that make added motion a liability from creeping back:
|
||||
an animation that outlives or destroys the object it is animating, and a
|
||||
graphics effect left attached after a fade, which would quietly move a whole
|
||||
subtree onto Qt's offscreen composite path for the rest of the session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
# The module disables itself under the offscreen platform so widget grabs in the
|
||||
# other suites capture settled frames; these tests are about the animation, so
|
||||
# they opt back in.
|
||||
os.environ["DOCTOR_MOTION"] = "on"
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QEvent, QPoint, QPointF, Qt
|
||||
from PySide6.QtGui import QWheelEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QLabel,
|
||||
QScrollArea,
|
||||
QStackedWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from doctor_workstation.ui import motion
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def _stack(application: QApplication) -> tuple[QWidget, QStackedWidget, QLabel]:
|
||||
host = QWidget()
|
||||
host.resize(320, 120)
|
||||
layout = QVBoxLayout(host)
|
||||
stack = QStackedWidget()
|
||||
first, second = QLabel("A"), QLabel("B")
|
||||
stack.addWidget(first)
|
||||
stack.addWidget(second)
|
||||
layout.addWidget(stack)
|
||||
host.show()
|
||||
application.processEvents()
|
||||
return host, stack, second
|
||||
|
||||
|
||||
def test_page_transition_fades_and_settles_upward(application: QApplication) -> None:
|
||||
host, stack, incoming = _stack(application)
|
||||
origin = incoming.pos()
|
||||
|
||||
motion.switch_stack(stack, 1)
|
||||
|
||||
animations = incoming._doctor_motion
|
||||
rise, fade = animations["enter"], animations["fade"]
|
||||
assert rise.duration() == fade.duration() == motion.BASE
|
||||
|
||||
samples = []
|
||||
for at in (0, motion.BASE // 2, motion.BASE - 1):
|
||||
rise.setCurrentTime(at)
|
||||
fade.setCurrentTime(at)
|
||||
samples.append((incoming.pos().y() - origin.y(), incoming.graphicsEffect().opacity()))
|
||||
|
||||
offsets = [offset for offset, _ in samples]
|
||||
opacities = [opacity for _, opacity in samples]
|
||||
assert offsets == sorted(offsets, reverse=True), "the page must settle downward-to-up"
|
||||
assert opacities == sorted(opacities), "opacity must rise monotonically"
|
||||
assert offsets[0] == motion.RISE and opacities[0] == pytest.approx(0.0)
|
||||
host.close()
|
||||
|
||||
|
||||
def test_fade_detaches_its_graphics_effect_when_it_finishes(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""A left-behind opacity effect is a permanent frame-rate tax, not a leak."""
|
||||
|
||||
host, stack, incoming = _stack(application)
|
||||
motion.switch_stack(stack, 1)
|
||||
assert incoming.graphicsEffect() is not None
|
||||
|
||||
fade = incoming._doctor_motion["fade"]
|
||||
fade.setCurrentTime(fade.duration())
|
||||
# The detach is deferred by one event-loop turn on purpose, so that the
|
||||
# effect is not destroyed from inside the signal it is emitting.
|
||||
application.processEvents()
|
||||
application.processEvents()
|
||||
|
||||
assert incoming.graphicsEffect() is None
|
||||
host.close()
|
||||
|
||||
|
||||
def test_motion_can_be_turned_off_without_leaving_widgets_mid_state(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("DOCTOR_MOTION", "off")
|
||||
assert motion.reduced_motion()
|
||||
|
||||
host, stack, incoming = _stack(application)
|
||||
origin = incoming.pos()
|
||||
motion.switch_stack(stack, 1)
|
||||
|
||||
assert stack.currentIndex() == 1
|
||||
assert incoming.pos() == origin
|
||||
assert incoming.graphicsEffect() is None
|
||||
host.close()
|
||||
|
||||
|
||||
def _scroll_area(application: QApplication) -> QScrollArea:
|
||||
area = QScrollArea()
|
||||
area.setWidgetResizable(True)
|
||||
area.setWidget(QLabel("\n".join(f"line {index}" for index in range(300))))
|
||||
area.resize(300, 200)
|
||||
area.show()
|
||||
application.processEvents()
|
||||
return area
|
||||
|
||||
|
||||
def _wheel(delta: int) -> QWheelEvent:
|
||||
return QWheelEvent(
|
||||
QPointF(50, 50),
|
||||
QPointF(50, 50),
|
||||
QPoint(0, 0),
|
||||
QPoint(0, delta),
|
||||
Qt.MouseButton.NoButton,
|
||||
Qt.KeyboardModifier.NoModifier,
|
||||
Qt.ScrollPhase.NoScrollPhase,
|
||||
False,
|
||||
)
|
||||
|
||||
|
||||
def test_wheel_scrolling_is_eased_rather_than_jumped(application: QApplication) -> None:
|
||||
area = _scroll_area(application)
|
||||
motion.install_smooth_scroll(area)
|
||||
scroller = area._doctor_smooth_scroll
|
||||
bar = area.verticalScrollBar()
|
||||
|
||||
assert scroller.eventFilter(area.viewport(), _wheel(-120))
|
||||
animation = scroller._animation
|
||||
assert animation.endValue() == motion.SCROLL_STEP
|
||||
|
||||
values = []
|
||||
for at in (0, 60, 120, animation.duration() - 1):
|
||||
animation.setCurrentTime(at)
|
||||
values.append(bar.value())
|
||||
|
||||
assert values[0] == 0
|
||||
assert values == sorted(values)
|
||||
assert values[-1] < motion.SCROLL_STEP, "an eased curve never reaches its end early"
|
||||
animation.stop()
|
||||
area.close()
|
||||
|
||||
|
||||
def test_wheel_at_either_end_is_handed_back_to_the_enclosing_area(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""Swallowing the wheel at the extremes is what makes nested panes feel stuck."""
|
||||
|
||||
area = _scroll_area(application)
|
||||
motion.install_smooth_scroll(area)
|
||||
scroller = area._doctor_smooth_scroll
|
||||
bar = area.verticalScrollBar()
|
||||
|
||||
bar.setValue(bar.minimum())
|
||||
application.processEvents()
|
||||
assert not scroller.eventFilter(area.viewport(), _wheel(120))
|
||||
assert scroller.eventFilter(area.viewport(), _wheel(-120))
|
||||
|
||||
scroller._animation.stop()
|
||||
bar.setValue(bar.maximum())
|
||||
application.processEvents()
|
||||
assert not scroller.eventFilter(area.viewport(), _wheel(-120))
|
||||
area.close()
|
||||
|
||||
|
||||
def test_zoom_gestures_are_left_alone(application: QApplication) -> None:
|
||||
area = _scroll_area(application)
|
||||
motion.install_smooth_scroll(area)
|
||||
scroller = area._doctor_smooth_scroll
|
||||
|
||||
ctrl_wheel = QWheelEvent(
|
||||
QPointF(50, 50),
|
||||
QPointF(50, 50),
|
||||
QPoint(0, 0),
|
||||
QPoint(0, -120),
|
||||
Qt.MouseButton.NoButton,
|
||||
Qt.KeyboardModifier.ControlModifier,
|
||||
Qt.ScrollPhase.NoScrollPhase,
|
||||
False,
|
||||
)
|
||||
|
||||
assert not scroller.eventFilter(area.viewport(), ctrl_wheel)
|
||||
area.close()
|
||||
|
||||
|
||||
def test_install_is_idempotent(application: QApplication) -> None:
|
||||
area = _scroll_area(application)
|
||||
motion.install_smooth_scroll(area)
|
||||
first = area._doctor_smooth_scroll
|
||||
motion.install_smooth_scroll(area)
|
||||
|
||||
assert area._doctor_smooth_scroll is first
|
||||
area.close()
|
||||
|
||||
|
||||
def test_reading_surfaces_get_smooth_scrolling_from_the_theme(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""A QScrollArea should not have to opt in page by page."""
|
||||
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
apply_theme(application)
|
||||
area = QScrollArea()
|
||||
area.setWidget(QLabel("content"))
|
||||
area.show()
|
||||
area.ensurePolished()
|
||||
application.sendEvent(area, QEvent(QEvent.Type.Polish))
|
||||
application.processEvents()
|
||||
|
||||
assert getattr(area, "_doctor_smooth_scroll", None) is not None
|
||||
area.close()
|
||||
@@ -0,0 +1,641 @@
|
||||
"""Order contracts at risk when filters, summary and table receive the blue layout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QDate, QPoint, QRect, Qt, QTimer
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QLabel
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.pages import patients as patients_module
|
||||
from doctor_workstation.ui.pages.patients import PatientOrdersWorkspace, PatientsPage
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
class _Repository:
|
||||
def __init__(self) -> None:
|
||||
self.queries: list[dict[str, Any]] = []
|
||||
self.rows = [
|
||||
{
|
||||
"id": 901 + index,
|
||||
"order_no": f"PO2026081000{index + 1}",
|
||||
"patient_name": name,
|
||||
"patient_phone_masked": f"186****482{index}",
|
||||
"recipient_phone": f"1860000482{index}",
|
||||
"prescription_id": 801 + index,
|
||||
"diagnosis_id": 501 + index,
|
||||
"amount": 368 + index,
|
||||
"effective_amount": 368 + index,
|
||||
"prescription_audit_status": 1,
|
||||
"payment_slip_audit_status": 1,
|
||||
"fulfillment_status": 2,
|
||||
"assistant_name": "周医助",
|
||||
"doctor_name": "陈医生(演示)",
|
||||
"creator_name": "周医助",
|
||||
# Source-only values must not fill display fields that are absent.
|
||||
"create_time": "2026-09-05 10:15:00",
|
||||
"pay_orders": [{"id": 2001 + index, "pay_amount": 368 + index}],
|
||||
}
|
||||
for index, name in enumerate(("阿青", "林青", "赵青"))
|
||||
]
|
||||
self.total = 47
|
||||
self.summary = {
|
||||
"orders": 47,
|
||||
"amount": 12368.5,
|
||||
"pending": 8,
|
||||
"completed": 9,
|
||||
"rejected": 2,
|
||||
"rejection_rate": 4.3,
|
||||
}
|
||||
|
||||
def patient_orders(self, **query: Any) -> dict[str, Any]:
|
||||
self.queries.append(query)
|
||||
return {
|
||||
"lists": deepcopy(self.rows),
|
||||
"count": self.total,
|
||||
"extend": {
|
||||
"scope": {"label": "测试部门订单范围"},
|
||||
"summary": deepcopy(self.summary),
|
||||
},
|
||||
}
|
||||
|
||||
def list_patients(self, **_query: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"lists": [{"id": 501, "diagnosis_id": 501, "patient_name": "阿青"}],
|
||||
"count": 1,
|
||||
"extend": {"scope": {"label": "测试患者范围"}},
|
||||
}
|
||||
|
||||
def patient_progress(self, **_query: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"lists": [],
|
||||
"count": 0,
|
||||
"extend": {"scope": {"label": "测试面诊范围"}},
|
||||
}
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(3):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def _click(widget, application: QApplication) -> None:
|
||||
QTest.mouseClick(widget, Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
|
||||
|
||||
def _rect_in(widget, parent) -> QRect:
|
||||
return QRect(widget.mapTo(parent, QPoint()), widget.size())
|
||||
|
||||
|
||||
def _banner_text(workspace: PatientOrdersWorkspace) -> str:
|
||||
return " ".join(label.text() for label in workspace.banner.findChildren(QLabel))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
apply_theme(application)
|
||||
return application
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
def immediate(function: Any, *, on_success=None, on_error=None, on_finished=None):
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
if on_error:
|
||||
on_error(error)
|
||||
raise
|
||||
else:
|
||||
if on_success:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished:
|
||||
on_finished()
|
||||
|
||||
def reject_network(*_args: Any, **_kwargs: Any):
|
||||
pytest.fail("Order visual tests must use only local fixture data")
|
||||
|
||||
monkeypatch.setattr(socket.socket, "connect", reject_network)
|
||||
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
|
||||
monkeypatch.setattr(socket, "create_connection", reject_network)
|
||||
monkeypatch.setattr(patients_module, "run_async", immediate)
|
||||
opened = []
|
||||
|
||||
def create(*, permissions=("*",), width=1270, height=680, page=False):
|
||||
repository = _Repository()
|
||||
widget = (
|
||||
PatientsPage(repository, permissions=PermissionSet(list(permissions)))
|
||||
if page
|
||||
else PatientOrdersWorkspace(repository, PermissionSet(list(permissions)))
|
||||
)
|
||||
opened.append(widget)
|
||||
widget.resize(width, height)
|
||||
widget.show()
|
||||
if not page:
|
||||
widget.refresh()
|
||||
_settle(application)
|
||||
return widget, repository
|
||||
|
||||
yield create
|
||||
for widget in opened:
|
||||
for timer in widget.findChildren(QTimer):
|
||||
timer.stop()
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
_settle(application)
|
||||
|
||||
|
||||
def test_filter_defaults_and_reset_keep_edited_dates_but_remove_query_limit(
|
||||
application: QApplication, workspace_factory
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
default_query = {
|
||||
"keyword": "",
|
||||
"prescription_audit_status": None,
|
||||
"payment_slip_audit_status": None,
|
||||
"fulfillment_status": None,
|
||||
"start_date": "",
|
||||
"end_date": "",
|
||||
"page_no": 1,
|
||||
"page_size": 15,
|
||||
}
|
||||
assert repository.queries[0] == default_query
|
||||
assert all(query == {**default_query, "page_no": index + 1}
|
||||
for index, query in enumerate(repository.queries))
|
||||
assert not workspace.use_dates.isChecked()
|
||||
assert not workspace.start_date.isEnabled() and not workspace.end_date.isEnabled()
|
||||
assert workspace.start_date.date() == QDate.currentDate().addDays(-30)
|
||||
assert workspace.end_date.date() == QDate.currentDate()
|
||||
assert all(
|
||||
combo.currentData() is None
|
||||
for combo in (workspace.rx_audit, workspace.pay_audit, workspace.fulfillment)
|
||||
)
|
||||
|
||||
before_filters = len(repository.queries)
|
||||
workspace.keyword_edit.setText(" 林医生 ")
|
||||
workspace.rx_audit.setCurrentIndex(workspace.rx_audit.findData(2))
|
||||
workspace.pay_audit.setCurrentIndex(workspace.pay_audit.findData(0))
|
||||
workspace.fulfillment.setCurrentIndex(workspace.fulfillment.findData(9))
|
||||
assert len(repository.queries) == before_filters # Changing status does not auto-submit.
|
||||
_click(workspace.use_dates, application)
|
||||
assert workspace.start_date.isEnabled() and workspace.end_date.isEnabled()
|
||||
start, end = QDate(2026, 7, 3), QDate(2026, 8, 8)
|
||||
workspace.start_date.setDate(start)
|
||||
workspace.end_date.setDate(end)
|
||||
_click(workspace.search_button, application)
|
||||
assert repository.queries[before_filters] == {
|
||||
**default_query,
|
||||
"keyword": "林医生",
|
||||
"prescription_audit_status": 2,
|
||||
"payment_slip_audit_status": 0,
|
||||
"fulfillment_status": 9,
|
||||
"start_date": "2026-07-03",
|
||||
"end_date": "2026-08-08",
|
||||
}
|
||||
|
||||
workspace.pager.load_more()
|
||||
assert repository.queries[-1]["page_no"] == 2
|
||||
assert repository.queries[-1]["page_size"] == 15
|
||||
workspace.keyword_edit.setText("新检索")
|
||||
QTest.keyClick(workspace.keyword_edit, Qt.Key.Key_Return)
|
||||
_settle(application)
|
||||
assert next(query for query in repository.queries if query["keyword"] == "新检索")["page_no"] == 1
|
||||
workspace.start_date.setDate(end.addDays(1))
|
||||
before = len(repository.queries)
|
||||
_click(workspace.search_button, application)
|
||||
assert len(repository.queries) == before
|
||||
assert workspace.banner.isVisible()
|
||||
assert "开始日期不能晚于结束日期" in _banner_text(workspace)
|
||||
|
||||
_click(workspace.reset_button, application)
|
||||
assert repository.queries[-1] == {**default_query, "page_no": repository.queries[-1]["page_no"]}
|
||||
assert workspace.keyword_edit.text() == ""
|
||||
assert not workspace.use_dates.isChecked()
|
||||
assert not workspace.start_date.isEnabled() and not workspace.end_date.isEnabled()
|
||||
assert workspace.start_date.date() == end.addDays(1)
|
||||
assert workspace.end_date.date() == end
|
||||
assert not workspace.banner.isVisible()
|
||||
|
||||
|
||||
def test_twelve_columns_keep_source_text_distinct_ids_and_missing_display_fields(
|
||||
application: QApplication, workspace_factory
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
row = repository.rows[0]
|
||||
row["order_no"] = "PO-非常长的订单号-20260905-ABCDEFGHIJ"
|
||||
row["patient_name"] = "用于检查省略与提示的较长患者姓名"
|
||||
row["doctor_name"] = "用于检查分行显示与完整提示信息的开方医生(演示)"
|
||||
workspace.refresh()
|
||||
_settle(application)
|
||||
table = workspace.table
|
||||
assert [table.horizontalHeaderItem(column).text() for column in range(table.columnCount())] == [
|
||||
"订单",
|
||||
"患者",
|
||||
"处方 / 诊单",
|
||||
"有效金额",
|
||||
"处方审核",
|
||||
"支付审核",
|
||||
"履约",
|
||||
"支付单",
|
||||
"归属助理",
|
||||
"开方人",
|
||||
"创建人",
|
||||
"创建时间",
|
||||
]
|
||||
index = next(
|
||||
index
|
||||
for index in range(table.rowCount())
|
||||
if table.item(index, 0).data(Qt.ItemDataRole.UserRole)["id"] == 901
|
||||
)
|
||||
assert table.item(index, 0).text() == f"{row['order_no']} · #901"
|
||||
assert table.item(index, 1).text() == f"{row['patient_name']} · 186****4820"
|
||||
assert table.item(index, 2).text() == "#801 / #501"
|
||||
assert table.item(index, 9).text() == row["doctor_name"]
|
||||
assert table.item(index, 0).toolTip() == table.item(index, 0).text()
|
||||
assert table.item(index, 1).toolTip() == table.item(index, 1).text()
|
||||
assert table.item(index, 9).toolTip() == row["doctor_name"]
|
||||
assert table.item(index, 7).text() == "—"
|
||||
assert table.item(index, 11).text() == "—"
|
||||
for column in range(12):
|
||||
assert not table.isColumnHidden(column)
|
||||
assert table.item(index, column).data(Qt.ItemDataRole.UserRole) == row
|
||||
assert row["recipient_phone"] not in table.item(index, column).text()
|
||||
|
||||
|
||||
def test_sorted_selection_buttons_and_menu_emit_current_order_resource_ids(
|
||||
application: QApplication, workspace_factory
|
||||
) -> None:
|
||||
workspace, _repository = workspace_factory()
|
||||
table = workspace.table
|
||||
emitted = []
|
||||
workspace.diagnosis_requested.connect(lambda row: emitted.append(("diagnosis", row)))
|
||||
workspace.detail_requested.connect(lambda row: emitted.append(("detail", row)))
|
||||
workspace.action_requested.connect(lambda key, row: emitted.append((key, row)))
|
||||
table.sortItems(0, Qt.SortOrder.AscendingOrder)
|
||||
first_id = table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"]
|
||||
table.sortItems(0, Qt.SortOrder.DescendingOrder)
|
||||
_settle(application)
|
||||
assert table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"] != first_id
|
||||
for index in (0, 2, 1):
|
||||
table.selectRow(index)
|
||||
_settle(application)
|
||||
expected = table.item(index, 0).data(Qt.ItemDataRole.UserRole)
|
||||
workspace.diagnosis_button.click()
|
||||
workspace.detail_button.click()
|
||||
next(
|
||||
action for action in workspace.action_menu.actions() if action.text() == "确认发货"
|
||||
).trigger()
|
||||
assert [kind for kind, _row in emitted[-3:]] == ["diagnosis", "detail", "ship"]
|
||||
for _kind, row in emitted[-3:]:
|
||||
assert (row["id"], row["prescription_id"], row["diagnosis_id"]) == (
|
||||
expected["id"],
|
||||
expected["prescription_id"],
|
||||
expected["diagnosis_id"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("permissions", "menu", "detail"),
|
||||
[
|
||||
(("*",), ["撤回支付审核", "修改快递单号", "确认发货", "上传药房"], True),
|
||||
(("tcm.prescriptionOrder/detail",), [], True),
|
||||
(("tcm.prescriptionOrder/ship",), ["确认发货"], False),
|
||||
(("tcm.prescriptionOrder.detail", "tcm.prescriptionOrder.ship"), [], False),
|
||||
((), [], False),
|
||||
],
|
||||
)
|
||||
def test_selected_order_menu_requires_canonical_permissions(
|
||||
application: QApplication, workspace_factory, permissions, menu, detail
|
||||
) -> None:
|
||||
workspace, _repository = workspace_factory(permissions=permissions)
|
||||
assert [action.text() for action in workspace.action_menu.actions()] == menu
|
||||
assert workspace.action_button.isVisible() is bool(menu)
|
||||
assert workspace.detail_button.isVisible() is detail
|
||||
assert workspace.diagnosis_button.isEnabled()
|
||||
emitted = []
|
||||
workspace.detail_requested.connect(lambda row: emitted.append(row["id"]))
|
||||
workspace._request_detail() # The double-click path also checks permission.
|
||||
assert bool(emitted) is detail
|
||||
workspace.permissions = PermissionSet([])
|
||||
workspace._selection_changed()
|
||||
_settle(application)
|
||||
assert workspace.action_menu.actions() == []
|
||||
assert not workspace.action_button.isVisible()
|
||||
assert not workspace.detail_button.isVisible()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("rx", "pay", "fulfillment", "rx_text", "pay_text", "state", "exclusion"),
|
||||
[
|
||||
(0, 1, 4, "待审核", "已通过", "已取消", "已取消不计入"),
|
||||
(1, 2, 9, "已通过", "已驳回", "拒收", "拒收不计入"),
|
||||
(2, 0, 10, "已驳回", "待审核", "退款", "退款不计入"),
|
||||
(1, 1, 6, "已通过", "已通过", "已签收", ""),
|
||||
],
|
||||
)
|
||||
def test_amount_exclusion_reasons_and_independent_status_text_survive_rendering(
|
||||
application: QApplication,
|
||||
workspace_factory,
|
||||
rx,
|
||||
pay,
|
||||
fulfillment,
|
||||
rx_text,
|
||||
pay_text,
|
||||
state,
|
||||
exclusion,
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
repository.rows = [
|
||||
{
|
||||
**repository.rows[0],
|
||||
"prescription_audit_status": rx,
|
||||
"payment_slip_audit_status": pay,
|
||||
"fulfillment_status": fulfillment,
|
||||
"amount_included": not bool(exclusion),
|
||||
"amount_exclusion_text": exclusion,
|
||||
"effective_amount": 12368.5,
|
||||
}
|
||||
]
|
||||
workspace.refresh()
|
||||
_settle(application)
|
||||
assert workspace.table.item(0, 3).text() == (exclusion or "¥12,368.50")
|
||||
assert [workspace.table.item(0, column).text() for column in (4, 5, 6)] == [
|
||||
rx_text,
|
||||
pay_text,
|
||||
state,
|
||||
]
|
||||
if fulfillment == 6:
|
||||
refund = next(
|
||||
action for action in workspace.action_menu.actions() if action.text() == "退款"
|
||||
)
|
||||
assert refund.property("danger") is True
|
||||
|
||||
|
||||
def test_summary_uses_response_scope_and_legacy_aliases_without_recomputing_rows(
|
||||
application: QApplication, workspace_factory
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
assert {key: label.text() for key, label in workspace.metrics.items()} == {
|
||||
"orders": "47",
|
||||
"amount": "¥12,368.50",
|
||||
"pending": "8",
|
||||
"completed": "9",
|
||||
"rejected": "2",
|
||||
"rejection_rate": "4.3%",
|
||||
}
|
||||
assert workspace.scope_label.text() == "测试部门订单范围"
|
||||
assert workspace.pager.total == 47
|
||||
assert workspace.pager.page_size == 15
|
||||
assert workspace.pager.height() == 24
|
||||
# Preserve the existing ratio compatibility, including its known ambiguity.
|
||||
repository.summary = {
|
||||
"order_count": 20,
|
||||
"effective_amount": 700.25,
|
||||
"pending_audit": 3,
|
||||
"completed": 4,
|
||||
"rejected": 1,
|
||||
"rejection_rate": 0.05,
|
||||
}
|
||||
workspace.refresh()
|
||||
_settle(application)
|
||||
assert {key: label.text() for key, label in workspace.metrics.items()} == {
|
||||
"orders": "20",
|
||||
"amount": "¥700.25",
|
||||
"pending": "3",
|
||||
"completed": "4",
|
||||
"rejected": "1",
|
||||
"rejection_rate": "5.0%",
|
||||
}
|
||||
before = len(repository.queries)
|
||||
for metric in workspace.metrics.values():
|
||||
_click(metric, application)
|
||||
assert len(repository.queries) == before # The six metrics are read-only.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("silent", [False, True])
|
||||
def test_loading_freezes_queries_resets_rows_and_ignores_stale_success_and_failure(
|
||||
application: QApplication, workspace_factory, monkeypatch: pytest.MonkeyPatch, silent: bool
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
table = workspace.table
|
||||
queued = []
|
||||
monkeypatch.setattr(
|
||||
patients_module,
|
||||
"run_async",
|
||||
lambda function, **callbacks: queued.append((function, callbacks)),
|
||||
)
|
||||
workspace.keyword_edit.setText("旧请求")
|
||||
workspace.refresh(silent=silent)
|
||||
workspace.keyword_edit.setText("最新请求")
|
||||
workspace.rx_audit.setCurrentIndex(workspace.rx_audit.findData(2))
|
||||
workspace.pay_audit.setCurrentIndex(workspace.pay_audit.findData(0))
|
||||
workspace.fulfillment.setCurrentIndex(workspace.fulfillment.findData(9))
|
||||
workspace.use_dates.setChecked(True)
|
||||
workspace.start_date.setDate(QDate(2026, 7, 1))
|
||||
workspace.end_date.setDate(QDate(2026, 8, 1))
|
||||
workspace.refresh(silent=silent)
|
||||
_settle(application)
|
||||
assert table.rowCount() == 0
|
||||
assert workspace.content_stack.currentIndex() == 1
|
||||
assert workspace.pager.isVisible()
|
||||
assert not workspace.banner.isVisible()
|
||||
|
||||
workspace.keyword_edit.setText("尚未提交")
|
||||
workspace.rx_audit.setCurrentIndex(0)
|
||||
workspace.use_dates.setChecked(False)
|
||||
newer = queued[1][0]()
|
||||
assert repository.queries[-1] == {
|
||||
"keyword": "最新请求",
|
||||
"prescription_audit_status": 2,
|
||||
"payment_slip_audit_status": 0,
|
||||
"fulfillment_status": 9,
|
||||
"start_date": "2026-07-01",
|
||||
"end_date": "2026-08-01",
|
||||
"page_no": 1,
|
||||
"page_size": 15,
|
||||
}
|
||||
newer["extend"]["scope"]["label"] = "最新范围"
|
||||
queued[1][1]["on_success"](newer)
|
||||
current_item = table.item(0, 0)
|
||||
stale = queued[0][0]()
|
||||
assert repository.queries[-1]["keyword"] == "旧请求"
|
||||
queued[0][1]["on_success"](stale)
|
||||
queued[0][1]["on_error"](RuntimeError("过期失败"))
|
||||
_settle(application)
|
||||
assert table.item(0, 0) is current_item
|
||||
assert workspace.scope_label.text() == "最新范围"
|
||||
assert not workspace.banner.isVisible()
|
||||
|
||||
workspace.keyword_edit.setText("最新请求")
|
||||
workspace.rx_audit.setCurrentIndex(workspace.rx_audit.findData(2))
|
||||
workspace.use_dates.setChecked(True)
|
||||
workspace.refresh(silent=silent)
|
||||
queued[-1][1]["on_error"](RuntimeError("订单查询失败"))
|
||||
_settle(application)
|
||||
assert table.item(0, 0) is current_item
|
||||
assert workspace.metrics["orders"].text() == "47"
|
||||
assert workspace.banner.isVisible()
|
||||
assert "订单查询失败" in _banner_text(workspace)
|
||||
workspace.refresh(silent=silent)
|
||||
queued[-1][1]["on_success"]({"lists": [], "count": 0})
|
||||
_settle(application)
|
||||
assert table.rowCount() == 0
|
||||
assert workspace.content_stack.currentIndex() == 1
|
||||
assert "当前范围内暂无订单" in " ".join(
|
||||
label.text() for label in workspace.content_stack.currentWidget().findChildren(QLabel)
|
||||
)
|
||||
assert not workspace.diagnosis_button.isEnabled()
|
||||
assert not workspace.detail_button.isEnabled()
|
||||
assert workspace.action_menu.actions() == []
|
||||
assert workspace.pager.total == 0
|
||||
assert workspace.metrics["orders"].text() == "0"
|
||||
assert workspace.metrics["amount"].text() == "¥0.00"
|
||||
assert not workspace.banner.isVisible()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(1270, 680), (1014, 490), (760, 380)])
|
||||
def test_layout_keeps_filters_actions_and_load_status_reachable_at_narrow_viewports(
|
||||
application: QApplication, workspace_factory, width: int, height: int
|
||||
) -> None:
|
||||
workspace, _repository = workspace_factory(width=width, height=height)
|
||||
table = workspace.table
|
||||
assert workspace.size().width() == width
|
||||
assert workspace.size().height() == height
|
||||
assert table.columnCount() == 12
|
||||
assert table.horizontalHeader().height() == 46
|
||||
assert all(table.rowHeight(index) == 68 for index in range(table.rowCount()))
|
||||
assert table.font().pixelSize() == 14
|
||||
assert workspace.scope_label.font().pixelSize() == 13
|
||||
assert all(metric.font().pixelSize() == 18 for metric in workspace.metrics.values())
|
||||
for widget in (
|
||||
workspace.keyword_edit,
|
||||
workspace.rx_audit,
|
||||
workspace.pay_audit,
|
||||
workspace.fulfillment,
|
||||
workspace.search_button,
|
||||
workspace.reset_button,
|
||||
workspace.use_dates,
|
||||
workspace.start_date,
|
||||
workspace.end_date,
|
||||
):
|
||||
assert widget.isVisibleTo(workspace)
|
||||
assert workspace.filter_card.rect().contains(_rect_in(widget, workspace.filter_card))
|
||||
for metric in workspace.metrics.values():
|
||||
assert workspace.summary_strip.rect().contains(_rect_in(metric, workspace.summary_strip))
|
||||
if width == 1270:
|
||||
assert workspace.filter_card.height() == 132
|
||||
assert workspace.summary_strip.height() == 96
|
||||
if width == 760:
|
||||
assert workspace.filter_card.height() == 184
|
||||
assert (
|
||||
len(
|
||||
{
|
||||
metric.mapTo(workspace.summary_strip, QPoint()).y()
|
||||
for metric in workspace.metrics.values()
|
||||
}
|
||||
)
|
||||
== 2
|
||||
)
|
||||
assert table.horizontalScrollBar().maximum() > 0
|
||||
assert workspace.scroll.verticalScrollBar().maximum() > 0
|
||||
|
||||
for host in (
|
||||
workspace.filter_card,
|
||||
workspace.summary_strip,
|
||||
workspace.action_bar,
|
||||
workspace.pager,
|
||||
):
|
||||
workspace.scroll.ensureWidgetVisible(host, 0, 0)
|
||||
_settle(application)
|
||||
assert (
|
||||
workspace.scroll.viewport().rect().contains(_rect_in(host, workspace.scroll.viewport()))
|
||||
), host.objectName()
|
||||
for button in (workspace.diagnosis_button, workspace.detail_button, workspace.action_button):
|
||||
assert workspace.action_bar.rect().contains(_rect_in(button, workspace.action_bar))
|
||||
assert workspace.pager.height() == 24
|
||||
for widget in (workspace.pager.summary_label,):
|
||||
assert workspace.pager.rect().contains(_rect_in(widget, workspace.pager))
|
||||
table.horizontalScrollBar().setValue(table.horizontalScrollBar().maximum())
|
||||
table.scrollToBottom()
|
||||
_settle(application)
|
||||
assert table.columnViewportPosition(11) >= 0
|
||||
assert table.columnViewportPosition(11) + table.columnWidth(11) <= table.viewport().width()
|
||||
assert table.rowViewportPosition(2) + table.rowHeight(2) <= table.viewport().height()
|
||||
|
||||
|
||||
def test_tab_switches_keep_patient_styles_scope_deduplication_and_progress_timer(
|
||||
application: QApplication, workspace_factory
|
||||
) -> None:
|
||||
page, _repository = workspace_factory(width=1328, height=884, page=True)
|
||||
patient = page.patient_workspace
|
||||
progress = page.progress_workspace
|
||||
patient_style = patient.styleSheet()
|
||||
assert patient.table.objectName() == "PatientTable"
|
||||
assert patient.table.columnCount() == 10
|
||||
assert patient.table.rowHeight(0) == 66
|
||||
assert patient.table.horizontalHeader().height() == 42
|
||||
assert patient.table.font().pixelSize() == 14
|
||||
assert not page.scope_badge.isVisible()
|
||||
assert not patient.search_toolbar.isVisible()
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
_settle(application)
|
||||
assert patient.search_toolbar.isVisible()
|
||||
assert not progress.timer.isActive()
|
||||
page.tabs.setCurrentIndex(2)
|
||||
_settle(application)
|
||||
progress_style = progress.styleSheet()
|
||||
progress_table_font = progress.schedule_table.font()
|
||||
progress_metric_font = progress.overview["total"][0].font()
|
||||
assert progress.timer.interval() == 15_000
|
||||
assert progress.timer.isActive()
|
||||
assert not page.scope_badge.isVisible()
|
||||
assert not progress.scope_label.isVisible()
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
_settle(application)
|
||||
assert progress.scope_label.isVisible()
|
||||
assert progress_table_font.pixelSize() == 14
|
||||
assert progress_metric_font.pixelSize() == 18
|
||||
assert progress.schedule_table.rowCount() == 7
|
||||
assert progress.schedule_table.columnCount() == 7
|
||||
|
||||
page.tabs.setCurrentIndex(1)
|
||||
_settle(application)
|
||||
assert not progress.timer.isActive()
|
||||
assert not page.scope_badge.isVisible()
|
||||
assert not patient.search_toolbar.isVisible()
|
||||
assert page.order_workspace.scope_label.isVisible()
|
||||
assert page.order_workspace.scope_label.text() == "测试部门订单范围"
|
||||
assert patient.styleSheet() == patient_style
|
||||
assert progress.styleSheet() == progress_style
|
||||
assert progress.schedule_table.font() == progress_table_font
|
||||
assert progress.overview["total"][0].font() == progress_metric_font
|
||||
assert patient.table.rowHeight(0) == 66
|
||||
assert patient.table.horizontalHeader().height() == 42
|
||||
|
||||
page.tabs.setCurrentIndex(0)
|
||||
_settle(application)
|
||||
assert patient.search_toolbar.isVisible()
|
||||
assert not page.scope_badge.isVisible()
|
||||
assert not progress.timer.isActive()
|
||||
page.tabs.setCurrentIndex(2)
|
||||
_settle(application)
|
||||
assert not page.scope_badge.isVisible()
|
||||
assert progress.scope_label.isVisible()
|
||||
assert progress.timer.isActive()
|
||||
page.hide()
|
||||
_settle(application)
|
||||
assert not progress.timer.isActive()
|
||||
page.show()
|
||||
_settle(application)
|
||||
assert progress.timer.isActive()
|
||||
@@ -0,0 +1,483 @@
|
||||
"""Progress data/display contracts and native reachability after the blue redesign."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from copy import deepcopy
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QDate, QPoint, QRect, Qt, QTimer
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QAbstractItemView, QApplication, QLabel
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui.pages import patients as patients_module
|
||||
from doctor_workstation.ui.pages.patients import PatientProgressWorkspace, PatientsPage
|
||||
from doctor_workstation.ui.shell import ShellWindow
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
class _Repository:
|
||||
def __init__(self) -> None:
|
||||
self.queries: list[dict[str, Any]] = []
|
||||
self.result = {
|
||||
"lists": [{"id": 101, "diagnosis_id": 501, "patient_id": 301,
|
||||
"patient_name": "林晓岚", "doctor_name": "陈医生(演示)",
|
||||
"appointment_time": "09:00-09:30"}],
|
||||
"count": 37,
|
||||
"extend": {"scope": {"label": "本部门及下级"}, "schedule_mode": "self",
|
||||
"summary": {"waiting": 37, "completed": 2, "missed": 3}},
|
||||
}
|
||||
|
||||
def patient_progress(self, **query: Any) -> dict[str, Any]:
|
||||
self.queries.append(deepcopy(query))
|
||||
return deepcopy(self.result)
|
||||
|
||||
def list_patients(self, **_query: Any) -> dict[str, Any]:
|
||||
return {"lists": [], "count": 0}
|
||||
|
||||
def patient_orders(self, **_query: Any) -> dict[str, Any]:
|
||||
return {"lists": [], "count": 0}
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def _texts(table) -> list[list[str]]:
|
||||
return [[table.item(row, column).text() for column in range(table.columnCount())]
|
||||
for row in range(table.rowCount())]
|
||||
|
||||
|
||||
def _metrics(workspace) -> dict[str, tuple[str, str]]:
|
||||
return {key: (value.text(), hint.text()) for key, (value, hint) in workspace.overview.items()}
|
||||
|
||||
|
||||
def _rect_in(widget, parent) -> QRect:
|
||||
return QRect(widget.mapTo(parent, QPoint()), widget.size())
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
def immediate(function, *, on_success=None, on_error=None, on_finished=None):
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
if on_error:
|
||||
on_error(error)
|
||||
raise
|
||||
else:
|
||||
if on_success:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished:
|
||||
on_finished()
|
||||
|
||||
def reject_network(*_args, **_kwargs):
|
||||
pytest.fail("Progress regression tests must use local fixtures or Demo only")
|
||||
|
||||
monkeypatch.setattr(socket.socket, "connect", reject_network)
|
||||
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
|
||||
monkeypatch.setattr(socket, "create_connection", reject_network)
|
||||
monkeypatch.setattr(patients_module, "run_async", immediate)
|
||||
monkeypatch.setenv("DOCTOR_SMOKE_TEST", "1")
|
||||
opened = []
|
||||
|
||||
def create(*, page=False, shell=False, permissions=("*",), width=1274, height=696,
|
||||
repository=None, refresh=True):
|
||||
repository = repository if repository is not None else _Repository()
|
||||
permission_set = PermissionSet(list(permissions))
|
||||
if shell:
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||
# A real shell with only the tested navigation route, not unrelated workspaces.
|
||||
widget = ShellWindow(repository, {
|
||||
"user": session.user, "permissions": permission_set,
|
||||
"menu": [{"perms": "firstvisit.myPatient/lists"}],
|
||||
}, permissions=permission_set)
|
||||
elif page:
|
||||
widget = PatientsPage(repository, permissions=permission_set)
|
||||
else:
|
||||
widget = PatientProgressWorkspace(repository)
|
||||
opened.append(widget)
|
||||
widget.resize(width, height)
|
||||
widget.show()
|
||||
if shell:
|
||||
widget.navigate("patients")
|
||||
widget.pages["patients"].tabs.setCurrentIndex(2)
|
||||
elif page:
|
||||
widget.tabs.setCurrentIndex(2)
|
||||
elif refresh:
|
||||
widget.refresh()
|
||||
_settle(application)
|
||||
return widget, repository
|
||||
|
||||
yield create
|
||||
for widget in opened:
|
||||
for timer in widget.findChildren(QTimer):
|
||||
timer.stop()
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
_settle(application)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("extend", "total", "expected_mode", "expected"),
|
||||
[
|
||||
({"schedule_mode": "self", "summary": {"waiting": 5, "completed": 2, "missed": 1}},
|
||||
99, "按本人归属", {"total": ("8", "0 位接诊医生"), "waiting": ("5", "本人归属患者"), "completed": ("2", "已过号 1 人")}),
|
||||
({"schedule_mode": "ownership", "summary": {"completed": 3, "missed": 2}},
|
||||
7, "按本人归属", {"total": ("12", "0 位接诊医生"), "waiting": ("7", "本人归属患者"), "completed": ("3", "已过号 2 人")}),
|
||||
({"schedule_mode": "self", "summary": {"waiting": 8, "completed": 9, "missed": 7},
|
||||
"today_overview": {"total_visits": 0, "booked": 0, "completed": 0, "missed": 0, "doctor_count": 4}},
|
||||
99, "按本人归属", {"total": ("0", "4 位接诊医生"), "waiting": ("0", "本人归属患者"), "completed": ("0", "已过号 0 人")}),
|
||||
({"schedule_mode": "roster", "summary": {"waiting": 30, "completed": 40, "missed": 5},
|
||||
"today_overview": {"total_visits": 18, "booked": 12, "completed": 4, "empty_slots": 6, "doctor_count": 3, "missed": 2}},
|
||||
99, "与排班合并", {"total": ("18", "3 位接诊医生"), "waiting": ("12", "有效挂号"), "completed": ("6", "当前空号")}),
|
||||
({"schedule_mode": "roster", "summary": {"waiting": 6, "completed": 3, "missed": 1}},
|
||||
99, "与排班合并", {"total": ("10", "0 位接诊医生"), "waiting": ("6", "有效挂号"), "completed": ("0", "当前空号")}),
|
||||
({}, 4, "与排班合并", {"total": ("4", "0 位接诊医生"), "waiting": ("4", "有效挂号"), "completed": ("0", "当前空号")}),
|
||||
],
|
||||
ids=["self-summary", "ownership-total-fallback", "explicit-zero-wins", "roster-empty-slots", "roster-summary-fallback", "missing-extend"],
|
||||
)
|
||||
def test_overview_preserves_mode_fallbacks_and_fixed_completed_caption(
|
||||
workspace_factory, extend, total, expected_mode, expected
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
repository.result.update(count=total, extend=extend)
|
||||
workspace.refresh()
|
||||
assert _metrics(workspace) == expected
|
||||
assert workspace.mode_label.text() == expected_mode
|
||||
assert [label.text() for label in workspace.overview_card.findChildren(QLabel)
|
||||
if label.text() in {"今日面诊总数", "待面诊", "已完成"}] == ["今日面诊总数", "待面诊", "已完成"]
|
||||
assert workspace.queue_count.text() == f"共 {total} 人 · 每 15 秒刷新"
|
||||
assert workspace.queue_table.rowCount() == 1 # Totals are server scope, not visible-row counts.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("week_schedule", [None, [], "invalid"], ids=["missing", "empty", "invalid-type"])
|
||||
def test_schedule_fallback_is_seven_consecutive_days_with_blank_doctors(
|
||||
workspace_factory, monkeypatch, week_schedule
|
||||
) -> None:
|
||||
# Sunday fixes the distinction between rolling seven days and a calendar week.
|
||||
class Sunday:
|
||||
@staticmethod
|
||||
def currentDate():
|
||||
return QDate(2026, 9, 6)
|
||||
|
||||
monkeypatch.setattr(patients_module, "QDate", Sunday)
|
||||
workspace, repository = workspace_factory()
|
||||
repository.result["extend"]["week_schedule"] = week_schedule
|
||||
workspace.refresh()
|
||||
rows = _texts(workspace.schedule_table)
|
||||
assert [row[0] for row in rows] == ["09-06 周日", "09-07 周一", "09-08 周二", "09-09 周三", "09-10 周四", "09-11 周五", "09-12 周六"]
|
||||
assert all(row[1:6] == ["0"] * 5 and row[6] == "" for row in rows)
|
||||
|
||||
|
||||
def test_schedule_zero_priority_missing_fallback_and_doctor_names(workspace_factory) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
repository.result["extend"]["week_schedule"] = [
|
||||
{"date_text": "09-05", "weekday": "周六", "doctor_count": 2, "total_appointments": 0,
|
||||
"waiting_appointments": 0, "completed_appointments": 0, "total_slots": 11,
|
||||
"booked_slots": 7, "empty_slots": 4, "missed_appointments": 1, "doctors": []},
|
||||
{"date_text": "09-06", "weekday": "周日", "doctor_count": 2, "total_appointments": None,
|
||||
"waiting_appointments": "", "total_slots": 11, "booked_slots": 7, "empty_slots": 4,
|
||||
"missed_appointments": 0, "doctors": [{"doctor_name": "甲医生"}, {"name": "乙医生"}]},
|
||||
{"date_text": "09-07", "weekday": "周一", "doctors": "invalid"},
|
||||
]
|
||||
workspace.refresh()
|
||||
rows = {row[0]: row for row in _texts(workspace.schedule_table)}
|
||||
assert rows["09-05 周六"] == ["09-05 周六", "2", "0", "0", "0", "1", ""]
|
||||
assert rows["09-06 周日"] == ["09-06 周日", "2", "11", "7", "4", "0", "甲医生、乙医生"]
|
||||
assert rows["09-07 周一"][6] == "—"
|
||||
|
||||
|
||||
def test_demo_row_keeps_missing_queue_fields_and_empty_cells_visually_blank(
|
||||
application, workspace_factory
|
||||
) -> None:
|
||||
repository = DemoDoctorRepository()
|
||||
repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||
workspace, _ = workspace_factory(repository=repository)
|
||||
assert _texts(workspace.queue_table) == [["—", "林晓岚", "陈医生(演示)", "09:00", "0 位", "—"]]
|
||||
assert _metrics(workspace) == {"total": ("1", "0 位接诊医生"), "waiting": ("1", "本人归属患者"), "completed": ("0", "已过号 0 人")}
|
||||
assert workspace.scope == "演示医生本人患者"
|
||||
assert workspace.pager.summary_label.text() == "共 1 条 · 已全部加载"
|
||||
assert not workspace.pager.has_more
|
||||
table = workspace.schedule_table
|
||||
table.scrollToItem(table.item(0, 6))
|
||||
_settle(application)
|
||||
rect = table.visualItemRect(table.item(0, 6)).adjusted(3, 3, -3, -3)
|
||||
image = table.viewport().grab(rect).toImage()
|
||||
assert not image.isNull()
|
||||
# Read the actual delegate output, not just the model's DisplayRole: blank is not “—”.
|
||||
assert all(image.pixelColor(x, y).lightness() > 160
|
||||
for x in range(image.width()) for y in range(image.height()))
|
||||
dash_table = workspace.queue_table
|
||||
dash_image = dash_table.viewport().grab(dash_table.visualItemRect(dash_table.item(0, 0))).toImage()
|
||||
assert any(dash_image.pixelColor(x, y).lightness() < 100
|
||||
for x in range(dash_image.width()) for y in range(dash_image.height()))
|
||||
|
||||
|
||||
def test_queue_waiting_statuses_keep_source_counts_and_no_status_desc_fallback(workspace_factory) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
examples = [
|
||||
({"queue_status": "consulting", "queue_status_text": "就诊中", "ahead_count": 12}, "0(进行中)", "就诊中"),
|
||||
({"queue_status": "next", "queue_status_text": "待确认", "ahead_count": 12}, "0(待接诊)", "待确认"),
|
||||
({"queue_status": "waiting", "queue_status_text": "等待中", "ahead_count": "3"}, "3 位 · 约 45 分钟", "等待中"),
|
||||
({"queue_status": "waiting", "ahead_count": 2, "estimated_wait_minutes": 7}, "2 位 · 约 7 分钟", "—"),
|
||||
({"queue_status": "missed", "queue_status_text": "已过号", "ahead_count": 2, "estimated_wait_minutes": -5}, "2 位 · 约 0 分钟", "已过号"),
|
||||
({"queue_status": "completed", "queue_status_text": "已完成", "ahead_count": -8}, "0 位", "已完成"),
|
||||
({"status_desc": "待接诊", "diagnosis_confirmed": 1, "ahead_count": "bad"}, "0 位", "—"),
|
||||
]
|
||||
base = repository.result["lists"][0]
|
||||
repository.result["lists"] = [dict(base, **fields, id=100 + index, queue_no=40 + index)
|
||||
for index, (fields, _, _) in enumerate(examples)]
|
||||
workspace.refresh()
|
||||
table = workspace.queue_table
|
||||
for row_index in range(table.rowCount()):
|
||||
row = table.item(row_index, 0).data(Qt.ItemDataRole.UserRole)
|
||||
fields, expected_wait, expected_status = examples[row["id"] - 100]
|
||||
assert table.item(row_index, 0).text() == str(row["queue_no"])
|
||||
assert table.item(row_index, 4).text() == expected_wait
|
||||
assert table.item(row_index, 5).text() == expected_status
|
||||
assert all(table.item(row_index, col).data(Qt.ItemDataRole.UserRole) == row for col in range(6))
|
||||
assert table.selectionMode() == QAbstractItemView.SelectionMode.SingleSelection
|
||||
assert table.editTriggers() == QAbstractItemView.EditTrigger.NoEditTriggers
|
||||
assert table.isSortingEnabled()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("permissions", "expected"),
|
||||
[(('tcm.diagnosis/edit', 'tcm.diagnosis/readonlyDetail'), "edit"),
|
||||
(("tcm.diagnosis/readonlyDetail",), "read"), ((), "denied"),
|
||||
(("tcm.diagnosis.edit", "tcm.diagnosis.readonlyDetail"), "denied")],
|
||||
ids=["edit-preferred", "readonly", "no-permission", "noncanonical-permission"],
|
||||
)
|
||||
def test_sorted_double_click_uses_selected_diagnosis_and_canonical_permissions(
|
||||
application, workspace_factory, monkeypatch, permissions, expected
|
||||
) -> None:
|
||||
page, repository = workspace_factory(page=True, width=1328, height=884, permissions=permissions)
|
||||
workspace = page.progress_workspace
|
||||
repository.result["lists"] = [dict(repository.result["lists"][0], id=101 + index,
|
||||
diagnosis_id=501 + index, patient_id=301 + index,
|
||||
patient_name=name, queue_no=index + 1)
|
||||
for index, name in enumerate(("阿青", "林青", "赵青"))]
|
||||
workspace.refresh()
|
||||
calls, toasts = [], []
|
||||
dialog = SimpleNamespace(
|
||||
open_for=lambda resource, **kwargs: calls.append(("edit", resource, kwargs)),
|
||||
open_view_only=lambda resource, **kwargs: calls.append(("read", resource, kwargs)),
|
||||
)
|
||||
monkeypatch.setattr(page, "_ensure_diagnosis_dialog", lambda: dialog)
|
||||
monkeypatch.setattr(patients_module, "show_toast", lambda _parent, text, *_args: toasts.append(text))
|
||||
table = workspace.queue_table
|
||||
table.sortItems(0, Qt.SortOrder.DescendingOrder)
|
||||
table.selectRow(0)
|
||||
table.scrollToItem(table.item(0, 1))
|
||||
workspace.scroll.ensureWidgetVisible(table, 0, 0)
|
||||
_settle(application)
|
||||
row = table.item(0, 0).data(Qt.ItemDataRole.UserRole)
|
||||
assert row["id"] == 103 # A sorted row must not resolve to the first source row (101).
|
||||
point = table.visualItemRect(table.item(0, 1)).center()
|
||||
QTest.mouseClick(table.viewport(), Qt.MouseButton.LeftButton, pos=point)
|
||||
QTest.mouseDClick(table.viewport(), Qt.MouseButton.LeftButton, pos=point)
|
||||
_settle(application)
|
||||
if expected == "denied":
|
||||
assert calls == [] and toasts == ["当前账号没有诊单查看权限。"]
|
||||
else:
|
||||
assert len(calls) == 1 and calls[0][0:2] == (expected, row["diagnosis_id"])
|
||||
assert calls[0][1] != row["patient_id"] and calls[0][2]["seed"] == row
|
||||
assert not toasts
|
||||
|
||||
|
||||
def test_selected_row_id_fallback_and_invalid_diagnosis_never_use_patient_id(
|
||||
workspace_factory, monkeypatch
|
||||
) -> None:
|
||||
page, repository = workspace_factory(page=True, permissions=("tcm.diagnosis/edit",))
|
||||
workspace = page.progress_workspace
|
||||
calls, toasts = [], []
|
||||
monkeypatch.setattr(page, "_ensure_diagnosis_dialog", lambda: SimpleNamespace(
|
||||
open_for=lambda resource, **kwargs: calls.append(resource)))
|
||||
monkeypatch.setattr(patients_module, "show_toast", lambda _parent, text, *_args: toasts.append(text))
|
||||
for row, expected in [({"id": 810, "patient_id": 910}, 810),
|
||||
({"id": 811, "diagnosis_id": 0, "patient_id": 911}, None),
|
||||
({"patient_id": 912}, None)]:
|
||||
repository.result["lists"] = [row]
|
||||
workspace.refresh()
|
||||
workspace.queue_table.selectRow(0)
|
||||
before = len(calls)
|
||||
workspace.queue_table.itemDoubleClicked.emit(workspace.queue_table.item(0, 0))
|
||||
assert calls[before:] == ([] if expected is None else [expected])
|
||||
assert toasts == ["患者诊单信息不完整。", "患者诊单信息不完整。"]
|
||||
|
||||
|
||||
def test_query_captures_today_status_and_page_before_worker_runs(
|
||||
application, workspace_factory, monkeypatch
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
queued = []
|
||||
monkeypatch.setattr(patients_module, "run_async", lambda function, **callbacks: queued.append((function, callbacks)))
|
||||
today = QDate.currentDate().toString("yyyy-MM-dd")
|
||||
workspace.refresh(silent=True)
|
||||
queued.pop()[1]["on_success"](deepcopy(repository.result))
|
||||
workspace.pager.load_more()
|
||||
workspace.pager.load_more()
|
||||
assert len(queued) == 1 # Repeated bottom events share the in-flight request.
|
||||
assert not workspace.banner.isVisible()
|
||||
# Later GUI changes must not reach the pending worker's immutable query.
|
||||
workspace._page = 9
|
||||
queued[0][0]()
|
||||
assert repository.queries[-1:] == [
|
||||
{"status": 1, "start_date": today, "end_date": today, "page_no": 2, "page_size": 15},
|
||||
]
|
||||
assert "page" not in repository.queries[-1]
|
||||
assert workspace.pager.page_size == 15
|
||||
|
||||
|
||||
def test_stale_success_error_and_latest_error_preserve_the_last_good_data(
|
||||
application, workspace_factory, monkeypatch
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
pending = []
|
||||
monkeypatch.setattr(patients_module, "run_async", lambda _function, **callbacks: pending.append(callbacks))
|
||||
workspace.refresh()
|
||||
# A date rollover changes the query; repeated same-day polls reuse in-flight work.
|
||||
tomorrow = patients_module.QDate.currentDate().addDays(1)
|
||||
monkeypatch.setattr(patients_module, "QDate", SimpleNamespace(currentDate=lambda: tomorrow))
|
||||
workspace.refresh()
|
||||
newer = deepcopy(repository.result)
|
||||
newer["lists"][0].update(id=202, patient_name="最新结果")
|
||||
newer["count"] = 22
|
||||
newer["extend"].update(scope={"label": "新范围"}, summary={"waiting": 22, "completed": 4, "missed": 2})
|
||||
pending[1]["on_success"](newer)
|
||||
_settle(application)
|
||||
snapshot = (_texts(workspace.queue_table), _texts(workspace.schedule_table), _metrics(workspace),
|
||||
workspace.scope, workspace.queue_count.text(), workspace.pager.total, workspace.queue_stack.currentIndex())
|
||||
pending[0]["on_success"]({"lists": [], "count": 0})
|
||||
pending[0]["on_error"](RuntimeError("obsolete failure"))
|
||||
_settle(application)
|
||||
assert not workspace.banner.isVisible()
|
||||
assert snapshot == (_texts(workspace.queue_table), _texts(workspace.schedule_table), _metrics(workspace),
|
||||
workspace.scope, workspace.queue_count.text(), workspace.pager.total, workspace.queue_stack.currentIndex())
|
||||
workspace.refresh(silent=True)
|
||||
assert not workspace.banner.isVisible()
|
||||
pending[2]["on_error"](RuntimeError("连接失败,请稍后刷新重试。"))
|
||||
_settle(application)
|
||||
assert workspace.banner.isVisible() and workspace.banner.property("kind") == "danger"
|
||||
assert "连接失败" in workspace.banner.label.text()
|
||||
assert snapshot == (_texts(workspace.queue_table), _texts(workspace.schedule_table), _metrics(workspace),
|
||||
workspace.scope, workspace.queue_count.text(), workspace.pager.total, workspace.queue_stack.currentIndex())
|
||||
|
||||
|
||||
def test_empty_list_keeps_total_week_and_compact_status_visible(
|
||||
application, workspace_factory
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
repository.result.update(lists=[], count=37)
|
||||
workspace.refresh()
|
||||
_settle(application)
|
||||
assert workspace.queue_stack.currentIndex() == 1
|
||||
assert not workspace.queue_table.isVisible() and workspace.pager.isVisible()
|
||||
assert workspace.pager.height() == 24
|
||||
assert workspace.schedule_table.rowCount() == 7
|
||||
assert workspace.queue_count.text() == "共 37 人 · 每 15 秒刷新"
|
||||
labels = [label.text() for label in workspace.queue_stack.currentWidget().findChildren(QLabel)]
|
||||
assert "今日暂无候诊患者" in labels
|
||||
assert "当前权限范围内没有有效挂号。" in labels
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(1536, 960), (1366, 768), (1024, 768)])
|
||||
def test_actual_shell_keeps_all_columns_rows_and_load_status_reachable(
|
||||
application, workspace_factory, width, height
|
||||
) -> None:
|
||||
shell, _ = workspace_factory(shell=True, width=width, height=height)
|
||||
page = shell.pages["patients"]
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
_settle(application)
|
||||
workspace = page.progress_workspace
|
||||
assert (shell.width(), shell.height()) == (width, height)
|
||||
assert not page.scope_badge.isVisible() and workspace.scope_label.isVisible()
|
||||
schedule, queue = workspace.schedule_table, workspace.queue_table
|
||||
assert (schedule.rowCount(), schedule.columnCount(), queue.columnCount()) == (7, 7, 6)
|
||||
if width == 1536:
|
||||
assert schedule.verticalScrollBar().value() == 0
|
||||
assert schedule.rowViewportPosition(6) + schedule.rowHeight(6) <= schedule.viewport().height()
|
||||
assert workspace.scroll.viewport().rect().contains(_rect_in(schedule, workspace.scroll.viewport()))
|
||||
for table in (schedule, queue):
|
||||
workspace.scroll.ensureWidgetVisible(table, 0, 0)
|
||||
_settle(application)
|
||||
assert workspace.scroll.viewport().rect().contains(_rect_in(table, workspace.scroll.viewport()))
|
||||
for column in range(table.columnCount()):
|
||||
item = table.item(table.rowCount() - 1, column)
|
||||
table.scrollToItem(item, QAbstractItemView.ScrollHint.PositionAtCenter)
|
||||
_settle(application)
|
||||
assert not table.isColumnHidden(column)
|
||||
assert table.viewport().rect().contains(table.visualItemRect(item)), (width, column)
|
||||
workspace.scroll.ensureWidgetVisible(workspace.pager, 0, 0)
|
||||
_settle(application)
|
||||
assert workspace.scroll.viewport().rect().contains(_rect_in(workspace.pager, workspace.scroll.viewport()))
|
||||
assert workspace.pager.height() == 24
|
||||
for child in (workspace.pager.summary_label,):
|
||||
assert workspace.pager.rect().contains(_rect_in(child, workspace.pager))
|
||||
if width == 1024:
|
||||
assert schedule.horizontalScrollBar().maximum() > 0
|
||||
assert queue.horizontalScrollBar().maximum() > 0
|
||||
assert workspace.scroll.verticalScrollBar().maximum() > 0
|
||||
|
||||
|
||||
def test_splitter_mouse_drag_reallocates_space_but_cannot_collapse_panels(
|
||||
application, workspace_factory
|
||||
) -> None:
|
||||
workspace, _ = workspace_factory()
|
||||
splitter = workspace.splitter
|
||||
before = splitter.sizes()
|
||||
handle = splitter.handle(1)
|
||||
point = handle.rect().center()
|
||||
QTest.mousePress(handle, Qt.MouseButton.LeftButton, pos=point)
|
||||
QTest.mouseMove(handle, point + QPoint(0, -60))
|
||||
QTest.mouseRelease(handle, Qt.MouseButton.LeftButton, pos=point + QPoint(0, -60))
|
||||
_settle(application)
|
||||
assert splitter.sizes()[0] < before[0]
|
||||
assert not splitter.childrenCollapsible()
|
||||
for sizes in ([0, 1000], [1000, 0]):
|
||||
splitter.setSizes(sizes)
|
||||
_settle(application)
|
||||
assert splitter.sizes()[0] >= workspace.schedule_card.minimumHeight() > 0
|
||||
assert splitter.sizes()[1] >= workspace.queue_card.minimumHeight() > 0
|
||||
|
||||
|
||||
def test_typography_and_styles_are_local_to_the_progress_workspace(
|
||||
application, workspace_factory
|
||||
) -> None:
|
||||
app_style = application.styleSheet()
|
||||
unrelated = QLabel("外部标签")
|
||||
unrelated.ensurePolished()
|
||||
original_font = unrelated.font()
|
||||
workspace, _ = workspace_factory()
|
||||
assert workspace.schedule_table.font().pixelSize() == 14
|
||||
assert workspace.queue_table.font().pixelSize() == 14
|
||||
assert workspace.scope_label.font().pixelSize() == 13
|
||||
assert workspace.mode_label.font().pixelSize() == 13
|
||||
assert workspace.queue_count.font().pixelSize() == 13
|
||||
for value, hint in workspace.overview.values():
|
||||
assert value.font().pixelSize() == 18 and hint.font().pixelSize() == 13
|
||||
for label in workspace.findChildren(QLabel):
|
||||
if label.property("role") == "sectionTitle":
|
||||
assert label.font().pixelSize() == 14
|
||||
assert application.styleSheet() == app_style
|
||||
assert "ProgressWorkspace" not in app_style
|
||||
assert unrelated.font() == original_font and not unrelated.styleSheet()
|
||||
unrelated.deleteLater()
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Patient workspaces keep resource identity while appending server pages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QDate, Qt
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QCheckBox, QPushButton
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.pages import patients as patients_module
|
||||
from doctor_workstation.ui.pages.patients import (
|
||||
PatientListWorkspace,
|
||||
PatientOrdersWorkspace,
|
||||
PatientProgressWorkspace,
|
||||
PatientsPage,
|
||||
)
|
||||
|
||||
|
||||
class Repository:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
self.failed_pages: set[int] = set()
|
||||
self.version = 0
|
||||
|
||||
def _list(self, **query: Any) -> dict[str, Any]:
|
||||
self.calls.append(dict(query))
|
||||
page = query["page_no"]
|
||||
if page in self.failed_pages:
|
||||
raise RuntimeError("连接失败,请重试。")
|
||||
start = (page - 1) * query["page_size"]
|
||||
rows = [
|
||||
{
|
||||
"id": index + 100,
|
||||
"diagnosis_id": index + 1000,
|
||||
"patient_id": index + 2000,
|
||||
"appointment_id": index + 3000,
|
||||
"order_id": index + 4000,
|
||||
"patient_name": f"患者 {index:02d} · {query.get('keyword', '')}{self.version}",
|
||||
"appointment_status": 1,
|
||||
"prescription_audit_status": 0,
|
||||
"payment_slip_audit_status": 0,
|
||||
"fulfillment_status": 2,
|
||||
"queue_no": index + 1,
|
||||
"queue_status": "waiting",
|
||||
"queue_status_text": "等待中",
|
||||
}
|
||||
for index in range(start, min(start + query["page_size"], 37))
|
||||
]
|
||||
return {
|
||||
"lists": rows,
|
||||
"count": 37,
|
||||
"extend": {
|
||||
"scope": {"label": "本人患者"},
|
||||
"summary": {"orders": 37, "amount": 3700, "today": 37, "waiting": 37},
|
||||
"schedule_mode": "ownership",
|
||||
},
|
||||
}
|
||||
|
||||
list_patients = _list
|
||||
patient_orders = _list
|
||||
patient_progress = _list
|
||||
|
||||
|
||||
def run_immediately(function: Any, **callbacks: Any) -> object:
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
callbacks["on_error"](error)
|
||||
else:
|
||||
callbacks["on_success"](result)
|
||||
finally:
|
||||
if callbacks.get("on_finished"):
|
||||
callbacks["on_finished"]()
|
||||
return object()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture(params=["patients", "orders", "progress"])
|
||||
def workspace(request: Any, application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(patients_module, "run_async", run_immediately)
|
||||
repository = Repository()
|
||||
permissions = PermissionSet(["*"])
|
||||
constructors = {
|
||||
"patients": lambda: PatientListWorkspace(repository, permissions),
|
||||
"orders": lambda: PatientOrdersWorkspace(repository, permissions),
|
||||
"progress": lambda: PatientProgressWorkspace(repository),
|
||||
}
|
||||
widget = constructors[request.param]()
|
||||
widget.resize(1280, 900)
|
||||
widget.show()
|
||||
application.processEvents()
|
||||
yield request.param, widget, repository
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def table_for(workspace: Any) -> Any:
|
||||
return getattr(workspace, "table", None) or workspace.queue_table
|
||||
|
||||
|
||||
def settle(application: QApplication) -> None:
|
||||
application.processEvents()
|
||||
QTest.qWait(60)
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def scroll_down(workspace: Any, application: QApplication) -> None:
|
||||
table = table_for(workspace)
|
||||
table.verticalScrollBar().setValue(table.verticalScrollBar().maximum())
|
||||
settle(application)
|
||||
|
||||
|
||||
def test_scrolling_appends_without_duplicates_or_selection_loss(workspace, application):
|
||||
kind, widget, repository = workspace
|
||||
widget.refresh()
|
||||
settle(application)
|
||||
table = table_for(widget)
|
||||
assert table.rowCount() == 15
|
||||
assert widget.pager.height() == 24
|
||||
assert not hasattr(widget.pager, "page_changed")
|
||||
assert not any(button.text().isdigit() for button in widget.pager.findChildren(QPushButton))
|
||||
table.sortItems(0, Qt.SortOrder.DescendingOrder)
|
||||
table.selectRow(5)
|
||||
chosen = table.current_data()["id"]
|
||||
if kind == "patients":
|
||||
table.cellWidget(5, 0).findChild(QCheckBox).setChecked(True)
|
||||
scroll_down(widget, application)
|
||||
assert table.rowCount() == 30
|
||||
assert table.current_data()["id"] == chosen
|
||||
if kind == "patients":
|
||||
selected_row = next(index for index in range(table.rowCount())
|
||||
if table.item(index, 0).data(Qt.ItemDataRole.UserRole)["id"] == chosen)
|
||||
assert table.cellWidget(selected_row, 0).findChild(QCheckBox).isChecked()
|
||||
scroll_down(widget, application)
|
||||
assert table.rowCount() == 37
|
||||
assert len({table.item(index, 0).data(Qt.ItemDataRole.UserRole)["id"]
|
||||
for index in range(table.rowCount())}) == 37
|
||||
assert [query["page_no"] for query in repository.calls] == [1, 2, 3]
|
||||
assert all(query["page_size"] == 15 and "page" not in query for query in repository.calls)
|
||||
assert widget.pager.page == widget._page == 3
|
||||
assert widget.pager.total == 37
|
||||
assert not widget.pager.has_more
|
||||
assert widget.scope == "本人患者"
|
||||
scroll_down(widget, application)
|
||||
assert len(repository.calls) == 3
|
||||
|
||||
|
||||
def test_refresh_retains_loaded_prefix_scroll_and_distinct_action_ids(workspace, application):
|
||||
kind, widget, repository = workspace
|
||||
widget.refresh()
|
||||
settle(application)
|
||||
scroll_down(widget, application)
|
||||
table = table_for(widget)
|
||||
table.selectRow(20)
|
||||
chosen = dict(table.current_data())
|
||||
scroll = table.verticalScrollBar().value()
|
||||
repository.version = 1
|
||||
widget.refresh(silent=True)
|
||||
assert [query["page_no"] for query in repository.calls] == [1, 2, 1, 2]
|
||||
assert table.rowCount() == 30
|
||||
assert table.current_data()["id"] == chosen["id"]
|
||||
assert table.current_data()["patient_name"].endswith("1")
|
||||
assert table.verticalScrollBar().value() == scroll
|
||||
selected = []
|
||||
if kind == "patients":
|
||||
widget.diagnosis_requested.connect(lambda row, _edit: selected.append(row))
|
||||
widget._open_selected_diagnosis()
|
||||
elif kind == "orders":
|
||||
widget.detail_requested.connect(selected.append)
|
||||
widget._request_detail()
|
||||
else:
|
||||
widget.diagnosis_requested.connect(selected.append)
|
||||
widget._open_selected()
|
||||
assert selected[0]["id"] == chosen["id"]
|
||||
assert selected[0]["diagnosis_id"] == chosen["diagnosis_id"]
|
||||
assert selected[0]["diagnosis_id"] != selected[0]["patient_id"]
|
||||
|
||||
|
||||
def test_failed_append_keeps_rows_and_retry_continues_same_page(workspace, application):
|
||||
_kind, widget, repository = workspace
|
||||
repository.failed_pages.add(2)
|
||||
widget.refresh()
|
||||
settle(application)
|
||||
scroll_down(widget, application)
|
||||
table = table_for(widget)
|
||||
assert table.rowCount() == 15
|
||||
assert widget.pager.page == 1
|
||||
assert widget.pager.retry_button.isVisible()
|
||||
settle(application)
|
||||
assert [query["page_no"] for query in repository.calls] == [1, 2]
|
||||
repository.failed_pages.clear()
|
||||
widget.pager.retry_button.click()
|
||||
assert table.rowCount() == 30
|
||||
assert [query["page_no"] for query in repository.calls] == [1, 2, 2]
|
||||
assert not widget.pager.retry_button.isVisible()
|
||||
|
||||
|
||||
def test_refresh_preserves_horizontal_scroll_at_narrow_width(workspace, application):
|
||||
_kind, widget, _repository = workspace
|
||||
widget.resize(760, 600)
|
||||
widget.refresh()
|
||||
settle(application)
|
||||
table = table_for(widget)
|
||||
horizontal = table.horizontalScrollBar()
|
||||
assert horizontal.maximum() > 0
|
||||
horizontal.setValue(horizontal.maximum())
|
||||
offset = horizontal.value()
|
||||
widget.refresh(silent=True)
|
||||
assert horizontal.value() == offset
|
||||
|
||||
|
||||
def test_first_page_failure_keeps_compact_retry_accessible(workspace, application):
|
||||
_kind, widget, repository = workspace
|
||||
repository.failed_pages.add(1)
|
||||
widget.refresh()
|
||||
settle(application)
|
||||
widget.scroll.ensureWidgetVisible(widget.pager)
|
||||
settle(application)
|
||||
assert table_for(widget).rowCount() == 0
|
||||
assert widget.pager.isVisible()
|
||||
assert widget.pager.retry_button.isVisible()
|
||||
assert widget.pager.retry_button.height() <= widget.pager.height()
|
||||
repository.failed_pages.clear()
|
||||
widget.pager.retry_button.click()
|
||||
assert table_for(widget).rowCount() == 15
|
||||
|
||||
|
||||
def test_filter_change_restarts_and_ignores_old_append(workspace, application, monkeypatch):
|
||||
kind, widget, repository = workspace
|
||||
widget.refresh()
|
||||
settle(application)
|
||||
pending = []
|
||||
monkeypatch.setattr(patients_module, "run_async",
|
||||
lambda function, **callbacks: pending.append((function, callbacks)))
|
||||
# Capture the new runner for the next scroll request without changing query.
|
||||
widget.refresh(silent=True)
|
||||
pending.pop()[1]["on_success"](repository._list(page_no=1, page_size=15))
|
||||
scroll_down(widget, application)
|
||||
assert len(pending) == 1
|
||||
if kind == "progress":
|
||||
class Tomorrow:
|
||||
@staticmethod
|
||||
def currentDate():
|
||||
return QDate.currentDate().addDays(1)
|
||||
|
||||
monkeypatch.setattr(patients_module, "QDate", Tomorrow)
|
||||
else:
|
||||
widget.keyword_edit.setText("新条件")
|
||||
widget.refresh()
|
||||
assert len(pending) == 2
|
||||
stale_function, stale_callbacks = pending[0]
|
||||
current_function, current_callbacks = pending[1]
|
||||
current_callbacks["on_success"](current_function())
|
||||
stale_callbacks["on_success"](stale_function())
|
||||
stale_callbacks["on_error"](RuntimeError("旧请求错误"))
|
||||
assert table_for(widget).rowCount() == 15
|
||||
assert widget.pager.page == 1
|
||||
assert not widget.banner.isVisible()
|
||||
latest = repository.calls[-2]
|
||||
assert latest["page_no"] == 1
|
||||
if kind == "progress":
|
||||
assert latest["start_date"] == QDate.currentDate().addDays(1).toString("yyyy-MM-dd")
|
||||
else:
|
||||
assert latest["keyword"] == "新条件"
|
||||
|
||||
|
||||
def test_patient_route_uses_eight_pixel_bottom_margin(application, monkeypatch):
|
||||
monkeypatch.setattr(patients_module, "run_async", run_immediately)
|
||||
page = PatientsPage(Repository(), PermissionSet(["*"]))
|
||||
assert page.layout().contentsMargins().bottom() == 8
|
||||
assert page.order_workspace.action_bar.height() == 54
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
application.processEvents()
|
||||
@@ -0,0 +1,328 @@
|
||||
"""Interaction contracts at risk when the patient list moves to the blue layout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QDate, Qt, QTimer
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QCheckBox, QLabel
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.pages import patients as patients_module
|
||||
from doctor_workstation.ui.pages.patients import PatientListWorkspace
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
class _Repository:
|
||||
def __init__(self) -> None:
|
||||
self.queries: list[dict[str, Any]] = []
|
||||
self.rows = [
|
||||
{
|
||||
"id": 601 + index,
|
||||
"diagnosis_id": 601 + index,
|
||||
"source_patient_id": 301 + index,
|
||||
"appointment_id": 401 + index if index != 2 else 0,
|
||||
"patient_name": name,
|
||||
"gender": 2,
|
||||
"age": 36,
|
||||
"assistant_name": "测试医助",
|
||||
"assistant_id": 81 if index != 2 else 0,
|
||||
"appointment_doctor_name": "测试医生" if index != 2 else "",
|
||||
"appointment_status": (1, 4, 0)[index],
|
||||
"appointment_status_text": "未预约" if index == 2 else "待接诊",
|
||||
"appointment_time_text": "2026-09-05 09:00" if index != 2 else "—",
|
||||
"has_id_card": index != 1,
|
||||
"revisit_count": index,
|
||||
"confirmation_text": "待确认",
|
||||
"diagnosis_date_text": "初诊",
|
||||
"phone_masked": f"138****120{index}",
|
||||
}
|
||||
for index, name in enumerate(("阿青", "林青", "赵青"))
|
||||
]
|
||||
|
||||
def list_patients(self, **query: Any) -> dict[str, Any]:
|
||||
self.queries.append(query)
|
||||
return {
|
||||
"lists": deepcopy(self.rows),
|
||||
"count": len(self.rows),
|
||||
"extend": {
|
||||
"scope": {"label": "测试部门可见患者"},
|
||||
"summary": {"today": 2, "tomorrow": 1, "day_after": 0},
|
||||
"dates": {"today": QDate.currentDate().toString("yyyy-MM-dd")},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(3):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
apply_theme(application)
|
||||
return application
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
def immediate(function: Any, *, on_success=None, on_error=None, on_finished=None):
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
if on_error:
|
||||
on_error(error)
|
||||
raise
|
||||
else:
|
||||
if on_success:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished:
|
||||
on_finished()
|
||||
|
||||
def reject_network(*_args: Any, **_kwargs: Any):
|
||||
pytest.fail("Patient visual tests must use only local fixture data")
|
||||
|
||||
monkeypatch.setattr(socket.socket, "connect", reject_network)
|
||||
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
|
||||
monkeypatch.setattr(socket, "create_connection", reject_network)
|
||||
monkeypatch.setattr(patients_module, "run_async", immediate)
|
||||
opened = []
|
||||
|
||||
def create(*, permissions=("*",), width=1270, height=700):
|
||||
repository = _Repository()
|
||||
workspace = PatientListWorkspace(repository, PermissionSet(list(permissions)))
|
||||
opened.append(workspace)
|
||||
workspace.resize(width, height)
|
||||
workspace.show()
|
||||
workspace.refresh()
|
||||
_settle(application)
|
||||
return workspace, repository
|
||||
|
||||
yield create
|
||||
for workspace in opened:
|
||||
for timer in workspace.findChildren(QTimer):
|
||||
timer.stop()
|
||||
workspace.close()
|
||||
workspace.deleteLater()
|
||||
_settle(application)
|
||||
|
||||
|
||||
def _click(widget, application: QApplication) -> None:
|
||||
QTest.mouseClick(widget, Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
|
||||
|
||||
def _date_selection(workspace: PatientListWorkspace) -> list[str]:
|
||||
return [key for key, button in workspace.quick_buttons.items() if button.isChecked()] + (
|
||||
["custom"] if workspace.custom_date_button.isChecked() else []
|
||||
)
|
||||
|
||||
|
||||
def test_filters_preserve_independent_dimensions_and_reset_to_unlimited_dates(
|
||||
application: QApplication, workspace_factory
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
assert repository.queries == [{
|
||||
"keyword": "", "status": "", "start_date": "", "end_date": "",
|
||||
"page_no": 1, "page_size": 15,
|
||||
}]
|
||||
assert _date_selection(workspace) == ["all"]
|
||||
assert not workspace.start_date.isEnabled()
|
||||
assert not workspace.end_date.isEnabled()
|
||||
assert workspace.start_date.date() == workspace.end_date.date() == QDate.currentDate()
|
||||
assert workspace.scope_label.text() == "测试部门可见患者"
|
||||
assert workspace.summary_buttons["today"].text() == f"今日预约 · {QDate.currentDate().toString('yyyy-MM-dd')}\n2 人"
|
||||
assert workspace.summary_buttons["tomorrow"].text() == "明日预约\n1 人"
|
||||
assert workspace.summary_buttons["day_after"].text() == "后天预约\n0 人"
|
||||
|
||||
workspace.keyword_edit.setText(" 林医生 ")
|
||||
workspace.pager.load_more()
|
||||
_click(workspace.status_buttons["pending_interview"], application)
|
||||
assert repository.queries[-1]["page_no"] == 1
|
||||
for mode, offset in (("today", 0), ("tomorrow", 1), ("day_after", 2)):
|
||||
_click(workspace.summary_buttons[mode], application)
|
||||
expected = QDate.currentDate().addDays(offset).toString("yyyy-MM-dd")
|
||||
assert repository.queries[-1] == {
|
||||
"keyword": "林医生", "status": "pending_interview", "start_date": expected,
|
||||
"end_date": expected, "page_no": 1, "page_size": 15,
|
||||
}
|
||||
assert _date_selection(workspace) == [mode]
|
||||
assert workspace.status_buttons["pending_interview"].isChecked()
|
||||
assert workspace.summary_buttons["today"].text().endswith("2 人")
|
||||
|
||||
_click(workspace.custom_date_button, application)
|
||||
assert _date_selection(workspace) == ["custom"]
|
||||
assert workspace.start_date.isEnabled() and workspace.end_date.isEnabled()
|
||||
# Clicking the selected custom mode keeps a visible selection.
|
||||
_click(workspace.custom_date_button, application)
|
||||
assert _date_selection(workspace) == ["custom"]
|
||||
workspace.start_date.setDate(QDate.currentDate().addDays(-4))
|
||||
workspace.end_date.setDate(QDate.currentDate().addDays(-2))
|
||||
workspace.end_date.editingFinished.emit()
|
||||
assert repository.queries[-1]["start_date"] == QDate.currentDate().addDays(-4).toString("yyyy-MM-dd")
|
||||
assert repository.queries[-1]["end_date"] == QDate.currentDate().addDays(-2).toString("yyyy-MM-dd")
|
||||
assert _date_selection(workspace) == ["custom"]
|
||||
|
||||
workspace.start_date.setDate(QDate.currentDate().addDays(3))
|
||||
before = len(repository.queries)
|
||||
workspace.start_date.editingFinished.emit()
|
||||
_click(workspace.search_button, application)
|
||||
assert len(repository.queries) == before
|
||||
assert workspace.banner.isVisible()
|
||||
assert "开始日期不能晚于结束日期" in " ".join(label.text() for label in workspace.banner.findChildren(QLabel))
|
||||
|
||||
_click(workspace.reset_button, application)
|
||||
assert repository.queries[-1] == {
|
||||
"keyword": "", "status": "", "start_date": "", "end_date": "",
|
||||
"page_no": 1, "page_size": 15,
|
||||
}
|
||||
assert workspace.keyword_edit.text() == ""
|
||||
assert [key for key, button in workspace.status_buttons.items() if button.isChecked()] == [""]
|
||||
assert _date_selection(workspace) == ["all"]
|
||||
assert not workspace.start_date.isEnabled() and not workspace.end_date.isEnabled()
|
||||
assert not workspace.banner.isVisible()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("permissions", "expected", "editable"), [
|
||||
(("*",), ["诊单", "AI 分析"], True),
|
||||
(("tcm.diagnosis/readonlyDetail",), ["查看"], False),
|
||||
((), [], None),
|
||||
])
|
||||
def test_row_actions_obey_edit_readonly_and_no_permission(
|
||||
application: QApplication, workspace_factory, permissions, expected, editable
|
||||
) -> None:
|
||||
workspace, _repository = workspace_factory(permissions=permissions)
|
||||
opened = []
|
||||
workspace.diagnosis_requested.connect(lambda row, can_edit: opened.append((row["diagnosis_id"], can_edit)))
|
||||
for index in range(workspace.table.rowCount()):
|
||||
row = workspace.table.item(index, 0).data(Qt.ItemDataRole.UserRole)
|
||||
actions = workspace.table.cellWidget(index, 9)
|
||||
assert actions.visible_labels == expected
|
||||
if permissions == ("*",):
|
||||
overflow = ["预约", "重新指派" if row["assistant_id"] else "指派医助"]
|
||||
if not row["has_id_card"]:
|
||||
overflow.append("补全身份证")
|
||||
overflow.append("关联订单")
|
||||
if row["appointment_id"] and row["appointment_status"] in (1, 4):
|
||||
overflow.append("取消")
|
||||
assert actions.overflow_labels == overflow
|
||||
assert all(entry.property("danger") is True for entry in actions.menu.actions() if entry.text() == "取消")
|
||||
else:
|
||||
assert actions.more_button is None
|
||||
assert actions.overflow_labels == []
|
||||
if editable is not None:
|
||||
_click(actions.buttons[0], application)
|
||||
assert opened[-1] == (row["diagnosis_id"], editable)
|
||||
if editable is None:
|
||||
workspace.table.selectRow(0)
|
||||
workspace._open_selected_diagnosis()
|
||||
assert opened == []
|
||||
assert workspace.bottom_actions.isHidden()
|
||||
|
||||
|
||||
def _selector(workspace: PatientListWorkspace, row: int) -> QCheckBox:
|
||||
return workspace.table.cellWidget(row, 0).findChild(QCheckBox)
|
||||
|
||||
|
||||
def _assert_single_selection(workspace: PatientListWorkspace) -> None:
|
||||
checked = [row for row in range(workspace.table.rowCount()) if _selector(workspace, row).isChecked()]
|
||||
assert len(checked) <= 1
|
||||
if checked:
|
||||
assert checked == [workspace.table.currentRow()]
|
||||
|
||||
|
||||
def test_sorted_selectors_and_row_actions_keep_distinct_resource_ids(
|
||||
application: QApplication, workspace_factory
|
||||
) -> None:
|
||||
workspace, _repository = workspace_factory()
|
||||
table = workspace.table
|
||||
first_id = table.item(0, 0).data(Qt.ItemDataRole.UserRole)["diagnosis_id"]
|
||||
table.sortItems(1, Qt.SortOrder.DescendingOrder)
|
||||
_settle(application)
|
||||
assert table.item(0, 0).data(Qt.ItemDataRole.UserRole)["diagnosis_id"] != first_id
|
||||
for index in (0, 1):
|
||||
expected = table.item(index, 0).data(Qt.ItemDataRole.UserRole)
|
||||
_click(_selector(workspace, index), application)
|
||||
assert table.current_data()["diagnosis_id"] == expected["diagnosis_id"]
|
||||
assert _selector(workspace, index).isChecked()
|
||||
_assert_single_selection(workspace)
|
||||
|
||||
# Leave a different row current: every callback must use its own sorted row.
|
||||
table.selectRow(2)
|
||||
_settle(application)
|
||||
_assert_single_selection(workspace)
|
||||
table.horizontalScrollBar().setValue(table.horizontalScrollBar().maximum())
|
||||
emitted = []
|
||||
workspace.diagnosis_requested.connect(lambda row, editable: emitted.append(("diagnosis", row, editable)))
|
||||
workspace.ai_consult_requested.connect(lambda row: emitted.append(("ai", row, None)))
|
||||
workspace.appointment_requested.connect(lambda row: emitted.append(("appointment", row, None)))
|
||||
for index in range(table.rowCount()):
|
||||
expected = table.item(index, 0).data(Qt.ItemDataRole.UserRole)
|
||||
actions = table.cellWidget(index, 9)
|
||||
actions.buttons[0].click()
|
||||
actions.buttons[1].click()
|
||||
next(action for action in actions.menu.actions() if action.text() == "预约").trigger()
|
||||
for kind, row, editable in emitted[-3:]:
|
||||
assert row["diagnosis_id"] == expected["diagnosis_id"]
|
||||
assert row["source_patient_id"] == expected["source_patient_id"]
|
||||
assert row["appointment_id"] == expected["appointment_id"]
|
||||
if kind == "diagnosis":
|
||||
assert editable is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("silent", [False, True])
|
||||
def test_loading_resets_changed_query_and_ignores_stale_success_and_error(
|
||||
application: QApplication, workspace_factory, monkeypatch: pytest.MonkeyPatch, silent: bool
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
table = workspace.table
|
||||
queued = []
|
||||
monkeypatch.setattr(patients_module, "run_async", lambda function, **callbacks: queued.append((function, callbacks)))
|
||||
workspace.keyword_edit.setText("旧请求")
|
||||
workspace.refresh(silent=silent)
|
||||
workspace.keyword_edit.setText("最新请求")
|
||||
workspace.refresh(silent=silent)
|
||||
_settle(application)
|
||||
assert table.rowCount() == 0
|
||||
assert workspace.content_stack.currentIndex() == 1
|
||||
assert workspace.pager.isVisible()
|
||||
assert not workspace.banner.isVisible()
|
||||
|
||||
# Workers must keep the UI-thread snapshot even after text changes again.
|
||||
workspace.keyword_edit.setText("尚未查询")
|
||||
newer = queued[1][0]()
|
||||
assert repository.queries[-1]["keyword"] == "最新请求"
|
||||
newer["lists"][0]["patient_name"] = "最新结果"
|
||||
newer["extend"]["scope"]["label"] = "最新范围"
|
||||
queued[1][1]["on_success"](newer)
|
||||
current_item = table.item(0, 1)
|
||||
assert current_item.text().startswith("最新结果")
|
||||
stale = queued[0][0]()
|
||||
assert repository.queries[-1]["keyword"] == "旧请求"
|
||||
queued[0][1]["on_success"](stale)
|
||||
queued[0][1]["on_error"](RuntimeError("过期失败"))
|
||||
_settle(application)
|
||||
assert table.item(0, 1) is current_item
|
||||
assert workspace.scope_label.text() == "最新范围"
|
||||
assert not workspace.banner.isVisible()
|
||||
|
||||
workspace.keyword_edit.setText("最新请求")
|
||||
workspace.refresh(silent=silent)
|
||||
queued[-1][1]["on_error"](RuntimeError("患者查询失败"))
|
||||
_settle(application)
|
||||
assert table.item(0, 1) is current_item
|
||||
assert workspace.banner.isVisible()
|
||||
assert "患者查询失败" in " ".join(label.text() for label in workspace.banner.findChildren(QLabel))
|
||||
workspace.refresh(silent=silent)
|
||||
queued[-1][1]["on_success"](queued[-1][0]())
|
||||
_settle(application)
|
||||
assert not workspace.banner.isVisible()
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Library display units and query/identity contracts after the approved redesign."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from copy import deepcopy
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QPushButton
|
||||
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui.pages import prescription_library as library
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
class QueryRepository:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.rows = [
|
||||
{"id": 41, "prescription_name": "乙方", "formula_type": "aux", "is_public": False,
|
||||
"creator_id": 7, "creator_name": "本页医生", "efficacy": "益气养阴",
|
||||
"herbs": [{"name": "白芍", "dosage": "15g"}], "create_time": "2026-07-15 10:30"},
|
||||
{"id": 42, "prescription_name": "甲方", "formula_type": "main", "is_public": True,
|
||||
"creator_id": 8, "creator_name": "其他医生", "efficacy": "清热祛湿",
|
||||
"herbs": [{"name": "茯苓", "dosage": 10}], "create_time": "2026-07-18 16:20"},
|
||||
]
|
||||
|
||||
def list_prescription_templates(self, **query):
|
||||
self.calls.append(deepcopy(query))
|
||||
# Server total deliberately differs from the current-page/effect-filter count.
|
||||
return {"lists": deepcopy(self.rows), "count": 44}
|
||||
|
||||
|
||||
def settle(app):
|
||||
for _ in range(4):
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def record_id(record):
|
||||
return record["id"] if isinstance(record, dict) else record.id
|
||||
|
||||
|
||||
def row_record(table, index):
|
||||
return table.item(index, 0).data(Qt.ItemDataRole.UserRole)
|
||||
|
||||
|
||||
def row_buttons(table, index):
|
||||
return {button.accessibleName(): button for button in table.cellWidget(index, 9).findChildren(QPushButton)}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def local_execution(monkeypatch):
|
||||
def reject(*_args, **_kwargs):
|
||||
pytest.fail("Library regression tests must not contact external services")
|
||||
|
||||
def immediate(function, *, on_success=None, on_error=None, on_finished=None):
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
if on_error:
|
||||
on_error(error)
|
||||
raise
|
||||
else:
|
||||
if on_success:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished:
|
||||
on_finished()
|
||||
|
||||
monkeypatch.setattr(socket.socket, "connect", reject)
|
||||
monkeypatch.setattr(socket.socket, "connect_ex", reject)
|
||||
monkeypatch.setattr(socket, "create_connection", reject)
|
||||
monkeypatch.setattr(library, "run_async", immediate)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page_factory(application):
|
||||
opened = []
|
||||
|
||||
def create(repository=None, *, permissions=None, user=None):
|
||||
repository = repository or QueryRepository()
|
||||
page = library.PrescriptionLibraryPage(
|
||||
repository, {"*"} if permissions is None else permissions,
|
||||
user or SimpleNamespace(id=7, root=0, role_ids=[]),
|
||||
)
|
||||
opened.append(page)
|
||||
page.resize(1328, 884)
|
||||
page.show()
|
||||
settle(application)
|
||||
assert page.table.rowCount() == 2
|
||||
return page, repository
|
||||
|
||||
yield create
|
||||
for page in opened:
|
||||
for timer in page.findChildren(QTimer):
|
||||
timer.stop()
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
settle(application)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dose, expected", [(10, "10g"), ("10", "10g"), ("10g", "10g"), ("15G", "15G"), (0, "0g")])
|
||||
def test_display_unit_is_added_once_without_mutating_dose(dose, expected):
|
||||
row = {"herbs": [{"medicine_id": 11, "name": "白芍", "dosage": dose}]}
|
||||
before = deepcopy(row)
|
||||
assert library._herbs_detail(None, row) == f"白芍 {expected}"
|
||||
assert row == before
|
||||
assert library._herbs_detail(None, {"herbs": [{"medicine_name": "茯苓", "amount": dose}]}) == f"茯苓 {expected}"
|
||||
|
||||
|
||||
def test_demo_raw_doses_and_default_unselected_state_are_preserved(page_factory, monkeypatch):
|
||||
monkeypatch.setattr(library, "datetime", SimpleNamespace(now=lambda: datetime(2026, 9, 5)))
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||
originals = {identifier: repository.get_prescription_template(identifier) for identifier in (701, 702)}
|
||||
snapshots = {identifier: deepcopy((row.herbs, row.raw)) for identifier, row in originals.items()}
|
||||
assert library._herbs_detail(None, originals[701]) == "柴胡 10g、白芍 15g、茯苓 15g"
|
||||
assert library._herbs_detail(None, originals[702]) == "酸枣仁 20g、夜交藤 30g"
|
||||
assert {identifier: (row.herbs, row.raw) for identifier, row in originals.items()} == snapshots
|
||||
page, _ = page_factory(repository, permissions=session.permissions, user=session.user)
|
||||
assert page.table.columnCount() == 10
|
||||
assert [page.table.horizontalHeader().logicalIndex(index) for index in range(10)] == list(range(10))
|
||||
assert page.table.currentRow() == -1
|
||||
assert all(not button.isEnabled() for button in (page.view_button, page.ai_button, page.edit_button, page.delete_button))
|
||||
assert [page.metric_cards[key].value_label.text() for key in ("total", "private", "public", "month")] == ["2", "1", "1", "0"]
|
||||
for index in range(2):
|
||||
assert all(button.isEnabled() for button in row_buttons(page.table, index).values())
|
||||
assert len(row_buttons(page.table, index)) == 4
|
||||
assert page.table.item(index, 5).text() == "—"
|
||||
identifier = record_id(row_record(page.table, index))
|
||||
assert page.table.item(index, 4).text() == library._herbs_detail(None, originals[identifier])
|
||||
|
||||
|
||||
def test_query_return_reset_favorite_tab_and_page_send_original_dto(page_factory, application):
|
||||
page, repository = page_factory()
|
||||
repository.calls.clear()
|
||||
page.name_filter.setText(" 测试名称 ")
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
settle(application)
|
||||
page.formula_filter.setCurrentIndex(page.formula_filter.findData("主方"))
|
||||
page.visibility_filter.setCurrentIndex(page.visibility_filter.findData(1))
|
||||
page.effect_filter.setCurrentIndex(page.effect_filter.findData("清热祛湿"))
|
||||
assert repository.calls == [] # Combo selection still requires query or Return.
|
||||
QTest.keyClick(page.name_filter, Qt.Key.Key_Return)
|
||||
settle(application)
|
||||
filtered = {"prescription_name": "测试名称", "formula_type": "主方", "is_public": 1, "page_no": 1, "page_size": 15}
|
||||
assert repository.calls == [filtered]
|
||||
assert page.table.rowCount() == 1
|
||||
assert record_id(row_record(page.table, 0)) == 42
|
||||
assert page.metric_cards["total"].value_label.text() == "44"
|
||||
assert page.metric_cards["private"].value_label.text() == "0"
|
||||
assert page.metric_cards["public"].value_label.text() == "1"
|
||||
page.pager.load_more()
|
||||
assert repository.calls[-1] == dict(filtered, page_no=2)
|
||||
assert page.pager.page == 2
|
||||
page.favorite_tab.click()
|
||||
assert repository.calls[-2:] == [filtered, dict(filtered, page_no=2)] # Same query refreshes its loaded prefix.
|
||||
assert page.favorite_tab.isChecked() and not page.all_tab.isChecked()
|
||||
page.reset_button.click()
|
||||
assert repository.calls[-1] == {"prescription_name": "", "formula_type": "", "is_public": "", "page_no": 1, "page_size": 15}
|
||||
assert page.effect_filter.currentData() == ""
|
||||
assert page.favorite_tab.isChecked()
|
||||
assert page.table.rowCount() == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("column, order", [(0, Qt.SortOrder.DescendingOrder), (2, Qt.SortOrder.DescendingOrder)])
|
||||
def test_sort_then_refresh_keeps_selection_tags_and_each_action_bound_to_visible_id(
|
||||
page_factory, application, monkeypatch, column, order
|
||||
):
|
||||
page, repository = page_factory()
|
||||
original_rows = deepcopy(repository.rows)
|
||||
table = page.table
|
||||
visited = []
|
||||
monkeypatch.setattr(page, "_view_selected", lambda: visited.append(record_id(table.current_data())))
|
||||
table.sortItems(column, order)
|
||||
settle(application)
|
||||
assert [record_id(row_record(table, row)) for row in range(2)] == [42, 41]
|
||||
|
||||
def verify_visible_actions():
|
||||
for index in range(2):
|
||||
record = row_record(table, index)
|
||||
assert table.item(index, 2).text() == ("主方" if record["formula_type"] == "main" else "辅方")
|
||||
assert table.item(index, 6).text() == ("所有人可见" if record["is_public"] else "仅自己可见")
|
||||
buttons = row_buttons(table, index)
|
||||
assert buttons["编辑处方模板"].isEnabled() == (record["creator_id"] == 7)
|
||||
table.selectRow(1 - index)
|
||||
button = buttons["查看处方模板"]
|
||||
QTest.mouseClick(button, Qt.MouseButton.LeftButton, pos=button.rect().center())
|
||||
assert visited[-1] == record["id"]
|
||||
assert record_id(table.current_data()) == record["id"]
|
||||
|
||||
verify_visible_actions()
|
||||
table.selectRow(next(index for index in range(2) if record_id(row_record(table, index)) == 41))
|
||||
page.refresh()
|
||||
settle(application)
|
||||
assert record_id(table.current_data()) == 41
|
||||
assert [record_id(row_record(table, row)) for row in range(2)] == [42, 41]
|
||||
verify_visible_actions()
|
||||
assert visited == [42, 41, 42, 41]
|
||||
assert repository.rows == original_rows
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"permissions, user, expected_actions, can_edit_foreign",
|
||||
[({"wcf.prescription/read"}, SimpleNamespace(id=7, root=0, role_ids=[]), {"查看处方模板", "AI 解释"}, False),
|
||||
({"wcf.prescription/*"}, SimpleNamespace(id=7, root=0, role_ids=[]), {"查看处方模板", "AI 解释", "编辑处方模板", "删除处方模板"}, False),
|
||||
({"wcf.prescription/*"}, SimpleNamespace(id=7, root=0, role_ids=[3]), {"查看处方模板", "AI 解释", "编辑处方模板", "删除处方模板"}, True)],
|
||||
)
|
||||
def test_row_action_permissions_and_foreign_public_management_are_preserved(
|
||||
page_factory, permissions, user, expected_actions, can_edit_foreign
|
||||
):
|
||||
page, _ = page_factory(permissions=permissions, user=user)
|
||||
index = next(index for index in range(2) if record_id(row_record(page.table, index)) == 42)
|
||||
buttons = row_buttons(page.table, index)
|
||||
assert set(buttons) == expected_actions
|
||||
assert buttons["查看处方模板"].isEnabled()
|
||||
assert buttons["AI 解释"].isEnabled()
|
||||
if "编辑处方模板" in buttons:
|
||||
assert buttons["编辑处方模板"].isEnabled() == can_edit_foreign
|
||||
assert buttons["删除处方模板"].isEnabled() == can_edit_foreign
|
||||
else:
|
||||
assert page.edit_button.isHidden() and page.delete_button.isHidden()
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Real prescription pages append server pages without losing row actions."""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.ui.pages import prescription_library, prescriptions
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def inline(function, *, on_success, on_error, on_finished):
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
on_error(error)
|
||||
else:
|
||||
on_success(result)
|
||||
finally:
|
||||
on_finished()
|
||||
|
||||
|
||||
class Repository:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def get(self, **q):
|
||||
self.calls.append(dict(q))
|
||||
start = (q["page_no"] - 1) * q["page_size"]
|
||||
return {
|
||||
"lists": [
|
||||
{
|
||||
"id": i,
|
||||
"prescription_name": f"Template {i}",
|
||||
"sn": f"RX{i}",
|
||||
"patient_name": f"Patient {i}",
|
||||
"creator_id": 1,
|
||||
"is_public": False,
|
||||
"efficacy": "清热祛湿" if i > 15 else "益气养阴",
|
||||
"herbs": [],
|
||||
"create_time": "2026-09-01 10:00",
|
||||
}
|
||||
for i in range(start + 1, min(start + q["page_size"], 37) + 1)
|
||||
],
|
||||
"count": 37,
|
||||
"extend": {"doctors": [{"id": 1, "name": "Doctor"}]},
|
||||
}
|
||||
|
||||
list_prescriptions = get
|
||||
list_prescription_templates = get
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"module,cls",
|
||||
[
|
||||
(prescriptions, prescriptions.PrescriptionsPage),
|
||||
(prescription_library, prescription_library.PrescriptionLibraryPage),
|
||||
],
|
||||
)
|
||||
def test_scroll_append_refresh_and_filter_reset(app, monkeypatch, module, cls):
|
||||
monkeypatch.setattr(module, "run_async", inline)
|
||||
repo = Repository()
|
||||
page = cls(repo, {"*"}, SimpleNamespace(id=1, root=1, role_ids=[]))
|
||||
page.resize(1250, 760)
|
||||
page.show()
|
||||
page.refresh()
|
||||
# Let deferred filter/table geometry settle before scrolling its real viewport.
|
||||
for _ in range(3):
|
||||
app.processEvents()
|
||||
QTest.qWait(35)
|
||||
assert page.table.rowCount() == 15
|
||||
page.table.selectRow(4)
|
||||
before_id = page.table.current_data()["id"]
|
||||
page.table.verticalScrollBar().setValue(page.table.verticalScrollBar().maximum())
|
||||
for _ in range(50):
|
||||
if page.table.rowCount() == 30:
|
||||
break
|
||||
QTest.qWait(20)
|
||||
assert page.table.rowCount() == 30 and page.table.current_data()["id"] == before_id
|
||||
assert (
|
||||
len({page.table.item(r, 0).data(Qt.ItemDataRole.UserRole)["id"] for r in range(30)}) == 30
|
||||
)
|
||||
page.refresh()
|
||||
assert page.table.rowCount() == 30
|
||||
assert [q["page_no"] for q in repo.calls[-2:]] == [1, 2]
|
||||
page.table.verticalScrollBar().setValue(page.table.verticalScrollBar().maximum())
|
||||
QTest.qWait(90)
|
||||
assert page.table.rowCount() == 37 and not page.pager.has_more
|
||||
edit = page.name_filter if module is prescription_library else page.patient_filter
|
||||
edit.setText("changed")
|
||||
page._search()
|
||||
assert page.table.rowCount() == 15 and repo.calls[-1]["page_no"] == 1
|
||||
assert page.pager.height() == 24 and page.layout().contentsMargins().bottom() == 8
|
||||
page.close()
|
||||
|
||||
|
||||
def test_local_effect_filter_continues_until_matching_rows_are_visible(app, monkeypatch):
|
||||
monkeypatch.setattr(prescription_library, "run_async", inline)
|
||||
page = prescription_library.PrescriptionLibraryPage(
|
||||
Repository(), {"*"}, SimpleNamespace(id=1, root=1, role_ids=[])
|
||||
)
|
||||
page.resize(1250, 760)
|
||||
page.show()
|
||||
page.effect_filter.setCurrentIndex(page.effect_filter.findData("清热祛湿"))
|
||||
page._search()
|
||||
QTest.qWait(180)
|
||||
assert page.table.rowCount() >= 15
|
||||
assert page.table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"] > 15
|
||||
page.close()
|
||||
|
||||
|
||||
def test_audit_status_sort_keeps_actions_on_the_visible_prescription(app, monkeypatch):
|
||||
from PySide6.QtWidgets import QPushButton
|
||||
|
||||
monkeypatch.setattr(prescriptions, "run_async", inline)
|
||||
|
||||
class AuditRepository(Repository):
|
||||
def get(self, **query):
|
||||
result = super().get(**query)
|
||||
for row in result["lists"]:
|
||||
identifier = row["id"]
|
||||
row.update(
|
||||
audit_status=identifier % 3,
|
||||
audit_remark="rejected reason" if identifier % 3 == 2 else "",
|
||||
business_prescription_audit_rejected=identifier % 2 == 0,
|
||||
business_prescription_audit_remark="business reason"
|
||||
if identifier % 2 == 0
|
||||
else "",
|
||||
)
|
||||
return result
|
||||
|
||||
list_prescriptions = get
|
||||
|
||||
page = prescriptions.PrescriptionsPage(AuditRepository(), {"*"}, SimpleNamespace(id=1))
|
||||
page.refresh()
|
||||
opened = []
|
||||
page._view_selected = lambda: opened.append(page.table.current_data()["id"])
|
||||
page.table.sortItems(6, Qt.SortOrder.DescendingOrder)
|
||||
page.pager.load_more()
|
||||
page.refresh()
|
||||
assert page.table.rowCount() == 30
|
||||
for row in range(page.table.rowCount()):
|
||||
expected = page.table.item(row, 0).data(Qt.ItemDataRole.UserRole)["id"]
|
||||
buttons = page.table.cellWidget(row, 2).findChildren(QPushButton)
|
||||
next(button for button in buttons if button.accessibleName() == "查看处方").click()
|
||||
assert opened[-1] == expected
|
||||
page.close()
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Native interactions and reachability unique to the issued-prescription blue UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, QRect, Qt, QTimer
|
||||
from PySide6.QtGui import QIcon
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QAbstractItemView, QApplication, QPushButton
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui import icons
|
||||
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
|
||||
from doctor_workstation.ui.shell import ShellWindow
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(5):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def _rect_in(widget, parent) -> QRect:
|
||||
return QRect(widget.mapTo(parent, QPoint()), widget.size())
|
||||
|
||||
|
||||
def _assert_visible_within(widget, viewport) -> None:
|
||||
assert widget.isVisible()
|
||||
rect = _rect_in(widget, viewport)
|
||||
assert viewport.rect().contains(rect), (widget.objectName(), rect, viewport.rect())
|
||||
|
||||
|
||||
def _record_id(table, row: int) -> int:
|
||||
return int(table.item(row, 0).data(Qt.ItemDataRole.UserRole).id)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def shell_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
network_attempts = []
|
||||
|
||||
def reject_network(*_args, **_kwargs):
|
||||
network_attempts.append("socket")
|
||||
pytest.fail("Prescription blue interaction tests must use local Demo data only")
|
||||
|
||||
def immediate(function, *, on_success=None, on_error=None, on_finished=None):
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
if on_error is not None:
|
||||
on_error(error)
|
||||
raise
|
||||
else:
|
||||
if on_success is not None:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
|
||||
monkeypatch.setattr(socket.socket, "connect", reject_network)
|
||||
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
|
||||
monkeypatch.setattr(socket, "create_connection", reject_network)
|
||||
monkeypatch.setattr(prescriptions_module, "run_async", immediate)
|
||||
monkeypatch.setenv("DOCTOR_SMOKE_TEST", "1")
|
||||
opened = []
|
||||
|
||||
def create(width=1536, height=960):
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||
permissions = PermissionSet(["*"])
|
||||
shell = ShellWindow(repository, {
|
||||
"user": session.user,
|
||||
"permissions": permissions,
|
||||
"demo_mode": True,
|
||||
"menu": [{"perms": "tcm.prescription/lists"}],
|
||||
}, permissions=permissions)
|
||||
opened.append(shell)
|
||||
shell.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
|
||||
shell.resize(width, height)
|
||||
shell.show()
|
||||
assert shell.navigate("prescriptions")
|
||||
_settle(application)
|
||||
page = shell.pages["prescriptions"]
|
||||
assert {_record_id(page.table, row) for row in range(page.table.rowCount())} == {801, 802}
|
||||
assert (shell.width(), shell.height()) == (width, height)
|
||||
return shell, page
|
||||
|
||||
yield create
|
||||
for shell in opened:
|
||||
for timer in shell.findChildren(QTimer):
|
||||
timer.stop()
|
||||
shell.close()
|
||||
shell.deleteLater()
|
||||
_settle(application)
|
||||
assert network_attempts == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("width", "height", "filter_height"),
|
||||
[(1536, 960, 144), (1366, 768, 144), (1024, 768, 200), (1024, 640, 200)],
|
||||
ids=["full-desktop", "compact-desktop", "small-window", "minimum-window"],
|
||||
)
|
||||
def test_shell_preserves_all_columns_filters_and_reachable_pager(
|
||||
shell_factory, application, width, height, filter_height
|
||||
) -> None:
|
||||
shell, page = shell_factory(width, height)
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
_settle(application)
|
||||
table = page.table
|
||||
assert [column.key for column in table.columns] == [
|
||||
"__selected__", "sn", "__actions__", "prescription_type", "is_system_auto",
|
||||
"patient_name", "audit_status", "void_status", "doctor_name", "assistant_name", "create_time",
|
||||
]
|
||||
assert [table.horizontalHeader().logicalIndex(index) for index in range(11)] == [
|
||||
0, 1, 3, 4, 5, 6, 7, 8, 9, 10, 2,
|
||||
]
|
||||
assert table.horizontalHeaderItem(2).text() == "操作"
|
||||
assert page.filter_card.height() == filter_height
|
||||
for name in ("quick_date", "start_time", "end_time", "audit_filter", "source_filter",
|
||||
"sn_filter", "patient_filter", "doctor_filter", "query_button", "reset_button"):
|
||||
_assert_visible_within(getattr(page, name), page.filter_card)
|
||||
|
||||
outer_scroll = page.scroll.verticalScrollBar()
|
||||
if width >= 1366:
|
||||
assert outer_scroll.maximum() == 0
|
||||
_assert_visible_within(page.filter_card, page.scroll.viewport())
|
||||
_assert_visible_within(page.pager, page.scroll.viewport())
|
||||
else:
|
||||
# The compact footer allows 1024x768 to fit without outer scrolling.
|
||||
if height <= 640:
|
||||
assert outer_scroll.maximum() > 0
|
||||
assert table.horizontalScrollBar().maximum() > 0
|
||||
|
||||
if width == 1536:
|
||||
assert table.horizontalScrollBar().maximum() == 0
|
||||
for row in range(table.rowCount()):
|
||||
for column in range(11):
|
||||
assert table.viewport().rect().contains(table.visualItemRect(table.item(row, column))), (row, column)
|
||||
_assert_visible_within(page.pager, shell)
|
||||
|
||||
# Every logical column remains reachable in the native table at every width.
|
||||
outer_scroll.setValue(outer_scroll.maximum())
|
||||
for column in range(11):
|
||||
table.scrollToItem(table.item(0, column), QAbstractItemView.ScrollHint.EnsureVisible)
|
||||
_settle(application)
|
||||
assert table.viewport().rect().contains(table.visualItemRect(table.item(0, column))), column
|
||||
table.horizontalScrollBar().setValue(table.horizontalScrollBar().maximum())
|
||||
_settle(application)
|
||||
action_host = table.cellWidget(0, 2)
|
||||
assert action_host is not None
|
||||
_assert_visible_within(action_host, table.viewport())
|
||||
_assert_visible_within(action_host, page.scroll.viewport())
|
||||
_assert_visible_within(page.pager, page.scroll.viewport())
|
||||
_assert_visible_within(page.pager.summary_label, shell)
|
||||
assert page.pager.height() == 24
|
||||
assert not hasattr(page.pager, "next")
|
||||
for button in page.pager.findChildren(QPushButton):
|
||||
if button.isVisible():
|
||||
_assert_visible_within(button, page.scroll.viewport())
|
||||
for button in action_host.findChildren(QPushButton):
|
||||
_assert_visible_within(button, table.viewport())
|
||||
|
||||
|
||||
def test_native_checkbox_center_clicks_and_space_keep_row_binding(shell_factory, application) -> None:
|
||||
_shell, page = shell_factory()
|
||||
table = page.table
|
||||
row = next(index for index in range(table.rowCount()) if _record_id(table, index) == 802)
|
||||
selector = table.item(row, 0)
|
||||
table.setCurrentItem(selector)
|
||||
table.setFocus()
|
||||
_settle(application)
|
||||
center = table.visualItemRect(selector).center()
|
||||
assert table.viewport().rect().contains(center)
|
||||
assert selector.checkState() == Qt.CheckState.Unchecked
|
||||
|
||||
QTest.mouseClick(table.viewport(), Qt.MouseButton.LeftButton, pos=center)
|
||||
assert selector.checkState() == Qt.CheckState.Checked
|
||||
QTest.mouseClick(table.viewport(), Qt.MouseButton.LeftButton, pos=center)
|
||||
assert selector.checkState() == Qt.CheckState.Unchecked
|
||||
QTest.keyClick(table, Qt.Key.Key_Space)
|
||||
assert selector.checkState() == Qt.CheckState.Checked
|
||||
assert int(table.current_data().id) == 802
|
||||
assert selector.data(Qt.ItemDataRole.UserRole).id == 802
|
||||
|
||||
|
||||
@pytest.mark.parametrize("order", [Qt.SortOrder.AscendingOrder, Qt.SortOrder.DescendingOrder])
|
||||
def test_sorted_native_row_view_button_targets_visible_record(
|
||||
shell_factory, application, monkeypatch, order
|
||||
) -> None:
|
||||
_shell, page = shell_factory()
|
||||
table = page.table
|
||||
visited = []
|
||||
monkeypatch.setattr(page, "_view_selected", lambda: visited.append(int(table.current_data().id)))
|
||||
table.sortItems(1, order)
|
||||
_settle(application)
|
||||
expected_order = [801, 802] if order == Qt.SortOrder.AscendingOrder else [802, 801]
|
||||
assert [_record_id(table, index) for index in range(2)] == expected_order
|
||||
for row in range(2):
|
||||
# Start on the other record, so a stale action that only uses selection fails.
|
||||
table.selectRow(1 - row)
|
||||
host = table.cellWidget(row, 2)
|
||||
button = next(button for button in host.findChildren(QPushButton)
|
||||
if button.accessibleName() == "查看处方")
|
||||
_assert_visible_within(button, table.viewport())
|
||||
QTest.mouseClick(button, Qt.MouseButton.LeftButton, pos=button.rect().center())
|
||||
assert visited[-1] == expected_order[row]
|
||||
assert int(table.current_data().id) == expected_order[row]
|
||||
assert visited == expected_order
|
||||
|
||||
|
||||
def test_blue_icon_disabled_variant_does_not_modify_shared_icon_cache(application) -> None:
|
||||
kind, color, size = "pencil", "#1769E8", 19
|
||||
shared = icons.icon(kind, color, size)
|
||||
original_key = shared.cacheKey()
|
||||
original_normal = shared.pixmap(size, size, QIcon.Mode.Normal).toImage()
|
||||
original_disabled = shared.pixmap(size, size, QIcon.Mode.Disabled).toImage()
|
||||
|
||||
blue = prescriptions_module._blue_prescription_icon(kind, color, size)
|
||||
|
||||
assert icons.icon(kind, color, size) is shared
|
||||
assert shared.cacheKey() == original_key
|
||||
assert shared.pixmap(size, size, QIcon.Mode.Normal).toImage() == original_normal
|
||||
assert shared.pixmap(size, size, QIcon.Mode.Disabled).toImage() == original_disabled
|
||||
assert blue.pixmap(size, size, QIcon.Mode.Normal).toImage() == original_normal
|
||||
assert blue.pixmap(size, size, QIcon.Mode.Disabled).toImage() == icons.pixmap(kind, "#A4ADBA", size).toImage()
|
||||
assert blue.pixmap(size, size, QIcon.Mode.Disabled).toImage() != original_disabled
|
||||
|
||||
|
||||
def test_long_doctor_button_paints_compactly_without_truncating_real_value(shell_factory, application) -> None:
|
||||
_shell, page = shell_factory(1024, 768)
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
_settle(application)
|
||||
button = page.doctor_filter.button
|
||||
full_text = "联合会诊专家门诊陈医生、疑难病联合门诊林医生 等3人"
|
||||
button.setText(full_text)
|
||||
_settle(application)
|
||||
assert button.fontMetrics().horizontalAdvance(full_text) > button.width() - 48
|
||||
assert button.text() == full_text
|
||||
assert button.toolTip() == full_text
|
||||
assert not button.grab().isNull() # Execute the actual compact paint path.
|
||||
assert button.text() == full_text
|
||||
assert page.doctor_filter.values() == []
|
||||
@@ -0,0 +1,190 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QPalette
|
||||
from PySide6.QtWidgets import QApplication, QFrame, QLabel, QPushButton
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.pages import reception as reception_module
|
||||
from doctor_workstation.ui.pages.reception import ReceptionPage
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
def settle(app):
|
||||
for _ in range(8):
|
||||
app.processEvents()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page(application, monkeypatch):
|
||||
monkeypatch.setattr(reception_module, "run_async", lambda *_args, **_kwargs: None)
|
||||
widget = ReceptionPage(object(), PermissionSet(["*"]))
|
||||
widget.resize(1340, 900)
|
||||
widget.show()
|
||||
widget.poll_timer.stop()
|
||||
widget.detail_stack.setCurrentIndex(1)
|
||||
widget.patient_name_label.setText("林晓岚")
|
||||
widget.patient_meta_label.setText("女 · 46岁 · 138****1203 | 就诊号:101")
|
||||
settle(application)
|
||||
yield widget
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
settle(application)
|
||||
|
||||
|
||||
def test_primary_action_and_reading_ink_use_separate_colors(page, application):
|
||||
page.video_button.setEnabled(True)
|
||||
settle(application)
|
||||
assert page.video_button.palette().color(QPalette.ColorRole.Button).name() == "#1769e8"
|
||||
assert page.video_button.palette().color(QPalette.ColorRole.ButtonText).name() == "#ffffff"
|
||||
assert page.case_labels["present"].palette().color(QPalette.ColorRole.WindowText).name() == "#273244"
|
||||
page.video_button.setEnabled(False)
|
||||
settle(application)
|
||||
assert page.video_button.palette().color(QPalette.ColorRole.ButtonText).name() == "#8e8f90"
|
||||
|
||||
|
||||
def test_typography_preserves_regular_body_and_distinct_values(page):
|
||||
body = page.case_labels["present"]
|
||||
assert body.font().pixelSize() == 14
|
||||
assert body.font().weight() == 400
|
||||
assert page.patient_name_label.font().pixelSize() == 22
|
||||
assert page.patient_name_label.font().weight() == 600
|
||||
assert page.vital_labels["height"].font().pixelSize() == 18
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width", [1340, 1080, 816])
|
||||
def test_identity_and_actions_stay_inside_hero_at_both_layouts(page, application, width):
|
||||
page.resize(width, 900)
|
||||
settle(application)
|
||||
hero = page.findChild(QFrame, "ReceptionHero")
|
||||
for button in (page.notify_button, page.history_button, page.video_button, page.more_button):
|
||||
assert hero.rect().contains(button.geometry())
|
||||
assert button.width() >= button.minimumSizeHint().width()
|
||||
if width == 1340:
|
||||
assert abs(page.video_button.geometry().center().y() - page.patient_avatar_label.geometry().center().y()) < 20
|
||||
else:
|
||||
assert page.video_button.geometry().top() >= page.patient_meta_label.geometry().bottom()
|
||||
|
||||
|
||||
def test_body_growth_stays_readable_and_does_not_cover_actions(page, application):
|
||||
body = page.case_labels["present"]
|
||||
body.setText("患者自述近期口干,睡眠较浅,日常饮食与作息较规律。\n" * 12)
|
||||
settle(application)
|
||||
assert body.height() >= body.heightForWidth(body.width())
|
||||
assert page.detail_scroll.verticalScrollBar().maximum() > 0
|
||||
assert body.textFormat() == Qt.TextFormat.PlainText
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["6.8 mmol/L", "1234.56 mg/g", "≤0.25 mg/L", "—"])
|
||||
def test_lab_value_keeps_full_plain_text_and_wraps_long_units(page, application, value):
|
||||
label = page.lab_labels["fasting"]
|
||||
label.setText(value)
|
||||
settle(application)
|
||||
assert label.text() == value
|
||||
assert label.textFormat() == Qt.TextFormat.PlainText
|
||||
for width in (55, 120):
|
||||
layout, size = label._value_layout(width)
|
||||
assert sum(layout.lineAt(i).textLength() for i in range(layout.lineCount())) == len(value)
|
||||
assert size.height() == label.heightForWidth(width)
|
||||
for i in range(layout.lineCount()):
|
||||
line = layout.lineAt(i)
|
||||
assert line.naturalTextWidth() <= width + 1
|
||||
assert line.y() + line.height() <= size.height()
|
||||
|
||||
|
||||
def test_queue_summary_and_navigation_tabs_remain_available(page):
|
||||
summary = page.findChild(QLabel, "ReceptionQueueSummary")
|
||||
assert summary.isVisible()
|
||||
assert [page.detail_tabs.tabText(i) for i in range(page.detail_tabs.count())] == [
|
||||
"问诊信息", "检查报告", "用药记录", "日常记录", "随访记录", "健康数据"
|
||||
]
|
||||
|
||||
|
||||
def test_risks_remain_in_full_report_while_preview_matches_approved_image(page, application):
|
||||
page.ai_analysis_stack.setCurrentWidget(page.ai_analysis_content_page)
|
||||
settle(application)
|
||||
page._render_ai_risk_chips([
|
||||
{"label": "血糖波动风险", "level": "medium"},
|
||||
{"label": "睡眠质量下降", "level": "low"},
|
||||
])
|
||||
settle(application)
|
||||
assert not page.ai_risk_chip_host.isVisible()
|
||||
dialog = reception_module._ReceptionAiAnalysisDialog({"qwen": {
|
||||
"diagnosis_advice": "需要结合病史复核。",
|
||||
"risk_assessment": [{"label": "血糖波动风险", "level": "medium"}],
|
||||
"treatment_advice": "完整治疗建议仍可阅读。",
|
||||
}})
|
||||
dialog.show()
|
||||
settle(application)
|
||||
try:
|
||||
risk = next(label for label in dialog.findChildren(QLabel) if label.text() == "血糖波动风险")
|
||||
assert risk.isVisible()
|
||||
assert risk.width() >= risk.fontMetrics().horizontalAdvance(risk.text())
|
||||
assert any(label.text() == "完整治疗建议仍可阅读。" for label in dialog.findChildren(QLabel))
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_compact_ai_keeps_full_treatment_data_without_pushing_controls_out_of_view(page, application):
|
||||
page.ai_analysis_stack.setCurrentWidget(page.ai_analysis_content_page)
|
||||
page.ai_summary_label.setText("需要结合完整病史与复诊记录核对。" * 25)
|
||||
page.ai_treatment_label.setText("请结合患者实际情况复核完整分析。" * 25)
|
||||
page._render_ai_risk_chips([{"label": "血糖波动风险", "level": "medium"}])
|
||||
settle(application)
|
||||
assert page.ai_treatment_label.text() == "请结合患者实际情况复核完整分析。" * 25
|
||||
assert not page.ai_treatment_label.isVisible()
|
||||
assert not page.ai_question_edit.isVisible()
|
||||
assert not page.ai_analysis_history_button.isVisible()
|
||||
actions = [action.text() for action in page.more_button.menu().actions()]
|
||||
assert "向 AI 提问" in actions
|
||||
assert "历史 AI 报告" in actions
|
||||
assert "重新分析 AI 报告" in actions
|
||||
assert page.ai_analysis_card.height() < 320
|
||||
|
||||
|
||||
def test_patient_switch_clears_previous_condition_tooltip(page):
|
||||
page.patient_meta_label.setToolTip("上一位患者的病情提示")
|
||||
page._reset_detail_content({"id": 202, "patient_name": "新患者"})
|
||||
assert page.patient_meta_label.toolTip() == ""
|
||||
page.patient_meta_label.setToolTip("已失效的病情提示")
|
||||
page._render_detail_load_failure("详情暂不可用")
|
||||
assert page.patient_meta_label.toolTip() == ""
|
||||
|
||||
|
||||
def test_moved_ai_controls_keep_actions_and_enabled_state(application, monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(reception_module, "run_async", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(ReceptionPage, "_open_ai_analysis_dialog", lambda self: calls.append("history"))
|
||||
monkeypatch.setattr(ReceptionPage, "_regenerate_patient_ai_reports", lambda self: calls.append("regenerate"))
|
||||
monkeypatch.setattr(ReceptionPage, "_open_ai_assistant", lambda self, prompt: calls.append(("ask", prompt)))
|
||||
widget = ReceptionPage(object(), PermissionSet(["*"]))
|
||||
try:
|
||||
widget.poll_timer.stop()
|
||||
assert not widget.ai_history_action.isEnabled()
|
||||
assert not widget.ai_regenerate_action.isEnabled()
|
||||
widget._ai_analysis_histories["qwen"] = [{"diagnosis_advice": "示例分析"}]
|
||||
widget._ai_analysis_patient_flow = True
|
||||
widget._ai_analysis_patient_id = 101
|
||||
widget._sync_ai_analysis_view()
|
||||
assert widget.ai_history_action.isEnabled()
|
||||
assert widget.ai_regenerate_action.isEnabled()
|
||||
widget.ai_history_action.trigger()
|
||||
widget.ai_regenerate_action.trigger()
|
||||
next(action for action in widget.more_button.menu().actions() if action.text() == "向 AI 提问").trigger()
|
||||
widget.ai_assistant_card.findChild(QPushButton, "ReceptionAiTitle").click()
|
||||
assert calls == ["history", "regenerate", ("ask", ""), ("ask", "")]
|
||||
widget._ai_analysis_regenerating = True
|
||||
widget._sync_ai_analysis_view()
|
||||
assert not widget.ai_regenerate_action.isEnabled()
|
||||
finally:
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
settle(application)
|
||||
@@ -0,0 +1,379 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, QSize
|
||||
from PySide6.QtGui import QFont, QFontInfo, QIcon
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from doctor_workstation.ui import shell as shell_module
|
||||
from doctor_workstation.ui.reception_style import (
|
||||
TECH_BLUE,
|
||||
body_family,
|
||||
heading_family,
|
||||
number_family,
|
||||
)
|
||||
from doctor_workstation.ui.shell import NavigationItem, ShellWindow
|
||||
from doctor_workstation.ui.theme import COLORS, apply_theme
|
||||
|
||||
|
||||
class _Page(QWidget):
|
||||
def __init__(self, _repository: Any, *, parent: QWidget, **_kwargs: Any) -> None:
|
||||
super().__init__(parent)
|
||||
layout = QVBoxLayout(self)
|
||||
self.label = QLabel("原有页面正文", self)
|
||||
layout.addWidget(self.label)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def window(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
navigation = [
|
||||
NavigationItem(key, title, "", _Page, ("doctor.appointment/lists",))
|
||||
for key, title in (
|
||||
("appointments", "挂号列表"),
|
||||
("consultations", "问诊列表"),
|
||||
("reception", "接诊台"),
|
||||
("patients", "我的患者"),
|
||||
("prescriptions", "已开处方"),
|
||||
("prescription_library", "我的处方库"),
|
||||
("legacy_reference", "原版框架参照"),
|
||||
)
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
shell_module,
|
||||
"_resolve_navigation",
|
||||
lambda *_args, **_kwargs: [(item, item.title) for item in navigation],
|
||||
)
|
||||
monkeypatch.setattr(shell_module.motion, "reduced_motion", lambda: True)
|
||||
shell = ShellWindow(
|
||||
object(),
|
||||
{"user": {"name": "陈医生"}, "demo_mode": True},
|
||||
permissions={"doctor.appointment/lists", "tcm.diagnosis/aiAssistant"},
|
||||
)
|
||||
shell.resize(1536, 960)
|
||||
shell.show()
|
||||
application.processEvents()
|
||||
yield shell
|
||||
shell.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def _pixel(widget: QWidget, x: int, y: int) -> str:
|
||||
image = widget.grab().toImage()
|
||||
ratio = image.devicePixelRatio()
|
||||
return image.pixelColor(round(x * ratio), round(y * ratio)).name().upper()
|
||||
|
||||
|
||||
def test_approved_chrome_restores_styles_fonts_icons_and_geometry(
|
||||
application: QApplication, window: ShellWindow
|
||||
) -> None:
|
||||
assert window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
original_app_qss = application.styleSheet()
|
||||
original_app_font = application.font().toString()
|
||||
original_colors = dict(COLORS)
|
||||
original_page_fonts = {key: page.label.font().toString() for key, page in window.pages.items()}
|
||||
original_styles = dict(window._legacy_chrome_styles)
|
||||
original_avatar = window.user_menu_button.icon().pixmap(34, 34).toImage()
|
||||
original_ai = window.ai_top_button.icon().pixmap(18, 18).toImage()
|
||||
original_nav = (
|
||||
window.nav_buttons["reception"]
|
||||
.icon()
|
||||
.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.On)
|
||||
.toImage()
|
||||
)
|
||||
|
||||
for key in ("appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library", "consultations", "appointments"):
|
||||
assert window.navigate(key)
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 208
|
||||
assert _pixel(window, 6, 300) == "#F3F7FD"
|
||||
assert _pixel(window.sidebar, 5, 300) == "#F3F7FD"
|
||||
assert _pixel(window.workspace, 600, 10) == TECH_BLUE["surface"]
|
||||
assert window.nav_buttons[key].font().family() == body_family()
|
||||
assert window.nav_buttons[key].font().weight() == QFont.Weight.Normal
|
||||
assert window.brand_name.font().family() == heading_family()
|
||||
assert window.brand_name.font().weight() == QFont.Weight.DemiBold
|
||||
assert window.assistant_glyph.property("receptionTechBlue") is True
|
||||
assert window.assistant_status.property("receptionTechBlue") is True
|
||||
assert original_avatar != window.user_menu_button.icon().pixmap(34, 34).toImage()
|
||||
assert original_ai != window.ai_top_button.icon().pixmap(18, 18).toImage()
|
||||
assert (
|
||||
original_nav
|
||||
!= window.nav_buttons["reception"]
|
||||
.icon()
|
||||
.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.On)
|
||||
.toImage()
|
||||
)
|
||||
assert {
|
||||
key: page.label.font().toString() for key, page in window.pages.items()
|
||||
} == original_page_fonts
|
||||
|
||||
assert window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 190
|
||||
assert _pixel(window, 6, 300) == COLORS["canvas"].upper()
|
||||
assert _pixel(window, 18, 313) == COLORS["canvas"].upper()
|
||||
assert {widget: widget.styleSheet() for widget in original_styles} == original_styles
|
||||
assert window.user_menu_button.icon().pixmap(34, 34).toImage() == original_avatar
|
||||
assert window.ai_top_button.icon().pixmap(18, 18).toImage() == original_ai
|
||||
assert (
|
||||
window.nav_buttons["reception"]
|
||||
.icon()
|
||||
.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.On)
|
||||
.toImage()
|
||||
== original_nav
|
||||
)
|
||||
assert window.assistant_glyph.property("receptionTechBlue") is False
|
||||
assert window.assistant_status.property("receptionTechBlue") is False
|
||||
|
||||
assert application.styleSheet() == original_app_qss
|
||||
assert application.font().toString() == original_app_font
|
||||
assert original_colors == COLORS
|
||||
assert {
|
||||
key: page.label.font().toString() for key, page in window.pages.items()
|
||||
} == original_page_fonts
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"])
|
||||
def test_selected_navigation_and_ai_tile_use_approved_blue(
|
||||
application: QApplication, window: ShellWindow, key: str
|
||||
) -> None:
|
||||
window.navigate(key)
|
||||
application.processEvents()
|
||||
button = window.nav_buttons[key]
|
||||
button.clearFocus()
|
||||
application.processEvents()
|
||||
assert _pixel(button, 1, button.height() // 2) == TECH_BLUE["accent"]
|
||||
assert _pixel(button, button.width() - 12, button.height() // 2) == TECH_BLUE["selection"]
|
||||
assert _pixel(window.assistant_glyph, 10, 25) == TECH_BLUE["accent"]
|
||||
assert _pixel(window.assistant_status, 3, 9) == TECH_BLUE["accent"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"])
|
||||
def test_approved_brand_mark_uses_blue_medical_cross_and_restores_original(
|
||||
application: QApplication, window: ShellWindow, key: str
|
||||
) -> None:
|
||||
assert window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
mark = window.brand_mark
|
||||
original = mark.grab().toImage()
|
||||
assert window.navigate(key)
|
||||
application.processEvents()
|
||||
assert mark.property("receptionTechBlue") is True
|
||||
assert mark.width() == mark.height() == 38
|
||||
assert _pixel(mark, 5, 18) == TECH_BLUE["accent"]
|
||||
assert _pixel(mark, 19, 19) == TECH_BLUE["accent"]
|
||||
assert _pixel(mark, 18, 9) == TECH_BLUE["surface"]
|
||||
assert original != mark.grab().toImage()
|
||||
assert window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
assert mark.property("receptionTechBlue") is False
|
||||
assert mark.grab().toImage() == original
|
||||
|
||||
|
||||
def test_collapsed_rail_keeps_state_across_approved_and_original_pages(
|
||||
application: QApplication, window: ShellWindow
|
||||
) -> None:
|
||||
window.toggle_sidebar()
|
||||
assert window.sidebar.width() == 68
|
||||
window.navigate("reception")
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 68
|
||||
assert window.nav_buttons["reception"].isChecked()
|
||||
assert not window.assistant_card.isVisible()
|
||||
assert window.nav_buttons["reception"].toolTip() == "接诊台"
|
||||
window.toggle_sidebar()
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 208
|
||||
assert window.assistant_card.isVisible()
|
||||
window.toggle_sidebar()
|
||||
window.navigate("appointments")
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 68
|
||||
assert window.nav_buttons["appointments"].isChecked()
|
||||
assert window.nav_buttons["appointments"].toolTip() == "挂号列表"
|
||||
assert not window.assistant_card.isVisible()
|
||||
window.navigate("consultations")
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 68
|
||||
assert window.nav_buttons["consultations"].isChecked()
|
||||
assert window.nav_buttons["consultations"].toolTip() == "问诊列表"
|
||||
assert not window.assistant_card.isVisible()
|
||||
window.navigate("patients")
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 68
|
||||
assert window.nav_buttons["patients"].isChecked()
|
||||
window.toggle_sidebar()
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 208
|
||||
window.toggle_sidebar()
|
||||
window.navigate("prescriptions")
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 68
|
||||
assert window.nav_buttons["prescriptions"].isChecked()
|
||||
assert window.nav_buttons["prescriptions"].toolTip() == "已开处方"
|
||||
assert not window.assistant_card.isVisible()
|
||||
window.toggle_sidebar()
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 208
|
||||
window.toggle_sidebar()
|
||||
window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 68
|
||||
window.toggle_sidebar()
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 190
|
||||
assert window.nav_buttons["legacy_reference"].text() == "原版框架参照"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"])
|
||||
def test_approved_shell_geometry_and_function_entries_restore(
|
||||
application: QApplication, window: ShellWindow, key: str
|
||||
) -> None:
|
||||
assert window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
original_geometry = {
|
||||
widget: (widget.size(), widget.iconSize() if hasattr(widget, "iconSize") else None)
|
||||
for widget in (
|
||||
window.sidebar, window.topbar, window.search_host, window.brand_mark,
|
||||
window.assistant_card, window.assistant_button, window.assistant_status,
|
||||
window.upload_settings_button, window.user_menu_button,
|
||||
window.refresh_button, window.notification_button, window.settings_button,
|
||||
*window.nav_buttons.values(),
|
||||
)
|
||||
}
|
||||
assert window.navigate(key)
|
||||
application.processEvents()
|
||||
assert window.workspace.pos() == QPoint(208, 0)
|
||||
assert window.topbar.height() == 76
|
||||
assert window.centralWidget().layout().contentsMargins().isNull()
|
||||
assert window.search_host.mapTo(window, QPoint()) == QPoint(234, 15)
|
||||
assert window.search_host.size() == QSize(384, 44)
|
||||
assert window.assistant_card.mapTo(window, QPoint()) == QPoint(20, 695)
|
||||
assert window.assistant_card.size() == QSize(168, 171)
|
||||
assert window.assistant_button.height() == 46
|
||||
assert window.brand_name.font().pixelSize() == 18
|
||||
assert window.brand_subtitle.font().pixelSize() == 13
|
||||
for index, button in enumerate(window.nav_buttons.values()):
|
||||
assert button.mapTo(window, QPoint()).y() == 99 + 58 * index
|
||||
assert button.height() == 52
|
||||
assert button.font().pixelSize() == 15
|
||||
assert button.iconSize() == QSize(20, 20)
|
||||
assert not window.fold_button.isVisible()
|
||||
assert not window.ai_top_button.isVisible()
|
||||
assert window.menu_sidebar_action.isVisible()
|
||||
assert window.menu_ai_action.isVisible()
|
||||
window.menu_sidebar_action.trigger()
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 68
|
||||
assert window.menu_sidebar_action.isVisible()
|
||||
window.menu_sidebar_action.trigger()
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 208
|
||||
assert window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
assert window.workspace.pos() == QPoint(203, 13)
|
||||
assert window.fold_button.isVisible()
|
||||
assert window.ai_top_button.isVisible()
|
||||
assert not window.menu_sidebar_action.isVisible()
|
||||
assert not window.menu_ai_action.isVisible()
|
||||
assert not window.user_separator.isVisible()
|
||||
for widget, (size, icon_size) in original_geometry.items():
|
||||
assert widget.size() == size
|
||||
if icon_size is not None:
|
||||
assert widget.iconSize() == icon_size
|
||||
|
||||
|
||||
def test_reception_fonts_resolve_real_requested_weights(application: QApplication) -> None:
|
||||
for family, weights in (
|
||||
(body_family(), (QFont.Weight.Normal,)),
|
||||
(heading_family(), (QFont.Weight.Medium, QFont.Weight.DemiBold)),
|
||||
(number_family(), (QFont.Weight.Normal, QFont.Weight.DemiBold)),
|
||||
):
|
||||
for weight in weights:
|
||||
font = QFont(family)
|
||||
font.setPixelSize(15)
|
||||
font.setWeight(weight)
|
||||
resolved = QFontInfo(font)
|
||||
assert resolved.family() == family
|
||||
assert resolved.weight() == weight
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"])
|
||||
def test_minimum_window_keeps_search_and_all_chrome_actions_separate(
|
||||
application: QApplication, window: ShellWindow, key: str
|
||||
) -> None:
|
||||
window.navigate(key)
|
||||
window.resize(1024, 640)
|
||||
application.processEvents()
|
||||
assert window.width() == 1024
|
||||
assert 220 <= window.search_host.width() < 384
|
||||
controls = (
|
||||
window.search_host, window.refresh_button, window.notification_button,
|
||||
window.settings_button, window.user_menu_button, window.minimize_button,
|
||||
window.fullscreen_button, window.close_button,
|
||||
)
|
||||
for left, right in zip(controls, controls[1:], strict=False):
|
||||
assert left.x() + left.width() <= right.x()
|
||||
assert right.x() + right.width() <= window.topbar.width()
|
||||
window.resize(1536, 960)
|
||||
application.processEvents()
|
||||
assert window.search_host.size() == QSize(384, 44)
|
||||
|
||||
|
||||
def test_refresh_animation_keeps_running_and_restores_palette_on_page_switch(
|
||||
application: QApplication, window: ShellWindow
|
||||
) -> None:
|
||||
window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
button = window.refresh_button
|
||||
original = button.icon().pixmap(18, 18).toImage()
|
||||
button.start_spin()
|
||||
for key in ("appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"):
|
||||
window.navigate(key)
|
||||
application.processEvents()
|
||||
assert button._timer.isActive()
|
||||
assert button.property("receptionTechBlue") is True
|
||||
window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
assert button._timer.isActive()
|
||||
assert button.property("receptionTechBlue") is False
|
||||
assert button._resting.pixmap(18, 18).toImage() == original
|
||||
button._timer.stop()
|
||||
|
||||
|
||||
def test_navigation_between_approved_pages_keeps_existing_chrome_assets(
|
||||
application: QApplication, window: ShellWindow
|
||||
) -> None:
|
||||
assert window.navigate("appointments")
|
||||
application.processEvents()
|
||||
styles = {widget: widget.styleSheet() for widget in window._legacy_chrome_styles}
|
||||
icons = {
|
||||
widget: widget.icon().cacheKey()
|
||||
for widget in (
|
||||
window.user_menu_button, window.refresh_button, window.ai_top_button,
|
||||
*window.nav_buttons.values(),
|
||||
)
|
||||
}
|
||||
window.global_search.setText("患者查询")
|
||||
|
||||
for key in ("consultations", "reception", "patients", "prescriptions", "prescription_library", "appointments", "consultations"):
|
||||
assert window.navigate(key)
|
||||
application.processEvents()
|
||||
assert window.nav_buttons[key].isChecked()
|
||||
assert window.global_search.text() == "患者查询"
|
||||
assert window._reception_chrome_active
|
||||
assert {widget: widget.styleSheet() for widget in styles} == styles
|
||||
assert {widget: widget.icon().cacheKey() for widget in icons} == icons
|
||||
@@ -0,0 +1,145 @@
|
||||
"""List pages must load without a banner that reflows the page.
|
||||
|
||||
Every list refresh used to raise an info banner ("正在加载…") into the page
|
||||
layout and drop it again on completion. That is two relayouts per refresh - the
|
||||
filter bar and the whole table jump down and back - and the appointment and
|
||||
consultation pages poll every five seconds, so the jump repeated on its own with
|
||||
nobody touching the keyboard.
|
||||
|
||||
Errors still surface on the banner; only the routine load is silent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui.pages import appointments as appointments_module
|
||||
from doctor_workstation.ui.pages import prescription_library as library_module
|
||||
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
|
||||
from doctor_workstation.ui.pages.appointments import AppointmentsPage
|
||||
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
|
||||
from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Run the worker inline so the banner state can be inspected mid-load."""
|
||||
|
||||
def run_immediately(
|
||||
function: Any,
|
||||
*args: Any,
|
||||
on_success: Any = None,
|
||||
on_error: Any = None,
|
||||
on_finished: Any = None,
|
||||
**kwargs: Any,
|
||||
) -> object:
|
||||
try:
|
||||
result = function(*args, **kwargs)
|
||||
except Exception as error: # noqa: BLE001 - mirrors the real worker
|
||||
if on_error is not None:
|
||||
on_error(error)
|
||||
else:
|
||||
if on_success is not None:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
return object()
|
||||
|
||||
for module in (prescriptions_module, library_module, appointments_module):
|
||||
monkeypatch.setattr(module, "run_async", run_immediately)
|
||||
|
||||
|
||||
def _pages(application: QApplication) -> list[Any]:
|
||||
del application
|
||||
repository = DemoDoctorRepository()
|
||||
return [
|
||||
PrescriptionsPage(repository, ["*"]),
|
||||
PrescriptionLibraryPage(
|
||||
repository, ["*"], SimpleNamespace(id=1, root=1, role_ids=[])
|
||||
),
|
||||
AppointmentsPage(repository, PermissionSet(["*"])),
|
||||
]
|
||||
|
||||
|
||||
def test_a_list_refresh_never_raises_a_loading_banner(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
seen: list[tuple[str, str]] = []
|
||||
|
||||
for page in _pages(application):
|
||||
banner = page.banner
|
||||
original = banner.show_message
|
||||
|
||||
def record(text: str, kind: str = "info", _page: Any = page) -> None:
|
||||
seen.append((type(_page).__name__, text))
|
||||
|
||||
banner.show_message = record # type: ignore[method-assign]
|
||||
page.refresh()
|
||||
application.processEvents()
|
||||
banner.show_message = original # type: ignore[method-assign]
|
||||
page.close()
|
||||
|
||||
assert seen == [], f"a list refresh still announced itself: {seen}"
|
||||
|
||||
|
||||
def test_a_refresh_does_not_move_anything_on_the_page(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""The real symptom was reflow, so assert on geometry rather than on a flag.
|
||||
|
||||
A hidden widget keeps whatever size it was last given, so checking the
|
||||
banner's own height proves nothing; what matters is that the table below it
|
||||
does not shift when a refresh runs.
|
||||
"""
|
||||
|
||||
for page in _pages(application):
|
||||
page.resize(1200, 700)
|
||||
page.show()
|
||||
application.processEvents()
|
||||
table = page.table
|
||||
settled = (table.mapTo(page, table.rect().topLeft()), table.size())
|
||||
|
||||
for _ in range(3):
|
||||
page.refresh()
|
||||
application.processEvents()
|
||||
assert not page.banner.isVisible()
|
||||
assert (table.mapTo(page, table.rect().topLeft()), table.size()) == settled
|
||||
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_a_failed_load_still_reports_on_the_banner(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""Silence is only for the happy path; failures must stay visible."""
|
||||
|
||||
class Failing(DemoDoctorRepository):
|
||||
def list_prescriptions(self, **_filters: Any) -> Any:
|
||||
raise RuntimeError("上游不可用")
|
||||
|
||||
page = PrescriptionsPage(Failing(), ["*"])
|
||||
page.resize(1200, 700)
|
||||
page.show()
|
||||
page.refresh()
|
||||
application.processEvents()
|
||||
|
||||
assert page.banner.isVisible()
|
||||
assert page.banner.label.text()
|
||||
page.close()
|
||||
application.processEvents()
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from PySide6.QtGui import QPalette
|
||||
from PySide6.QtWidgets import QApplication, QPushButton
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.pages import reception as reception_module
|
||||
from doctor_workstation.ui.pages.reception import ReceptionDailyRecordsPanel, ReceptionPage
|
||||
from doctor_workstation.ui.theme import COLORS, apply_theme
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
def settle(app):
|
||||
for _ in range(6):
|
||||
app.processEvents()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page(application, monkeypatch):
|
||||
monkeypatch.setattr(reception_module, "run_async", lambda *_args, **_kwargs: None)
|
||||
widget = ReceptionPage(object(), PermissionSet([]))
|
||||
widget.resize(1080, 760)
|
||||
widget.show()
|
||||
widget.poll_timer.stop()
|
||||
widget.detail_stack.setCurrentIndex(1)
|
||||
settle(application)
|
||||
yield widget
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
settle(application)
|
||||
|
||||
|
||||
def test_reception_prompts_remain_readable_in_a_narrow_workspace(page, application):
|
||||
settle(application)
|
||||
for button in page.ai_prompt_buttons:
|
||||
assert button.width() >= button.minimumSizeHint().width()
|
||||
assert button.parentWidget().rect().contains(button.geometry())
|
||||
analysis = page.ai_analysis_card.geometry()
|
||||
assistant = page.ai_assistant_card.geometry()
|
||||
assert assistant.top() > analysis.bottom()
|
||||
|
||||
|
||||
def test_hidden_tall_consultation_does_not_expand_daily_records(page, application):
|
||||
page.detail_tabs.setCurrentIndex(0)
|
||||
settle(application)
|
||||
consultation_height = page.clinical_pages.height()
|
||||
page.detail_tabs.setCurrentIndex(3)
|
||||
settle(application)
|
||||
assert page.clinical_pages.height() < consultation_height - 80
|
||||
|
||||
|
||||
def test_short_risk_labels_stay_on_one_line(page, application):
|
||||
page._render_ai_risk_chips([{"label": "血糖波动风险", "level": "medium"}])
|
||||
page.ai_analysis_stack.setCurrentWidget(page.ai_analysis_content_page)
|
||||
settle(application)
|
||||
chip = page.ai_risk_chip_layout.itemAt(0).widget()
|
||||
assert chip.width() >= chip.fontMetrics().horizontalAdvance(chip.text()) + 18
|
||||
|
||||
|
||||
def test_seven_day_matrix_fits_readable_columns_without_unnecessary_horizontal_scroll(application):
|
||||
panel = ReceptionDailyRecordsPanel()
|
||||
panel.resize(900, 580)
|
||||
panel.set_data({}, [])
|
||||
panel.show()
|
||||
settle(application)
|
||||
try:
|
||||
assert panel.matrix.columnCount() == 8
|
||||
assert panel.matrix.horizontalScrollBar().maximum() == 0
|
||||
assert all(panel.matrix.columnWidth(i) >= 96 for i in range(1, 8))
|
||||
panel.range_buttons["30"].click()
|
||||
panel.set_data({}, [])
|
||||
settle(application)
|
||||
assert panel.matrix.columnCount() == 31
|
||||
assert panel.matrix.horizontalScrollBar().maximum() > 0
|
||||
assert panel.matrix.columnWidth(1) >= 96
|
||||
finally:
|
||||
panel.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("variant", ["secondary", "success", "warning", "danger", "link"])
|
||||
def test_disabled_semantic_buttons_do_not_look_actionable(application, variant):
|
||||
button = QPushButton("查看记录")
|
||||
button.setProperty("variant", variant)
|
||||
button.setEnabled(False)
|
||||
button.ensurePolished()
|
||||
assert button.palette().color(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText).name() == COLORS["disabled_text"].lower()
|
||||
button.close()
|
||||
Reference in New Issue
Block a user