"""备课工作流服务测试(LessonPlanWorkflowService). 测试 4 步编排 + 状态机 + 降级路径。 使用 WorkflowStateStore(redis=None) 内存降级模式 + MockProvider + Mock 客户端。 """ import json from unittest.mock import AsyncMock, MagicMock import pytest from src.ai.clients.content_client import ContentClientMock, CreatedQuestion from src.ai.clients.data_ana_client import DataAnaClientMock from src.ai.errors import AIWorkflowNotFoundError, AIWorkflowStateInvalidError from src.ai.models.question import GeneratedQuestionData from src.ai.prompt_service import PromptTemplateService from src.ai.providers import ProviderFailoverChain from src.ai.providers.circuit_breaker import CircuitBreaker from src.ai.services.evaluation import QualityGate, RuleValidator from src.ai.workflow.lesson_plan_workflow import LessonPlanWorkflowService from src.ai.workflow.state_store import WorkflowState, WorkflowStateStore from .conftest import MockProvider # 有效的 LLM JSON 输出(通过三道防线评估) VALID_QUESTION_JSON = json.dumps( { "question": "什么是函数?", "answer": "函数是一种对应关系", "explanation": "函数定义", "difficulty": "medium", "question_type": "short_answer", } ) def _make_chain(provider: MockProvider | None = None) -> ProviderFailoverChain: """构建含单个 MockProvider 的 failover chain.""" return ProviderFailoverChain([provider or MockProvider()], CircuitBreaker()) def _make_state(**overrides: object) -> WorkflowState: """构建测试用 WorkflowState.""" defaults: dict[str, object] = { "user_id": "u-1", "school_id": "s-1", "class_id": "c-1", "subject_id": "math", "topic": "函数", "target_difficulty": "medium", "question_count": 1, } defaults.update(overrides) return WorkflowState(**defaults) # type: ignore[arg-type] def _make_service( store: WorkflowStateStore | None = None, provider: MockProvider | None = None, content_client: object | None = None, data_ana_client: object | None = None, prompt_service: PromptTemplateService | MagicMock | None = None, quality_gate: QualityGate | None = None, ) -> LessonPlanWorkflowService: """构建测试用 LessonPlanWorkflowService.""" return LessonPlanWorkflowService( state_store=store or WorkflowStateStore(redis=None), failover_chain=_make_chain(provider), prompt_service=prompt_service, quality_gate=quality_gate, content_client=content_client, # type: ignore[arg-type] data_ana_client=data_ana_client, # type: ignore[arg-type] ) class TestLessonPlanWorkflowStart: """start / get_status 测试.""" async def test_start_creates_workflow(self) -> None: svc = _make_service(provider=MockProvider(response_content=VALID_QUESTION_JSON)) state = await svc.start( user_id="u-1", school_id="s-1", class_id="c-1", subject_id="math", topic="函数", question_count=1, ) assert state.status == "pending" assert state.workflow_id != "" assert state.topic == "函数" async def test_start_and_wait_for_completion(self) -> None: svc = _make_service( provider=MockProvider(response_content=VALID_QUESTION_JSON), content_client=ContentClientMock(), data_ana_client=DataAnaClientMock(), ) state = await svc.start( user_id="u-1", school_id="s-1", class_id="c-1", subject_id="math", topic="函数", question_count=1, ) assert state.status == "pending" # 等待后台任务完成 task = svc._background_tasks.get(state.workflow_id) assert task is not None await task final = await svc.get_status(state.workflow_id) assert final.status in ("pending_review", "failed") if final.status == "pending_review": assert len(final.questions) == 1 async def test_get_status_not_found_raises(self) -> None: svc = _make_service() with pytest.raises(AIWorkflowNotFoundError): await svc.get_status("nonexistent-id") class TestLessonPlanWorkflowConfirm: """confirm 测试.""" async def test_confirm_wrong_state_raises(self) -> None: store = WorkflowStateStore(redis=None) state = _make_state(status="pending") await store.create(state) svc = _make_service(store=store) with pytest.raises(AIWorkflowStateInvalidError): await svc.confirm(state.workflow_id) async def test_confirm_success(self) -> None: store = WorkflowStateStore(redis=None) state = _make_state( status="pending_review", questions=[ GeneratedQuestionData( question="q1", answer="a1", explanation="e1", question_type="short_answer", difficulty="easy", knowledge_point_ids=["kp_1"], ) ], ) await store.create(state) svc = _make_service(store=store, content_client=ContentClientMock()) result = await svc.confirm(state.workflow_id) assert result["success"] is True assert len(result["persisted_question_ids"]) == 1 # 验证状态更新为 persisted final = await store.get(state.workflow_id) assert final.status == "persisted" async def test_confirm_no_content_client_returns_error(self) -> None: store = WorkflowStateStore(redis=None) state = _make_state( status="pending_review", questions=[GeneratedQuestionData(question="q1", answer="a1", explanation="e1")], ) await store.create(state) svc = _make_service(store=store, content_client=None) result = await svc.confirm(state.workflow_id) assert result["success"] is False assert "content client not configured" in result["error"] async def test_confirm_with_modifications(self) -> None: store = WorkflowStateStore(redis=None) state = _make_state( status="pending_review", questions=[ GeneratedQuestionData( question="original", answer="a1", explanation="e1", question_type="short_answer", difficulty="easy", knowledge_point_ids=["kp_1"], ) ], ) await store.create(state) content_client = AsyncMock() content_client.create_questions.return_value = [ CreatedQuestion(id="q_1", question="modified question"), ] svc = _make_service(store=store, content_client=content_client) result = await svc.confirm( state.workflow_id, modifications={"0": "modified question"}, ) assert result["success"] is True # 验证修改已应用到传入 content_client 的题目 call_args = content_client.create_questions.call_args questions_passed = call_args.args[0] assert questions_passed[0].question == "modified question" class TestLessonPlanWorkflowSteps: """4 步编排内部方法测试.""" async def test_step1_analyze_success(self) -> None: svc = _make_service(data_ana_client=DataAnaClientMock()) state = _make_state() analysis = await svc._step1_analyze(state) assert "class_performance" in analysis assert analysis["class_performance"]["average_score"] == 78.5 assert analysis["class_performance"]["student_count"] == 2 assert "weak_students" in analysis async def test_step1_analyze_no_client_degraded(self) -> None: svc = _make_service(data_ana_client=None) state = _make_state() analysis = await svc._step1_analyze(state) assert analysis["degraded"] is True assert "not configured" in analysis["degraded_reason"] async def test_step2_recommend_success(self) -> None: svc = _make_service(content_client=ContentClientMock()) state = _make_state() kps = await svc._step2_recommend(state) assert len(kps) == 2 assert kps[0]["id"] == "kp_001" async def test_step2_recommend_no_client_fallback(self) -> None: svc = _make_service(content_client=None) state = _make_state(topic="函数") kps = await svc._step2_recommend(state) # 无 content_client 时降级到内置 3 个默认知识点 assert len(kps) == 3 assert "基础概念" in kps[0]["title"] assert "函数" in kps[0]["title"] async def test_step3_generate_success(self) -> None: provider = MockProvider(response_content=VALID_QUESTION_JSON) svc = _make_service( provider=provider, quality_gate=QualityGate(rule_validator=RuleValidator()), ) state = _make_state(question_count=1, target_difficulty="medium") questions = await svc._step3_generate( state, [{"id": "kp_1", "title": "KP1"}], ) assert len(questions) == 1 assert questions[0].question == "什么是函数?" assert questions[0].answer == "函数是一种对应关系" assert questions[0].degraded is False async def test_step3_generate_all_retries_fail(self) -> None: provider = MockProvider(fail=True) svc = _make_service(provider=provider) state = _make_state(question_count=1) questions = await svc._step3_generate( state, [{"id": "kp_1", "title": "KP1"}], ) assert len(questions) == 1 assert questions[0].degraded is True assert "max retries exceeded" in questions[0].degraded_reason class TestLessonPlanWorkflowPrompt: """prompt 渲染测试.""" def test_render_generate_prompt_with_template(self) -> None: prompt_service = MagicMock() prompt_service.render.return_value = "rendered prompt with template" svc = _make_service(prompt_service=prompt_service) state = _make_state() prompt = svc._render_generate_prompt(state, ["kp_1"], 0) assert prompt == "rendered prompt with template" prompt_service.render.assert_called_once() args = prompt_service.render.call_args assert args.args[0] == "lesson_plan_generate" def test_render_generate_prompt_fallback(self) -> None: svc = _make_service(prompt_service=None) state = _make_state(topic="函数", subject_id="math", target_difficulty="medium") prompt = svc._render_generate_prompt(state, ["kp_1"], 0) assert "函数" in prompt assert "math" in prompt assert "medium" in prompt assert "JSON" in prompt