Files
Hulumath-Web/backend/contest/test_tiebreak.py
T
huluxia 589adfa5e6
CI / test (pull_request) Successful in 4m2s
PR合并自动部署 / release-check (pull_request) Successful in 12s
PR合并自动部署 / deploy (pull_request) Successful in 14s
contest
2026-08-09 16:15:41 +08:00

318 lines
11 KiB
Python

"""端到端模拟两人实时竞赛,覆盖同分不同时长的平局判定场景。"""
from datetime import timedelta
import pytest
from django.test import Client
from django.utils import timezone
from accounts.models import User
from contest.models import (
Contest,
ContestQuestion,
Question,
QuestionVersion,
RealtimeMatch,
)
def _setup_realtime_contest(db, slug="tiebreak-contest", duration=60):
question = Question.objects.create(
slug=f"{slug}-q",
track=Question.Track.STANDARD,
)
version = QuestionVersion.objects.create(
question=question,
version=1,
prompt="18 + 24",
answer="42",
explanation="18 + 24 = 42",
)
contest = Contest.objects.create(
slug=slug,
title="同分决胜测试赛",
kind=Contest.Kind.REALTIME,
track=Question.Track.STANDARD,
status=Contest.Status.PUBLISHED,
duration_seconds=duration,
)
ContestQuestion.objects.create(
contest=contest,
question_version=version,
order=1,
points=100,
)
return contest
def _create_user(username, nickname):
return User.objects.create_user(
username=username,
password="StrongPass_2026",
nickname=nickname,
)
def _client(user):
c = Client()
c.force_login(user)
return c
def _create_and_join(first_client, second_client, contest):
created = first_client.post(
f"/api/v1/contests/{contest.slug}/challenges/",
{},
content_type="application/json",
)
assert created.status_code == 201
code = created.json()["challenge_code"]
joined = second_client.post(
"/api/v1/contests/challenges/join/",
{"challenge_code": code},
content_type="application/json",
)
assert joined.status_code == 200
assert joined.json()["status"] == "active"
match_id = joined.json()["match_id"]
first_state = first_client.get(f"/api/v1/contests/matches/{match_id}/").json()
second_state = second_client.get(f"/api/v1/contests/matches/{match_id}/").json()
return match_id, first_state, second_state
def _set_start(match, user, seconds_ago):
"""统一设置 match 和 attempt 的 started_at。"""
t = timezone.now() - timedelta(seconds=seconds_ago)
match.started_at = t
match.save(update_fields=["started_at"])
match.attempts.filter(user=user).update(started_at=t)
def _submit(client, attempt_id, answer, key):
return client.post(
f"/api/v1/contests/attempts/{attempt_id}/submit/",
{"answers": [{"order": 1, "answer": answer}]},
content_type="application/json",
HTTP_IDEMPOTENCY_KEY=key,
)
@pytest.mark.django_db
def test_同分但一方更快则快者胜(db):
"""两人答对同一题,但提交时间不同,用时短者获胜。"""
contest = _setup_realtime_contest(db, "tiebreak-fast")
first = _create_user("tie_p1", "快方")
second = _create_user("tie_p2", "慢方")
fc, sc = _client(first), _client(second)
match_id, fs, ss = _create_and_join(fc, sc, contest)
fa, sa = fs["attempt"]["attempt_id"], ss["attempt"]["attempt_id"]
match = RealtimeMatch.objects.get(id=match_id)
_set_start(match, first, 5)
r1 = _submit(fc, fa, "42", "tie-fast-one")
assert r1.status_code == 200
_set_start(match, second, 15)
r2 = _submit(sc, sa, "42", "tie-fast-two")
assert r2.status_code == 200
final = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
assert final["status"] == "completed"
assert final["result"]["winner"] == "self"
assert final["attempt"]["score"] == 100
assert final["opponent"]["score"] == 100
assert final["attempt"]["duration_ms"] < final["opponent"]["duration_ms"]
@pytest.mark.django_db
def test_同分且同时长则平局(db):
"""两人答对同一题且用时完全相同,判平局。"""
contest = _setup_realtime_contest(db, "tiebreak-draw")
first = _create_user("draw_p1", "甲")
second = _create_user("draw_p2", "乙")
fc, sc = _client(first), _client(second)
match_id, fs, ss = _create_and_join(fc, sc, contest)
fa, sa = fs["attempt"]["attempt_id"], ss["attempt"]["attempt_id"]
match = RealtimeMatch.objects.get(id=match_id)
_set_start(match, first, 5)
_submit(fc, fa, "42", "draw-one")
_set_start(match, second, 5)
_submit(sc, sa, "42", "draw-two")
final = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
assert final["status"] == "completed"
assert final["result"]["winner"] == "draw"
@pytest.mark.django_db
def test_一方答对一方答错则答对者胜(db):
"""一人答对一人答错,答对者胜(不受时间影响)。"""
contest = _setup_realtime_contest(db, "tiebreak-correct")
first = _create_user("corr_p1", "答对方")
second = _create_user("corr_p2", "答错方")
fc, sc = _client(first), _client(second)
match_id, fs, ss = _create_and_join(fc, sc, contest)
fa, sa = fs["attempt"]["attempt_id"], ss["attempt"]["attempt_id"]
match = RealtimeMatch.objects.get(id=match_id)
_set_start(match, first, 3)
_submit(fc, fa, "42", "corr-one")
_set_start(match, second, 10)
_submit(sc, sa, "0", "corr-two")
final = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
assert final["status"] == "completed"
assert final["result"]["winner"] == "self"
assert final["attempt"]["score"] == 100
assert final["opponent"]["score"] == 0
@pytest.mark.django_db
def test_双方都答错则快者胜(db):
"""两人都答错,分数相同(0 分),比较用时,快者胜。"""
contest = _setup_realtime_contest(db, "tiebreak-both-wrong")
first = _create_user("wrong_p1", "快错")
second = _create_user("wrong_p2", "慢错")
fc, sc = _client(first), _client(second)
match_id, fs, ss = _create_and_join(fc, sc, contest)
fa, sa = fs["attempt"]["attempt_id"], ss["attempt"]["attempt_id"]
match = RealtimeMatch.objects.get(id=match_id)
_set_start(match, first, 5)
_submit(fc, fa, "0", "wrong-one")
_set_start(match, second, 12)
_submit(sc, sa, "0", "wrong-two")
final = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
assert final["status"] == "completed"
assert final["result"]["winner"] == "self"
assert final["attempt"]["score"] == 0
assert final["opponent"]["score"] == 0
@pytest.mark.django_db
def test_超时提交导致分数为零_同分时比较用时(db):
"""一方超时提交(分数为 0),另一方正常提交也得 0 分,同分快者胜。"""
contest = _setup_realtime_contest(db, "tiebreak-timeout", duration=10)
first = _create_user("to_p1", "超时方")
second = _create_user("to_p2", "正常零分方")
fc, sc = _client(first), _client(second)
match_id, fs, ss = _create_and_join(fc, sc, contest)
fa, sa = fs["attempt"]["attempt_id"], ss["attempt"]["attempt_id"]
match = RealtimeMatch.objects.get(id=match_id)
_set_start(match, first, 20)
r1 = _submit(fc, fa, "42", "to-one")
assert r1.status_code == 200
assert r1.json()["attempt"]["status"] == "expired"
_set_start(match, second, 3)
_submit(sc, sa, "0", "to-two")
final = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
assert final["status"] == "completed"
assert final["attempt"]["score"] == 0
assert final["opponent"]["score"] == 0
assert final["result"]["winner"] == "opponent"
@pytest.mark.django_db
def test_随机匹配同分快者胜(db):
"""通过随机匹配(非联机码)也能正确触发同分快者胜。"""
contest = _setup_realtime_contest(db, "tiebreak-random")
first = _create_user("rnd_p1", "随机快")
second = _create_user("rnd_p2", "随机慢")
fc, sc = _client(first), _client(second)
r1 = fc.post(
f"/api/v1/contests/{contest.slug}/matchmaking/",
{},
content_type="application/json",
)
r2 = sc.post(
f"/api/v1/contests/{contest.slug}/matchmaking/",
{},
content_type="application/json",
)
assert r1.json()["status"] == "active" or r2.json()["status"] == "active"
match_id = r1.json().get("match_id") or r2.json()["match_id"]
fs = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
ss = sc.get(f"/api/v1/contests/matches/{match_id}/").json()
fa, sa = fs["attempt"]["attempt_id"], ss["attempt"]["attempt_id"]
match = RealtimeMatch.objects.get(id=match_id)
_set_start(match, first, 4)
_submit(fc, fa, "42", "rnd-one")
_set_start(match, second, 8)
_submit(sc, sa, "42", "rnd-two")
final = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
assert final["status"] == "completed"
assert final["result"]["winner"] == "self"
assert final["attempt"]["score"] == 100
assert final["opponent"]["score"] == 100
@pytest.mark.django_db
def test_rating_变化在同分快者胜时正确(db):
"""同分快者胜时,Elo rating 按正常胜负变化(不平局)。"""
contest = _setup_realtime_contest(db, "tiebreak-rating")
first = _create_user("rate_p1", "Rating快")
second = _create_user("rate_p2", "Rating慢")
fc, sc = _client(first), _client(second)
match_id, fs, ss = _create_and_join(fc, sc, contest)
fa, sa = fs["attempt"]["attempt_id"], ss["attempt"]["attempt_id"]
match = RealtimeMatch.objects.get(id=match_id)
_set_start(match, first, 5)
_submit(fc, fa, "42", "rate-one")
_set_start(match, second, 12)
_submit(sc, sa, "42", "rate-two")
first.refresh_from_db()
second.refresh_from_db()
assert first.rating > 1000
assert second.rating < 1000
@pytest.mark.django_db
def test_双方都超时则由_refresh_自动结算判平(db):
"""双方都超时(分数都为 0),refresh_match_state 自动结算,duration 统一为时限,判平。"""
contest = _setup_realtime_contest(db, "tiebreak-both-timeout", duration=10)
first = _create_user("both_to_p1", "快超时")
second = _create_user("both_to_p2", "慢超时")
fc, sc = _client(first), _client(second)
match_id, _, _ = _create_and_join(fc, sc, contest)
# 两个人都不提交,直接让 match 超时
match = RealtimeMatch.objects.get(id=match_id)
match.started_at = timezone.now() - timedelta(seconds=15)
match.save(update_fields=["started_at"])
match.attempts.update(started_at=match.started_at)
from contest.services import refresh_match_state
refresh_match_state(match.id)
final = fc.get(f"/api/v1/contests/matches/{match_id}/").json()
assert final["status"] == "completed"
assert final["attempt"]["score"] == 0
assert final["opponent"]["score"] == 0
# 双方都超时,duration_ms 相同(都约 15000ms),判平
assert final["result"]["winner"] == "draw"