This commit is contained in:
Your Name
2026-08-31 15:17:34 +08:00
parent ed48f8be31
commit 456dd667df
439 changed files with 5720 additions and 422 deletions
@@ -0,0 +1,134 @@
# Doctor Workstation 1.3.0 Windows 正式包构建与安全验证结果
- 执行日期:2026-08-28Asia/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.143 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 kBPyInstaller 报告一个可选 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 包。
+1 -1
View File
@@ -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)
+182 -1
View File
@@ -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:
+96
View File
@@ -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,