更新
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
# Doctor Workstation 1.3.0 Windows 正式包构建与安全验证结果
|
||||
|
||||
- 执行日期:2026-08-28(Asia/Shanghai)
|
||||
- 工作目录:`D:\web\zyt\app`
|
||||
- 结论:通过。1.3.0 Windows 安装器与 ZIP 已重新构建;冻结媒体/入口门禁、隔离安装/启动/卸载冒烟和真实 Windows helper bootstrap 专项测试全部通过。
|
||||
- 安全边界:未修改生产源码或既有测试,未执行 reset/checkout,未访问现场服务端,也未用 `dist` 中旧的 1.1.0 安装器覆盖真实安装。安装器冒烟显式指定 1.3.0 文件并仅安装到随机临时目录。
|
||||
|
||||
## 最终产物
|
||||
|
||||
| 产物 | 字节数 | SHA-256 |
|
||||
| --- | ---: | --- |
|
||||
| `D:\web\zyt\app\dist\DoctorWorkstation-Setup-Windows-x64-1.3.0.exe` | 162917310 | `E1734C7B5E1619951AF81082FC4578D50BD881B5CD8C7AC7F4E658E8E28B74A7` |
|
||||
| `D:\web\zyt\app\dist\DoctorWorkstation-Windows-x64-1.3.0.zip` | 230820285 | `FAF2D4A68065C1ED528A1D887B5210B44FACBEBBC1CB89729FA3B690D7995CEF` |
|
||||
|
||||
`dist\SHA256SUMS.txt` 与重新计算的两个 SHA-256 完全一致。
|
||||
|
||||
## 命令、退出码与结果
|
||||
|
||||
以下命令均从 PowerShell 执行;未特别注明时工作目录为 `D:\web\zyt\app`。
|
||||
|
||||
### 1. 项目约束与 Trellis 检查
|
||||
|
||||
```powershell
|
||||
Get-Content -LiteralPath 'D:\web\zyt\AGENTS.md' -Raw
|
||||
if (Test-Path -LiteralPath 'D:\web\zyt\.trellis') { Get-ChildItem ... } else { 'NO_TRELLIS' }
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 结果:根 `AGENTS.md` 已完整读取;`D:\web\zyt\.trellis` 不存在,因此没有可继续读取的 `.trellis/workflow.md` 或分层 spec。
|
||||
|
||||
### 2. 初始共享工作树与构建输入只读检查
|
||||
|
||||
```powershell
|
||||
git status --short
|
||||
Get-Content src\doctor_workstation\__init__.py
|
||||
Get-Content scripts\package_windows.ps1
|
||||
Get-Content scripts\smoke_windows_installer.ps1
|
||||
rg -n "bootstrap|ready|helper|windows" tests\test_app_update.py
|
||||
Get-ChildItem dist -File
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 结果:开始时共享工作树已有大量未提交源码/测试变更和研究文件,均视为他人工作并保留;确认构建、安装器冒烟与 helper 测试入口存在。`dist` 内旧 1.1.0/1.2.0 包仅被列出,没有被安装或复制到真实安装位置。
|
||||
|
||||
### 3. 版本源逐行及实际导入检查
|
||||
|
||||
```powershell
|
||||
[System.IO.File]::ReadAllLines((Resolve-Path 'src\doctor_workstation\__init__.py'))
|
||||
.\.venv\Scripts\python.exe -c "import doctor_workstation; print(repr(doctor_workstation.__version__)); print(repr(doctor_workstation.DEBUG_MODE)); print(repr(doctor_workstation.ONLINE_API_BASE_URL))"
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 结果:`__version__ == '1.3.0'`;`DEBUG_MODE is False`;线上 API 基址为 `https://admin.zhenyangtang.com.cn`。
|
||||
|
||||
### 4. 打包脚本内置冻结门禁确认
|
||||
|
||||
```powershell
|
||||
rg -n "runtime_media_smoke|smoke|entry|frozen|media|DoctorWorkstation.exe|--smoke" scripts\package_windows.ps1 scripts\build_windows.ps1 packaging\runtime_media_smoke.py
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 结果:确认 `build_windows.ps1` 在构建后依次执行冻结 Qt 多媒体文件门禁、`--media-smoke-test` 冻结进程门禁和 `--smoke-test` 应用入口门禁。
|
||||
|
||||
### 5. 1.3.0 Windows 正式包重建
|
||||
|
||||
```powershell
|
||||
& .\scripts\package_windows.ps1
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 关键结果:
|
||||
- `uv sync --frozen --extra build` 成功;构建环境使用 Python 3.12.12、PyInstaller 6.22.0。
|
||||
- `npm ci` 成功,视频伴侣生产构建成功(Vite 6.1.1,43 modules transformed)。
|
||||
- PyInstaller onedir 冻结成功,输出 `dist\DoctorWorkstation\DoctorWorkstation.exe`。
|
||||
- `Frozen Qt multimedia file gate passed.`
|
||||
- `Frozen Qt multimedia smoke gate passed (--media-smoke-test, isolated offscreen mode).`
|
||||
- `Frozen application entry smoke gate passed (--smoke-test, isolated offscreen mode).`
|
||||
- 7-Zip 创建 1.3.0 ZIP 成功;Inno Setup 6.7.3 编译 1.3.0 安装器成功。
|
||||
- 脚本打印的最终哈希与本报告“最终产物”一致。
|
||||
- 非阻塞警告:Vite 报告单个 JS chunk 大于 500 kB;PyInstaller 报告一个可选 QML asset downloader 插件二进制不存在及若干 Windows 系统 DLL 静态解析警告。它们未阻断构建,且后续冻结媒体实际进程、应用入口及安装后启动门禁全部通过。
|
||||
|
||||
### 6. 新安装器隔离安装/启动/卸载冒烟
|
||||
|
||||
```powershell
|
||||
& .\scripts\smoke_windows_installer.ps1 -Installer (Resolve-Path -LiteralPath '.\dist\DoctorWorkstation-Setup-Windows-x64-1.3.0.exe').Path
|
||||
```
|
||||
|
||||
- 退出码:`0`(PowerShell 进程退出码)
|
||||
- 结果:`Installer icon/install/start/uninstall smoke test passed.`
|
||||
- 隔离目录:`C:\Users\pc\AppData\Local\Temp\doctor-workstation-installer-smoke-14b72f4137814eea8ede6bc48920f5f5`
|
||||
- 覆盖项:安装器/主程序/卸载器图标一致;安装目录内主程序和卸载器存在;安装后主程序 `--smoke-test` 返回 0;卸载返回 0;卸载后主程序不再存在。
|
||||
- 安全说明:脚本使用随机 `%LOCALAPPDATA%\Temp\doctor-workstation-installer-smoke-*\install`,环境变量、配置与日志均隔离;命令显式锁定 1.3.0 安装器,没有调用 1.1.0 包或现场服务端。
|
||||
|
||||
### 7. 真实 Windows helper bootstrap 专项测试
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -m pytest 'tests\test_app_update.py::test_inno_helper_executes_bootstrap_with_production_flags' -q
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 结果:`1 passed`(单点输出为 `.`)。该测试未 mock `subprocess.Popen`,会真实启动 PowerShell helper,并由测试生成的 helper 脚本在收到生产参数集后写入 ready 文件;最后断言 ready 文件内容为 `ready`,因此确认生产 flags 下 ready 握手实际产生。
|
||||
|
||||
### 8. 最终版本/生产 flag 复核及独立哈希核验
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -c "import doctor_workstation; assert doctor_workstation.__version__ == '1.3.0'; assert doctor_workstation.DEBUG_MODE is False"
|
||||
Get-Item .\dist\DoctorWorkstation-Setup-Windows-x64-1.3.0.exe
|
||||
Get-FileHash .\dist\DoctorWorkstation-Setup-Windows-x64-1.3.0.exe -Algorithm SHA256
|
||||
Get-Item .\dist\DoctorWorkstation-Windows-x64-1.3.0.zip
|
||||
Get-FileHash .\dist\DoctorWorkstation-Windows-x64-1.3.0.zip -Algorithm SHA256
|
||||
Compare-Object <重新计算的两行> (Get-Content .\dist\SHA256SUMS.txt)
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 结果:版本和 `DEBUG_MODE` 断言通过;EXE/ZIP 字节数和哈希如“最终产物”所列;`Compare-Object` 无差异。
|
||||
|
||||
### 9. 完成前共享工作树/产物复查
|
||||
|
||||
```powershell
|
||||
git status --short
|
||||
Get-ChildItem .\dist -File | Where-Object { $_.Name -match '1\.3\.0|SHA256SUMS' } | Select-Object Name,Length,LastWriteTime
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 结果:1.3.0 EXE、ZIP 和新 `SHA256SUMS.txt` 均存在;本任务未编辑生产源码或测试。完成复查时仍可见共享工作树中的既有 `src/doctor_workstation/services/app_update.py` 修改,未回退或覆盖。
|
||||
|
||||
## 验收结论
|
||||
|
||||
1. 版本/发布开关正确:`1.3.0`、`DEBUG_MODE=False`。
|
||||
2. 正式 EXE/ZIP 重建成功,内置三项冻结门禁全部通过。
|
||||
3. 1.3.0 安装器隔离安装、启动、卸载和残留检查通过。
|
||||
4. 真实 Windows helper bootstrap 在生产 flags 下成功生成 ready 文件。
|
||||
5. 最终大小与 SHA-256 已独立复核,且与 `SHA256SUMS.txt` 一致。
|
||||
6. 未触碰真实安装或现场服务端 1.1.0 包。
|
||||
@@ -3,7 +3,7 @@
|
||||
__all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"]
|
||||
|
||||
# Single source of truth for runtime, package, installer, and executable versions.
|
||||
__version__ = "1.3.0"
|
||||
__version__ = "1.2.0"
|
||||
|
||||
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
|
||||
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
|
||||
|
||||
@@ -714,7 +714,10 @@ def _spawn_inno_setup_applier(
|
||||
if exit_code is not None:
|
||||
raise OSError(f"更新助手启动后提前退出(代码 {exit_code})")
|
||||
time.sleep(0.05)
|
||||
process.terminate()
|
||||
try: # noqa: SIM105 - best-effort cleanup before reporting the handshake timeout
|
||||
process.terminate()
|
||||
except OSError:
|
||||
pass
|
||||
raise OSError(f"更新助手启动超时,未生成就绪标记:{ready_file}")
|
||||
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@ object names and dynamic properties so adjacent pages keep their own styling.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import weakref
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
import weakref
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import (
|
||||
@@ -1257,7 +1258,7 @@ class DiagnosisLineEdit(QLineEdit):
|
||||
self.setText(text)
|
||||
|
||||
|
||||
class DiagnosisTextEdit(QPlainTextEdit):
|
||||
class DiagnosisTextEdit(QPlainTextEdit):
|
||||
"""Multiline editor using the same scoped field state as line inputs."""
|
||||
|
||||
def __init__(self, rows: int = 3, parent: QWidget | None = None) -> None:
|
||||
@@ -1273,11 +1274,113 @@ class DiagnosisTextEdit(QPlainTextEdit):
|
||||
# QPlainTextEdit defaults to ~80 columns; that blows past the drawer width.
|
||||
return QSize(48, self._hint_height)
|
||||
|
||||
def sizeHint(self) -> QSize: # noqa: N802 - Qt virtual
|
||||
return QSize(160, self._hint_height)
|
||||
|
||||
|
||||
class DiagnosisComboBox(QComboBox):
|
||||
def sizeHint(self) -> QSize: # noqa: N802 - Qt virtual
|
||||
return QSize(160, self._hint_height)
|
||||
|
||||
|
||||
class ExpandableDiagnosisTextEdit(DiagnosisTextEdit):
|
||||
"""An inline long-text editor without an inner scrollbar when expanded."""
|
||||
|
||||
def __init__(self, rows: int = 3, parent: QWidget | None = None) -> None:
|
||||
super().__init__(rows, parent)
|
||||
self._collapsed_height = self._hint_height
|
||||
self._caption = "内容"
|
||||
self._expand_host: QWidget | None = None
|
||||
self.expand_button = QPushButton("展开全部", self)
|
||||
self.expand_button.setObjectName("DiagnosisTextExpandButton")
|
||||
self.expand_button.setProperty("variant", "link")
|
||||
self.expand_button.setCheckable(True)
|
||||
self.expand_button.setAutoDefault(False)
|
||||
self.expand_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.expand_button.setToolTip("展开文本框,查看和编辑全部内容")
|
||||
self.expand_button.hide()
|
||||
self.expand_button.toggled.connect(self._toggle_expanded)
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self._height_sync = QTimer(self)
|
||||
self._height_sync.setSingleShot(True)
|
||||
self._height_sync.setInterval(0)
|
||||
self._height_sync.timeout.connect(self._sync_content_height)
|
||||
self.textChanged.connect(self._height_sync.start)
|
||||
self.document().documentLayout().documentSizeChanged.connect(
|
||||
lambda _size: self._height_sync.start()
|
||||
)
|
||||
|
||||
def wrap_with_expander(self, caption: str) -> QWidget:
|
||||
"""Keep the original plain-text editor as the form's save/readonly target."""
|
||||
|
||||
if self._expand_host is None:
|
||||
self._caption = caption
|
||||
self.setAccessibleName(caption)
|
||||
self.expand_button.setAccessibleName(f"展开{caption}全部内容")
|
||||
self._expand_host = QWidget()
|
||||
self._expand_host.setMinimumWidth(0)
|
||||
self._expand_host.setFocusProxy(self)
|
||||
layout = QVBoxLayout(self._expand_host)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(2)
|
||||
layout.addWidget(self)
|
||||
layout.addWidget(self.expand_button, 0, Qt.AlignmentFlag.AlignRight)
|
||||
return self._expand_host
|
||||
|
||||
def setPlainText(self, text: str) -> None: # noqa: N802 - Qt API
|
||||
# A newly loaded record starts compact; typing/paste/undo keep its state.
|
||||
self.expand_button.setChecked(False)
|
||||
super().setPlainText(text)
|
||||
|
||||
def _toggle_expanded(self, expanded: bool) -> None:
|
||||
self.expand_button.setText("收起" if expanded else "展开全部")
|
||||
self.expand_button.setAccessibleName(
|
||||
f"收起{self._caption}" if expanded else f"展开{self._caption}全部内容"
|
||||
)
|
||||
self.expand_button.setToolTip(
|
||||
"收起为紧凑文本框,内容不会丢失" if expanded else "展开文本框,查看和编辑全部内容"
|
||||
)
|
||||
self.setVerticalScrollBarPolicy(
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||
if expanded
|
||||
else Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||||
)
|
||||
self.verticalScrollBar().setValue(0)
|
||||
self._height_sync.start()
|
||||
|
||||
def _sync_content_height(self) -> None:
|
||||
expanded = self.expand_button.isChecked()
|
||||
document = self.document()
|
||||
document_layout = document.documentLayout()
|
||||
# QPlainTextDocumentLayout.documentSize().height() counts lines, not
|
||||
# pixels. Measure the actual wrapped blocks, including offscreen ones.
|
||||
height = self.height() - self.viewport().height() + 2 * document.documentMargin()
|
||||
block = document.begin()
|
||||
while block.isValid():
|
||||
height += document_layout.blockBoundingRect(block).height()
|
||||
if not expanded and height > self._collapsed_height:
|
||||
break
|
||||
block = block.next()
|
||||
self.expand_button.setVisible(expanded or height > self._collapsed_height)
|
||||
target = max(self._collapsed_height, ceil(height)) if expanded else self._collapsed_height
|
||||
if self._hint_height != target:
|
||||
self._hint_height = target
|
||||
self.setFixedHeight(target)
|
||||
self.updateGeometry()
|
||||
|
||||
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 - Qt API
|
||||
super().resizeEvent(event)
|
||||
if hasattr(self, "_height_sync"):
|
||||
self._height_sync.start()
|
||||
|
||||
def showEvent(self, event: QShowEvent) -> None: # noqa: N802 - Qt API
|
||||
super().showEvent(event)
|
||||
self._height_sync.start()
|
||||
|
||||
def changeEvent(self, event: QEvent) -> None: # noqa: N802 - Qt API
|
||||
super().changeEvent(event)
|
||||
if event.type() in (QEvent.Type.FontChange, QEvent.Type.StyleChange) and hasattr(
|
||||
self, "_height_sync"
|
||||
):
|
||||
self._height_sync.start()
|
||||
|
||||
|
||||
class DiagnosisComboBox(QComboBox):
|
||||
"""Choice editor with the legacy text API used by diagnosis saving."""
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -46,6 +46,7 @@ from ..diagnosis_drawer import (
|
||||
DiagnosisSwitch,
|
||||
DiagnosisTabWidget,
|
||||
DiagnosisTextEdit,
|
||||
ExpandableDiagnosisTextEdit,
|
||||
LoadingOverlay,
|
||||
MessageStrip,
|
||||
NotesTimeline,
|
||||
@@ -300,7 +301,7 @@ _FORM_SECTIONS: tuple[tuple[str, tuple[tuple[tuple[str, str, int, str], ...], ..
|
||||
("渠道", "create_source", 12, "create_source"),
|
||||
),
|
||||
(("统计端就诊卡", "show_card", 12, "show_card"),),
|
||||
(("在用药物", "current_medications", 24, "textarea3"),),
|
||||
(("在用药物", "current_medications", 24, "expandable_textarea3"),),
|
||||
),
|
||||
),
|
||||
(
|
||||
@@ -331,7 +332,7 @@ _FORM_SECTIONS: tuple[tuple[str, tuple[tuple[tuple[str, str, int, str], ...], ..
|
||||
(("小便情况", "urine_condition", 24, "urine_condition_choices"),),
|
||||
(("大便情况", "stool_condition", 24, "stool_condition_choices"),),
|
||||
(("腰肾情况", "kidney_condition", 24, "kidney_condition_choices"),),
|
||||
(("其他补充", "symptoms", 24, "textarea3"),),
|
||||
(("其他补充", "symptoms", 24, "expandable_textarea3"),),
|
||||
),
|
||||
),
|
||||
("既往史", ((("既往史", "past_history", 24, "past_history_choices"),),)),
|
||||
@@ -1234,11 +1235,12 @@ class DiagnosisDialog(QDialog):
|
||||
occupied = 0
|
||||
for label, key, span, field_kind in row_fields:
|
||||
editor = self._ensure_editor(key, field_kind)
|
||||
display = (
|
||||
editor.wrap_with_unit()
|
||||
if isinstance(editor, DiagnosisNumberEdit)
|
||||
else editor
|
||||
)
|
||||
if isinstance(editor, DiagnosisNumberEdit):
|
||||
display = editor.wrap_with_unit()
|
||||
elif isinstance(editor, ExpandableDiagnosisTextEdit):
|
||||
display = editor.wrap_with_expander(label)
|
||||
else:
|
||||
display = editor
|
||||
container = self._field_container(label, display, span, editor=editor)
|
||||
row_layout.addWidget(container, span)
|
||||
fields.append(container)
|
||||
@@ -1371,9 +1373,13 @@ class DiagnosisDialog(QDialog):
|
||||
columns=4 if multiple else 5,
|
||||
)
|
||||
self._choice_fields[key] = editor
|
||||
elif field_kind.startswith("textarea"):
|
||||
elif field_kind.startswith(("textarea", "expandable_textarea")):
|
||||
rows = int(field_kind[-1])
|
||||
editor = DiagnosisTextEdit(rows)
|
||||
editor = (
|
||||
ExpandableDiagnosisTextEdit(rows)
|
||||
if field_kind.startswith("expandable_")
|
||||
else DiagnosisTextEdit(rows)
|
||||
)
|
||||
else:
|
||||
editor = DiagnosisLineEdit()
|
||||
editor.setObjectName(f"DiagnosisField_{key.removeprefix('__')}")
|
||||
|
||||
@@ -259,6 +259,7 @@ QWidget#ReceptionPage QLabel#StatusBadge {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
QWidget#ReceptionPage QPushButton#ReceptionNotifyButton,
|
||||
QWidget#ReceptionPage QPushButton#ReceptionHistoryButton,
|
||||
QWidget#ReceptionPage QPushButton#ReceptionImButton {
|
||||
min-height: 34px;
|
||||
@@ -282,6 +283,7 @@ QWidget#ReceptionPage QPushButton#ReceptionMoreButton {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
QWidget#ReceptionPage QPushButton#ReceptionNotifyButton:hover,
|
||||
QWidget#ReceptionPage QPushButton#ReceptionHistoryButton:hover,
|
||||
QWidget#ReceptionPage QPushButton#ReceptionImButton:hover {
|
||||
color: #5469F0;
|
||||
@@ -3832,6 +3834,10 @@ class ReceptionPage(QWidget):
|
||||
self.complete_button.clicked.connect(self._complete_appointment)
|
||||
self.complete_button.setVisible(self._can_complete)
|
||||
patient_head.addWidget(self.complete_button)
|
||||
self.notify_button = QPushButton("通知医助", hero)
|
||||
self.notify_button.setObjectName("ReceptionNotifyButton")
|
||||
self.notify_button.clicked.connect(self._notify_assistant)
|
||||
patient_head.addWidget(self.notify_button)
|
||||
self.history_button = QPushButton("查看历史", hero)
|
||||
self.history_button.setObjectName("ReceptionHistoryButton")
|
||||
self.history_button.setIcon(_painted_reception_action_icon("info", "#3F4E75"))
|
||||
@@ -3847,7 +3853,6 @@ class ReceptionPage(QWidget):
|
||||
self.more_button = QPushButton("更多", hero)
|
||||
self.more_button.setObjectName("ReceptionMoreButton")
|
||||
more_menu = QMenu(self.more_button)
|
||||
more_menu.addAction("通知医助").triggered.connect(self._notify_assistant)
|
||||
edit_action = more_menu.addAction("编辑病历")
|
||||
edit_action.setVisible(self._can_edit)
|
||||
edit_action.triggered.connect(self._edit_diagnosis)
|
||||
@@ -3865,8 +3870,6 @@ class ReceptionPage(QWidget):
|
||||
action_compat = QWidget(hero)
|
||||
action_compat.setFixedSize(0, 0)
|
||||
action_compat.move(-100, -100)
|
||||
self.notify_button = QPushButton("通知医助", action_compat)
|
||||
self.notify_button.clicked.connect(self._notify_assistant)
|
||||
self.edit_button = QPushButton("编辑病历", action_compat)
|
||||
self.edit_button.setProperty("variant", "secondary")
|
||||
self.edit_button.clicked.connect(self._edit_diagnosis)
|
||||
|
||||
@@ -9,7 +9,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, Qt
|
||||
from PySide6.QtGui import QImage
|
||||
from PySide6.QtGui import QImage, QTextCursor
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
@@ -29,6 +29,7 @@ from doctor_workstation.ui.diagnosis_drawer import (
|
||||
DiagnosisLineEdit,
|
||||
DiagnosisNumberEdit,
|
||||
DiagnosisSwitch,
|
||||
ExpandableDiagnosisTextEdit,
|
||||
SaveStateButton,
|
||||
)
|
||||
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
|
||||
@@ -751,6 +752,186 @@ def test_semantic_form_controls_keep_desktop_grid_and_canonical_diagnosis_type(
|
||||
dialog.close()
|
||||
|
||||
|
||||
def _settle_text_layout(application: QApplication) -> None:
|
||||
# Editor measurement and the containing scroll area's layout are deferred.
|
||||
for _ in range(12):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["symptoms", "current_medications"])
|
||||
@pytest.mark.parametrize("size", [(1024, 640), (1440, 900)])
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
[
|
||||
"这是一段用于验证自动换行的测试记录。" * 40,
|
||||
"\n".join(f"第{index + 1}条:测试记录,保留原始换行。" for index in range(18)),
|
||||
"LongUnbrokenTestValue" * 80,
|
||||
],
|
||||
ids=["wrapped-chinese", "multiline", "unbroken-text"],
|
||||
)
|
||||
def test_long_diagnosis_fields_expand_inline_without_inner_scrolling(
|
||||
application: QApplication, key: str, size: tuple[int, int], text: str
|
||||
) -> None:
|
||||
repository = VisualRepository()
|
||||
repository.detail["diagnosis"][key] = text
|
||||
dialog = _open_dialog(application, size, mode="edit", repository=repository)
|
||||
try:
|
||||
_settle_text_layout(application)
|
||||
editor = dialog.edit_fields[key]
|
||||
assert isinstance(editor, ExpandableDiagnosisTextEdit)
|
||||
assert editor.height() == 72
|
||||
assert editor.expand_button.isVisibleTo(dialog)
|
||||
assert not editor.expand_button.autoDefault()
|
||||
assert editor.expand_button.text() == "展开全部"
|
||||
body = dialog.findChild(QScrollArea, "DiagnosisDrawerBody")
|
||||
footer = dialog.findChild(QFrame, "DiagnosisDrawerFooter")
|
||||
footer_before = footer.geometry()
|
||||
outer_range_before = body.verticalScrollBar().maximum()
|
||||
horizontal_range_before = body.horizontalScrollBar().maximum()
|
||||
editor.verticalScrollBar().setValue(editor.verticalScrollBar().maximum())
|
||||
|
||||
editor.expand_button.click()
|
||||
_settle_text_layout(application)
|
||||
assert editor.expand_button.text() == "收起"
|
||||
assert editor.height() > 72
|
||||
assert editor.sizeHint().height() == editor.height()
|
||||
assert not editor._height_sync.isActive()
|
||||
assert editor.verticalScrollBar().maximum() == 0
|
||||
assert editor.horizontalScrollBar().maximum() == 0
|
||||
assert not editor.verticalScrollBar().isVisibleTo(dialog)
|
||||
last_block = editor.blockBoundingGeometry(editor.document().lastBlock())
|
||||
assert last_block.translated(editor.contentOffset()).bottom() <= editor.viewport().height()
|
||||
assert body.verticalScrollBar().maximum() > outer_range_before
|
||||
# The existing narrow form has a small minimum-width overflow; expanding
|
||||
# these fields must not increase it or clip their right edge.
|
||||
assert body.horizontalScrollBar().maximum() == horizontal_range_before
|
||||
assert editor.mapTo(body.viewport(), QPoint(editor.width(), 0)).x() <= body.viewport().width()
|
||||
assert footer.geometry() == footer_before
|
||||
assert editor.toPlainText() == text
|
||||
assert not repository.updates
|
||||
|
||||
editor.expand_button.click()
|
||||
_settle_text_layout(application)
|
||||
assert editor.height() == 72
|
||||
assert editor.toPlainText() == text
|
||||
assert editor.expand_button.text() == "展开全部"
|
||||
assert not editor._height_sync.isActive()
|
||||
finally:
|
||||
dialog.close()
|
||||
_settle_text_layout(application)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["symptoms", "current_medications"])
|
||||
def test_expanded_diagnosis_text_reflows_on_resize_and_keeps_edit_save_contract(
|
||||
application: QApplication, key: str
|
||||
) -> None:
|
||||
repository = VisualRepository()
|
||||
original = "自动换行测试内容,保持完整文本。" * 30
|
||||
repository.detail["diagnosis"][key] = original
|
||||
dialog = _open_dialog(application, (1440, 900), mode="edit", repository=repository)
|
||||
try:
|
||||
_settle_text_layout(application)
|
||||
editor = dialog.edit_fields[key]
|
||||
editor.expand_button.click()
|
||||
_settle_text_layout(application)
|
||||
wide_height = editor.height()
|
||||
dialog.resize(1024, 640)
|
||||
_settle_text_layout(application)
|
||||
assert editor.height() > wide_height
|
||||
assert editor.verticalScrollBar().maximum() == 0
|
||||
dialog.resize(1440, 900)
|
||||
_settle_text_layout(application)
|
||||
assert editor.height() == wide_height
|
||||
|
||||
editor.moveCursor(QTextCursor.MoveOperation.End)
|
||||
added = "\n新增测试记录,保存时不能截断。" * 12
|
||||
editor.insertPlainText(added)
|
||||
_settle_text_layout(application)
|
||||
assert editor.height() > wide_height
|
||||
assert editor.verticalScrollBar().maximum() == 0
|
||||
cursor_position = editor.textCursor().position()
|
||||
editor.expand_button.click()
|
||||
editor.expand_button.click()
|
||||
_settle_text_layout(application)
|
||||
assert editor.textCursor().position() == cursor_position
|
||||
editor.undo()
|
||||
_settle_text_layout(application)
|
||||
assert editor.toPlainText() == original
|
||||
assert editor.height() == wide_height
|
||||
editor.redo()
|
||||
editor.expand_button.click()
|
||||
_settle_text_layout(application)
|
||||
dialog._save()
|
||||
assert repository.updates[-1][key] == original + added
|
||||
finally:
|
||||
dialog.close()
|
||||
_settle_text_layout(application)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["symptoms", "current_medications"])
|
||||
def test_short_and_cleared_diagnosis_text_keep_compact_layout(
|
||||
application: QApplication, key: str
|
||||
) -> None:
|
||||
dialog = _open_dialog(application, (1024, 640), mode="edit")
|
||||
try:
|
||||
editor = dialog.edit_fields[key]
|
||||
for text in ("", "简短测试记录"):
|
||||
editor.setPlainText(text)
|
||||
_settle_text_layout(application)
|
||||
assert editor.height() == 72
|
||||
assert not editor.expand_button.isVisibleTo(dialog)
|
||||
editor.insertPlainText("\n很长的测试记录" * 20)
|
||||
_settle_text_layout(application)
|
||||
assert editor.expand_button.isVisibleTo(dialog)
|
||||
editor.expand_button.click()
|
||||
_settle_text_layout(application)
|
||||
editor.selectAll()
|
||||
editor.insertPlainText("")
|
||||
_settle_text_layout(application)
|
||||
assert editor.toPlainText() == ""
|
||||
assert editor.height() == 72
|
||||
assert editor.expand_button.text() == "收起"
|
||||
editor.expand_button.click()
|
||||
_settle_text_layout(application)
|
||||
assert not editor.expand_button.isVisibleTo(dialog)
|
||||
assert not isinstance(dialog.edit_fields["remark"], ExpandableDiagnosisTextEdit)
|
||||
assert not isinstance(dialog.edit_fields["present_illness"], ExpandableDiagnosisTextEdit)
|
||||
finally:
|
||||
dialog.close()
|
||||
_settle_text_layout(application)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["symptoms", "current_medications"])
|
||||
def test_readonly_diagnosis_text_can_expand_and_reopen_starts_collapsed(
|
||||
application: QApplication, key: str
|
||||
) -> None:
|
||||
repository = VisualRepository()
|
||||
text = "只读长文本测试\n" * 20
|
||||
repository.detail["diagnosis"][key] = text
|
||||
dialog = _open_dialog(application, (1024, 640), mode="viewOnly", repository=repository)
|
||||
try:
|
||||
_settle_text_layout(application)
|
||||
editor = dialog.edit_fields[key]
|
||||
assert editor.isReadOnly()
|
||||
assert editor.expand_button.isEnabled()
|
||||
editor.expand_button.click()
|
||||
_settle_text_layout(application)
|
||||
assert editor.height() > 72
|
||||
assert editor.verticalScrollBar().maximum() == 0
|
||||
assert editor.isReadOnly()
|
||||
assert not dialog.save_button.isVisibleTo(dialog)
|
||||
assert not repository.updates
|
||||
dialog.close()
|
||||
dialog.open_view_only(501)
|
||||
_settle_text_layout(application)
|
||||
assert editor.height() == 72
|
||||
assert not editor.expand_button.isChecked()
|
||||
assert editor.toPlainText() == text
|
||||
finally:
|
||||
dialog.close()
|
||||
_settle_text_layout(application)
|
||||
|
||||
|
||||
def test_hpi_choice_chips_are_visible_after_dictionary_load(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
|
||||
@@ -1436,6 +1436,102 @@ def test_im_consult_is_visible_immediately_after_history(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width", [1280, 1494])
|
||||
@pytest.mark.parametrize("permissions", [[], ["*"]])
|
||||
def test_notify_assistant_is_a_visible_header_action_not_a_more_menu_item(
|
||||
application: QApplication,
|
||||
queued_async: list[dict[str, Any]],
|
||||
width: int,
|
||||
permissions: list[str],
|
||||
) -> None:
|
||||
page = ReceptionPage(DemoDoctorRepository(), PermissionSet(permissions))
|
||||
page.resize(width, 760)
|
||||
page.show()
|
||||
try:
|
||||
application.processEvents()
|
||||
# The first show initiates a queue reload and clears the selection.
|
||||
# Present a synthetic selected patient after that initial reset.
|
||||
page.patient_name_label.setText("测试患者")
|
||||
page.patient_meta_label.setText("女 · 42岁 · 138****8000 | 就诊号:31")
|
||||
page.detail_stack.setCurrentIndex(1)
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
hero = page.notify_button.parentWidget()
|
||||
assert hero.objectName() == "ReceptionHero"
|
||||
assert page.notify_button.isVisibleTo(page)
|
||||
assert page.notify_button.text() == "通知医助"
|
||||
assert page.notify_button.width() >= page.notify_button.sizeHint().width()
|
||||
assert page.notify_button.height() > 0
|
||||
assert "通知医助" not in [action.text() for action in page.more_button.menu().actions()]
|
||||
buttons = [
|
||||
button
|
||||
for button in (
|
||||
page.complete_button,
|
||||
page.notify_button,
|
||||
page.history_button,
|
||||
page.video_button,
|
||||
page.more_button,
|
||||
)
|
||||
if button.isVisibleTo(page)
|
||||
]
|
||||
for button in buttons:
|
||||
assert hero.rect().contains(button.geometry())
|
||||
assert button.width() >= button.sizeHint().width()
|
||||
right = button.mapTo(page.detail_scroll.viewport(), QPoint(button.width(), 0)).x()
|
||||
assert right <= page.detail_scroll.viewport().width()
|
||||
for previous, following in zip(buttons, buttons[1:], strict=False):
|
||||
assert previous.geometry().right() < following.geometry().left()
|
||||
finally:
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_notify_header_button_keeps_appointment_context_and_pending_state(
|
||||
application: QApplication,
|
||||
queued_async: list[dict[str, Any]],
|
||||
) -> None:
|
||||
sent: list[int] = []
|
||||
|
||||
class Repository:
|
||||
def notify_assistant(self, appointment_id: int) -> dict[str, bool]:
|
||||
sent.append(appointment_id)
|
||||
return {"success": True}
|
||||
|
||||
page = ReceptionPage(Repository(), PermissionSet([]))
|
||||
try:
|
||||
page._update_action_state({}, {})
|
||||
assert not page.notify_button.isEnabled()
|
||||
page._selected_appointment_id = 31
|
||||
page._selected_record = {"id": 31, "status": 1}
|
||||
page._update_action_state(page._selected_record, {})
|
||||
assert page.notify_button.isEnabled()
|
||||
queued_async.clear()
|
||||
page.notify_button.click()
|
||||
assert not page.notify_button.isEnabled()
|
||||
page.notify_button.click()
|
||||
assert len(queued_async) == 1
|
||||
notification = queued_async.pop()
|
||||
notification["function"]()
|
||||
assert sent == [31]
|
||||
notification["on_finished"]()
|
||||
assert page.notify_button.isEnabled()
|
||||
|
||||
page.notify_button.click()
|
||||
notification = queued_async.pop()
|
||||
page._selected_appointment_id = 32
|
||||
page._selected_record = {"id": 32, "status": 1}
|
||||
page._detail_generation += 1
|
||||
# An old request still targets its original appointment and cannot
|
||||
# re-enable a pending notification for a newly selected patient.
|
||||
notification["function"]()
|
||||
notification["on_finished"]()
|
||||
assert sent == [31, 31]
|
||||
assert not page.notify_button.isEnabled()
|
||||
finally:
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_reception_ai_report_button_follows_permission(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
|
||||
Reference in New Issue
Block a user