Files
zyt/app/tests/test_ai_consult_rendering_ui.py
2026-10-22 16:42:26 +08:00

255 lines
10 KiB
Python

"""Long replies retain content and chart snapshots while sharing GUI time."""
from __future__ import annotations
import os
from datetime import datetime, timedelta
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QEvent, Qt, QTimer
from PySide6.QtGui import QImage, QPainter, QTextDocument
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QLabel
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.dialogs import ai_consult as module
from doctor_workstation.ui.dialogs.ai_consult_charts import GlucoseReportCard
from doctor_workstation.ui.dialogs.ai_consult_metric_state import (
ABNORMAL_COLOR,
NORMAL_COLOR,
UNKNOWN_COLOR,
)
from doctor_workstation.ui.dialogs.ai_consult_report_visuals import (
build_report_visuals,
create_report_visual,
)
@pytest.fixture(scope="module")
def application():
return QApplication.instance() or QApplication([])
@pytest.mark.parametrize("rich", [False, True])
def test_wrapping_measurements_reuse_parsed_text_and_follow_text_font_width(application, rich):
text = ("核对原始资料及记录日期。" * 70) + "末尾标记"
if rich:
text = f'<div style="line-height:172%">{text}<br><b>末行</b></div>'
label = module._FittedText(text, rich=rich)
changes = []
label._measure_document.contentsChanged.connect(lambda: changes.append(True))
first = label._height_at_width(480)
parses = len(changes)
for _ in range(20):
assert label._height_at_width(480) == first
assert label._height_at_width(240) > first
assert len(changes) == parses # Layout negotiation must not keep reparsing HTML.
label.resize(480, 100)
label._fit()
measured_width = label._measure_document.textWidth()
for hypothetical in range(1, 800, 7):
assert label.heightForWidth(hypothetical) == label.minimumHeight()
assert label._measure_document.textWidth() == measured_width
font = label.font()
font.setPixelSize(22)
label.setFont(font)
changed = label._height_at_width(240)
oracle = QTextDocument()
oracle.setDefaultFont(font)
if rich:
oracle.setHtml(text)
else:
oracle.setPlainText(text)
oracle.setTextWidth(240)
assert changed == int(oracle.size().height()) + 1
label.setText("更短的记录")
assert label._height_at_width(240) < changed
label.deleteLater()
@pytest.mark.parametrize("payload", [
"## 标题\n\n- **重点**:核对原始记录。\n- 完整末行",
"<h2>标题</h2><p>完整末行</p>",
{"summary": "完整末行"}, "",
])
def test_rich_update_restores_document_layout_and_keeps_full_content(application, payload):
browser = module._RichMessage("ai")
browser.resize(420, 200)
browser.set_payload(payload)
assert browser.document().isLayoutEnabled()
if payload:
assert "完整末行" in browser.toPlainText()
assert browser.minimumHeight() >= int(browser.document().size().height())
browser.document().setLayoutEnabled(False)
browser.set_payload(payload)
assert not browser.document().isLayoutEnabled()
browser.deleteLater()
def test_failed_rich_render_does_not_leave_document_layout_disabled(application, monkeypatch):
browser = module._RichMessage("ai")
def fail(*args, **kwargs):
raise ValueError("synthetic render failure")
monkeypatch.setattr(module, "render_chat_payload", fail)
with pytest.raises(ValueError, match="synthetic"):
browser.set_payload("未完成的内容")
assert browser.document().isLayoutEnabled()
assert not browser._fitting
browser.deleteLater()
@pytest.fixture
def dialog(application, monkeypatch):
instance = module.AiConsultDialog(object(), PermissionSet(["tcm.diagnosis/aiAssistant"]))
monkeypatch.setattr(instance, "_load_workspace", lambda _generation: None)
instance.open_for(diagnosis_id=501, patient_id=1501)
instance._workspace_sections = {"tracking": {"value": {"diagnosis_id": 501, "blood_records": [
{"diagnosis_id": 501, "patient_id": 1501, "record_date": "2026-10-17",
"fasting_blood_sugar": 6.2, "fasting_blood_sugar_status": "normal"},
{"diagnosis_id": 501, "patient_id": 1501, "record_date": "2026-10-18",
"fasting_blood_sugar": 8.2, "fasting_blood_sugar_status": "high"},
]}}}
instance.resize(1280, 820)
instance.show()
yield instance
for timer in instance.findChildren(QTimer):
timer.stop()
instance.close()
instance.deleteLater()
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
application.processEvents()
def _complete_long_reply(dialog):
repeated = "核对血糖原始记录及时间,不补写缺失资料。" * 280
reply = ("完整合成资料:空腹血糖 6.2 mmol/L,另一条空腹血糖 8.2 mmol/L,糖化血红蛋白未记录。\n\n"
f"1. 血糖记录核对\n- 来源:{repeated}\n\n2. 随访记录\n- 复核:{repeated}\n- 独有末尾标记")
bubble = dialog._append_bubble("ai", "")
dialog._stream_bubble = bubble
generation, stream = dialog._generation, dialog._stream_generation
for start in range(0, len(reply), 137):
dialog._stream_event(generation, stream, {"event": "delta", "text": reply[start:start + 137]})
assert dialog._pending_chunks # Exercise a large unflushed tail at done.
dialog._stream_event(generation, stream, {"event": "done", "model_label": "千问"})
return bubble, reply, generation, stream
def _wait_for_report(bubble):
for _ in range(600):
if not bubble._report_render_pending:
return
QTest.qWait(5)
pytest.fail("completed report remained pending")
def test_deferred_long_report_keeps_tail_chart_snapshot_and_terminal_semantics(dialog):
bubble, reply, generation, stream = _complete_long_reply(dialog)
assert bubble._report_render_pending and bubble._clinical_panel is None
assert bubble._raw_payload == reply == dialog._stream_text
assert "独有末尾标记" in bubble.body.toPlainText()
assert dialog.send_button.isEnabled() and not dialog._asking
dialog._workspace_sections["tracking"]["value"]["blood_records"][0]["fasting_blood_sugar"] = 99
_wait_for_report(bubble)
assert bubble._clinical_panel is not None
card = bubble.findChild(GlucoseReportCard)
assert card.chart.data.readings[0].fasting == 6.2
intro = bubble._clinical_panel.findChild(QLabel, "AiConsultReportIntroBody").text()
assert all(color in intro for color in (NORMAL_COLOR, ABNORMAL_COLOR, UNKNOWN_COLOR))
dialog._stream_event(generation, stream, {"event": "delta", "text": "迟到内容"})
assert bubble._raw_payload == reply
assert len(bubble.findChildren(GlucoseReportCard)) == 1
@pytest.mark.parametrize("exit_path", ["patient", "close"])
def test_queued_report_cannot_render_after_patient_switch_or_close(dialog, exit_path):
bubble, _, _, _ = _complete_long_reply(dialog)
if exit_path == "patient":
dialog.open_for(diagnosis_id=502, patient_id=1502)
else:
dialog.close()
_wait_for_report(bubble)
assert bubble._clinical_panel is None
@pytest.mark.parametrize("field, changed", [
("patient_id", 1999), ("_chart_patient_id", 1999), ("diagnosis_id", 502),
])
def test_authoritative_owner_change_in_same_generation_cannot_attach_old_charts(dialog, field, changed):
bubble, reply, generation, _ = _complete_long_reply(dialog)
setattr(dialog, field, changed)
assert dialog._generation == generation
_wait_for_report(bubble)
assert bubble._clinical_panel is None
assert bubble.findChild(GlucoseReportCard) is None
assert bubble._raw_payload == reply
def test_new_request_keeps_previous_completed_report_and_its_frozen_records(dialog):
bubble, reply, generation, old_stream = _complete_long_reply(dialog)
dialog._cancel_stream()
current = dialog._append_bubble("ai", "")
dialog._stream_bubble = current
dialog._stream_event(generation, dialog._stream_generation, {"event": "delta", "text": "新的提问回复"})
dialog._stream_event(generation, old_stream, {"event": "done", "answer": "旧请求迟到内容"})
_wait_for_report(bubble)
assert bubble._clinical_panel is not None and bubble._raw_payload == reply
assert current._raw_payload == "新的提问回复"
assert current._clinical_panel is None
@pytest.mark.parametrize("width", [1080, 1600])
def test_deferred_long_report_tail_stays_reachable_after_wrapping(dialog, application, width):
dialog.resize(width, 820)
bubble, _, _, _ = _complete_long_reply(dialog)
_wait_for_report(bubble)
for _ in range(8):
QTest.qWait(5)
dialog.chat_scroll.verticalScrollBar().setValue(dialog.chat_scroll.verticalScrollBar().maximum())
for _ in range(8):
QTest.qWait(5)
tail = bubble._clinical_panel.findChildren(QLabel, "AiConsultReportBody")[-1]
assert "独有末尾标记" in tail.text()
bottom = tail.mapTo(dialog.chat_scroll.viewport(), tail.rect().bottomLeft())
assert dialog.chat_scroll.viewport().rect().contains(bottom)
assert dialog.chat_scroll.horizontalScrollBar().maximum() == 0
def test_trend_paint_and_hover_share_one_geometry_scan(application, monkeypatch):
rows = [
{"diagnosis_id": 501, "patient_id": 1501,
"record_date": (datetime(2026, 8, 1) + timedelta(days=day)).date().isoformat(),
"systolic_pressure": 130 + day % 12, "diastolic_pressure": 80 + day % 8}
for day in range(60)
]
data = build_report_visuals("血压", "", sections={"tracking": {"value": {
"diagnosis_id": 501, "blood_records": rows,
}}}, diagnosis_id=501, patient_id=1501)[0]
card = create_report_visual(data)
plot = card.chart
plot.resize(640, plot.height())
original = plot._geometry
calls = []
def geometry():
calls.append(True)
return original()
monkeypatch.setattr(plot, "_geometry", geometry)
image = QImage(plot.size(), QImage.Format.Format_ARGB32)
image.fill(Qt.GlobalColor.white)
painter = QPainter(image)
try:
plot._paint_trend(painter)
finally:
painter.end()
assert len(calls) == 1
position = plot._position(data.items[0])
calls.clear()
assert data.items[0].value_text in plot._point_tooltip(position)
assert len(calls) == 1
card.deleteLater()