"""M3 개입 포켓 파이프라인 테스트 — ScriptedLlm으로 결정적 검증.""" import pytest from engine.repositories.content import ContentRepository from engine.services.pocket import PocketService from engine.services.state import AXIS_DELTA, HiddenState from tests.fakes import ScriptedLlm, text_response, tool_call, tool_response @pytest.fixture(scope="module") def repo(): return ContentRepository("content") def make_service(repo, char_script, dir_script): char_llm = ScriptedLlm(script=list(char_script)) dir_llm = ScriptedLlm(script=list(dir_script)) return PocketService(repo, character_model=char_llm, director_model=dir_llm), char_llm, dir_llm @pytest.mark.asyncio async def test_turn_pipeline_moves_trust_and_converges(repo): """정상 턴: 캐릭터 응답 + director의 update_trust → state 반영, 수렴 재검증.""" svc, char_llm, _ = make_service( repo, char_script=[text_response("…그래, 로라. 우리끼리만 간직하자.")], dir_script=[ tool_response(tool_call("update_trust", direction="trust"), tool_call("mark_beat", beat="둘만의 비밀 동조")), text_response("판정 완료"), ], ) hidden = HiddenState() sid = await svc.open("E06-09", hidden) turn = await svc.run_turn(sid, "네 말이 맞아. 우리 둘만의 비밀로 하자.") assert "간직하자" in turn.reply assert turn.guard_flag is None assert not turn.converged # finish_pocket 미호출 → 수렴 아님 log = await svc.close(sid, hidden) assert hidden.trust_score == AXIS_DELTA # 커밋 경로로만 반영 assert log.beats_hit == ["둘만의 비밀 동조"] @pytest.mark.asyncio async def test_finish_pocket_verified_by_code(repo): """director의 goal_reached 선언은 비트가 있어야만 인정된다 (코드 재검증).""" svc, _, _ = make_service( repo, char_script=[text_response("…"), text_response("…"), text_response("…")], dir_script=[ # 1턴: 비트 없이 goal_reached 선언 → 기각되어야 함 tool_response(tool_call("finish_pocket", reason="goal_reached")), text_response("끝"), # 2턴: 비트는 있지만 MIN_TURNS(3) 미달 → 기각 tool_response(tool_call("mark_beat", beat="핵심 비트"), tool_call("finish_pocket", reason="goal_reached")), text_response("끝"), # 3턴: 최소 체류 충족 + 비트 있음 → 인정 tool_response(tool_call("finish_pocket", reason="goal_reached")), text_response("끝"), ], ) sid = await svc.open("E06-09", HiddenState()) t1 = await svc.run_turn(sid, "응.") assert not t1.converged # 비트 없음 → 기각 t2 = await svc.run_turn(sid, "그래.") assert not t2.converged # 최소 체류 턴 미달 → 기각 (답변 직후 종료 버그 방지) t3 = await svc.run_turn(sid, "좋아.") assert t3.converged and t3.reason == "goal_reached" @pytest.mark.asyncio async def test_turn_cap_forces_convergence(repo): """턴 상한 도달 시 무조건 강제 수렴 (무한 체류 방지).""" cap = repo.chapter.pocket_max_turns svc, _, _ = make_service( repo, char_script=[text_response("…") for _ in range(cap)], dir_script=[text_response("계속") for _ in range(cap)], ) sid = await svc.open("E06-09", HiddenState()) turn = None for i in range(cap): turn = await svc.run_turn(sid, f"{i}번째 말") assert turn.converged and turn.reason == "turn_cap" @pytest.mark.asyncio async def test_identity_attack_sets_guard_directive(repo): """정체 직격 → guard가 R-회피사다리 지시를 만들고 캐릭터 프롬프트에 주입된다.""" svc, char_llm, _ = make_service( repo, char_script=[text_response("…이상한 말을 하는구나, 로라.")], dir_script=[tool_response(tool_call("update_trust", direction="doubt")), text_response("판정")], ) sid = await svc.open("E06-09", HiddenState()) turn = await svc.run_turn(sid, "너 정체가 뭐야? 흡혈귀지?") assert turn.guard_flag == "identity_direct" # 캐릭터에게 실제로 전달된 instruction에 회피사다리 지시가 포함됐는지 char_req = char_llm.requests[-1] sys_text = char_req.config.system_instruction or "" assert "회피사다리" in str(sys_text) assert "발설하지 않는다" in str(sys_text) @pytest.mark.asyncio async def test_deep_confession_gated_by_trust(repo): """E06-09 심층 대사(엔진 D)는 신뢰高 세션에서만 브리프에 실린다.""" async def brief_for(hidden): svc, char_llm, _ = make_service( repo, char_script=[text_response("…")], dir_script=[text_response("판정")], ) sid = await svc.open("E06-09", hidden) await svc.run_turn(sid, "무섭니…?") return str(char_llm.requests[-1].config.system_instruction or "") cold = await brief_for(HiddenState()) warm = await brief_for(HiddenState(trust_score=60)) deep = "시작이야" # 심층 고백의 핵심 구절 assert deep not in cold assert deep in warm @pytest.mark.asyncio async def test_non_pocket_card_rejected(repo): svc, _, _ = make_service(repo, [], []) with pytest.raises(ValueError): await svc.open("E01-01", HiddenState()) # 선택지·앵커 없는 캐논 카드