in role-select.js, selectRole() runs in more precise ordering to ensure card hand for role selection passes to the next gamer after a selection is made; previous bug allowed multiple cards at a single gamer position, which prevented the card hand from making a circuit around the table before depletion; backend fixes including to apps.epic.views.select_role; +2 FTs & +1 IT asserts these features
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -34,9 +34,11 @@ var RoleSelect = (function () {
|
|||||||
var invSlot = document.getElementById("id_inv_role_card");
|
var invSlot = document.getElementById("id_inv_role_card");
|
||||||
if (invSlot) invSlot.appendChild(clean);
|
if (invSlot) invSlot.appendChild(clean);
|
||||||
|
|
||||||
// Optimistically mark role as taken so re-opened fan filters it
|
// Immediately lock the stack — do not wait for WS turn_changed
|
||||||
var stack = document.querySelector(".card-stack[data-starter-roles]");
|
var stack = document.querySelector(".card-stack[data-starter-roles]");
|
||||||
if (stack) {
|
if (stack) {
|
||||||
|
stack.dataset.state = "ineligible";
|
||||||
|
stack.removeEventListener("click", openFan);
|
||||||
var current = stack.dataset.starterRoles;
|
var current = stack.dataset.starterRoles;
|
||||||
stack.dataset.starterRoles = current ? current + "," + roleCode : roleCode;
|
stack.dataset.starterRoles = current ? current + "," + roleCode : roleCode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -628,6 +628,24 @@ class SelectRoleViewTest(TestCase):
|
|||||||
response, reverse("epic:gatekeeper", args=[self.room.id])
|
response, reverse("epic:gatekeeper", args=[self.room.id])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_same_gamer_cannot_double_pick_sequentially(self):
|
||||||
|
"""A second POST from the active gamer — after their role has been
|
||||||
|
saved — must redirect rather than assign a second role."""
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||||||
|
data={"role": "PC"},
|
||||||
|
)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||||||
|
data={"role": "BC"},
|
||||||
|
)
|
||||||
|
self.assertRedirects(
|
||||||
|
response, reverse("epic:gatekeeper", args=[self.room.id])
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
TableSeat.objects.filter(room=self.room, role__isnull=False).count(), 1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RevealPhaseRenderingTest(TestCase):
|
class RevealPhaseRenderingTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from datetime import timedelta
|
|||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from channels.layers import get_channel_layer
|
from channels.layers import get_channel_layer
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.db import transaction
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import redirect, render
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -374,15 +375,16 @@ def select_role(request, room_id):
|
|||||||
room = Room.objects.get(id=room_id)
|
room = Room.objects.get(id=room_id)
|
||||||
if room.table_status != Room.ROLE_SELECT:
|
if room.table_status != Room.ROLE_SELECT:
|
||||||
return redirect("epic:gatekeeper", room_id=room_id)
|
return redirect("epic:gatekeeper", room_id=room_id)
|
||||||
active_seat = room.table_seats.filter(
|
|
||||||
role__isnull=True
|
|
||||||
).order_by("slot_number").first()
|
|
||||||
if not active_seat or active_seat.gamer != request.user:
|
|
||||||
return redirect("epic:gatekeeper", room_id=room_id)
|
|
||||||
role = request.POST.get("role")
|
role = request.POST.get("role")
|
||||||
valid_roles = [code for code, _ in TableSeat.ROLE_CHOICES]
|
valid_roles = [code for code, _ in TableSeat.ROLE_CHOICES]
|
||||||
if not role or role not in valid_roles:
|
if not role or role not in valid_roles:
|
||||||
return redirect("epic:gatekeeper", room_id=room_id)
|
return redirect("epic:gatekeeper", room_id=room_id)
|
||||||
|
with transaction.atomic():
|
||||||
|
active_seat = room.table_seats.select_for_update().filter(
|
||||||
|
role__isnull=True
|
||||||
|
).order_by("slot_number").first()
|
||||||
|
if not active_seat or active_seat.gamer != request.user:
|
||||||
|
return redirect("epic:gatekeeper", room_id=room_id)
|
||||||
if room.table_seats.filter(role=role).exists():
|
if room.table_seats.filter(role=role).exists():
|
||||||
return HttpResponse(status=409)
|
return HttpResponse(status=409)
|
||||||
active_seat.role = role
|
active_seat.role = role
|
||||||
|
|||||||
@@ -403,6 +403,94 @@ class RoleSelectTest(FunctionalTest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test 4b — Stack locks out immediately after selection (no WS) #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_card_stack_ineligible_immediately_after_selection(self):
|
||||||
|
"""After clicking a role card the stack must flip to
|
||||||
|
data-state='ineligible' straight away — before any WS turn_changed
|
||||||
|
event could arrive. This test runs without a Channels server so
|
||||||
|
no WS event will fire; the fix must be entirely client-side."""
|
||||||
|
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||||||
|
room = Room.objects.create(name="Lockout Test", owner=founder)
|
||||||
|
_fill_room_via_orm(room, [
|
||||||
|
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||||||
|
"pal@test.io", "dude@test.io", "bro@test.io",
|
||||||
|
])
|
||||||
|
room.table_status = Room.ROLE_SELECT
|
||||||
|
room.save()
|
||||||
|
for slot in room.gate_slots.order_by("slot_number"):
|
||||||
|
TableSeat.objects.create(
|
||||||
|
room=room, gamer=slot.gamer, slot_number=slot.slot_number,
|
||||||
|
)
|
||||||
|
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||||||
|
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
self.browser.get(room_url)
|
||||||
|
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
|
||||||
|
)
|
||||||
|
).click()
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.ID, "id_role_select"))
|
||||||
|
self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
|
||||||
|
|
||||||
|
# No WS — only the JS fix can make this transition happen
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertEqual(
|
||||||
|
self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".card-stack"
|
||||||
|
).get_attribute("data-state"),
|
||||||
|
"ineligible",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_card_stack_cannot_be_reopened_after_selection(self):
|
||||||
|
"""Clicking the card stack immediately after picking a role must
|
||||||
|
not open a second fan — the listener must have been removed."""
|
||||||
|
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||||||
|
room = Room.objects.create(name="No-reopen Test", owner=founder)
|
||||||
|
_fill_room_via_orm(room, [
|
||||||
|
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||||||
|
"pal@test.io", "dude@test.io", "bro@test.io",
|
||||||
|
])
|
||||||
|
room.table_status = Room.ROLE_SELECT
|
||||||
|
room.save()
|
||||||
|
for slot in room.gate_slots.order_by("slot_number"):
|
||||||
|
TableSeat.objects.create(
|
||||||
|
room=room, gamer=slot.gamer, slot_number=slot.slot_number,
|
||||||
|
)
|
||||||
|
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||||||
|
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
self.browser.get(room_url)
|
||||||
|
|
||||||
|
# Open fan, pick a card
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
|
||||||
|
)
|
||||||
|
).click()
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.ID, "id_role_select"))
|
||||||
|
self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
|
||||||
|
|
||||||
|
# Wait for fan to close (selectRole closes it synchronously)
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertEqual(
|
||||||
|
len(self.browser.find_elements(By.ID, "id_role_select")), 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attempt to reopen — must not work
|
||||||
|
self.browser.find_element(By.CSS_SELECTOR, ".card-stack").click()
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertEqual(
|
||||||
|
len(self.browser.find_elements(By.ID, "id_role_select")), 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Test 7 — All roles revealed simultaneously after all gamers select #
|
# Test 7 — All roles revealed simultaneously after all gamers select #
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
Reference in New Issue
Block a user