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