204 lines
5.5 KiB
Python
204 lines
5.5 KiB
Python
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
|