import pytest from django.conf import settings from rest_framework.exceptions import ValidationError from accounts.models import User from math_life.models import ( Story, StoryChoice, StoryMark, StoryRun, StoryVersion, ) from math_life.services import ( get_run_payload, make_choice, score_mathbti, start_story, validate_story_content, ) from math_life.story_v12 import ( EASTER_EGGS, MARK_DEFINITIONS, V12_IDENTITY_SCORES, build_v12_story, ) @pytest.fixture def mathbti_definition(): return { "axes": [{"id": "style"}, {"id": "purpose"}], "scoring": {"axes": ["style", "purpose"], "cutoff": 1}, "questions": [ { "id": 1, "axis": "style", "options": [{"score": 0}, {"score": 2}], }, { "id": 2, "axis": "purpose", "options": [{"score": 0}, {"score": 2}], }, ], } def test_score_mathbti_按轴汇总并生成身份编码(mathbti_definition): code, scores = score_mathbti(mathbti_definition, [1, 0]) assert code == "10" assert scores == {"style": 2, "purpose": 0} def test_score_mathbti_答案数量不一致时拒绝(mathbti_definition): with pytest.raises(ValidationError, match="答案数量"): score_mathbti(mathbti_definition, [1]) def test_score_mathbti_负数选项索引必须拒绝(mathbti_definition): with pytest.raises(ValidationError, match="答案无效"): score_mathbti(mathbti_definition, [-1, 0]) def test_validate_story_content_报告缺失引用与不可达节点(): document = { "start_node": "start", "nodes": { "start": {"choices": [{"text": "错误入口", "next": "missing"}]}, "orphan": {"choices": []}, }, } errors = validate_story_content(document) assert any("不存在的节点 missing" in error for error in errors) assert any("不可达节点: orphan" in error for error in errors) @pytest.fixture def story_setup(db): user = User.objects.create_user( username="story_user", password="StrongPass_2026", nickname="剧情用户", ) story = Story.objects.create(slug="test-story", title="测试人生") version = StoryVersion.objects.create( story=story, version=1, is_published=True, content={ "start_node": "start", "nodes": { "start": { "scene": "起点", "choices": [ { "text": "向左", "next": "left_end", "effects": {"energy": -1, "favorability": {"高斯": 2}}, }, { "text": "向右", "next": "right_end", "effects": {"energy": 2}, }, ], }, "left_end": {"scene": "左结局", "choices": []}, "right_end": {"scene": "右结局", "choices": []}, }, }, ) return user, story, version @pytest.mark.django_db def test_make_choice_应用嵌套效果完成结局且幂等(story_setup): user, story, _ = story_setup started = start_story(user, story) result = make_choice( run_id=started["run_id"], user=user, choice_index=0, idempotency_key="choice-1", ) replay = make_choice( run_id=started["run_id"], user=user, choice_index=0, idempotency_key="choice-1", ) assert result["status"] == StoryRun.Status.COMPLETED assert result["current_node"] == "left_end" assert result["state"] == {"energy": -1, "favorability": {"高斯": 2}} assert replay["current_node"] == "left_end" assert StoryChoice.objects.count() == 1 @pytest.mark.django_db def test_make_choice_负数索引必须拒绝且存档不变(story_setup): user, story, _ = story_setup started = start_story(user, story) with pytest.raises(ValidationError, match="选项不存在"): make_choice( run_id=started["run_id"], user=user, choice_index=-1, idempotency_key="invalid-choice", ) run = StoryRun.objects.get(id=started["run_id"]) assert run.current_node == "start" assert StoryChoice.objects.count() == 0 def v12_story_document(): import json path = settings.PROJECT_ROOT / "docs" / "信仰者线_story.json" return build_v12_story(json.loads(path.read_text(encoding="utf-8"))) def test_v12_story_包含20印记且完全移除旧数值体系(): document = v12_story_document() acquired = { code for node in document["nodes"].values() for choice in node.get("choices", []) for code in choice.get("effects", {}).get("marks", []) } effect_keys = { key for node in document["nodes"].values() for choice in node.get("choices", []) for key in choice.get("effects", {}) } assert len(MARK_DEFINITIONS) == 20 assert acquired == {item["code"] for item in MARK_DEFINITIONS} assert not {"xinzhi", "shuli", "xiayi"} & effect_keys assert validate_story_content(document) == [] def test_v12_数学家五维评分均有高分且不使用过低分(): assert len(V12_IDENTITY_SCORES) == 16 for scores in V12_IDENTITY_SCORES.values(): assert set(scores) == {"眼光", "人文", "侦探", "建模", "联结"} assert min(scores.values()) >= 75 assert max(scores.values()) >= 90 @pytest.mark.django_db def test_v12_story_印记永久收集_彩蛋开放并按领域分发结局(): user = User.objects.create_user( username="marks_story_user", password="StrongPass_2026", nickname="印记玩家", ) story = Story.objects.create(slug="marks-story", title="印记人生") document = v12_story_document() StoryVersion.objects.create( story=story, version=2, is_published=True, content=document, ) started = start_story(user, story) first = make_choice( run_id=started["run_id"], user=user, choice_index=0, idempotency_key="mark-first", ) assert "prime_margin" in first["state"]["marks"] assert StoryMark.objects.filter( user=user, story=story, code="prime_margin", ).exists() run = StoryRun.objects.get(id=started["run_id"]) gauss = next(item for item in EASTER_EGGS if item["code"] == "gauss") run.current_node = gauss["trigger"] run.state = { "marks": gauss["required_marks"], "easter_eggs": [], "mark_counts": { "number_theory": 2, "algebraic_geometry": 0, "analysis": 0, "applied_mathematics": 0, }, } run.save(update_fields=["current_node", "state"]) payload = get_run_payload(run) assert any(choice["special"] for choice in payload["node"]["choices"]) run.current_node = "c8_noether_merge" run.state = { "marks": ["epsilon_promise", "late_draft", "limit_definition"], "easter_eggs": [], "mark_counts": { "number_theory": 0, "algebraic_geometry": 0, "analysis": 3, "applied_mathematics": 0, }, } run.save(update_fields=["current_node", "state"]) ending = make_choice( run_id=run.id, user=user, choice_index=0, idempotency_key="analysis-ending", ) assert ending["status"] == StoryRun.Status.COMPLETED assert ending["current_node"] == "ending_analysis" assert ending["chapter"] == {} assert ending["collection"]