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

239 lines
10 KiB
Python

"""Active streams keep parsed prefixes and deliver small deltas without GUI floods."""
from __future__ import annotations
import os
from threading import Event
from time import monotonic
from types import SimpleNamespace
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QEvent, QThreadPool, QTimer
from PySide6.QtGui import QTextBlockUserData, QTextCursor
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.dialogs import ai_consult as module
@pytest.fixture(scope="module")
def application():
return QApplication.instance() or QApplication([])
@pytest.mark.parametrize("width", [1, 7, 127])
@pytest.mark.parametrize("body", [
"# 核对记录\n\n**完整前段**,含中文与 emoji 🩺。\n\n- 第一项\n- 第二项\n\n末尾唯一标记",
"# 合成代码\n\n```text\n第一行\n\n第二行\n```\n\n完整结尾",
"# 合成表格\n\n| 记录 | 数值 |\n| --- | --- |\n| 测试 | 6.8 |\n\n待核对",
])
def test_incremental_markdown_preserves_content_and_terminal_format(application, width, body):
bubble = module._ChatBubble(role="ai", text=module.AI_STREAM_WAITING_TEXT)
full = ""
for offset in range(0, len(body), width):
chunk = body[offset:offset + width]
full += chunk
bubble.append_stream_text(chunk, full)
assert bubble._raw_payload == body
preview = bubble.body.toPlainText()
assert "完整" in preview or "待核对" in preview
bubble.finish_stream_text()
oracle = module._RichMessage("ai")
oracle.set_payload(body)
assert bubble.body.toPlainText() == oracle.toPlainText()
assert bubble.body.toHtml() == oracle.toHtml()
bubble.deleteLater()
oracle.deleteLater()
def test_updates_keep_stable_paragraph_objects_and_user_selection(application, monkeypatch):
browser = module._RichMessage("ai")
browser.append_stream_text("# 已有标题\n\n已有正文,不重新解析。\n\n")
prefix = browser.document().begin()
marker = QTextBlockUserData()
prefix.setUserData(marker)
cursor = browser.textCursor()
cursor.setPosition(0)
cursor.setPosition(4, QTextCursor.MoveMode.KeepAnchor)
browser.setTextCursor(cursor)
selected = browser.textCursor().selectedText()
monkeypatch.setattr(module, "render_chat_payload", lambda *args, **kwargs: pytest.fail("reparsed whole stream"))
for _ in range(90):
browser.append_stream_text("持续新增的一小段。")
assert browser.document().begin().userData() is marker
assert browser.document().begin().text() == "已有标题"
assert browser.textCursor().selectedText() == selected
assert browser.toPlainText().count("持续新增的一小段。") == 90
browser.deleteLater()
def test_long_unfinished_block_is_complete_and_formatted_on_completion(application):
bubble = module._ChatBubble(role="ai", text="")
text = "**长段落** " + "核对来源。" * 1500 + "末尾唯一标记"
full = ""
for offset in range(0, len(text), 73):
chunk = text[offset:offset + 73]
full += chunk
bubble.append_stream_text(chunk, full)
assert bubble.body._stream_tail_plain
assert bubble.body.toPlainText().count("末尾唯一标记") == 1
bubble.finish_stream_text()
assert "**" not in bubble.body.toPlainText()
assert bubble._raw_payload == text
bubble.deleteLater()
def test_partial_render_failure_restores_document_layout(application, monkeypatch):
browser = module._RichMessage("ai")
def fail(*args, **kwargs):
raise ValueError("synthetic format failure")
monkeypatch.setattr(module, "apply_reading_rhythm", fail)
with pytest.raises(ValueError, match="synthetic"):
browser.append_stream_text("合成片段")
assert browser.document().isLayoutEnabled() and not browser._fitting
browser.deleteLater()
@pytest.mark.parametrize("suffix", [" ", "\t", " "])
def test_unterminated_whitespace_is_not_a_stable_paragraph(application, suffix):
first = "正文\n" + suffix
assert module._stable_markdown_prefix(first) == 0
browser = module._RichMessage("ai")
browser.append_stream_text(first)
browser.append_stream_text("续行")
oracle = module._RichMessage("ai")
oracle.set_payload(first + "续行")
assert browser.toPlainText() == oracle.toPlainText()
assert browser.document().blockCount() == oracle.document().blockCount()
browser.deleteLater()
oracle.deleteLater()
def test_heading_size_and_list_spacing_are_stable_during_generation(application):
browser = module._RichMessage("ai")
for text in ["普通前段\n\n", "## 小节标题\n\n", "- 第一条\n- 第二条"]:
browser.append_stream_text(text)
block = browser.document().begin().next()
assert block.text() == "小节标题" and block.blockFormat().headingLevel() == 2
separator = block.next()
assert not separator.text()
assert separator.blockFormat().topMargin() == separator.blockFormat().bottomMargin() == 0
assert separator.blockFormat().lineHeight() == 1
assert "第一条" in browser.toPlainText() and "第二条" in browser.toPlainText()
browser.deleteLater()
def test_long_fence_can_close_after_plain_preview_without_losing_lines(application):
browser = module._RichMessage("ai")
source = "## 标题 🩺\n\n```text\n" + "唯一源码一行\n\n" * 350
browser.append_stream_text(source)
assert browser._stream_tail_plain and browser._stream_tail.startswith("```text")
browser.append_stream_text("```\n\n## 后续小节\n\n唯一结尾")
assert browser.toPlainText().count("唯一源码一行") == 350
assert browser.toPlainText().count("唯一结尾") == 1
assert "```" not in browser.toPlainText()
browser.deleteLater()
@pytest.mark.parametrize("exit_path", ["replace", "patient", "close"])
def test_queued_mailbox_cannot_change_new_request_or_closed_patient(application, monkeypatch, exit_path):
monkeypatch.setattr(module, "QThreadPool", SimpleNamespace(globalInstance=lambda: SimpleNamespace(start=lambda _: None)))
dialog = module.AiConsultDialog(object(), PermissionSet(["tcm.diagnosis/aiAssistant"]))
monkeypatch.setattr(dialog, "_load_workspace", lambda _: None)
dialog.open_for(diagnosis_id=501, patient_id=1501)
dialog._ask("旧问题")
old_worker = dialog._stream_worker
old_worker.signals.blockSignals(True)
old_worker._queue_event({"event": "delta", "text": "旧患者排队的尾段"})
if exit_path == "replace":
dialog._ask("新问题", replace_active=True)
elif exit_path == "patient":
dialog.open_for(diagnosis_id=502, patient_id=1502)
dialog._ask("新问题")
else:
dialog.close()
current = dialog._stream_bubble
expected = current._raw_payload if current is not None else None
old_worker.signals.blockSignals(False)
old_worker.signals.ready.emit()
old_worker._queue_event({"event": "delta", "text": "取消后迟到片段"})
QTest.qWait(10)
assert old_worker.is_cancelled() and old_worker.take_events() == []
if current is not None:
assert current._raw_payload == expected == module.AI_STREAM_PENDING_TEXT
assert dialog._session_closed is (exit_path == "close")
dialog.close()
dialog.deleteLater()
def test_worker_coalesces_thousands_of_deltas_before_the_gui(application):
worker = module._AiStreamWorker(object(), diagnosis_id=501, prompt="合成测试", task="summary")
wakes = []
worker.signals.ready.connect(lambda: wakes.append(True))
worker._queue_event({"event": "start", "model_label": "千问"})
chunks = ["中", "🩺", "\n", " ", "**"] * 2000
for chunk in chunks:
worker._queue_event({"event": "delta", "text": chunk})
worker._queue_event({"event": "done", "answer": "".join(chunks)})
events = worker.take_events()
assert [event["event"] for event in events] == ["start", "delta", "done"]
assert events[1]["text"] == "".join(chunks)
assert len(wakes) == 3 # start, first real text, terminal; independent of chunk count.
assert worker.take_events() == []
worker._queue_event({"event": "delta", "text": "过期片段"})
worker.cancel()
worker._queue_event({"event": "delta", "text": "取消后片段"})
assert worker.take_events() == []
@pytest.mark.parametrize("ending", ["done", "error", "eof"])
def test_paused_upstream_tail_paints_and_terminal_drain_is_lossless(application, monkeypatch, ending):
paused, release = Event(), Event()
class Repository:
def stream_diagnosis_ai(self, *args, **kwargs):
yield {"event": "start", "model_label": "千问"}
yield {"event": "delta", "text": "首段即时显示"}
yield {"event": "delta", "text": ",暂停前的尾段"}
paused.set()
assert release.wait(4)
yield {"event": "delta", "text": ",终态前的最后一字"}
if ending == "done":
yield {"event": "done"}
elif ending == "error":
raise RuntimeError("合成连接中断")
dialog = module.AiConsultDialog(Repository(), PermissionSet(["tcm.diagnosis/aiAssistant"]))
monkeypatch.setattr(dialog, "_load_workspace", lambda _: None)
dialog.open_for(diagnosis_id=501, patient_id=1501)
dialog.show()
try:
dialog._ask("合成小结")
deadline = monotonic() + 2
while (not paused.is_set() or "暂停前的尾段" not in dialog._stream_text) and monotonic() < deadline:
QTest.qWait(10)
assert "暂停前的尾段" in dialog._stream_bubble.body.toPlainText()
assert dialog._asking and not dialog._stream_completed
# A queued GUI action runs while the network worker is paused.
actions = []
QTimer.singleShot(0, lambda: actions.append(True))
QTest.qWait(10)
assert actions == [True]
release.set()
deadline = monotonic() + 2
while dialog._asking and monotonic() < deadline:
QTest.qWait(10)
assert not dialog._asking and dialog.send_button.isEnabled()
assert "终态前的最后一字" in dialog._stream_bubble.body.toPlainText()
assert not dialog._delivery_timer.isActive()
assert dialog._stream_completed is (ending == "done")
finally:
release.set()
for timer in dialog.findChildren(QTimer):
timer.stop()
dialog.close()
QThreadPool.globalInstance().waitForDone(2000)
dialog.deleteLater()
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)