"""作业 6:应用服务四接口组合测试。 验收演示对应(计划作业 6): 创建会话 -> set_info 写车牌 -> get_info 读回 -> chat 使用车牌继续核实 -> 分组读值 -> 删除 -> 再读与再次聊天均不泄漏旧数据。 spy 断言:set_info / get_info / delete_session 全程零模型调用。 """ import json import pytest from src.backends.chat import ChatResult, TextDelta, FormUpdate from src.services.app_service import AccidentAppService, SET_INFO_INVALID_KEY_MSG from src.services.repository import InMemorySessionRepository class SpyBackend: """记录调用的 ChatBackend 替身;form_update 模拟图产出的字段差量。""" def __init__(self, form_update=None): self.calls = [] # complete 收到的 ChatInput self.stream_calls = [] self._form_update = form_update or {} # 用调用次数当“模型调用”spy:真实 backend 每次 complete 至少一次 LLM self.llm_calls = 0 async def complete(self, chat_input): self.calls.append(chat_input) self.llm_calls += 1 return ChatResult( content="1002收到", status_code=None, form_update=dict(self._form_update), ) async def stream(self, chat_input): self.stream_calls.append(chat_input) self.llm_calls += 1 yield TextDelta("1002") if self._form_update: yield FormUpdate(dict(self._form_update)) yield TextDelta("收到") def make_service(form_update=None): backend = SpyBackend(form_update=form_update) service = AccidentAppService(backend, InMemorySessionRepository()) return service, backend # ---- set/get:白名单、分组、外部表示 ---- def test_set_then_get_single_key_roundtrip(): service, backend = make_service() code, msg = service.set_info("s1", "hphm1", "苏B12345") assert (code, msg) == ("200", "") assert json.loads(service.get_info("s1", "hphm1")) == "苏B12345" # set/get 零模型调用 assert backend.llm_calls == 0 def test_get_groups_match_existing_contract(): service, _ = make_service() service.set_info("s1", "ywrysw", "0") # False service.set_info("s1", "jdcsl", "0") # int 0 service.set_info("s1", "xm2", "张三") acd = json.loads(service.get_info("s1", "acdinfo")) assert acd["ywrysw"] == "0" # False -> "0" assert acd["jdcsl"] == "0" # int 0 -> "0"(内部类型不同) assert acd["sgyy"] == "" # 未知 -> 空串 human2 = json.loads(service.get_info("s1", "acdhuman2")) assert human2["xm2"] == "张三" assert human2["sfzmwh2"] == "" # 全字段齐全,未填为空 all_groups = json.loads(service.get_info("s1", "all")) assert set(all_groups) == {"acdinfo", "acdhuman1", "acdhuman2"} def test_get_unknown_key_returns_json_empty_string(): """兼容现状:未知 key 返回 JSON 空串 '""'。""" service, _ = make_service() service.set_info("s1", "hphm1", "苏B12345") assert service.get_info("s1", "no_such_key") == '""' assert service.get_info("ghost-session", "hphm1") == '""' def test_set_unknown_or_internal_key_rejected(): service, backend = make_service() code, msg = service.set_info("s1", "no_such_key", "x") assert code == "500" and msg == SET_INFO_INVALID_KEY_MSG code, _ = service.set_info("s1", "stage_code", "1002") assert code == "500" # 内部字段保护 assert service.get_info("s1", "hphm1") == '""' # 未被污染 assert backend.llm_calls == 0 def test_set_rejects_unparsable_value(): service, _ = make_service() code, msg = service.set_info("s1", "ywrysw", "maybe") assert code == "500" and "无法解析" in msg # ---- chat:注入 + 写回 ---- @pytest.mark.asyncio async def test_chat_sees_set_info_values_and_writes_back_patch(): """set_info 写入 -> chat 输入注入 -> 图差量写回 -> get_info 读到。""" service, backend = make_service(form_update={"ywrysw": False}) service.set_info("s1", "hphm1", "苏B12345") service.set_info("s1", "xm2", "李四") result = await service.chat( _input("s1", "我是刚才报警的驾驶员") ) assert result.content == "1002收到" # 图输入必须携带 repository 最新业务字段(车牌供核实使用) injected = backend.calls[0].injected_fields assert injected["person_fields"]["hphm1"] == "苏B12345" assert injected["person_fields"]["xm2"] == "李四" # 图产出的差量写回 repository,get_info 立即可读 assert json.loads(service.get_info("s1", "acdinfo"))["ywrysw"] == "0" assert json.loads(service.get_info("s1", "hphm1")) == "苏B12345" @pytest.mark.asyncio async def test_chat_form_patch_mapped_to_business_keys(): """图表单键差量经映射写回业务键(作业 6 映射的端到端)。""" service, backend = make_service(form_update={ "has_injury": False, "vehicle_count": 2, "accident_cause": "追尾", }) await service.chat(_input("s1", "两辆汽车追尾,没有人受伤")) acd = json.loads(service.get_info("s1", "acdinfo")) assert acd["ywrysw"] == "0" assert acd["jdcsl"] == "2" assert acd["sgyy"] == "追尾" # 表单键本身不落 repository(未知业务键 -> JSON 空串) assert json.loads(service.get_info("s1", "has_injury")) == "" @pytest.mark.asyncio async def test_set_info_acd_value_mapped_into_form_injection(): """set_info 写业务键 -> chat 注入为图表单键(反向映射)。""" service, backend = make_service() service.set_info("s1", "ywrysw", "0") service.set_info("s1", "jdcsl", "2") service.set_info("s1", "bjrjs", "1") # 无表单来源,不注入 await service.chat(_input("s1", "继续办理")) injected = backend.calls[0].injected_fields assert injected["accident_fields"] == {"has_injury": False, "vehicle_count": 2} @pytest.mark.asyncio async def test_chat_without_business_state_passes_no_injection(): service, backend = make_service() await service.chat(_input("fresh-session", "你好")) assert backend.calls[0].injected_fields is None @pytest.mark.asyncio async def test_stream_injects_fields_writes_back_form_update(): """流式:注入同 chat;FormUpdate 事件差量捕获写回后透传。""" service, backend = make_service(form_update={"has_injury": False}) service.set_info("s1", "xm1", "王五") events = [ event async for event in service.stream(_input("s1", "继续办理")) ] kinds = [type(event).__name__ for event in events] assert kinds == ["TextDelta", "FormUpdate", "TextDelta"] assert backend.stream_calls[0].injected_fields["person_fields"]["xm1"] == "王五" # FormUpdate 差量已写回 repository(表单键 -> 业务键) assert json.loads(service.get_info("s1", "acdinfo"))["ywrysw"] == "0" # ---- delete:清理 + 幂等 ---- @pytest.mark.asyncio async def test_delete_clears_business_state_and_is_idempotent(): drops = [] async def drop_checkpoint(session_id): drops.append(session_id) service, backend = make_service() service.set_info("s1", "hphm1", "苏B12345") await service.chat(_input("s1", "你好")) await service.delete_session("s1", drop_checkpoint=drop_checkpoint) assert drops == ["s1"] # 再读为空、再聊天不注入旧数据 assert service.get_info("s1", "hphm1") == '""' await service.chat(_input("s1", "还在吗")) assert backend.calls[-1].injected_fields is None # 重复删除固定行为:成功返回;checkpoint 清理会再次触发, # 但缺失时静默容忍(幂等语义由 drop_checkpoint 内部保证) await service.delete_session("s1", drop_checkpoint=drop_checkpoint) assert drops == ["s1", "s1"] assert backend.llm_calls == 2 # 两次 chat 各一次;set/get/delete 零调用 @pytest.mark.asyncio async def test_delete_tolerates_missing_checkpoint(): async def missing(_session_id): raise RuntimeError("no such thread") service, _ = make_service() service.set_info("s1", "xm1", "王五") # checkpoint 不存在视为删除成功的一部分,不向上抛 await service.delete_session("s1", drop_checkpoint=missing) assert service.get_info("s1", "xm1") == '""' def _input(session_id, text): from src.backends.chat import ChatInput return ChatInput(session_id=session_id, text=text, client_mode="direct") # ---- 并发隔离(A2):同会话串行、不同会话并行、delete 与 chat 互斥 ---- import asyncio class SerialProbeBackend: """记录 complete 执行区间的 backend:start/end 事件对可判断是否交错。""" def __init__(self, delay: float = 0.03): self.events: list[tuple[str, str]] = [] self._delay = delay async def complete(self, chat_input): self.events.append(("start", chat_input.text)) await asyncio.sleep(self._delay) self.events.append(("end", chat_input.text)) return ChatResult(content="1002收到") async def stream(self, chat_input): self.events.append(("start", chat_input.text)) await asyncio.sleep(self._delay) self.events.append(("end", chat_input.text)) yield TextDelta("1002收到") def _paired(events: list[tuple[str, str]]) -> bool: """每个 start 与其 end 相邻配对(无交错)。顺序任意。""" stack: list[str] = [] for kind, tag in events: if kind == "start": if stack: return False stack.append(tag) else: if not stack or stack.pop() != tag: return False return not stack @pytest.mark.asyncio async def test_concurrent_chats_same_session_serialize(): """同会话两个并发 chat 必须串行(区间不交错)。""" backend = SerialProbeBackend() service = AccidentAppService(backend, InMemorySessionRepository()) await asyncio.gather( service.chat(_input("s1", "a")), service.chat(_input("s1", "b")), ) assert sorted(e[1] for e in backend.events) == ["a", "a", "b", "b"] assert _paired(backend.events) @pytest.mark.asyncio async def test_concurrent_chats_different_sessions_run_parallel(): """不同会话的 chat 互不阻塞(第二个 start 在第一个 end 之前)。""" backend = SerialProbeBackend(delay=0.05) service = AccidentAppService(backend, InMemorySessionRepository()) await asyncio.gather( service.chat(_input("s1", "a")), service.chat(_input("s2", "b")), ) starts_before_first_end = [ tag for kind, tag in backend.events[:2] if kind == "start" ] assert sorted(starts_before_first_end) == ["a", "b"] @pytest.mark.asyncio async def test_concurrent_delete_serialization(): """在途 chat 与 delete 互斥:delete 等 chat 完成后才执行。""" backend = SerialProbeBackend() service = AccidentAppService(backend, InMemorySessionRepository()) async def noop_drop(_session_id): backend.events.append(("drop", "s1")) await asyncio.gather( service.chat(_input("s1", "a")), service.delete_session("s1", drop_checkpoint=noop_drop), ) # drop 必须出现在 chat 的 end 之后 kinds = [e for e in backend.events] drop_index = next(i for i, e in enumerate(kinds) if e[0] == "drop") end_index = next(i for i, e in enumerate(kinds) if e == ("end", "a")) assert drop_index > end_index @pytest.mark.asyncio async def test_concurrent_chat_and_set_info_no_corruption(): """set_info(同步无锁)与并发 chat 交错:repository 键值保持完整。""" backend = SerialProbeBackend() service = AccidentAppService(backend, InMemorySessionRepository()) results = await asyncio.gather( service.chat(_input("s1", "a")), asyncio.to_thread(service.set_info, "s1", "hphm1", "苏B12345"), ) assert results[1] == ("200", "") # 无论时序如何,最终两个写者之一的车牌值完整存在(无撕裂) assert json.loads(service.get_info("s1", "hphm1")) in ("苏B12345", "")