178 lines
8.6 KiB
Python
178 lines
8.6 KiB
Python
"""Paint the actual measured source text, including long unbroken record values."""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
from html import escape
|
||
|
||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||
|
||
import pytest
|
||
from PySide6.QtCore import QEvent, Qt, QTimer
|
||
from PySide6.QtGui import QTextCursor, QTextDocument
|
||
from PySide6.QtTest import QTest
|
||
from PySide6.QtWidgets import QApplication, QLabel, QTextBrowser
|
||
|
||
from doctor_workstation.core import PermissionSet
|
||
from doctor_workstation.ui.dialogs import ai_consult as module
|
||
from doctor_workstation.ui.theme import apply_theme
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def application():
|
||
app = QApplication.instance() or QApplication([])
|
||
apply_theme(app)
|
||
return app
|
||
|
||
|
||
@pytest.mark.parametrize("rich", [True, False])
|
||
def test_long_unbroken_tail_uses_actual_paint_document_and_exact_selection(application, rich):
|
||
text = "record_date" * 100 + "TAIL_SENTINEL"
|
||
label = module._FittedText(module._reading_html(text) if rich else text, rich=rich)
|
||
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||
try:
|
||
label.show()
|
||
for width in (528, 240, 760, 528):
|
||
label.resize(width, label.height())
|
||
QTest.qWait(5)
|
||
label._fit()
|
||
view = label._text_view
|
||
document = view.document()
|
||
assert document is label._measure_document
|
||
assert document.toPlainText() == text
|
||
layout = document.firstBlock().layout()
|
||
assert layout.lineCount() > 5
|
||
assert all(layout.lineAt(n).naturalTextWidth() <= view.viewport().width()
|
||
for n in range(layout.lineCount()))
|
||
cursor = QTextCursor(document)
|
||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||
view.setTextCursor(cursor)
|
||
assert view.cursorRect().bottom() < view.viewport().height()
|
||
assert view.verticalScrollBar().maximum() == 0
|
||
image = view.viewport().grab().toImage()
|
||
# The final line actually paints at its document position, rather
|
||
# than a separate QLabel painter centering one clipped long token.
|
||
rect = view.cursorRect()
|
||
assert any(image.pixelColor(x, y).alpha() > 0
|
||
and image.pixelColor(x, y).red() < 180
|
||
for y in range(max(0, rect.top()), min(image.height(), rect.bottom() + 1))
|
||
for x in range(image.width()))
|
||
label.setSelection(len(text) - len("TAIL_SENTINEL"), len("TAIL_SENTINEL"))
|
||
assert label.hasSelectedText() and label.selectedText() == "TAIL_SENTINEL"
|
||
finally:
|
||
label.close()
|
||
label.deleteLater()
|
||
|
||
|
||
@pytest.mark.parametrize("layers", [0, 1, 2])
|
||
def test_source_fields_are_readable_without_rewriting_original_reply(application, layers):
|
||
record = {"record_date": 1775577600, "record_time": "07:30", "fasting_blood_sugar": 6.2,
|
||
"unknown_key": "保留未知字段", "zero": 0, "flag": False, "missing": None,
|
||
"nested": {"note": "<script>保留字面文本</script>;血糖 <7.0 mmol/L"}}
|
||
raw = json.dumps(record, ensure_ascii=False, separators=(",", ":"))
|
||
for _ in range(layers):
|
||
raw = escape(raw)
|
||
original = raw
|
||
rendered = QTextDocument()
|
||
rendered.setHtml(module._reading_html(raw))
|
||
plain = rendered.toPlainText()
|
||
assert "记录日期:2026-04-08" in plain
|
||
assert "记录时间:07:30" in plain
|
||
assert "6.2" in plain and "unknown_key:保留未知字段" in plain
|
||
assert "zero:0" in plain and "flag:否" in plain and "missing:未记录" in plain
|
||
assert "<script>保留字面文本</script>" in plain and "血糖 <7.0 mmol/L" in plain
|
||
assert """ not in plain
|
||
assert raw == original
|
||
|
||
|
||
@pytest.mark.parametrize("raw", [
|
||
'{"record_date":1775577600,"record_date":1775664000,"note":"原文不删"}',
|
||
'{"record_date":1775577600,"note":"未闭合尾部',
|
||
'{"record_date":1775577600,"number":NaN}',
|
||
])
|
||
def test_unsupported_source_stays_literal_and_complete(application, raw):
|
||
rendered = QTextDocument()
|
||
rendered.setHtml(module._reading_html(escape(raw)))
|
||
assert rendered.toPlainText() == raw
|
||
|
||
|
||
def test_source_continuation_keeps_date_role_and_one_owning_bullet():
|
||
record = escape('{"record_date":1775577600,"note":"原记录终点"}')
|
||
raw = ("## 一、原始资料\n- 2026-04-08 · 日常记录原文(非本次建议):\\[\n"
|
||
+ record + "\n\\]\n\n## 二、待核对\n- " + "只核对已采集资料,不添加患者事实。" * 5)
|
||
original = raw
|
||
_, sections, _ = module.parse_structured_report(raw)
|
||
assert len(sections[0].items) == 1
|
||
assert "日常记录原文(非本次建议)" in sections[0].items[0]
|
||
shown = module._report_display_text(sections[0].items[0])
|
||
assert "2026-04-08" in shown and "原记录终点" in shown
|
||
assert r"\[" not in shown and r"\]" not in shown
|
||
assert raw == original
|
||
assert "<7.0" in module._plain_markdown("血糖 <7.0,餐后 >10 mmol/L")
|
||
|
||
|
||
def test_pretty_source_is_grouped_before_markdown_changes_keys_or_values():
|
||
record = {"record_date": 1775577600, "unknown__key": "字面 **内容** 与 __标记__",
|
||
"nested": {"text": "<script>原字面值</script>"}}
|
||
raw = ("## 一、原始资料\n- 2026-04-08 · 日常记录原文:\\[\n"
|
||
+ json.dumps(record, ensure_ascii=False, indent=2)
|
||
+ "\n\\]\n## 二、复核\n- " + "保留资料,不添加患者事实。" * 5)
|
||
original = raw
|
||
_, sections, _ = module.parse_structured_report(raw)
|
||
assert len(sections[0].items) == 1
|
||
shown = QTextDocument()
|
||
shown.setHtml(module._reading_html(sections[0].items[0]))
|
||
assert "unknown__key:字面 **内容** 与 __标记__" in shown.toPlainText()
|
||
assert "<script>原字面值</script>" in shown.toPlainText()
|
||
assert raw == original
|
||
|
||
|
||
@pytest.mark.parametrize("repeat", [1, 100])
|
||
def test_pretty_negative_array_entries_keep_signs_and_do_not_become_bullets(repeat):
|
||
values = ",\n".join([" -1", " -0.25", " -3.5e-06"] * repeat)
|
||
body = '{\n "record_date":1775577600,\n "values":[\n' + values + '\n ],\n "tail":"NEGATIVE_ARRAY_TAIL"\n}'
|
||
raw = ("## 一、资料\n- 日常记录原文:\\[\n" + body
|
||
+ "\n\\]\n- 独立下一项\n## 二、核对\n- " + "保留原数据和日期,不编造。" * 5)
|
||
_, sections, _ = module.parse_structured_report(raw)
|
||
assert len(sections[0].items) == 2
|
||
shown = module._report_display_text(sections[0].items[0])
|
||
assert "-1、-0.25、-3.5e-06" in shown
|
||
assert shown.count("-3.5e-06") == repeat
|
||
assert "NEGATIVE_ARRAY_TAIL" in shown
|
||
assert sections[0].items[1] == "独立下一项"
|
||
|
||
|
||
def test_sidebar_tools_do_not_duplicate_on_report_resize_and_scroll(application, monkeypatch):
|
||
dialog = module.AiConsultDialog(object(), PermissionSet(["tcm.diagnosis/aiAssistant"]))
|
||
monkeypatch.setattr(dialog, "_load_workspace", lambda _: None)
|
||
try:
|
||
dialog.open_for(diagnosis_id=501, patient_id=1501)
|
||
original = "## 一、核对资料\n- " + "完整记录,不编造缺项。" * 100 + "\n## 二、核对结尾\n- 独有结尾"
|
||
bubble = dialog._append_bubble("ai", original)
|
||
assert bubble.finalize_clinical_analysis()
|
||
dialog.show()
|
||
for width, height in ((1280, 820), (1635, 3708), (1080, 680), (1440, 900), (1280, 820)):
|
||
dialog.resize(width, height)
|
||
QTest.qWait(30)
|
||
tools = dialog.findChildren(module._ClickCard, "AiConsultToolCard")
|
||
suggestions = dialog.findChildren(module._ClickCard, "AiConsultSuggestRow")
|
||
assert len(tools) == 6 and len(suggestions) == 5
|
||
for card, (_, text, _) in zip(tools, module.TOOL_ITEMS):
|
||
labels = card.findChildren(QLabel)
|
||
assert sum(label.text() == text for label in labels) == 1
|
||
assert len([label for label in labels if not label.pixmap().isNull()]) == 1
|
||
for value in (0, dialog.chat_scroll.verticalScrollBar().maximum()):
|
||
dialog.chat_scroll.verticalScrollBar().setValue(value)
|
||
application.processEvents()
|
||
assert bubble._raw_payload == original
|
||
assert dialog.findChild(module.QWidget, "AiConsultSideCanvas") is not None
|
||
assert dialog.findChild(module.QWidget, "AiConsultSideViewport") is not None
|
||
assert bubble.findChildren(QTextBrowser, "AiConsultFittedViewport")
|
||
finally:
|
||
for timer in dialog.findChildren(QTimer):
|
||
timer.stop()
|
||
dialog.close()
|
||
dialog.deleteLater()
|
||
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||
application.processEvents()
|