@@ -0,0 +1,203 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.utils import timezone
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from accounts.models import User
|
||||
from contest.models import (
|
||||
Contest,
|
||||
ContestAttempt,
|
||||
ContestQuestion,
|
||||
Question,
|
||||
QuestionVersion,
|
||||
RatingHistory,
|
||||
RealtimeMatch,
|
||||
)
|
||||
from contest.services import (
|
||||
finalize_match,
|
||||
find_match,
|
||||
normalize_answer,
|
||||
start_attempt,
|
||||
submit_attempt,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(db):
|
||||
return User.objects.create_user(
|
||||
username="contest_user",
|
||||
password="StrongPass_2026",
|
||||
nickname="比赛用户",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def daily_contest(db):
|
||||
question = Question.objects.create(
|
||||
slug="sum-question",
|
||||
track=Question.Track.STANDARD,
|
||||
)
|
||||
version = QuestionVersion.objects.create(
|
||||
question=question,
|
||||
version=1,
|
||||
prompt="17 + 25",
|
||||
answer="42",
|
||||
explanation="相加得 42",
|
||||
)
|
||||
contest = Contest.objects.create(
|
||||
slug="daily-test",
|
||||
title="测试今日赛",
|
||||
kind=Contest.Kind.DAILY,
|
||||
track=Question.Track.STANDARD,
|
||||
status=Contest.Status.PUBLISHED,
|
||||
duration_seconds=60,
|
||||
)
|
||||
ContestQuestion.objects.create(
|
||||
contest=contest,
|
||||
question_version=version,
|
||||
order=1,
|
||||
points=100,
|
||||
)
|
||||
return contest
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw, expected",
|
||||
[
|
||||
pytest.param(" 1.0 ", "1", id="小数标准化"),
|
||||
pytest.param("ABC ", "abc", id="文本去空格并转小写"),
|
||||
pytest.param("-0", "-0", id="保留十进制负零表示"),
|
||||
],
|
||||
)
|
||||
def test_normalize_answer_标准化输入(raw, expected):
|
||||
assert normalize_answer(raw) == expected
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_start_attempt_每日赛重复进入复用同一记录(user, daily_contest):
|
||||
first = start_attempt(user, daily_contest)
|
||||
second = start_attempt(user, daily_contest)
|
||||
|
||||
assert first["attempt_id"] == second["attempt_id"]
|
||||
assert ContestAttempt.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_submit_attempt_服务端判分且幂等重放(user, daily_contest):
|
||||
started = start_attempt(user, daily_contest)
|
||||
|
||||
result = submit_attempt(
|
||||
user,
|
||||
started["attempt_id"],
|
||||
[{"order": 1, "answer": "42.0"}],
|
||||
"submission-1",
|
||||
)
|
||||
replay = submit_attempt(
|
||||
user,
|
||||
started["attempt_id"],
|
||||
[{"order": 1, "answer": "0"}],
|
||||
"submission-1",
|
||||
)
|
||||
|
||||
assert result["status"] == ContestAttempt.Status.SUBMITTED
|
||||
assert result["score"] == 100
|
||||
assert result["correct_count"] == 1
|
||||
assert result["questions"][0]["correct_answer"] == "42"
|
||||
assert replay["score"] == 100
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_submit_attempt_缺少幂等键时拒绝(user, daily_contest):
|
||||
started = start_attempt(user, daily_contest)
|
||||
|
||||
with pytest.raises(ValidationError, match="幂等键"):
|
||||
submit_attempt(
|
||||
user,
|
||||
started["attempt_id"],
|
||||
[{"order": 1, "answer": "42"}],
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_submit_attempt_畸形题号返回校验错误且记录保持进行中(user, daily_contest):
|
||||
started = start_attempt(user, daily_contest)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
submit_attempt(
|
||||
user,
|
||||
started["attempt_id"],
|
||||
[{"order": "first", "answer": "42"}],
|
||||
"malformed-order",
|
||||
)
|
||||
|
||||
attempt = ContestAttempt.objects.get(id=started["attempt_id"])
|
||||
assert attempt.status == ContestAttempt.Status.ACTIVE
|
||||
assert attempt.answers.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_submit_attempt_超过服务端时限不计分(user, daily_contest):
|
||||
started = start_attempt(user, daily_contest)
|
||||
ContestAttempt.objects.filter(id=started["attempt_id"]).update(
|
||||
started_at=timezone.now() - timedelta(seconds=61)
|
||||
)
|
||||
|
||||
result = submit_attempt(
|
||||
user,
|
||||
started["attempt_id"],
|
||||
[{"order": 1, "answer": "42"}],
|
||||
"late-submit",
|
||||
)
|
||||
|
||||
assert result["status"] == ContestAttempt.Status.EXPIRED
|
||||
assert result["score"] == 0
|
||||
assert result["correct_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_find_match_同分玩家配对并由_finalize_match_唯一结算_rating():
|
||||
first = User.objects.create_user(
|
||||
username="player_one",
|
||||
password="StrongPass_2026",
|
||||
nickname="玩家一",
|
||||
)
|
||||
second = User.objects.create_user(
|
||||
username="player_two",
|
||||
password="StrongPass_2026",
|
||||
nickname="玩家二",
|
||||
)
|
||||
contest = Contest.objects.create(
|
||||
slug="realtime-test",
|
||||
title="测试实时赛",
|
||||
kind=Contest.Kind.REALTIME,
|
||||
track=Question.Track.STANDARD,
|
||||
status=Contest.Status.PUBLISHED,
|
||||
)
|
||||
|
||||
waiting = find_match(first, contest)
|
||||
active = find_match(second, contest)
|
||||
active.refresh_from_db()
|
||||
|
||||
assert waiting.id == active.id
|
||||
assert active.status == RealtimeMatch.Status.ACTIVE
|
||||
assert active.attempts.count() == 2
|
||||
|
||||
active.attempts.filter(user=first).update(
|
||||
status=ContestAttempt.Status.SUBMITTED,
|
||||
score=200,
|
||||
)
|
||||
active.attempts.filter(user=second).update(
|
||||
status=ContestAttempt.Status.SUBMITTED,
|
||||
score=100,
|
||||
)
|
||||
finalized = finalize_match(active.id)
|
||||
first.refresh_from_db()
|
||||
second.refresh_from_db()
|
||||
|
||||
assert finalized.status == RealtimeMatch.Status.COMPLETED
|
||||
assert finalized.winner == first
|
||||
assert first.rating == 1016
|
||||
assert second.rating == 984
|
||||
assert RatingHistory.objects.filter(match=active).count() == 2
|
||||
Reference in New Issue
Block a user