更新
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user