267 lines
7.7 KiB
Python
267 lines
7.7 KiB
Python
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"]
|