This commit is contained in:
Your Name
2026-10-17 16:08:24 +08:00
parent c67b3eff69
commit 56f85f7d89
57 changed files with 4170 additions and 57 deletions
+355 -2
View File
@@ -2,13 +2,16 @@ from __future__ import annotations
import json
import os
from threading import Event
from time import monotonic
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import httpx
import pytest
from PySide6.QtCore import QPoint, Qt
from PySide6.QtCore import QPoint, Qt, QThread
from PySide6.QtTest import QTest
from PySide6.QtWidgets import (
QApplication,
@@ -21,7 +24,7 @@ from PySide6.QtWidgets import (
)
from doctor_workstation.core import PermissionSet
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.services import ApiClient, DemoDoctorRepository, RemoteDoctorRepository
from doctor_workstation.ui.dialogs import ai_consult as ai_consult_module
from doctor_workstation.ui.dialogs.ai_consult import (
AiConsultDialog,
@@ -1577,6 +1580,356 @@ def test_answered_stream_replaces_the_pending_placeholder(
dialog.close()
def test_stream_clock_covers_preparation_first_text_and_completion(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
dialog = _silent_dialog(monkeypatch)
now = [100.0]
monkeypatch.setattr(ai_consult_module, "monotonic", lambda: now[0])
dialog._ask("请生成问诊小结")
bubble = dialog._stream_bubble
assert bubble is not None
generation, stream_generation = dialog._generation, dialog._stream_generation
assert "已等待 0 秒" in bubble.stamp.text()
assert dialog._stream_elapsed_timer.isActive()
now[0] = 103.0
dialog._stream_elapsed_timer.timeout.emit()
assert "已等待 3 秒" in bubble.stamp.text()
dialog._stream_event(generation, stream_generation, {"event": "start"})
assert "已等待 3 秒" in bubble.stamp.text()
now[0] = 104.2
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "问诊小结"})
# No timer flush or done event is needed to display the first text.
assert bubble._raw_payload == "问诊小结"
assert "生成中" in bubble.stamp.text()
assert "已用时 4 秒" in bubble.stamp.text()
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": ":请补充舌脉。"})
assert dialog._flush_timer.isActive()
now[0] = 108.7
dialog._stream_event(
generation, stream_generation, {"event": "done", "model_label": "千问"}
)
assert bubble._raw_payload == "问诊小结:请补充舌脉。"
assert "千问" in bubble.stamp.text()
assert "首字 4.2 秒" in bubble.stamp.text()
assert "耗时 8.7 秒" in bubble.stamp.text()
assert not dialog._stream_elapsed_timer.isActive()
assert not dialog._flush_timer.isActive()
stamp = bubble.stamp.text()
now[0] = 130.0
dialog._stream_finished(generation, stream_generation, dialog._stream_worker)
dialog._update_stream_elapsed()
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "迟到内容"})
assert bubble.stamp.text() == stamp
assert bubble._raw_payload == "问诊小结:请补充舌脉。"
assert dialog.send_button.isEnabled()
dialog.close()
@pytest.mark.parametrize("ending", ["error", "timeout", "silent", "switch", "close"])
def test_stream_clock_stops_on_every_exit(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
ending: str,
) -> None:
dialog = _silent_dialog(monkeypatch)
now = [100.0]
monkeypatch.setattr(ai_consult_module, "monotonic", lambda: now[0])
dialog._ask("请总结病情")
bubble = dialog._stream_bubble
assert bubble is not None
now[0] = 112.4
generation, stream_generation = dialog._generation, dialog._stream_generation
worker = dialog._stream_worker
if ending == "error":
dialog._stream_failed(generation, stream_generation, RuntimeError("模型繁忙"))
dialog._stream_finished(generation, stream_generation, worker)
elif ending == "timeout":
dialog._stream_timed_out()
elif ending == "silent":
dialog._stream_finished(generation, stream_generation, worker)
elif ending == "switch":
dialog.open_for(diagnosis_id=502, patient_id=302)
else:
dialog.close()
assert not dialog._stream_elapsed_timer.isActive()
assert not dialog._stream_timeout_timer.isActive()
assert not dialog._asking
assert dialog.send_button.isEnabled()
if ending in {"error", "timeout", "silent"}:
assert "耗时 12.4 秒" in bubble.stamp.text()
stamp = bubble.stamp.text()
now[0] = 140.0
dialog._update_stream_elapsed()
assert bubble.stamp.text() == stamp
if ending == "switch":
dialog._ask("新患者的问题")
assert dialog._stream_bubble is not None
assert "已等待 0 秒" in dialog._stream_bubble.stamp.text()
assert dialog._stream_first_delta_at is None
dialog.close()
def test_http_stream_paints_before_upstream_finishes_on_the_ui_thread(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Exercise HTTP parsing, repository, real worker and Qt delivery together."""
release = Event()
waiting_for_end = Event()
class DelayedStream(httpx.SyncByteStream):
def __iter__(self):
first = (
'event: start\r\ndata: {"model_label":"千问"}\r\n\r\n'
'event: delta\r\ndata: {"text":"先收到的内容"}\r\n\r\n'
).encode()
# A network chunk may end partway through a Chinese character.
split = first.index("先".encode()) + 1
yield first[:split]
yield first[split:]
waiting_for_end.set()
if not release.wait(5):
raise AssertionError("UI did not receive the first streamed text")
yield (
'event: delta\r\ndata: {"text":",然后完成。"}\r\n\r\n'
'event: done\r\ndata: {"model_label":"千问"}\r\n\r\n'
).encode()
client = ApiClient(
"https://example.test",
transport=httpx.MockTransport(
lambda request: httpx.Response(
200, headers={"content-type": "text/event-stream"}, stream=DelayedStream()
)
),
)
dialog = AiConsultDialog(RemoteDoctorRepository(client))
dialog.diagnosis_id = 501
dialog._context_notice_shown = True
delivered_on = []
original_event = dialog._stream_event
def record_event(*args: Any) -> None:
delivered_on.append(QThread.currentThread())
original_event(*args)
monkeypatch.setattr(dialog, "_stream_event", record_event)
dialog.show()
try:
dialog._ask("请生成问诊小结")
deadline = monotonic() + 3
while (not dialog._stream_text or not waiting_for_end.is_set()) and monotonic() < deadline:
QTest.qWait(10)
bubble = dialog._stream_bubble
assert bubble is not None and bubble.body is not None
assert waiting_for_end.is_set()
assert bubble.body.toPlainText() == "先收到的内容"
assert not dialog._stream_completed
assert dialog._asking
assert "生成中" in bubble.stamp.text()
release.set()
deadline = monotonic() + 3
while dialog._asking and monotonic() < deadline:
QTest.qWait(10)
assert dialog._stream_completed
assert not dialog._asking
assert bubble.body.toPlainText() == "先收到的内容,然后完成。"
assert delivered_on and all(thread == application.thread() for thread in delivered_on)
assert not dialog._stream_elapsed_timer.isActive()
finally:
release.set()
dialog.close()
client.close()
@pytest.mark.parametrize("terminal", ["done", "error"])
def test_every_quick_tool_can_start_after_terminal_event_before_worker_exit(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
terminal: str,
) -> None:
dialog = _silent_dialog(monkeypatch)
dialog.show()
application.processEvents()
cards = dialog.findChildren(ai_consult_module._ClickCard, "AiConsultToolCard")
assert len(cards) == len(ai_consult_module.TOOL_ITEMS) == 6
previous = []
for card, (_kind, label, prompt) in zip(cards, ai_consult_module.TOOL_ITEMS, strict=True):
# Exercise the text surface the doctor clicks, including event propagation.
target = next(child for child in card.findChildren(QLabel) if child.text() == label)
QTest.mouseClick(target, Qt.MouseButton.LeftButton)
worker = dialog._stream_worker
assert worker is not None and worker.prompt == prompt
assert dialog._asking
generation, stream_generation = dialog._generation, dialog._stream_generation
for old_generation, old_stream_generation, old_worker in previous:
dialog._stream_finished(old_generation, old_stream_generation, old_worker)
assert dialog._stream_worker is worker
assert dialog._asking
previous.append((generation, stream_generation, worker))
if terminal == "done":
dialog._stream_event(generation, stream_generation, {"event": "done", "answer": "已完成"})
else:
dialog._stream_failed(generation, stream_generation, RuntimeError("模型繁忙"))
# The transport may take longer to close; it must not keep the UI locked.
assert not dialog._asking
assert dialog.send_button.isEnabled()
dialog.close()
def test_clicking_another_quick_tool_switches_the_active_request(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
dialog = _silent_dialog(monkeypatch)
notices = []
monkeypatch.setattr(ai_consult_module, "show_toast", lambda *args: notices.append(args[1]))
dialog.show()
application.processEvents()
cards = dialog.findChildren(ai_consult_module._ClickCard, "AiConsultToolCard")
QTest.mouseClick(cards[0], Qt.MouseButton.LeftButton)
old_worker, old_bubble = dialog._stream_worker, dialog._stream_bubble
old_generation, old_stream_generation = dialog._generation, dialog._stream_generation
assert old_worker is not None and old_bubble is not None
dialog._stream_event(old_generation, old_stream_generation, {"event": "delta", "text": "已收到的分析"})
QTest.mouseClick(cards[1], Qt.MouseButton.LeftButton)
worker, bubble = dialog._stream_worker, dialog._stream_bubble
assert worker is not None and worker is not old_worker
assert worker.prompt == ai_consult_module.TOOL_ITEMS[1][2]
assert old_worker.is_cancelled()
assert "已收到的分析" in old_bubble._raw_payload
assert "已停止" in old_bubble._raw_payload
assert bubble is not None and bubble is not old_bubble
assert bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT
assert "已等待 0 秒" in bubble.stamp.text()
assert dialog._asking
dialog._stream_event(old_generation, old_stream_generation, {"event": "done", "answer": "旧结果"})
dialog._stream_failed(old_generation, old_stream_generation, RuntimeError("旧错误"))
dialog._stream_finished(old_generation, old_stream_generation, old_worker)
assert dialog._stream_worker is worker
assert dialog._asking
assert bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT
# Repeated clicks on the same running tool do not submit duplicate requests.
QTest.mouseClick(cards[1], Qt.MouseButton.LeftButton)
assert dialog._stream_worker is worker
assert not worker.is_cancelled()
assert notices and "正在生成" in notices[-1]
dialog.close()
def test_stream_worker_does_not_wait_for_another_event_after_done() -> None:
continued_after_done = []
class DoneThenIdleRepository:
def stream_diagnosis_ai(self, *args: Any, **kwargs: Any):
yield {"event": "done", "answer": "已完成"}
continued_after_done.append(True)
raise AssertionError("the terminal event must end iteration")
worker = ai_consult_module._AiStreamWorker(
DoneThenIdleRepository(), diagnosis_id=501, prompt="请分析", task="custom"
)
events, errors, finished = [], [], []
worker.signals.event.connect(events.append)
worker.signals.error.connect(errors.append)
worker.signals.finished.connect(lambda: finished.append(True))
worker.run()
assert [event["event"] for event in events] == ["done"]
assert continued_after_done == []
assert errors == []
assert finished == [True]
@pytest.mark.parametrize("entry", ["tool", "chip", "menu", "suggestion"])
def test_quick_actions_switch_requests_and_reveal_the_chat(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
entry: str,
) -> None:
dialog = _silent_dialog(monkeypatch)
dialog.show()
application.processEvents()
dialog._ask("之前的请求")
old_worker = dialog._stream_worker
assert old_worker is not None
dialog.tabs.setCurrentIndex(1)
dialog._set_chat_filter("doctor")
if entry == "tool":
target = dialog.findChildren(ai_consult_module._ClickCard, "AiConsultToolCard")[0]
prompt = ai_consult_module.TOOL_ITEMS[0][2]
QTest.mouseClick(target, Qt.MouseButton.LeftButton)
elif entry == "chip":
target = next(button for button in dialog.findChildren(QPushButton) if button.text() == "总结当前病情")
prompt = ai_consult_module.QUICK_PROMPTS[0][1]
target.click()
elif entry == "menu":
more = next(button for button in dialog.findChildren(QPushButton) if button.text() == "更多")
prompt = ai_consult_module.QUICK_PROMPTS[3][1]
more.menu().actions()[0].trigger()
else:
target = dialog.findChildren(ai_consult_module._ClickCard, "AiConsultSuggestRow")[0]
prompt = f"请针对该问题给出问诊话术与判断要点:{ai_consult_module.SUGGESTIONS[0]}"
QTest.mouseClick(target, Qt.MouseButton.LeftButton)
assert old_worker.is_cancelled()
assert dialog._stream_worker is not None and dialog._stream_worker.prompt == prompt
assert dialog.tabs.currentIndex() == dialog.center_stack.currentIndex() == 0
assert dialog._chat_filter == "all"
assert dialog._stream_bubble is not None and dialog._stream_bubble.isVisible()
dialog.close()
@pytest.mark.parametrize("exit_action", ["back", "side", "escape", "accept", "reject", "close"])
def test_all_dialog_exit_paths_cancel_stream_and_allow_a_fresh_session(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
exit_action: str,
) -> None:
dialog = _silent_dialog(monkeypatch)
dialog.show()
application.processEvents()
dialog._ask("请分析病情")
worker = dialog._stream_worker
assert worker is not None
generation, stream_generation = dialog._generation, dialog._stream_generation
if exit_action == "back":
next(button for button in dialog.findChildren(QPushButton) if button.text() == "返回").click()
elif exit_action == "side":
dialog.findChild(QPushButton, "AiConsultSideClose").click()
elif exit_action == "escape":
QTest.keyClick(dialog, Qt.Key.Key_Escape)
else:
getattr(dialog, exit_action)()
assert not dialog.isVisible()
assert worker.is_cancelled()
assert not dialog._asking
assert not dialog._stream_timeout_timer.isActive()
assert not dialog._stream_elapsed_timer.isActive()
closed_generation = dialog._generation
assert closed_generation > generation
dialog.close()
assert dialog._generation == closed_generation
dialog.open_for(diagnosis_id=502, patient_id=302)
dialog.show()
dialog._ask("新患者的问题")
new_worker, bubble = dialog._stream_worker, dialog._stream_bubble
assert new_worker is not None and not new_worker.is_cancelled()
dialog._stream_event(generation, stream_generation, {"event": "done", "answer": "旧患者结果"})
dialog._stream_finished(generation, stream_generation, worker)
assert dialog._stream_worker is new_worker
assert dialog._asking
assert bubble is not None and bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT
dialog.close()
def test_ai_consult_window_can_be_maximized(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,