diff --git a/app/routes/evaluations.py b/app/routes/evaluations.py index 83b06ad..66d962f 100644 --- a/app/routes/evaluations.py +++ b/app/routes/evaluations.py @@ -3,11 +3,13 @@ Uses polymorphic isinstance checks instead of role-string comparisons. """ +import math + from flask import Blueprint, flash, redirect, render_template, request, url_for from flask_babel import gettext as _ from flask_login import current_user, login_required from marshmallow import ValidationError -from sqlalchemy import func +from sqlalchemy import false, func, select from sqlalchemy.orm import aliased from app.extensions import db @@ -16,14 +18,18 @@ from app.models import ( EVALUATION_CRITERIA, GAME_POSITIONS, Admin, + Coach, Evaluation, + Manager, Player, + Scout, Tryout, TryoutRegistration, User, ) -from app.pagination import paginate +from app.pagination import DEFAULT_PER_PAGE, MAX_PER_PAGE from app.responders import respond_after_change +from app.time_utils import utc_now_naive from app.validators import EvaluationSchema evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations') @@ -63,95 +69,260 @@ def _apply_evaluation(evaluation, scores, comments, position): evaluation.position_recommendation = position +def _visible_evaluations_query(user): + """Evaluations the acting user is allowed to see, as a query. + + Admins and scouts see everything. Managers and coaches see the + evaluations authored by managers and coaches inside the tryouts they + manage; players never reach the evaluation pages. + """ + if isinstance(user, (Admin, Scout)): + return Evaluation.query + tryout_ids = [t.id for t in user.get_visible_tryouts()] + if not tryout_ids: + return Evaluation.query.filter(false()) + evaluator_alias = aliased(User, name='visible_evaluator') + return ( + Evaluation.query + .join(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id) + .filter(evaluator_alias.role.in_(['manager', 'coach'])) + .filter(Evaluation.tryout_id.in_(tryout_ids)) + ) + + +def _visible_evaluation_ids(user): + """A ``select`` of the ids of evaluations the acting user may see.""" + ids = _visible_evaluations_query(user).with_entities(Evaluation.id).subquery() + return select(ids.c.id) + + +def _average(values): + """Mean of the non-None values, rounded to one decimal (or None).""" + values = [value for value in values if value is not None] + if not values: + return None + return round(sum(values) / len(values), 1) + + +def _aggregate(evaluations): + """Per-criterion averages plus a combined overall average.""" + criterion_averages = [ + (label, _average([getattr(e, field) for e in evaluations])) + for field, label in EVALUATION_CRITERIA + ] + return { + 'criterion_averages': criterion_averages, + 'combined_average': _average([e.overall_score for e in evaluations]), + 'count': len(evaluations), + } + + +def _evaluation_view(evaluation, evaluator, tryout): + """Shape an Evaluation for the templates (scores in criterion order).""" + return { + 'evaluation': evaluation, + 'evaluator': evaluator, + 'tryout': tryout, + 'scores': [getattr(evaluation, field) for field, _ in EVALUATION_CRITERIA], + 'overall': evaluation.overall_score, + 'position': evaluation.position_recommendation, + 'comment': evaluation.comments, + 'date': evaluation.created_at, + } + + +def _players_by_average(user): + """Players with visible evaluations, best combined average first.""" + visible_ids = _visible_evaluation_ids(user) + rows = ( + db.session.query( + Evaluation.player_id, + func.avg(Evaluation.overall_score).label('avg_score'), + ) + .filter(Evaluation.id.in_(visible_ids)) + .group_by(Evaluation.player_id) + .all() + ) + players_by_id = _users_by_id(row.player_id for row in rows) + ordered = [ + (players_by_id[row.player_id], row.avg_score) + for row in rows + if row.player_id in players_by_id and isinstance(players_by_id[row.player_id], Player) + ] + ordered.sort( + key=lambda pair: ( + pair[1] is None, + -(pair[1] if pair[1] is not None else 0), + pair[0].username.lower(), + ) + ) + return ordered + + @evaluations_bp.route('') @login_required def list_evaluations(): - """List all evaluations accessible to the current user.""" + """List player evaluations grouped by player, with a per-player view.""" user = current_user if isinstance(user, Player): flash(_('You do not have permission to view evaluations.'), 'danger') return redirect(url_for('main.dashboard')) - sort_column = request.args.get('sort', 'created_at') + sort_column = request.args.get('sort', 'overall_score') sort_order = request.args.get('order', 'desc') if sort_order not in ('asc', 'desc'): sort_order = 'desc' - player_alias = aliased(User, name='eval_player') - evaluator_alias = aliased(User, name='eval_evaluator') + visible_ids = _visible_evaluation_ids(user) - sort_map = { - 'tryout': Tryout.title, - 'player': player_alias.username, - 'evaluator': evaluator_alias.username, - 'mecanics_score': Evaluation.mecanics_score, - 'cohesion_score': Evaluation.cohesion_score, - 'communication_score': Evaluation.communication_score, - 'gamesense_score': Evaluation.gamesense_score, - 'versatility_score': Evaluation.versatility_score, - 'discipline_score': Evaluation.discipline_score, - 'analysis_score': Evaluation.analysis_score, - 'sport_ethics_score': Evaluation.sport_ethics_score, - 'mental_score': Evaluation.mental_score, - 'overall_score': Evaluation.overall_score, - 'position_recommendation': Evaluation.position_recommendation, - 'created_at': Evaluation.created_at, - } - - sort_expr = sort_map.get(sort_column, Evaluation.created_at) - if sort_order == 'asc': - sort_expr = sort_expr.asc() - else: - sort_expr = sort_expr.desc() - - if isinstance(user, Admin): - evaluations_page = paginate( - Evaluation.query.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) - .outerjoin(player_alias, Evaluation.player_id == player_alias.id) - .outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id) - .order_by(sort_expr, Evaluation.id) + aggregate = ( + db.session.query( + Evaluation.player_id, + func.count(Evaluation.id).label('eval_count'), + func.avg(Evaluation.overall_score).label('avg_score'), + func.max(Evaluation.created_at).label('last_eval'), ) - avg_scores = ( - db.session.query( - Evaluation.player_id, - func.count(Evaluation.id).label('eval_count'), - func.avg(Evaluation.overall_score).label('avg_score'), - ) - .group_by(Evaluation.player_id) + .filter(Evaluation.id.in_(visible_ids)) + .group_by(Evaluation.player_id) + .all() + ) + + players_by_id = _users_by_id(row.player_id for row in aggregate) + grouped = [] + for row in aggregate: + player = players_by_id.get(row.player_id) + if not player or not isinstance(player, Player): + continue + grouped.append( + { + 'player': player, + 'avg': round(row.avg_score, 1) if row.avg_score is not None else None, + 'count': row.eval_count, + 'last_eval': row.last_eval, + 'evaluations': [], + } + ) + + def sort_value(entry): + if sort_column == 'player': + return entry['player'].username.lower() + if sort_column == 'count': + return entry['count'] + if sort_column == 'created_at': + return entry['last_eval'] or utc_now_naive() + return entry['avg'] if entry['avg'] is not None else float('-inf') + + grouped.sort(key=sort_value, reverse=(sort_order == 'desc')) + + page = request.args.get('page', 1, type=int) or 1 + page = max(1, page) + per_page = request.args.get('per_page', DEFAULT_PER_PAGE, type=int) or DEFAULT_PER_PAGE + per_page = max(1, min(per_page, MAX_PER_PAGE)) + + total = len(grouped) + pages = math.ceil(total / per_page) if total else 1 + page = min(page, pages) + start = (page - 1) * per_page + page_items = grouped[start:start + per_page] + + players_on_page = {entry['player'].id: entry for entry in page_items} + if players_on_page: + evaluations = ( + Evaluation.query + .filter(Evaluation.player_id.in_(players_on_page)) + .filter(Evaluation.id.in_(visible_ids)) + .order_by(Evaluation.created_at.desc(), Evaluation.id.desc()) .all() ) - players_by_id = _users_by_id(row.player_id for row in avg_scores) - player_scores = {} - for row in avg_scores: - p = players_by_id.get(row.player_id) - if p: - player_scores[p.id] = { - 'player': p, - 'count': row.eval_count, - 'avg': round(row.avg_score, 1) if row.avg_score else 0, - } - else: - # Everyone still here evaluates: players were redirected above, and - # can_evaluate() is true for the four remaining roles. The former - # `else` branch listed evaluations *received* — a player's view, - # unreachable from this point (ARCH-007). - evaluations_page = paginate( - Evaluation.query.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) - .outerjoin(player_alias, Evaluation.player_id == player_alias.id) - .outerjoin(evaluator_alias, Evaluation.evaluator_id == evaluator_alias.id) - .filter(Evaluation.evaluator_id == user.id) - .order_by(sort_expr, Evaluation.id) + evaluators_by_id = _users_by_id(e.evaluator_id for e in evaluations) + tryout_ids = [e.tryout_id for e in evaluations if e.tryout_id] + tryouts_by_id = ( + {t.id: t for t in Tryout.query.filter(Tryout.id.in_(tryout_ids)).all()} + if tryout_ids + else {} ) - player_scores = {} + for evaluation in evaluations: + players_on_page[evaluation.player_id]['evaluations'].append( + _evaluation_view( + evaluation, + evaluators_by_id.get(evaluation.evaluator_id), + tryouts_by_id.get(evaluation.tryout_id), + ) + ) + + pagination = { + 'page': page, + 'pages': pages, + 'total': total, + 'has_prev': page > 1, + 'has_next': page < pages, + 'prev_num': page - 1, + 'next_num': page + 1, + } return render_template( 'pages/evaluations.html', - evaluations=evaluations_page.items, - pagination=evaluations_page, - player_scores=player_scores, + players=page_items, + pagination=pagination, sort_column=sort_column, sort_order=sort_order, + evaluation_criteria=EVALUATION_CRITERIA, + first_player_id=grouped[0]['player'].id if grouped else None, + ) + + +@evaluations_bp.route('/player/') +@login_required +def player_evaluations(player_id): + """One player's evaluations, with prev/next navigation between players.""" + user = current_user + if not user.can_evaluate(): + flash(_('You do not have permission to view evaluations.'), 'danger') + return redirect(url_for('main.dashboard')) + + player = db.get_or_404(User, player_id) + if not isinstance(player, Player): + flash(_('Can only view evaluations for players.'), 'danger') + return redirect(url_for('evaluations.list_evaluations')) + + ordered = _players_by_average(user) + index = next((i for i, (p, _avg) in enumerate(ordered) if p.id == player.id), None) + prev_player = ordered[index - 1][0] if index is not None and index > 0 else None + next_player = ordered[index + 1][0] if index is not None and index + 1 < len(ordered) else None + + visible_ids = _visible_evaluation_ids(user) + evaluations = ( + Evaluation.query + .filter(Evaluation.player_id == player.id) + .filter(Evaluation.id.in_(visible_ids)) + .order_by(Evaluation.created_at.desc(), Evaluation.id.desc()) + .all() + ) + evaluators_by_id = _users_by_id(e.evaluator_id for e in evaluations) + tryout_ids = [e.tryout_id for e in evaluations if e.tryout_id] + tryouts_by_id = ( + {t.id: t for t in Tryout.query.filter(Tryout.id.in_(tryout_ids)).all()} + if tryout_ids + else {} + ) + evaluation_views = [ + _evaluation_view(e, evaluators_by_id.get(e.evaluator_id), tryouts_by_id.get(e.tryout_id)) + for e in evaluations + ] + + aggregates = _aggregate(evaluations) + + return render_template( + 'pages/player_evaluations.html', + player=player, + evaluations=evaluation_views, + prev_player=prev_player, + next_player=next_player, + criterion_averages=aggregates['criterion_averages'], + combined_average=aggregates['combined_average'], + evaluation_count=aggregates['count'], + evaluation_criteria=EVALUATION_CRITERIA, ) diff --git a/app/templates/pages/evaluations.html b/app/templates/pages/evaluations.html index 3b1b4e2..ac4812f 100644 --- a/app/templates/pages/evaluations.html +++ b/app/templates/pages/evaluations.html @@ -5,7 +5,7 @@ {% block breadcrumb %}Home / Evaluations{% endblock %} {% block content %} -{% set sort_column = sort_column | default('created_at') %} +{% set sort_column = sort_column | default('overall_score') %} {% set sort_order = sort_order | default('desc') %} {% macro sort_link(column, label) %} @@ -22,76 +22,93 @@ {% endmacro %} -{% if current_user.role == 'admin' and player_scores %} -
- {% for pid, data in player_scores.items() %} -
-
- -
-
-

{{ data.player.username[:12] }}

-

Avg: {{ data.avg }} / {{ data.count }} evals

-
-
- {% endfor %} +
+ + {{ _('Table') }} + + {% if first_player_id %} + + {{ _('By player') }} + + {% endif %}
-{% endif %}
-

- {% if current_user.role == 'player' %} - My Evaluations - {% else %} - All Evaluations - {% endif %} -

+

{{ _('Player Evaluations') }}

- {{ sort_link('tryout', 'Tryout') }} + {{ sort_link('player', 'Player') }} - {{ sort_link('evaluator', 'Evaluator') }} - {{ sort_link('mecanics_score', 'Mecanics') }} - {{ sort_link('cohesion_score', 'Cohesion') }} - {{ sort_link('communication_score', 'Communication') }} - {{ sort_link('gamesense_score', 'Gamesense') }} - {{ sort_link('versatility_score', 'Versatility') }} - {{ sort_link('discipline_score', 'Discipline') }} - {{ sort_link('analysis_score', 'Analysis') }} - {{ sort_link('sport_ethics_score', 'Sport Ethics') }} - {{ sort_link('mental_score', 'Mental') }} - {{ sort_link('overall_score', 'Overall') }} - {{ sort_link('position_recommendation', 'Position') }} - {{ sort_link('created_at', 'Date') }} + {{ sort_link('overall_score', 'Average') }} + {{ sort_link('count', 'Evaluations') }} + - {% for eval in evaluations %} - - - - - - - - - - - - - - - - + {% for entry in players %} + + + + + + + + + {% else %} -
{{ _('Open') }}
{{ eval.tryout.title if eval.tryout else 'Deleted Tryout' }}{{ eval.player.username if eval.player else 'Deleted Player' }}{{ eval.evaluator.username if eval.evaluator else 'Deleted Evaluator' }}{{ eval.mecanics_score or '-' }}{{ eval.cohesion_score or '-' }}{{ eval.communication_score or '-' }}{{ eval.gamesense_score or '-' }}{{ eval.versatility_score or '-' }}{{ eval.discipline_score or '-' }}{{ eval.analysis_score or '-' }}{{ eval.sport_ethics_score or '-' }}{{ eval.mental_score or '-' }}{{ eval.overall_score or '-' }}{{ eval.position_recommendation or 'N/A' }}{{ eval.created_at.strftime('%m/%d/%Y') }}
+ + {{ entry.player.username }} + + {{ entry.avg if entry.avg is not none else '-' }}{{ entry.count }} + + + +
+

{{ _('No evaluations yet') }}

@@ -106,3 +123,37 @@
{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/app/templates/pages/player_evaluations.html b/app/templates/pages/player_evaluations.html new file mode 100644 index 0000000..8a05f81 --- /dev/null +++ b/app/templates/pages/player_evaluations.html @@ -0,0 +1,108 @@ +{% extends "layouts/base.html" %} +{% block title %}{{ player.username }} - {{ _('Evaluations') }} - UdeS team manager{% endblock %} +{% block page_title %}{{ _('Player Evaluations') }}{% endblock %} +{% block breadcrumb %}Home / {{ _('Evaluations') }} / {{ player.username }}{% endblock %} + +{% block content %} + + +
+
+

{{ player.username }}

+
+ {% if prev_player %} + + {{ prev_player.username }} + + {% else %} + + {% endif %} + {% if next_player %} + + {{ next_player.username }} + + {% else %} + + {% endif %} +
+
+
+
+
+
+
+

{{ combined_average if combined_average is not none else '-' }}

+

{{ _('Combined average') }} · {{ evaluation_count }} {{ _('evaluations') }}

+
+
+ {% for label, avg in criterion_averages %} +
+
+
+

{{ avg if avg is not none else '-' }}

+

{{ label }}

+
+
+ {% endfor %} +
+ +
+ + + + + + {% for field, label in evaluation_criteria %} + + {% endfor %} + + + + + + + + {% for ev in evaluations %} + + + + {% for score in ev.scores %} + + {% endfor %} + + + + + + {% else %} + + + + {% endfor %} + +
{{ _('Evaluator') }}{{ _('Tryout') }}{{ label }}{{ _('Overall') }}{{ _('Position') }}{{ _('Comment') }}{{ _('Date') }}
{{ ev.evaluator.username if ev.evaluator else _('Deleted evaluator') }}{{ ev.tryout.title if ev.tryout else _('Deleted tryout') }}{{ score if score is not none else '-' }}{{ ev.overall if ev.overall is not none else '-' }}{{ ev.position or '-' }}{{ ev.comment or '-' }}{{ ev.date.strftime('%m/%d/%Y') if ev.date else '-' }}
+
+ +

{{ _('No evaluations for this player yet') }}

+
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/tests/test_player_evaluations.py b/tests/test_player_evaluations.py new file mode 100644 index 0000000..f1048f1 --- /dev/null +++ b/tests/test_player_evaluations.py @@ -0,0 +1,248 @@ +"""Player-specific evaluation views (the "By player" toggle and its page). + +These pages regroup the flat evaluation table by player: one row per player in +the table view, and a dedicated page per player with left/right navigation and +per-criterion averages. Visibility follows the viewer's role — managers and +coaches see staff-authored evaluations inside their own tryouts. +""" + +from datetime import date + +import pytest + +from app.extensions import db +from app.models import Evaluation, Tryout + + +@pytest.fixture +def staff_setup(app, as_role, make_user): + """A coach who manages one tryout, plus staff, a player, and a foreign tryout.""" + coach_id = as_role('coach', username='viewer_coach') + manager_id = make_user('manager', username='other_manager') + admin_id = make_user('admin', username='the_admin') + player_id = make_user('player', username='the_player') + outsider_id = make_user('coach', username='outsider_coach') + + with app.app_context(): + tryout = Tryout( + title='Spring', + game='Valorant', + date=date(2030, 4, 1), + created_by=coach_id, + coach_id=coach_id, + ) + db.session.add(tryout) + db.session.flush() + + other_tryout = Tryout( + title='Other', + game='Valorant', + date=date(2030, 5, 1), + created_by=outsider_id, + ) + db.session.add(other_tryout) + db.session.commit() + + return { + 'coach_id': coach_id, + 'manager_id': manager_id, + 'admin_id': admin_id, + 'player_id': player_id, + 'tryout_id': tryout.id, + 'other_tryout_id': other_tryout.id, + } + + +class TestVisibility: + def test_coach_sees_staff_evaluations_only_in_their_tryout(self, app, client, staff_setup): + with app.app_context(): + db.session.add( + Evaluation( + tryout_id=staff_setup['tryout_id'], + player_id=staff_setup['player_id'], + evaluator_id=staff_setup['manager_id'], + overall_score=7, + comments='manager in own tryout', + ) + ) + db.session.add( + Evaluation( + tryout_id=staff_setup['tryout_id'], + player_id=staff_setup['player_id'], + evaluator_id=staff_setup['admin_id'], + overall_score=5, + comments='admin authored', + ) + ) + db.session.add( + Evaluation( + tryout_id=staff_setup['other_tryout_id'], + player_id=staff_setup['player_id'], + evaluator_id=staff_setup['manager_id'], + overall_score=9, + comments='manager other tryout', + ) + ) + db.session.commit() + + body = client.get(f"/evaluations/player/{staff_setup['player_id']}").get_data(as_text=True) + + assert 'manager in own tryout' in body + assert 'admin authored' not in body + assert 'manager other tryout' not in body + + def test_admin_sees_every_evaluation(self, app, client, as_role, make_user): + admin_id = as_role('admin', username='boss') + coach_id = make_user('coach', username='some_coach') + player_id = make_user('player', username='star') + + with app.app_context(): + tryout = Tryout( + title='T', + game='Valorant', + date=date(2030, 1, 1), + created_by=admin_id, + ) + db.session.add(tryout) + db.session.flush() + db.session.add( + Evaluation( + tryout_id=tryout.id, + player_id=player_id, + evaluator_id=coach_id, + overall_score=6, + comments='coach comment', + ) + ) + db.session.add( + Evaluation( + tryout_id=tryout.id, + player_id=player_id, + evaluator_id=admin_id, + overall_score=4, + comments='admin comment', + ) + ) + db.session.commit() + + body = client.get(f'/evaluations/player/{player_id}').get_data(as_text=True) + + assert 'coach comment' in body + assert 'admin comment' in body + + def test_a_player_cannot_open_the_player_page(self, client, as_role): + player_id = as_role('player') + response = client.get(f'/evaluations/player/{player_id}', follow_redirects=False) + assert response.status_code in (301, 302) + + +class TestAggregates: + def test_combined_and_per_criterion_averages(self, app, client, as_role, make_user): + coach_id = as_role('coach', username='agg_coach') + coach2_id = make_user('coach', username='agg_coach2') + player_id = make_user('player', username='agg_player') + + with app.app_context(): + tryout = Tryout( + title='Agg', + game='Valorant', + date=date(2030, 1, 1), + created_by=coach_id, + coach_id=coach_id, + ) + db.session.add(tryout) + db.session.flush() + first = Evaluation(tryout_id=tryout.id, player_id=player_id, evaluator_id=coach_id) + first.apply_scores({'mecanics_score': 8, 'mental_score': 6}) + second = Evaluation(tryout_id=tryout.id, player_id=player_id, evaluator_id=coach2_id) + second.apply_scores({'mecanics_score': 4, 'mental_score': 8}) + db.session.add_all([first, second]) + db.session.commit() + + body = client.get(f'/evaluations/player/{player_id}').get_data(as_text=True) + + # overall: (7 + 6) / 2 = 6.5; mecanics: 6.0; mental: 7.0. + assert '6.5' in body + assert '6.0' in body + assert '7.0' in body + + +class TestNavigation: + def test_players_are_ordered_best_first_with_arrows(self, app, client, as_role, make_user): + coach_id = as_role('coach', username='order_coach') + high = make_user('player', username='zzz_high') + mid = make_user('player', username='aaa_mid') + low = make_user('player', username='mmm_low') + + with app.app_context(): + tryout = Tryout( + title='Order', + game='Valorant', + date=date(2030, 1, 1), + created_by=coach_id, + coach_id=coach_id, + ) + db.session.add(tryout) + db.session.flush() + for player_id, score in [(high, 8), (mid, 5), (low, 3)]: + db.session.add( + Evaluation( + tryout_id=tryout.id, + player_id=player_id, + evaluator_id=coach_id, + overall_score=score, + ) + ) + db.session.commit() + + best = client.get(f'/evaluations/player/{high}').get_data(as_text=True) + middle = client.get(f'/evaluations/player/{mid}').get_data(as_text=True) + worst = client.get(f'/evaluations/player/{low}').get_data(as_text=True) + + # Best: no previous (left arrow disabled), next is the middle player. + assert 'aria-disabled="true"' in best + assert f'/evaluations/player/{mid}' in best + assert f'/evaluations/player/{low}' not in best + + # Middle: both arrows active, pointing at the neighbours. + assert f'/evaluations/player/{high}' in middle + assert f'/evaluations/player/{low}' in middle + assert 'aria-disabled="true"' not in middle + + # Worst: no next (right arrow disabled), previous is the middle player. + assert 'aria-disabled="true"' in worst + assert f'/evaluations/player/{mid}' in worst + assert f'/evaluations/player/{high}' not in worst + + +class TestTableView: + def test_table_groups_by_player_and_keeps_the_comment(self, app, client, as_role, make_user): + coach_id = as_role('coach', username='table_coach') + player_id = make_user('player', username='table_player') + + with app.app_context(): + tryout = Tryout( + title='T', + game='Valorant', + date=date(2030, 1, 1), + created_by=coach_id, + coach_id=coach_id, + ) + db.session.add(tryout) + db.session.flush() + db.session.add( + Evaluation( + tryout_id=tryout.id, + player_id=player_id, + evaluator_id=coach_id, + overall_score=7, + comments='a distinctive comment', + ) + ) + db.session.commit() + + body = client.get('/evaluations').get_data(as_text=True) + + assert 'table_player' in body + assert f'/evaluations/player/{player_id}' in body + assert 'a distinctive comment' in body