351 lines
13 KiB
Python
351 lines
13 KiB
Python
"""Two rewrites that had to keep answering the same thing — PERF-003/004.
|
|
|
|
Both replaced a Python loop over rows with a query. The risk of that kind
|
|
of change is not that it breaks loudly; it is that it quietly answers
|
|
something slightly different — a player counted twice, an inactive account
|
|
included, a NULL swallowed. So these tests state the answer, not the plan.
|
|
|
|
The query counts are asserted too: the whole point was the number of round
|
|
trips, and nothing else in the suite would notice it creeping back.
|
|
"""
|
|
|
|
from datetime import date, time
|
|
|
|
import pytest
|
|
from sqlalchemy import event
|
|
|
|
from app.extensions import db
|
|
from app.models import Evaluation, PlayerDisponibility, Tryout, TryoutRegistration, User
|
|
|
|
|
|
@pytest.fixture
|
|
def count_queries(app):
|
|
"""Count SELECT statements issued inside the block."""
|
|
|
|
class Counter:
|
|
def __init__(self):
|
|
self.total = 0
|
|
|
|
def _counter():
|
|
counter = Counter()
|
|
|
|
def _before(conn, cursor, statement, parameters, context, executemany):
|
|
if statement.lstrip().upper().startswith('SELECT'):
|
|
counter.total += 1
|
|
|
|
event.listen(db.engine, 'before_cursor_execute', _before)
|
|
counter.stop = lambda: event.remove(db.engine, 'before_cursor_execute', _before)
|
|
return counter
|
|
|
|
return _counter
|
|
|
|
|
|
class TestAvailableAtTime:
|
|
"""get_players_available_at_time ran one query per active player."""
|
|
|
|
@staticmethod
|
|
def _slot(player_id, day, start, end):
|
|
db.session.add(
|
|
PlayerDisponibility(
|
|
player_id=player_id, day_of_week=day, start_time=start, end_time=end
|
|
)
|
|
)
|
|
|
|
def test_a_player_inside_the_window_is_returned(self, app, make_user):
|
|
from app.routes.matches import get_players_available_at_time
|
|
|
|
player_id = make_user('player')
|
|
with app.app_context():
|
|
# 2030-04-01 is a Monday, weekday() == 0.
|
|
self._slot(player_id, 0, time(18, 0), time(21, 0))
|
|
db.session.commit()
|
|
|
|
assert get_players_available_at_time('2030-04-01', '19:00') == [player_id]
|
|
|
|
def test_the_end_of_the_window_is_exclusive(self, app, make_user):
|
|
from app.routes.matches import get_players_available_at_time
|
|
|
|
player_id = make_user('player')
|
|
with app.app_context():
|
|
self._slot(player_id, 0, time(18, 0), time(21, 0))
|
|
db.session.commit()
|
|
|
|
assert get_players_available_at_time('2030-04-01', '21:00') == []
|
|
assert get_players_available_at_time('2030-04-01', '18:00') == [player_id]
|
|
|
|
def test_another_day_does_not_count(self, app, make_user):
|
|
from app.routes.matches import get_players_available_at_time
|
|
|
|
player_id = make_user('player')
|
|
with app.app_context():
|
|
self._slot(player_id, 2, time(18, 0), time(21, 0))
|
|
db.session.commit()
|
|
|
|
assert get_players_available_at_time('2030-04-01', '19:00') == []
|
|
|
|
def test_a_deactivated_account_is_excluded(self, app, make_user):
|
|
from app.routes.matches import get_players_available_at_time
|
|
|
|
player_id = make_user('player')
|
|
with app.app_context():
|
|
db.session.get(User, player_id).is_active_account = False
|
|
self._slot(player_id, 0, time(18, 0), time(21, 0))
|
|
db.session.commit()
|
|
|
|
assert get_players_available_at_time('2030-04-01', '19:00') == []
|
|
|
|
def test_two_overlapping_slots_report_the_player_once(self, app, make_user):
|
|
from app.routes.matches import get_players_available_at_time
|
|
|
|
player_id = make_user('player')
|
|
with app.app_context():
|
|
self._slot(player_id, 0, time(18, 0), time(21, 0))
|
|
self._slot(player_id, 0, time(19, 0), time(22, 0))
|
|
db.session.commit()
|
|
|
|
assert get_players_available_at_time('2030-04-01', '19:30') == [player_id]
|
|
|
|
def test_bad_input_answers_nothing(self, app):
|
|
from app.routes.matches import get_players_available_at_time
|
|
|
|
with app.app_context():
|
|
assert get_players_available_at_time('not-a-date', '19:00') == []
|
|
assert get_players_available_at_time('2030-04-01', '99:99') == []
|
|
|
|
def test_the_cost_does_not_grow_with_the_squad(self, app, make_user, count_queries):
|
|
"""One query per player is what this replaced."""
|
|
player_ids = [make_user('player') for _ in range(6)]
|
|
with app.app_context():
|
|
for player_id in player_ids:
|
|
self._slot(player_id, 0, time(18, 0), time(21, 0))
|
|
db.session.commit()
|
|
|
|
from app.routes.matches import get_players_available_at_time
|
|
|
|
counter = count_queries()
|
|
try:
|
|
result = get_players_available_at_time('2030-04-01', '19:00')
|
|
finally:
|
|
counter.stop()
|
|
|
|
assert sorted(result) == sorted(player_ids)
|
|
# The lower bound matters as much as the upper one: a listener
|
|
# that never fires would make this assertion vacuous.
|
|
assert 1 <= counter.total <= 2, f'{counter.total} SELECTs for 6 players'
|
|
|
|
|
|
class TestPendingEvaluations:
|
|
"""The coach dashboard loaded every registration in the club and every
|
|
evaluation the coach had written, to produce one integer."""
|
|
|
|
@staticmethod
|
|
def _register(player_id, tryout_id, status='registered'):
|
|
db.session.add(TryoutRegistration(tryout_id=tryout_id, player_id=player_id, status=status))
|
|
|
|
@pytest.fixture
|
|
def tryout_id(self, app, make_user):
|
|
with app.app_context():
|
|
tryout = Tryout(
|
|
title='Open', game='Valorant', date=date(2030, 3, 1), created_by=make_user('admin')
|
|
)
|
|
db.session.add(tryout)
|
|
db.session.commit()
|
|
return tryout.id
|
|
|
|
def _pending(self, client):
|
|
"""The number the dashboard renders, read back from the view."""
|
|
from app.routes import main
|
|
|
|
captured = {}
|
|
original = main.render_template
|
|
|
|
def _capture(template, **context):
|
|
captured.update(context)
|
|
return original(template, **context)
|
|
|
|
main.render_template = _capture
|
|
try:
|
|
client.get('/dashboard')
|
|
finally:
|
|
main.render_template = original
|
|
return captured['stats']['pending_evaluations']
|
|
|
|
def test_a_registered_player_is_pending(self, app, client, as_role, make_user, tryout_id):
|
|
player_id = make_user('player')
|
|
as_role('coach')
|
|
with app.app_context():
|
|
self._register(player_id, tryout_id)
|
|
db.session.commit()
|
|
|
|
assert self._pending(client) == 1
|
|
|
|
def test_an_evaluated_player_is_not(self, app, client, as_role, make_user, tryout_id):
|
|
player_id = make_user('player')
|
|
coach_id = as_role('coach')
|
|
with app.app_context():
|
|
self._register(player_id, tryout_id)
|
|
db.session.add(
|
|
Evaluation(player_id=player_id, evaluator_id=coach_id, tryout_id=tryout_id)
|
|
)
|
|
db.session.commit()
|
|
|
|
assert self._pending(client) == 0
|
|
|
|
def test_another_coachs_evaluation_does_not_clear_it(
|
|
self, app, client, as_role, make_user, tryout_id
|
|
):
|
|
"""The tally is per coach: someone else's work is not yours."""
|
|
player_id = make_user('player')
|
|
other_coach = make_user('coach')
|
|
as_role('coach')
|
|
with app.app_context():
|
|
self._register(player_id, tryout_id)
|
|
db.session.add(
|
|
Evaluation(player_id=player_id, evaluator_id=other_coach, tryout_id=tryout_id)
|
|
)
|
|
db.session.commit()
|
|
|
|
assert self._pending(client) == 1
|
|
|
|
def test_a_withdrawn_registration_is_not_counted(
|
|
self, app, client, as_role, make_user, tryout_id
|
|
):
|
|
player_id = make_user('player')
|
|
as_role('coach')
|
|
with app.app_context():
|
|
self._register(player_id, tryout_id, status='withdrawn')
|
|
db.session.commit()
|
|
|
|
assert self._pending(client) == 0
|
|
|
|
def test_a_player_registered_twice_counts_once(
|
|
self, app, client, as_role, make_user, tryout_id
|
|
):
|
|
"""DB-006 has not landed yet, so double registrations are possible;
|
|
the old set() absorbed them and the new count must too."""
|
|
player_id = make_user('player')
|
|
as_role('coach')
|
|
with app.app_context():
|
|
self._register(player_id, tryout_id)
|
|
self._register(player_id, tryout_id, status='attended')
|
|
db.session.commit()
|
|
|
|
assert self._pending(client) == 1
|
|
|
|
|
|
class TestRegisteredPlayersForMatchForm:
|
|
"""The match forms used to issue one or two user lookups per registration."""
|
|
|
|
def test_the_result_is_unique_ordered_and_constant_cost(self, app, make_user, count_queries):
|
|
admin_id = make_user('admin')
|
|
player_ids = [make_user('player', username=name) for name in ('zulu', 'alpha', 'mike')]
|
|
|
|
with app.app_context():
|
|
tryout = Tryout(
|
|
title='Match form',
|
|
game='Valorant',
|
|
date=date(2030, 3, 1),
|
|
created_by=admin_id,
|
|
)
|
|
db.session.add(tryout)
|
|
db.session.flush()
|
|
for player_id in player_ids:
|
|
db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_id))
|
|
# DB-006 is pending, so prove the UI remains unique even when the
|
|
# current database already contains a duplicate registration.
|
|
db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_ids[0]))
|
|
db.session.commit()
|
|
tryout_id = tryout.id
|
|
|
|
from app.routes.matches import registered_players
|
|
|
|
counter = count_queries()
|
|
try:
|
|
players = registered_players(tryout_id)
|
|
finally:
|
|
counter.stop()
|
|
|
|
assert [player.username for player in players] == ['alpha', 'mike', 'zulu']
|
|
assert counter.total == 1
|
|
|
|
|
|
class TestViewTryout:
|
|
"""PERF-001 — the most-visited page in the application ran one query
|
|
per registration, one per player evaluated, one per team, and one per
|
|
team member. Thirty registrants and four teams put it past a hundred
|
|
round trips for a single render."""
|
|
|
|
@pytest.fixture
|
|
def populated_tryout(self, app, make_user):
|
|
"""A tryout with ten registrants split across two teams."""
|
|
from app.models import Match, MatchParticipant, Team, TeamMember
|
|
|
|
admin_id = make_user('admin')
|
|
player_ids = [make_user('player') for _ in range(10)]
|
|
|
|
with app.app_context():
|
|
tryout = Tryout(
|
|
title='Spring', game='Valorant', date=date(2030, 3, 1), created_by=admin_id
|
|
)
|
|
db.session.add(tryout)
|
|
db.session.flush()
|
|
|
|
for player_id in player_ids:
|
|
db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_id))
|
|
|
|
for index in range(2):
|
|
team = Team(tryout_id=tryout.id, name=f'Team {index}', created_by=admin_id)
|
|
db.session.add(team)
|
|
db.session.flush()
|
|
for player_id in player_ids[index * 5 : (index + 1) * 5]:
|
|
db.session.add(TeamMember(team_id=team.id, player_id=player_id))
|
|
|
|
match = Match(
|
|
tryout_id=tryout.id,
|
|
title='Scrim',
|
|
date=date(2030, 3, 2),
|
|
match_type='player_vs_player',
|
|
created_by=admin_id,
|
|
)
|
|
db.session.add(match)
|
|
db.session.flush()
|
|
for side, player_id in enumerate(player_ids[:4]):
|
|
db.session.add(
|
|
MatchParticipant(
|
|
match_id=match.id, player_id=player_id, team_side=(side % 2) + 1
|
|
)
|
|
)
|
|
db.session.commit()
|
|
return tryout.id, admin_id, player_ids
|
|
|
|
def test_the_page_still_shows_everyone(self, app, client, as_role, populated_tryout):
|
|
tryout_id, _admin_id, player_ids = populated_tryout
|
|
as_role('admin')
|
|
|
|
body = client.get(f'/tryouts/{tryout_id}').get_data(as_text=True)
|
|
|
|
with app.app_context():
|
|
usernames = [db.session.get(User, pid).username for pid in player_ids]
|
|
for username in usernames:
|
|
assert username in body, f'{username} is missing from the page'
|
|
|
|
def test_the_cost_does_not_grow_with_the_squad(
|
|
self, app, client, as_role, populated_tryout, count_queries
|
|
):
|
|
tryout_id, _admin_id, _player_ids = populated_tryout
|
|
as_role('admin')
|
|
|
|
with app.app_context():
|
|
counter = count_queries()
|
|
try:
|
|
response = client.get(f'/tryouts/{tryout_id}')
|
|
finally:
|
|
counter.stop()
|
|
|
|
assert response.status_code == 200
|
|
# Ten registrants, two teams of five, one match of four. The old
|
|
# shape spent more than thirty queries on those rows alone; the
|
|
# budget here is deliberately loose but far below that, and it can
|
|
# only be lowered.
|
|
assert 1 <= counter.total <= 25, f'{counter.total} SELECTs to render one tryout'
|