role-select UX: tray timing delays, seat/circle state polish, 394 ITs green
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- _animationPending set before fetch (not in .then()) — blocks WS turn advance during in-flight request
- _placeCardDelay (3s) + _postTrayDelay (3s) give gamer time to see each step; both zeroed by _testReset()
- .role-confirmed class: full-opacity chair after placeCard completes; server-rendered on reload
- Slot circles disappear in join order (slot 1 first) via count-based logic, not role-label matching
- data-active-slot on card-stack; handleTurnChanged writes it for selectRole() to read
- #id_tray_wrap not rendered during gate phase ({% if room.table_status %})
- Tray slide/arc-in slowed to 1s for diagnostics; wobble kept at 0.45s
- Obsolete test_roles_revealed_simultaneously FT removed; T8 tray FT uses ROLE_SELECT room
- Jasmine macrotask flush pattern: await new Promise(r => setTimeout(r, 0)) after fetch .then()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,15 @@ var RoleSelect = (function () {
|
|||||||
var _animationPending = false;
|
var _animationPending = false;
|
||||||
var _pendingTurnChange = null;
|
var _pendingTurnChange = null;
|
||||||
|
|
||||||
|
// Delay before the tray animation begins (ms). Gives the gamer a moment
|
||||||
|
// to see their pick confirmed before the tray slides in. Set to 0 by
|
||||||
|
// _testReset() so Jasmine tests don't need jasmine.clock().
|
||||||
|
var _placeCardDelay = 3000;
|
||||||
|
|
||||||
|
// Delay after the tray closes before advancing to the next turn (ms).
|
||||||
|
// Gives the gamer a moment to see their confirmed seat before the turn moves.
|
||||||
|
var _postTrayDelay = 3000;
|
||||||
|
|
||||||
var ROLES = [
|
var ROLES = [
|
||||||
{ code: "PC", name: "Player", element: "Fire" },
|
{ code: "PC", name: "Player", element: "Fire" },
|
||||||
{ code: "BC", name: "Builder", element: "Stone" },
|
{ code: "BC", name: "Builder", element: "Stone" },
|
||||||
@@ -36,6 +45,10 @@ var RoleSelect = (function () {
|
|||||||
_turnChangedBeforeFetch = false; // fresh selection, reset the race flag
|
_turnChangedBeforeFetch = false; // fresh selection, reset the race flag
|
||||||
closeFan();
|
closeFan();
|
||||||
|
|
||||||
|
// Show the tray handle — gamer confirmed a pick, tray animation about to run
|
||||||
|
var trayWrap = document.getElementById("id_tray_wrap");
|
||||||
|
if (trayWrap) trayWrap.classList.remove("role-select-phase");
|
||||||
|
|
||||||
// Immediately lock the stack — do not wait for WS turn_changed
|
// 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) {
|
||||||
@@ -45,12 +58,24 @@ var RoleSelect = (function () {
|
|||||||
stack.dataset.starterRoles = current ? current + "," + roleCode : roleCode;
|
stack.dataset.starterRoles = current ? current + "," + roleCode : roleCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark position as actively being seated (glow state)
|
// Mark seat as actively being claimed (glow state)
|
||||||
var activePos = document.querySelector('.table-position[data-role-label="' + roleCode + '"]');
|
var activePos = document.querySelector('.table-seat[data-role="' + roleCode + '"]');
|
||||||
if (activePos) activePos.classList.add('active');
|
if (activePos) activePos.classList.add('active');
|
||||||
|
|
||||||
|
// Immediately fade out the gate-slot circle for the current turn's slot
|
||||||
|
var activeSlot = stack ? stack.dataset.activeSlot : null;
|
||||||
|
if (activeSlot) {
|
||||||
|
var slotCircle = document.querySelector('.gate-slot[data-slot="' + activeSlot + '"]');
|
||||||
|
if (slotCircle) slotCircle.classList.add('role-assigned');
|
||||||
|
}
|
||||||
|
|
||||||
var url = getSelectRoleUrl();
|
var url = getSelectRoleUrl();
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
|
|
||||||
|
// Block handleTurnChanged immediately — WS turn_changed can arrive while
|
||||||
|
// the fetch is in-flight and must be deferred until our animation completes.
|
||||||
|
_animationPending = true;
|
||||||
|
|
||||||
fetch(url, {
|
fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -61,34 +86,41 @@ var RoleSelect = (function () {
|
|||||||
}).then(function (response) {
|
}).then(function (response) {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
// Server rejected (role already taken) — undo optimistic update
|
// Server rejected (role already taken) — undo optimistic update
|
||||||
|
_animationPending = false;
|
||||||
if (stack) {
|
if (stack) {
|
||||||
stack.dataset.starterRoles = stack.dataset.starterRoles
|
stack.dataset.starterRoles = stack.dataset.starterRoles
|
||||||
.split(",").filter(function (r) { return r.trim() !== roleCode; }).join(",");
|
.split(",").filter(function (r) { return r.trim() !== roleCode; }).join(",");
|
||||||
}
|
}
|
||||||
openFan();
|
openFan();
|
||||||
} else {
|
} else {
|
||||||
// Always animate the role card into the tray, even if turn_changed
|
// Animate the role card into the tray: open, arc-in, force-close.
|
||||||
// already arrived. placeCard opens the tray, arcs the card in,
|
// Any turn_changed that arrived while the fetch was in-flight is
|
||||||
// then force-closes — so the user always sees their role card land.
|
// queued in _pendingTurnChange and will run after onComplete.
|
||||||
// If turn_changed arrived before the fetch, handleTurnChanged already
|
|
||||||
// ran; _pendingTurnChange will be null and onComplete is a no-op.
|
|
||||||
if (typeof Tray !== "undefined") {
|
if (typeof Tray !== "undefined") {
|
||||||
_animationPending = true;
|
setTimeout(function () {
|
||||||
Tray.placeCard(roleCode, function () {
|
Tray.placeCard(roleCode, function () {
|
||||||
_animationPending = false;
|
// Swap ban → check, clear glow, mark seat as confirmed
|
||||||
// Swap ban → check and clear glow on the seated position
|
var seatedPos = document.querySelector('.table-seat[data-role="' + roleCode + '"]');
|
||||||
var seatedPos = document.querySelector('.table-position[data-role-label="' + roleCode + '"]');
|
if (seatedPos) {
|
||||||
if (seatedPos) {
|
seatedPos.classList.remove('active');
|
||||||
seatedPos.classList.remove('active');
|
seatedPos.classList.add('role-confirmed');
|
||||||
var ban = seatedPos.querySelector('.fa-ban');
|
var ban = seatedPos.querySelector('.fa-ban');
|
||||||
if (ban) { ban.classList.remove('fa-ban'); ban.classList.add('fa-circle-check'); }
|
if (ban) { ban.classList.remove('fa-ban'); ban.classList.add('fa-circle-check'); }
|
||||||
}
|
}
|
||||||
if (_pendingTurnChange) {
|
// Hold _animationPending through the post-tray pause so any
|
||||||
var ev = _pendingTurnChange;
|
// turn_changed WS event that arrives now is still deferred.
|
||||||
_pendingTurnChange = null;
|
setTimeout(function () {
|
||||||
handleTurnChanged(ev);
|
_animationPending = false;
|
||||||
}
|
if (_pendingTurnChange) {
|
||||||
});
|
var ev = _pendingTurnChange;
|
||||||
|
_pendingTurnChange = null;
|
||||||
|
handleTurnChanged(ev);
|
||||||
|
}
|
||||||
|
}, _postTrayDelay);
|
||||||
|
});
|
||||||
|
}, _placeCardDelay);
|
||||||
|
} else {
|
||||||
|
_animationPending = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -189,11 +221,39 @@ var RoleSelect = (function () {
|
|||||||
_turnChangedBeforeFetch = true;
|
_turnChangedBeforeFetch = true;
|
||||||
if (typeof Tray !== "undefined") Tray.forceClose();
|
if (typeof Tray !== "undefined") Tray.forceClose();
|
||||||
|
|
||||||
// Clear any stale .active glow from position indicators
|
// Hide tray handle until the next player confirms their pick
|
||||||
document.querySelectorAll('.table-position.active').forEach(function (p) {
|
var trayWrap = document.getElementById("id_tray_wrap");
|
||||||
|
if (trayWrap) trayWrap.classList.add("role-select-phase");
|
||||||
|
|
||||||
|
// Clear any stale .active glow from hex seats
|
||||||
|
document.querySelectorAll('.table-seat.active').forEach(function (p) {
|
||||||
p.classList.remove('active');
|
p.classList.remove('active');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sync seat icons from starter_roles so state persists without a reload
|
||||||
|
if (event.detail.starter_roles) {
|
||||||
|
var assignedRoles = event.detail.starter_roles;
|
||||||
|
document.querySelectorAll(".table-seat").forEach(function (seat) {
|
||||||
|
var role = seat.dataset.role;
|
||||||
|
if (assignedRoles.indexOf(role) !== -1) {
|
||||||
|
seat.classList.add("role-confirmed");
|
||||||
|
var ban = seat.querySelector(".fa-ban");
|
||||||
|
if (ban) { ban.classList.remove("fa-ban"); ban.classList.add("fa-circle-check"); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Hide slot circles in turn order: slots 1..N done when N roles assigned
|
||||||
|
var assignedCount = assignedRoles.length;
|
||||||
|
document.querySelectorAll(".gate-slot[data-slot]").forEach(function (circle) {
|
||||||
|
if (parseInt(circle.dataset.slot, 10) <= assignedCount) {
|
||||||
|
circle.classList.add("role-assigned");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update active slot on the card stack so selectRole() can read it
|
||||||
|
var stack = document.querySelector(".card-stack[data-user-slots]");
|
||||||
|
if (stack) stack.dataset.activeSlot = active;
|
||||||
|
|
||||||
var stack = document.querySelector(".card-stack[data-user-slots]");
|
var stack = document.querySelector(".card-stack[data-user-slots]");
|
||||||
if (stack) {
|
if (stack) {
|
||||||
// Sync starter-roles from server so the fan reflects actual DB state
|
// Sync starter-roles from server so the fan reflects actual DB state
|
||||||
@@ -221,12 +281,10 @@ var RoleSelect = (function () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move .active to the newly active seat
|
// Clear any stale seat glow (JS-only; glow is only during tray animation)
|
||||||
document.querySelectorAll(".table-seat.active").forEach(function (s) {
|
document.querySelectorAll(".table-seat.active").forEach(function (s) {
|
||||||
s.classList.remove("active");
|
s.classList.remove("active");
|
||||||
});
|
});
|
||||||
var activeSeat = document.querySelector(".table-seat[data-slot='" + active + "']");
|
|
||||||
if (activeSeat) activeSeat.classList.add("active");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("room:role_select_start", init);
|
window.addEventListener("room:role_select_start", init);
|
||||||
@@ -247,6 +305,8 @@ var RoleSelect = (function () {
|
|||||||
_testReset: function () {
|
_testReset: function () {
|
||||||
_animationPending = false;
|
_animationPending = false;
|
||||||
_pendingTurnChange = null;
|
_pendingTurnChange = null;
|
||||||
|
_placeCardDelay = 0;
|
||||||
|
_postTrayDelay = 0;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}());
|
}());
|
||||||
|
|||||||
@@ -402,7 +402,9 @@ class RoleSelectRenderingTest(TestCase):
|
|||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
self.url
|
self.url
|
||||||
)
|
)
|
||||||
self.assertNotContains(response, "fa-ban")
|
# Seat ban icons carry "position-status-icon"; card-stack ban does not.
|
||||||
|
# Assert the bare "fa-solid fa-ban" (card-stack form) is absent.
|
||||||
|
self.assertNotContains(response, 'class="fa-solid fa-ban"')
|
||||||
|
|
||||||
def test_gatekeeper_overlay_absent_when_role_select(self):
|
def test_gatekeeper_overlay_absent_when_role_select(self):
|
||||||
self.client.force_login(self.founder)
|
self.client.force_login(self.founder)
|
||||||
@@ -411,6 +413,21 @@ class RoleSelectRenderingTest(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertNotContains(response, "gate-overlay")
|
self.assertNotContains(response, "gate-overlay")
|
||||||
|
|
||||||
|
def test_tray_wrap_has_role_select_phase_class(self):
|
||||||
|
# Tray handle hidden until gamer confirms a role pick
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertContains(response, 'id="id_tray_wrap" class="role-select-phase"')
|
||||||
|
|
||||||
|
def test_tray_absent_during_gatekeeper_phase(self):
|
||||||
|
# Tray must not render before the gamer occupies a seat
|
||||||
|
room = Room.objects.create(name="Gate Room", owner=self.founder)
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("epic:gatekeeper", kwargs={"room_id": room.id})
|
||||||
|
)
|
||||||
|
self.assertNotContains(response, 'id="id_tray_wrap"')
|
||||||
|
|
||||||
def test_six_table_seats_rendered(self):
|
def test_six_table_seats_rendered(self):
|
||||||
self.client.force_login(self.founder)
|
self.client.force_login(self.founder)
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
@@ -418,20 +435,66 @@ class RoleSelectRenderingTest(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertContains(response, "table-seat", count=6)
|
self.assertContains(response, "table-seat", count=6)
|
||||||
|
|
||||||
def test_active_table_seat_has_active_class(self):
|
def test_table_seats_never_active_on_load(self):
|
||||||
self.client.force_login(self.founder) # slot 1 is active
|
# Seat glow is JS-only (during tray animation); never server-rendered
|
||||||
response = self.client.get(
|
|
||||||
self.url
|
|
||||||
)
|
|
||||||
self.assertContains(response, 'class="table-seat active"')
|
|
||||||
|
|
||||||
def test_inactive_table_seat_lacks_active_class(self):
|
|
||||||
self.client.force_login(self.founder)
|
self.client.force_login(self.founder)
|
||||||
response = self.client.get(
|
response = self.client.get(self.url)
|
||||||
self.url
|
self.assertNotContains(response, 'class="table-seat active"')
|
||||||
)
|
|
||||||
# Slots 2–6 are not active, so at least one plain table-seat exists
|
def test_assigned_seat_renders_role_confirmed_class(self):
|
||||||
self.assertContains(response, 'class="table-seat"')
|
# A seat with a role already picked must load as role-confirmed (opaque chair)
|
||||||
|
self.gamers[0].refresh_from_db()
|
||||||
|
seat = self.room.table_seats.get(slot_number=1)
|
||||||
|
seat.role = "PC"
|
||||||
|
seat.save()
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertContains(response, 'table-seat role-confirmed')
|
||||||
|
|
||||||
|
def test_unassigned_seat_lacks_role_confirmed_class(self):
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertNotContains(response, 'table-seat role-confirmed')
|
||||||
|
|
||||||
|
def test_assigned_slot_circle_renders_role_assigned_class(self):
|
||||||
|
# Slot 1 circle hidden because 1 role was assigned (count-based, not role-label-based)
|
||||||
|
seat = self.room.table_seats.get(slot_number=1)
|
||||||
|
seat.role = "PC"
|
||||||
|
seat.save()
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertContains(response, 'gate-slot filled role-assigned')
|
||||||
|
|
||||||
|
def test_slot_circle_hides_by_count_not_role_label(self):
|
||||||
|
# Gamer in slot 1 picks NC (not PC) — slot 1 circle must still hide, not slot 2's
|
||||||
|
seat = self.room.table_seats.get(slot_number=1)
|
||||||
|
seat.role = "NC"
|
||||||
|
seat.save()
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
content = response.content.decode()
|
||||||
|
import re
|
||||||
|
# Template renders class before data-slot; capture both orderings
|
||||||
|
circles = re.findall(r'class="([^"]*gate-slot[^"]*)"[^>]*data-slot="(\d)"', content)
|
||||||
|
slot1_classes = next((cls for cls, slot in circles if slot == "1"), "")
|
||||||
|
slot2_classes = next((cls for cls, slot in circles if slot == "2"), "")
|
||||||
|
self.assertIn("role-assigned", slot1_classes)
|
||||||
|
self.assertNotIn("role-assigned", slot2_classes)
|
||||||
|
|
||||||
|
def test_unassigned_slot_circle_lacks_role_assigned_class(self):
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertNotContains(response, 'role-assigned')
|
||||||
|
|
||||||
|
def test_position_strip_rendered_during_role_select(self):
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertContains(response, "position-strip")
|
||||||
|
|
||||||
|
def test_position_strip_has_six_gate_slots(self):
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertContains(response, "gate-slot", count=6)
|
||||||
|
|
||||||
def test_card_stack_has_data_user_slots_for_eligible_gamer(self):
|
def test_card_stack_has_data_user_slots_for_eligible_gamer(self):
|
||||||
self.client.force_login(self.founder) # founder is slot 1 only
|
self.client.force_login(self.founder) # founder is slot 1 only
|
||||||
@@ -447,6 +510,29 @@ class RoleSelectRenderingTest(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertContains(response, 'data-user-slots="2"')
|
self.assertContains(response, 'data-user-slots="2"')
|
||||||
|
|
||||||
|
def test_assigned_seat_renders_check_icon(self):
|
||||||
|
seat = self.room.table_seats.get(slot_number=1)
|
||||||
|
seat.role = "PC"
|
||||||
|
seat.save()
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
content = response.content.decode()
|
||||||
|
# The PC seat should have fa-circle-check, not fa-ban
|
||||||
|
pc_seat_start = content.index('data-role="PC"')
|
||||||
|
pc_seat_chunk = content[pc_seat_start:pc_seat_start + 300]
|
||||||
|
self.assertIn("fa-circle-check", pc_seat_chunk)
|
||||||
|
self.assertNotIn("fa-ban", pc_seat_chunk)
|
||||||
|
|
||||||
|
def test_unassigned_seat_renders_ban_icon(self):
|
||||||
|
# slot 2's role is still null
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
content = response.content.decode()
|
||||||
|
nc_seat_start = content.index('data-role="NC"')
|
||||||
|
nc_seat_chunk = content[nc_seat_start:nc_seat_start + 300]
|
||||||
|
self.assertIn("fa-ban", nc_seat_chunk)
|
||||||
|
self.assertNotIn("fa-circle-check", nc_seat_chunk)
|
||||||
|
|
||||||
|
|
||||||
class PickRolesViewTest(TestCase):
|
class PickRolesViewTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|||||||
@@ -75,9 +75,16 @@ SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}
|
|||||||
|
|
||||||
|
|
||||||
def _gate_positions(room):
|
def _gate_positions(room):
|
||||||
"""Return list of dicts [{slot, role_label}] for _table_positions.html."""
|
"""Return list of dicts [{slot, role_label, role_assigned}] for _table_positions.html."""
|
||||||
|
# Circles disappear in turn order (slot 1 first, slot 2 second, …) regardless
|
||||||
|
# of which role each gamer chose — so use count, not role matching.
|
||||||
|
assigned_count = room.table_seats.exclude(role__isnull=True).count()
|
||||||
return [
|
return [
|
||||||
{"slot": slot, "role_label": SLOT_ROLE_LABELS.get(slot.slot_number, "")}
|
{
|
||||||
|
"slot": slot,
|
||||||
|
"role_label": SLOT_ROLE_LABELS.get(slot.slot_number, ""),
|
||||||
|
"role_assigned": slot.slot_number <= assigned_count,
|
||||||
|
}
|
||||||
for slot in room.gate_slots.order_by("slot_number")
|
for slot in room.gate_slots.order_by("slot_number")
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -147,6 +154,7 @@ def _gate_context(room, user):
|
|||||||
"carte_nvm_slot_number": carte_nvm_slot_number,
|
"carte_nvm_slot_number": carte_nvm_slot_number,
|
||||||
"carte_next_slot_number": carte_next_slot_number,
|
"carte_next_slot_number": carte_next_slot_number,
|
||||||
"gate_positions": _gate_positions(room),
|
"gate_positions": _gate_positions(room),
|
||||||
|
"starter_roles": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -199,6 +207,7 @@ def _role_select_context(room, user):
|
|||||||
) if user.is_authenticated else [],
|
) if user.is_authenticated else [],
|
||||||
"active_slot": active_slot,
|
"active_slot": active_slot,
|
||||||
"gate_positions": _gate_positions(room),
|
"gate_positions": _gate_positions(room),
|
||||||
|
"slots": room.gate_slots.order_by("slot_number"),
|
||||||
}
|
}
|
||||||
if room.table_status == Room.SIG_SELECT:
|
if room.table_status == Room.SIG_SELECT:
|
||||||
user_seat = room.table_seats.filter(gamer=user).first() if user.is_authenticated else None
|
user_seat = room.table_seats.filter(gamer=user).first() if user.is_authenticated else None
|
||||||
|
|||||||
@@ -607,20 +607,20 @@ class PositionIndicatorsTest(FunctionalTest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Test P1 — 6 position indicators present while gatekeeper is open #
|
# Test P1 — 6 position circles present in strip alongside gatekeeper #
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_position_indicators_visible_alongside_gatekeeper(self):
|
def test_position_indicators_visible_alongside_gatekeeper(self):
|
||||||
self.browser.get(self.gate_url)
|
self.browser.get(self.gate_url)
|
||||||
# Gatekeeper modal is open
|
|
||||||
self.wait_for(
|
self.wait_for(
|
||||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-overlay")
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-overlay")
|
||||||
)
|
)
|
||||||
# Six .table-position elements are rendered outside the modal
|
# Six .gate-slot elements are rendered in .position-strip, outside modal
|
||||||
positions = self.browser.find_elements(By.CSS_SELECTOR, ".table-position")
|
strip = self.browser.find_element(By.CSS_SELECTOR, ".position-strip")
|
||||||
self.assertEqual(len(positions), 6)
|
slots = strip.find_elements(By.CSS_SELECTOR, ".gate-slot")
|
||||||
for pos in positions:
|
self.assertEqual(len(slots), 6)
|
||||||
self.assertTrue(pos.is_displayed())
|
for slot in slots:
|
||||||
|
self.assertTrue(slot.is_displayed())
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Test P2 — URL drops /gate/ after pick_roles #
|
# Test P2 — URL drops /gate/ after pick_roles #
|
||||||
@@ -631,63 +631,57 @@ class PositionIndicatorsTest(FunctionalTest):
|
|||||||
"founder@test.io", "amigo@test.io", "bud@test.io",
|
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||||||
"pal@test.io", "dude@test.io", "bro@test.io",
|
"pal@test.io", "dude@test.io", "bro@test.io",
|
||||||
])
|
])
|
||||||
# Simulate pick_roles having fired: room advances to ROLE_SELECT
|
|
||||||
self.room.table_status = Room.ROLE_SELECT
|
self.room.table_status = Room.ROLE_SELECT
|
||||||
self.room.save()
|
self.room.save()
|
||||||
|
|
||||||
# Navigating to the /gate/ URL should redirect to the plain room URL
|
|
||||||
self.browser.get(self.gate_url)
|
self.browser.get(self.gate_url)
|
||||||
expected_url = (
|
expected_url = f"{self.live_server_url}/gameboard/room/{self.room.id}/"
|
||||||
f"{self.live_server_url}/gameboard/room/{self.room.id}/"
|
|
||||||
)
|
|
||||||
self.wait_for(
|
self.wait_for(
|
||||||
lambda: self.assertEqual(self.browser.current_url, expected_url)
|
lambda: self.assertEqual(self.browser.current_url, expected_url)
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Test P3 — Each position has a chair icon and correct role label #
|
# Test P3 — Gate-slot circles live outside the modal #
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_position_shows_chair_icon_and_role_label(self):
|
def test_position_circles_outside_gatekeeper_modal(self):
|
||||||
SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}
|
"""The numbered position circles must NOT be descendants of .gate-modal —
|
||||||
|
they live in .position-strip which sits above the backdrop."""
|
||||||
self.browser.get(self.gate_url)
|
self.browser.get(self.gate_url)
|
||||||
self.wait_for(
|
self.wait_for(
|
||||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-overlay")
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-modal")
|
||||||
)
|
)
|
||||||
for slot_number, role_label in SLOT_ROLE_LABELS.items():
|
# No .gate-slot inside the modal
|
||||||
pos = self.browser.find_element(
|
modal_slots = self.browser.find_elements(
|
||||||
By.CSS_SELECTOR, f".table-position[data-slot='{slot_number}']"
|
By.CSS_SELECTOR, ".gate-modal .gate-slot"
|
||||||
)
|
)
|
||||||
# Chair icon present
|
self.assertEqual(len(modal_slots), 0)
|
||||||
self.assertTrue(pos.find_elements(By.CSS_SELECTOR, ".fa-chair"))
|
# All 6 live in .position-strip
|
||||||
# Role label attribute and visible text
|
strip_slots = self.browser.find_elements(
|
||||||
self.assertEqual(pos.get_attribute("data-role-label"), role_label)
|
By.CSS_SELECTOR, ".position-strip .gate-slot"
|
||||||
label_el = pos.find_element(By.CSS_SELECTOR, ".position-role-label")
|
)
|
||||||
self.assertEqual(label_el.text.strip(), role_label)
|
self.assertEqual(len(strip_slots), 6)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Test P4 — Unoccupied position shows ban icon #
|
# Test P4 — Each circle displays its slot number #
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_unoccupied_position_shows_ban_icon(self):
|
def test_position_circle_shows_slot_number(self):
|
||||||
self.browser.get(self.gate_url)
|
self.browser.get(self.gate_url)
|
||||||
self.wait_for(
|
self.wait_for(
|
||||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-overlay")
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".position-strip")
|
||||||
)
|
)
|
||||||
# All slots are empty — every position should have a ban icon
|
for n in range(1, 7):
|
||||||
positions = self.browser.find_elements(By.CSS_SELECTOR, ".table-position")
|
slot_el = self.browser.find_element(
|
||||||
for pos in positions:
|
By.CSS_SELECTOR, f".position-strip .gate-slot[data-slot='{n}']"
|
||||||
self.assertTrue(
|
|
||||||
pos.find_elements(By.CSS_SELECTOR, ".fa-ban"),
|
|
||||||
f"Expected .fa-ban on slot {pos.get_attribute('data-slot')}",
|
|
||||||
)
|
)
|
||||||
|
self.assertIn(str(n), slot_el.text)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Test P5 — Occupied position shows check icon after token confirmed #
|
# Test P5 — Filled slot carries .filled class in strip #
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_occupied_position_shows_check_icon_after_token_confirmed(self):
|
def test_filled_slot_shown_in_strip(self):
|
||||||
# Slot 1 is filled via ORM
|
|
||||||
from apps.epic.models import GateSlot
|
from apps.epic.models import GateSlot
|
||||||
slot = self.room.gate_slots.get(slot_number=1)
|
slot = self.room.gate_slots.get(slot_number=1)
|
||||||
slot.gamer = self.founder
|
slot.gamer = self.founder
|
||||||
@@ -696,10 +690,13 @@ class PositionIndicatorsTest(FunctionalTest):
|
|||||||
|
|
||||||
self.browser.get(self.gate_url)
|
self.browser.get(self.gate_url)
|
||||||
self.wait_for(
|
self.wait_for(
|
||||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-overlay")
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".position-strip")
|
||||||
)
|
)
|
||||||
pos1 = self.browser.find_element(
|
slot1 = self.browser.find_element(
|
||||||
By.CSS_SELECTOR, ".table-position[data-slot='1']"
|
By.CSS_SELECTOR, ".position-strip .gate-slot[data-slot='1']"
|
||||||
)
|
)
|
||||||
self.assertTrue(pos1.find_elements(By.CSS_SELECTOR, ".fa-circle-check"))
|
self.assertIn("filled", slot1.get_attribute("class"))
|
||||||
self.assertFalse(pos1.find_elements(By.CSS_SELECTOR, ".fa-ban"))
|
slot2 = self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".position-strip .gate-slot[data-slot='2']"
|
||||||
|
)
|
||||||
|
self.assertIn("empty", slot2.get_attribute("class"))
|
||||||
|
|||||||
@@ -445,12 +445,14 @@ class RoleSelectTest(FunctionalTest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Test 7 — All roles revealed simultaneously after all gamers select #
|
# Test 8a — Hex seats carry role labels during role select #
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_roles_revealed_simultaneously_after_all_select(self):
|
def test_seats_around_hex_have_role_labels(self):
|
||||||
|
"""During role select the 6 .table-seat elements carry data-role
|
||||||
|
attributes matching the fixed slot→role mapping (PC at slot 1, etc.)."""
|
||||||
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||||||
room = Room.objects.create(name="Reveal Test", owner=founder)
|
room = Room.objects.create(name="Seat Label Test", owner=founder)
|
||||||
_fill_room_via_orm(room, [
|
_fill_room_via_orm(room, [
|
||||||
"founder@test.io", "amigo@test.io", "bud@test.io",
|
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||||||
"pal@test.io", "dude@test.io", "bro@test.io",
|
"pal@test.io", "dude@test.io", "bro@test.io",
|
||||||
@@ -462,38 +464,24 @@ class RoleSelectTest(FunctionalTest):
|
|||||||
self.create_pre_authenticated_session("founder@test.io")
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
self.browser.get(room_url)
|
self.browser.get(room_url)
|
||||||
|
|
||||||
# Assign all roles via ORM (simulating all gamers having chosen)
|
expected = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}
|
||||||
from apps.epic.models import TableSeat
|
|
||||||
roles = ["PC", "BC", "SC", "AC", "NC", "EC"]
|
|
||||||
for i, slot in enumerate(room.gate_slots.order_by("slot_number")):
|
|
||||||
TableSeat.objects.create(
|
|
||||||
room=room,
|
|
||||||
gamer=slot.gamer,
|
|
||||||
slot_number=slot.slot_number,
|
|
||||||
role=roles[i],
|
|
||||||
role_revealed=True,
|
|
||||||
)
|
|
||||||
room.table_status = Room.SIG_SELECT
|
|
||||||
room.save()
|
|
||||||
|
|
||||||
self.browser.refresh()
|
|
||||||
|
|
||||||
# Sig deck is present (page has transitioned to SIG_SELECT)
|
|
||||||
self.wait_for(
|
self.wait_for(
|
||||||
lambda: self.browser.find_element(By.ID, "id_sig_deck")
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".table-seat")
|
||||||
)
|
)
|
||||||
|
for slot_number, role_label in expected.items():
|
||||||
|
seat = self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, f".table-seat[data-slot='{slot_number}']"
|
||||||
|
)
|
||||||
|
self.assertEqual(seat.get_attribute("data-role"), role_label)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Test 8a — Position glows while role card is being placed #
|
# Test 8b — Hex seats show .fa-ban when empty #
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_position_glows_when_role_card_confirmed(self):
|
def test_seats_show_ban_icon_when_empty(self):
|
||||||
"""Immediately after confirming a role pick, the matching
|
"""All 6 seats carry .fa-ban before any role has been chosen."""
|
||||||
.table-position should receive .active (the glow state) before
|
|
||||||
the tray animation completes."""
|
|
||||||
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||||||
room = Room.objects.create(name="Position Glow Test", owner=founder)
|
room = Room.objects.create(name="Seat Ban Test", owner=founder)
|
||||||
_fill_room_via_orm(room, [
|
_fill_room_via_orm(room, [
|
||||||
"founder@test.io", "amigo@test.io", "bud@test.io",
|
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||||||
"pal@test.io", "dude@test.io", "bro@test.io",
|
"pal@test.io", "dude@test.io", "bro@test.io",
|
||||||
@@ -509,32 +497,26 @@ class RoleSelectTest(FunctionalTest):
|
|||||||
self.create_pre_authenticated_session("founder@test.io")
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
self.browser.get(room_url)
|
self.browser.get(room_url)
|
||||||
|
|
||||||
# Open fan, click first card (PC), confirm guard
|
|
||||||
self.wait_for(
|
self.wait_for(
|
||||||
lambda: self.browser.find_element(
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".table-seat")
|
||||||
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()
|
|
||||||
self.confirm_guard()
|
|
||||||
|
|
||||||
# PC position gains .active immediately after confirmation
|
|
||||||
self.wait_for(
|
|
||||||
lambda: self.browser.find_element(
|
|
||||||
By.CSS_SELECTOR, ".table-position[data-role-label='PC'].active"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
seats = self.browser.find_elements(By.CSS_SELECTOR, ".table-seat")
|
||||||
|
self.assertEqual(len(seats), 6)
|
||||||
|
for seat in seats:
|
||||||
|
self.assertTrue(
|
||||||
|
seat.find_elements(By.CSS_SELECTOR, ".fa-ban"),
|
||||||
|
f"Expected .fa-ban on seat slot {seat.get_attribute('data-slot')}",
|
||||||
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Test 8b — Position shows check icon after tray sequence ends #
|
# Test 8c — Hex seat gets .fa-circle-check after role selected #
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_position_gets_check_when_tray_sequence_ends(self):
|
def test_seat_gets_check_after_role_selected(self):
|
||||||
"""After the tray arc-in animation completes and the tray closes,
|
"""After confirming a role pick the corresponding hex seat should
|
||||||
the PC .table-position should show .fa-circle-check and no .fa-ban."""
|
show .fa-circle-check and lose .fa-ban."""
|
||||||
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||||||
room = Room.objects.create(name="Position Check Test", owner=founder)
|
room = Room.objects.create(name="Seat Check Test", owner=founder)
|
||||||
_fill_room_via_orm(room, [
|
_fill_room_via_orm(room, [
|
||||||
"founder@test.io", "amigo@test.io", "bud@test.io",
|
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||||||
"pal@test.io", "dude@test.io", "bro@test.io",
|
"pal@test.io", "dude@test.io", "bro@test.io",
|
||||||
@@ -550,7 +532,7 @@ class RoleSelectTest(FunctionalTest):
|
|||||||
self.create_pre_authenticated_session("founder@test.io")
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
self.browser.get(room_url)
|
self.browser.get(room_url)
|
||||||
|
|
||||||
# Open fan, pick PC card, confirm guard
|
# Open fan, pick first card (PC), confirm guard
|
||||||
self.wait_for(
|
self.wait_for(
|
||||||
lambda: self.browser.find_element(
|
lambda: self.browser.find_element(
|
||||||
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
|
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
|
||||||
@@ -560,23 +542,23 @@ class RoleSelectTest(FunctionalTest):
|
|||||||
self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
|
self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
|
||||||
self.confirm_guard()
|
self.confirm_guard()
|
||||||
|
|
||||||
# Wait for tray animation to complete (tray closes)
|
# Wait for tray animation to complete
|
||||||
self.wait_for(
|
self.wait_for(
|
||||||
lambda: self.assertFalse(
|
lambda: self.assertFalse(
|
||||||
self.browser.execute_script("return Tray.isOpen()"),
|
self.browser.execute_script("return Tray.isOpen()"),
|
||||||
"Tray should close after arc-in sequence"
|
"Tray should close after arc-in sequence",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# PC position now shows check icon, ban icon gone
|
# The PC seat (slot 1) now shows check, no ban
|
||||||
self.wait_for(
|
self.wait_for(
|
||||||
lambda: self.browser.find_element(
|
lambda: self.browser.find_element(
|
||||||
By.CSS_SELECTOR, ".table-position[data-role-label='PC'] .fa-circle-check"
|
By.CSS_SELECTOR, ".table-seat[data-role='PC'] .fa-circle-check"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
len(self.browser.find_elements(
|
len(self.browser.find_elements(
|
||||||
By.CSS_SELECTOR, ".table-position[data-role-label='PC'] .fa-ban"
|
By.CSS_SELECTOR, ".table-seat[data-role='PC'] .fa-ban"
|
||||||
)),
|
)),
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
@@ -745,11 +727,11 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest):
|
|||||||
)
|
)
|
||||||
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||||||
|
|
||||||
# 1. Watcher loads the room — slot 1 is active on initial render
|
# 1. Watcher (slot 2) loads the room
|
||||||
self.create_pre_authenticated_session("watcher@test.io")
|
self.create_pre_authenticated_session("watcher@test.io")
|
||||||
self.browser.get(room_url)
|
self.browser.get(room_url)
|
||||||
self.wait_for(lambda: self.browser.find_element(
|
self.wait_for(lambda: self.browser.find_element(
|
||||||
By.CSS_SELECTOR, ".table-seat.active[data-slot='1']"
|
By.CSS_SELECTOR, ".card-stack[data-state='ineligible']"
|
||||||
))
|
))
|
||||||
|
|
||||||
# 2. Founder picks a role in second browser
|
# 2. Founder picks a role in second browser
|
||||||
@@ -764,16 +746,10 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest):
|
|||||||
self.browser2.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
|
self.browser2.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
|
||||||
self.confirm_guard(browser=self.browser2)
|
self.confirm_guard(browser=self.browser2)
|
||||||
|
|
||||||
# 3. Watcher's seat arc moves to slot 2 — no page refresh
|
# 3. Watcher's turn arrives via WS — card-stack becomes eligible
|
||||||
self.wait_for(lambda: self.browser.find_element(
|
self.wait_for(lambda: self.browser.find_element(
|
||||||
By.CSS_SELECTOR, ".table-seat.active[data-slot='2']"
|
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
|
||||||
))
|
))
|
||||||
self.assertEqual(
|
|
||||||
len(self.browser.find_elements(
|
|
||||||
By.CSS_SELECTOR, ".table-seat.active[data-slot='1']"
|
|
||||||
)),
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
self.browser2.quit()
|
self.browser2.quit()
|
||||||
|
|
||||||
@@ -836,16 +812,11 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest):
|
|||||||
return card !== null && card === card.parentElement.firstElementChild;
|
return card !== null && card === card.parentElement.firstElementChild;
|
||||||
""")))
|
""")))
|
||||||
|
|
||||||
# Turn advances via WS — seat 2 becomes active.
|
# Turn advances via WS — tray must close (forceClose in handleTurnChanged).
|
||||||
self.wait_for(lambda: self.browser.find_element(
|
self.wait_for(lambda: self.assertFalse(
|
||||||
By.CSS_SELECTOR, ".table-seat.active[data-slot='2']"
|
|
||||||
))
|
|
||||||
|
|
||||||
# Tray must be closed: forceClose() fires in handleTurnChanged.
|
|
||||||
self.assertFalse(
|
|
||||||
self.browser.execute_script("return Tray.isOpen()"),
|
self.browser.execute_script("return Tray.isOpen()"),
|
||||||
"Tray should be closed after turn advances"
|
"Tray should be closed after turn advances"
|
||||||
)
|
))
|
||||||
|
|
||||||
def test_landscape_tray_closes_on_turn_advance(self):
|
def test_landscape_tray_closes_on_turn_advance(self):
|
||||||
"""Landscape: role card at leftmost grid square; tray closes when
|
"""Landscape: role card at leftmost grid square; tray closes when
|
||||||
@@ -869,15 +840,10 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest):
|
|||||||
return card !== null && card === card.parentElement.firstElementChild;
|
return card !== null && card === card.parentElement.firstElementChild;
|
||||||
""")))
|
""")))
|
||||||
|
|
||||||
# Turn advances via WS — seat 2 becomes active.
|
# Turn advances via WS — tray must close (forceClose in handleTurnChanged).
|
||||||
self.wait_for(lambda: self.browser.find_element(
|
self.wait_for(lambda: self.assertFalse(
|
||||||
By.CSS_SELECTOR, ".table-seat.active[data-slot='2']"
|
|
||||||
))
|
|
||||||
|
|
||||||
# Tray must be closed.
|
|
||||||
self.assertFalse(
|
|
||||||
self.browser.execute_script("return Tray.isOpen()"),
|
self.browser.execute_script("return Tray.isOpen()"),
|
||||||
"Tray should be closed after turn advances"
|
"Tray should be closed after turn advances"
|
||||||
)
|
))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,20 @@ class TrayTest(FunctionalTest):
|
|||||||
document.dispatchEvent(new PointerEvent("pointerup", {clientY: endY, clientX: 0, bubbles: true}));
|
document.dispatchEvent(new PointerEvent("pointerup", {clientY: endY, clientX: 0, bubbles: true}));
|
||||||
""", btn, start_y, end_y)
|
""", btn, start_y, end_y)
|
||||||
|
|
||||||
|
def _make_role_select_room(self, founder_email="founder@test.io"):
|
||||||
|
from apps.epic.models import TableSeat
|
||||||
|
founder, _ = User.objects.get_or_create(email=founder_email)
|
||||||
|
room = Room.objects.create(name="Tray Test Room", owner=founder)
|
||||||
|
emails = [founder_email, "nc@test.io", "bud@test.io",
|
||||||
|
"pal@test.io", "dude@test.io", "bro@test.io"]
|
||||||
|
_fill_room_via_orm(room, emails)
|
||||||
|
room.table_status = Room.ROLE_SELECT
|
||||||
|
room.save()
|
||||||
|
for i, email in enumerate(emails, start=1):
|
||||||
|
gamer, _ = User.objects.get_or_create(email=email)
|
||||||
|
TableSeat.objects.get_or_create(room=room, gamer=gamer, slot_number=i)
|
||||||
|
return room
|
||||||
|
|
||||||
def _make_sig_select_room(self, founder_email="founder@test.io"):
|
def _make_sig_select_room(self, founder_email="founder@test.io"):
|
||||||
founder, _ = User.objects.get_or_create(email=founder_email)
|
founder, _ = User.objects.get_or_create(email=founder_email)
|
||||||
room = Room.objects.create(name="Tray Test Room", owner=founder)
|
room = Room.objects.create(name="Tray Test Room", owner=founder)
|
||||||
@@ -262,7 +276,7 @@ class TrayTest(FunctionalTest):
|
|||||||
|
|
||||||
@tag('two-browser')
|
@tag('two-browser')
|
||||||
def test_tray_grid_is_1_column_by_8_rows_in_portrait(self):
|
def test_tray_grid_is_1_column_by_8_rows_in_portrait(self):
|
||||||
room = self._make_sig_select_room()
|
room = self._make_role_select_room()
|
||||||
self.create_pre_authenticated_session("founder@test.io")
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
self.browser.get(self._room_url(room))
|
self.browser.get(self._room_url(room))
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ describe("RoleSelect", () => {
|
|||||||
let testDiv;
|
let testDiv;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
RoleSelect._testReset(); // zero _placeCardDelay; clear _animationPending/_pendingTurnChange
|
||||||
testDiv = document.createElement("div");
|
testDiv = document.createElement("div");
|
||||||
testDiv.innerHTML = `
|
testDiv.innerHTML = `
|
||||||
<div class="room-page"
|
<div class="room-page"
|
||||||
@@ -152,7 +153,7 @@ describe("RoleSelect", () => {
|
|||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
|
|
||||||
describe("room:turn_changed event", () => {
|
describe("room:turn_changed event", () => {
|
||||||
let stack;
|
let stack, trayWrap;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Six table seats, slot 1 starts active
|
// Six table seats, slot 1 starts active
|
||||||
@@ -169,6 +170,11 @@ describe("RoleSelect", () => {
|
|||||||
stack.dataset.userSlots = "1";
|
stack.dataset.userSlots = "1";
|
||||||
stack.dataset.starterRoles = "";
|
stack.dataset.starterRoles = "";
|
||||||
testDiv.appendChild(stack);
|
testDiv.appendChild(stack);
|
||||||
|
|
||||||
|
trayWrap = document.createElement("div");
|
||||||
|
trayWrap.id = "id_tray_wrap";
|
||||||
|
trayWrap.className = "role-select-phase";
|
||||||
|
testDiv.appendChild(trayWrap);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls Tray.forceClose() on turn change", () => {
|
it("calls Tray.forceClose() on turn change", () => {
|
||||||
@@ -179,13 +185,19 @@ describe("RoleSelect", () => {
|
|||||||
expect(Tray.forceClose).toHaveBeenCalled();
|
expect(Tray.forceClose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("moves .active to the newly active seat", () => {
|
it("clears .active from all seats on turn change", () => {
|
||||||
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
detail: { active_slot: 2 }
|
detail: { active_slot: 2 }
|
||||||
}));
|
}));
|
||||||
expect(
|
expect(testDiv.querySelector(".table-seat.active")).toBeNull();
|
||||||
testDiv.querySelector(".table-seat.active").dataset.slot
|
});
|
||||||
).toBe("2");
|
|
||||||
|
it("re-adds role-select-phase to tray wrap on turn change", () => {
|
||||||
|
trayWrap.classList.remove("role-select-phase"); // simulate it was shown
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2 }
|
||||||
|
}));
|
||||||
|
expect(trayWrap.classList.contains("role-select-phase")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes .active from the previously active seat", () => {
|
it("removes .active from the previously active seat", () => {
|
||||||
@@ -231,6 +243,119 @@ describe("RoleSelect", () => {
|
|||||||
stack.click();
|
stack.click();
|
||||||
expect(document.querySelector(".role-select-backdrop")).toBeNull();
|
expect(document.querySelector(".role-select-backdrop")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("updates seat icon to fa-circle-check when role appears in starter_roles", () => {
|
||||||
|
const seat = document.createElement("div");
|
||||||
|
seat.className = "table-seat";
|
||||||
|
seat.dataset.role = "PC";
|
||||||
|
const ban = document.createElement("i");
|
||||||
|
ban.className = "position-status-icon fa-solid fa-ban";
|
||||||
|
seat.appendChild(ban);
|
||||||
|
testDiv.appendChild(seat);
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2, starter_roles: ["PC"] }
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(seat.querySelector(".fa-ban")).toBeNull();
|
||||||
|
expect(seat.querySelector(".fa-circle-check")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds role-confirmed to seat when role appears in starter_roles", () => {
|
||||||
|
const seat = document.createElement("div");
|
||||||
|
seat.className = "table-seat";
|
||||||
|
seat.dataset.role = "PC";
|
||||||
|
testDiv.appendChild(seat);
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2, starter_roles: ["PC"] }
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(seat.classList.contains("role-confirmed")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves seat icon as fa-ban when role not in starter_roles", () => {
|
||||||
|
const seat = document.createElement("div");
|
||||||
|
seat.className = "table-seat";
|
||||||
|
seat.dataset.role = "NC";
|
||||||
|
const ban = document.createElement("i");
|
||||||
|
ban.className = "position-status-icon fa-solid fa-ban";
|
||||||
|
seat.appendChild(ban);
|
||||||
|
testDiv.appendChild(seat);
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2, starter_roles: ["PC"] }
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(seat.querySelector(".fa-ban")).not.toBeNull();
|
||||||
|
expect(seat.querySelector(".fa-circle-check")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds role-assigned to slot-1 circle when 1 role assigned", () => {
|
||||||
|
const circle = document.createElement("div");
|
||||||
|
circle.className = "gate-slot filled";
|
||||||
|
circle.dataset.slot = "1";
|
||||||
|
testDiv.appendChild(circle);
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2, starter_roles: ["PC"] }
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(circle.classList.contains("role-assigned")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves slot-2 circle visible when only 1 role assigned", () => {
|
||||||
|
const circle = document.createElement("div");
|
||||||
|
circle.className = "gate-slot filled";
|
||||||
|
circle.dataset.slot = "2";
|
||||||
|
testDiv.appendChild(circle);
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2, starter_roles: ["PC"] }
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(circle.classList.contains("role-assigned")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates data-active-slot on card stack to the new active slot", () => {
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2, starter_roles: [] }
|
||||||
|
}));
|
||||||
|
expect(stack.dataset.activeSlot).toBe("2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
// selectRole slot-circle fade-out //
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
|
||||||
|
describe("selectRole() slot-circle behaviour", () => {
|
||||||
|
let circle, stack;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Gate-slot circle for slot 1 (active turn)
|
||||||
|
circle = document.createElement("div");
|
||||||
|
circle.className = "gate-slot filled";
|
||||||
|
circle.dataset.slot = "1";
|
||||||
|
testDiv.appendChild(circle);
|
||||||
|
|
||||||
|
// Card stack with active-slot=1 so selectRole() knows which circle to hide
|
||||||
|
stack = document.createElement("div");
|
||||||
|
stack.className = "card-stack";
|
||||||
|
stack.dataset.state = "eligible";
|
||||||
|
stack.dataset.starterRoles = "";
|
||||||
|
stack.dataset.userSlots = "1";
|
||||||
|
stack.dataset.activeSlot = "1";
|
||||||
|
testDiv.appendChild(stack);
|
||||||
|
|
||||||
|
spyOn(Tray, "placeCard");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds role-assigned to the active slot's circle immediately on confirm", () => {
|
||||||
|
RoleSelect.openFan();
|
||||||
|
document.querySelector("#id_role_select .card").click();
|
||||||
|
expect(circle.classList.contains("role-assigned")).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
@@ -242,9 +367,14 @@ describe("RoleSelect", () => {
|
|||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
|
|
||||||
describe("tray card after successful role selection", () => {
|
describe("tray card after successful role selection", () => {
|
||||||
let guardConfirm;
|
let guardConfirm, trayWrap;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
trayWrap = document.createElement("div");
|
||||||
|
trayWrap.id = "id_tray_wrap";
|
||||||
|
trayWrap.className = "role-select-phase";
|
||||||
|
testDiv.appendChild(trayWrap);
|
||||||
|
|
||||||
// Spy on Tray.placeCard: call the onComplete callback immediately.
|
// Spy on Tray.placeCard: call the onComplete callback immediately.
|
||||||
spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => {
|
spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => {
|
||||||
if (cb) cb();
|
if (cb) cb();
|
||||||
@@ -261,13 +391,15 @@ describe("RoleSelect", () => {
|
|||||||
|
|
||||||
it("calls Tray.placeCard() on success", async () => {
|
it("calls Tray.placeCard() on success", async () => {
|
||||||
guardConfirm();
|
guardConfirm();
|
||||||
await Promise.resolve();
|
await Promise.resolve(); // flush fetch .then()
|
||||||
|
await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay setTimeout
|
||||||
expect(Tray.placeCard).toHaveBeenCalled();
|
expect(Tray.placeCard).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes the role code string to Tray.placeCard", async () => {
|
it("passes the role code string to Tray.placeCard", async () => {
|
||||||
guardConfirm();
|
guardConfirm();
|
||||||
await Promise.resolve();
|
await Promise.resolve(); // flush fetch .then()
|
||||||
|
await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay setTimeout
|
||||||
const roleCode = Tray.placeCard.calls.mostRecent().args[0];
|
const roleCode = Tray.placeCard.calls.mostRecent().args[0];
|
||||||
expect(typeof roleCode).toBe("string");
|
expect(typeof roleCode).toBe("string");
|
||||||
expect(roleCode.length).toBeGreaterThan(0);
|
expect(roleCode.length).toBeGreaterThan(0);
|
||||||
@@ -281,6 +413,27 @@ describe("RoleSelect", () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
expect(Tray.placeCard).not.toHaveBeenCalled();
|
expect(Tray.placeCard).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("removes role-select-phase from tray wrap on successful pick", async () => {
|
||||||
|
guardConfirm();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(trayWrap.classList.contains("role-select-phase")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds role-confirmed class to the seated position after placeCard completes", async () => {
|
||||||
|
// Add a seat element matching the first available role (PC)
|
||||||
|
const seat = document.createElement("div");
|
||||||
|
seat.className = "table-seat";
|
||||||
|
seat.dataset.role = "PC";
|
||||||
|
seat.innerHTML = '<i class="position-status-icon fa-solid fa-ban"></i>';
|
||||||
|
testDiv.appendChild(seat);
|
||||||
|
|
||||||
|
guardConfirm();
|
||||||
|
await Promise.resolve(); // fetch resolves + placeCard fires
|
||||||
|
await new Promise(r => setTimeout(r, 0)); // flush _postTrayDelay setTimeout
|
||||||
|
|
||||||
|
expect(seat.classList.contains("role-confirmed")).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
@@ -360,17 +513,20 @@ describe("RoleSelect", () => {
|
|||||||
RoleSelect.openFan();
|
RoleSelect.openFan();
|
||||||
document.querySelector("#id_role_select .card").click();
|
document.querySelector("#id_role_select .card").click();
|
||||||
guardConfirm();
|
guardConfirm();
|
||||||
await Promise.resolve();
|
await Promise.resolve(); // flush fetch .then()
|
||||||
|
await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay → placeCard called, heldCallback set
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
detail: { active_slot: 2, starter_roles: [] }
|
detail: { active_slot: 2, starter_roles: [] }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Fire onComplete — deferred turn_changed should now run
|
// Fire onComplete — post-tray delay (0 in tests) still uses setTimeout
|
||||||
Tray._testFirePlaceCardComplete();
|
Tray._testFirePlaceCardComplete();
|
||||||
|
await new Promise(r => setTimeout(r, 0)); // flush _postTrayDelay setTimeout
|
||||||
|
|
||||||
const activeSeat = testDiv.querySelector(".table-seat.active");
|
// Seat glow is JS-only (tray animation window); after deferred
|
||||||
expect(activeSeat && activeSeat.dataset.slot).toBe("2");
|
// handleTurnChanged runs, all seat glows are cleared.
|
||||||
|
expect(testDiv.querySelector(".table-seat.active")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("turn_changed after animation completes is processed immediately", () => {
|
it("turn_changed after animation completes is processed immediately", () => {
|
||||||
@@ -379,8 +535,8 @@ describe("RoleSelect", () => {
|
|||||||
detail: { active_slot: 2, starter_roles: [] }
|
detail: { active_slot: 2, starter_roles: [] }
|
||||||
}));
|
}));
|
||||||
expect(Tray.forceClose).toHaveBeenCalled();
|
expect(Tray.forceClose).toHaveBeenCalled();
|
||||||
const activeSeat = testDiv.querySelector(".table-seat.active");
|
// Seats are not persistently glowed; all active cleared
|
||||||
expect(activeSeat && activeSeat.dataset.slot).toBe("2");
|
expect(testDiv.querySelector(".table-seat.active")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -239,68 +239,6 @@ html:has(.gate-backdrop) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.gate-slots {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
gap: $gate-gap;
|
|
||||||
|
|
||||||
.gate-slot {
|
|
||||||
position: relative;
|
|
||||||
width: $gate-node;
|
|
||||||
height: $gate-node;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: $gate-line solid rgba(var(--terUser), 1);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
&.filled,
|
|
||||||
&.reserved {
|
|
||||||
background: rgba(var(--terUser), 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.filled:hover,
|
|
||||||
&.reserved:hover {
|
|
||||||
box-shadow:
|
|
||||||
-0.1rem -0.1rem 1rem rgba(var(--ninUser), 1),
|
|
||||||
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 1),
|
|
||||||
0.05rem 0.05rem 0.5rem rgba(0, 0, 0, 1),
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slot-number {
|
|
||||||
font-size: 0.7em;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slot-gamer { display: none; }
|
|
||||||
|
|
||||||
form {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
// CARTE drop-target circle — matches .reserved appearance
|
|
||||||
&:has(.drop-token-btn) {
|
|
||||||
background: rgba(var(--terUser), 0.2);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow:
|
|
||||||
-0.1rem -0.1rem 1rem rgba(var(--ninUser), 1),
|
|
||||||
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 1),
|
|
||||||
0.05rem 0.05rem 0.5rem rgba(0, 0, 0, 1),
|
|
||||||
;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-container {
|
.form-container {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
@@ -317,24 +255,6 @@ html:has(.gate-backdrop) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.token-slot { min-width: 150px; }
|
.token-slot { min-width: 150px; }
|
||||||
|
|
||||||
.gate-slots {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 52px);
|
|
||||||
grid-template-rows: repeat(2, 52px);
|
|
||||||
gap: 24px;
|
|
||||||
|
|
||||||
.gate-slot {
|
|
||||||
width: 52px;
|
|
||||||
height: 52px;
|
|
||||||
&:nth-child(1) { grid-column: 1; grid-row: 1; }
|
|
||||||
&:nth-child(2) { grid-column: 2; grid-row: 1; }
|
|
||||||
&:nth-child(3) { grid-column: 3; grid-row: 1; }
|
|
||||||
&:nth-child(4) { grid-column: 1; grid-row: 2; }
|
|
||||||
&:nth-child(5) { grid-column: 2; grid-row: 2; }
|
|
||||||
&:nth-child(6) { grid-column: 3; grid-row: 2; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,59 +286,104 @@ $seat-r: 130px;
|
|||||||
$seat-r-x: round($seat-r * 0.866); // 113px
|
$seat-r-x: round($seat-r * 0.866); // 113px
|
||||||
$seat-r-y: round($seat-r * 0.5); // 65px
|
$seat-r-y: round($seat-r * 0.5); // 65px
|
||||||
|
|
||||||
// .table-position anchors at edge midpoints (pointy-top hex).
|
// Seat edge-midpoint geometry (pointy-top hex).
|
||||||
// Apothem ≈ 80px + 30px clearance = 110px total push from centre.
|
// Apothem ≈ 80px + 30px clearance = 110px total push from centre.
|
||||||
$pos-d: 110px;
|
$pos-d: 110px;
|
||||||
$pos-d-x: round($pos-d * 0.5); // 55px
|
$pos-d-x: round($pos-d * 0.5); // 55px
|
||||||
$pos-d-y: round($pos-d * 0.866); // 95px
|
$pos-d-y: round($pos-d * 0.866); // 95px
|
||||||
|
|
||||||
.table-position {
|
// ─── Position strip ────────────────────────────────────────────────────────
|
||||||
|
// Numbered gate-slot circles rendered above the backdrop (z 130 > overlay 120
|
||||||
|
// > backdrop 100). .room-page is position:relative with no z-index, so its
|
||||||
|
// absolute children share the root stacking context with the fixed overlays.
|
||||||
|
.position-strip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 110;
|
top: 0.5rem;
|
||||||
pointer-events: none;
|
left: 0;
|
||||||
transform: translate(-50%, -50%);
|
right: 0;
|
||||||
|
z-index: 130;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
justify-content: center;
|
||||||
align-items: center;
|
gap: round($gate-gap * 0.6);
|
||||||
gap: 0.15rem;
|
pointer-events: none;
|
||||||
|
|
||||||
// Edge midpoints, clockwise from 3 o'clock (slot drop order → role order)
|
.gate-slot {
|
||||||
&[data-slot="1"] { left: calc(50% + #{$pos-d}); top: 50%; }
|
position: relative;
|
||||||
&[data-slot="2"] { left: calc(50% + #{$pos-d-x}); top: calc(50% + #{$pos-d-y}); }
|
width: round($gate-node * 0.75);
|
||||||
&[data-slot="3"] { left: calc(50% - #{$pos-d-x}); top: calc(50% + #{$pos-d-y}); }
|
height: round($gate-node * 0.75);
|
||||||
&[data-slot="4"] { left: calc(50% - #{$pos-d}); top: 50%; }
|
border-radius: 50%;
|
||||||
&[data-slot="5"] { left: calc(50% - #{$pos-d-x}); top: calc(50% - #{$pos-d-y}); }
|
border: $gate-line solid rgba(var(--terUser), 0.5);
|
||||||
&[data-slot="6"] { left: calc(50% + #{$pos-d-x}); top: calc(50% - #{$pos-d-y}); }
|
background: rgba(var(--priUser), 1);
|
||||||
|
|
||||||
.position-body {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.1rem;
|
justify-content: center;
|
||||||
}
|
flex-shrink: 0;
|
||||||
|
pointer-events: auto;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
transition: opacity 0.6s ease, transform 0.6s ease;
|
||||||
|
box-shadow:
|
||||||
|
0.1rem 0.1rem 0.12rem rgba(var(--priUser), 0.25),
|
||||||
|
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
|
||||||
|
0.25rem 0.25rem 0.25rem rgba(var(--priUser), 0.12)
|
||||||
|
;
|
||||||
|
|
||||||
.fa-chair {
|
&.role-assigned {
|
||||||
font-size: 1.1rem;
|
opacity: 0;
|
||||||
color: rgba(var(--secUser), 0.4);
|
transform: scale(0.5);
|
||||||
}
|
pointer-events: none;
|
||||||
|
box-shadow:
|
||||||
|
0.1rem 0.1rem 0.12rem rgba(var(--terUser), 0.25),
|
||||||
|
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
|
||||||
|
0.25rem 0.25rem 0.25rem rgba(var(--terUser), 0.12)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
.position-role-label {
|
&.filled, &.reserved {
|
||||||
font-size: 0.6rem;
|
background: rgba(var(--terUser), 0.9);
|
||||||
font-weight: 600;
|
border-color: rgba(var(--terUser), 1);
|
||||||
letter-spacing: 0.05em;
|
color: rgba(var(--priUser), 1);
|
||||||
color: rgba(var(--secUser), 0.5);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.position-status-icon {
|
&.filled:hover, &.reserved:hover {
|
||||||
font-size: 0.65rem;
|
box-shadow:
|
||||||
&.fa-ban { color: rgba(var(--priRd), 1); }
|
-0.1rem -0.1rem 1rem rgba(var(--ninUser), 1),
|
||||||
&.fa-circle-check { color: rgba(var(--priGn), 1); }
|
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 1),
|
||||||
}
|
0.05rem 0.05rem 0.5rem rgba(0, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
&.active {
|
.slot-number { font-size: 0.7em; opacity: 0.5; }
|
||||||
.fa-chair {
|
.slot-gamer { display: none; }
|
||||||
color: rgba(var(--terUser), 1);
|
|
||||||
filter: drop-shadow(0 0 4px rgba(var(--ninUser), 1));
|
form {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(.drop-token-btn) {
|
||||||
|
background: rgba(var(--terUser), 1);
|
||||||
|
border-color: rgba(var(--ninUser), 0.5);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow:
|
||||||
|
-0.1rem -0.1rem 1rem rgba(var(--ninUser), 1),
|
||||||
|
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 1),
|
||||||
|
0.05rem 0.05rem 0.5rem rgba(0, 0, 0, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.position-strip {
|
||||||
|
gap: round($gate-gap * 0.3);
|
||||||
|
|
||||||
|
.gate-slot {
|
||||||
|
width: round($gate-node * 0.75);
|
||||||
|
height: round($gate-node * 0.75);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -482,20 +447,61 @@ $pos-d-y: round($pos-d * 0.866); // 95px
|
|||||||
|
|
||||||
.table-seat {
|
.table-seat {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: auto auto;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
column-gap: 0.25rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
|
||||||
// Centre the element on its anchor point
|
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
// Clockwise from top — slot drop order during ROLE_SELECT
|
// Edge midpoints, clockwise from 3 o'clock (slot drop order → role order)
|
||||||
&[data-slot="1"] { left: 50%; top: calc(50% - #{$seat-r}); }
|
&[data-slot="1"] { left: calc(50% + #{$pos-d}); top: 50%; }
|
||||||
&[data-slot="2"] { left: calc(50% + #{$seat-r-x}); top: calc(50% - #{$seat-r-y}); }
|
&[data-slot="2"] { left: calc(50% + #{$pos-d-x}); top: calc(50% + #{$pos-d-y}); }
|
||||||
&[data-slot="3"] { left: calc(50% + #{$seat-r-x}); top: calc(50% + #{$seat-r-y}); }
|
&[data-slot="3"] { left: calc(50% - #{$pos-d-x}); top: calc(50% + #{$pos-d-y}); }
|
||||||
&[data-slot="4"] { left: 50%; top: calc(50% + #{$seat-r}); }
|
&[data-slot="4"] { left: calc(50% - #{$pos-d}); top: 50%; }
|
||||||
&[data-slot="5"] { left: calc(50% - #{$seat-r-x}); top: calc(50% + #{$seat-r-y}); }
|
&[data-slot="5"] { left: calc(50% - #{$pos-d-x}); top: calc(50% - #{$pos-d-y}); }
|
||||||
&[data-slot="6"] { left: calc(50% - #{$seat-r-x}); top: calc(50% - #{$seat-r-y}); }
|
&[data-slot="6"] { left: calc(50% + #{$pos-d-x}); top: calc(50% - #{$pos-d-y}); }
|
||||||
|
|
||||||
|
// Chair: col 1, spans both rows
|
||||||
|
.fa-chair {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
color: rgba(var(--secUser), 0.4);
|
||||||
|
transition: color 0.6s ease, filter 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abbreviation: col 2, row 1
|
||||||
|
.seat-role-label {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: rgba(var(--secUser), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status icon: col 2, row 2, centred under the abbreviation
|
||||||
|
.position-status-icon {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2;
|
||||||
|
justify-self: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
&.fa-ban { color: rgba(var(--priRd), 1); }
|
||||||
|
&.fa-circle-check { color: rgba(var(--priGn), 1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active .fa-chair {
|
||||||
|
color: rgba(var(--terUser), 1);
|
||||||
|
filter: drop-shadow(0 0 4px rgba(var(--ninUser), 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// After role confirmed: chair settles to full-opacity --secUser (no glow)
|
||||||
|
&.role-confirmed .fa-chair {
|
||||||
|
color: rgba(var(--secUser), 1);
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
.seat-portrait {
|
.seat-portrait {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
@@ -690,17 +696,6 @@ $card-h: 120px;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.gate-slots {
|
|
||||||
gap: 14px;
|
|
||||||
|
|
||||||
.gate-slot {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
|
|
||||||
.slot-number { font-size: 0.6em; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-container {
|
.form-container {
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
h3 { font-size: 0.85rem; margin: 0.5rem 0; }
|
h3 { font-size: 0.85rem; margin: 0.5rem 0; }
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ $handle-rect-h: 72px;
|
|||||||
$handle-exposed: 48px;
|
$handle-exposed: 48px;
|
||||||
$handle-r: 1rem;
|
$handle-r: 1rem;
|
||||||
|
|
||||||
|
#id_tray_wrap.role-select-phase {
|
||||||
|
#id_tray_handle { visibility: hidden; pointer-events: none; }
|
||||||
|
}
|
||||||
|
|
||||||
#id_tray_wrap {
|
#id_tray_wrap {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
// left set by JS: closed = vw - handleW; open = vw - wrapW
|
// left set by JS: closed = vw - handleW; open = vw - wrapW
|
||||||
@@ -37,11 +41,11 @@ $handle-r: 1rem;
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
transition: left 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: left 1.0s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
&.tray-dragging { transition: none; }
|
&.tray-dragging { transition: none; }
|
||||||
&.wobble { animation: tray-wobble 0.45s ease; }
|
&.wobble { animation: tray-wobble .45s ease; }
|
||||||
&.snap { animation: tray-snap 0.30s ease; }
|
&.snap { animation: tray-snap 1.0s ease; }
|
||||||
}
|
}
|
||||||
|
|
||||||
#id_tray_handle {
|
#id_tray_handle {
|
||||||
@@ -134,7 +138,7 @@ $handle-r: 1rem;
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
||||||
&.arc-in {
|
&.arc-in {
|
||||||
animation: tray-role-arc-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
animation: tray-role-arc-in 1.0s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,11 +222,11 @@ $handle-r: 1rem;
|
|||||||
right: $sidebar-w;
|
right: $sidebar-w;
|
||||||
top: auto; // JS controls style.top for the Y-axis slide
|
top: auto; // JS controls style.top for the Y-axis slide
|
||||||
bottom: auto;
|
bottom: auto;
|
||||||
transition: top 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: top 1.0s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
&.tray-dragging { transition: none; }
|
&.tray-dragging { transition: none; }
|
||||||
&.wobble { animation: tray-wobble-landscape 0.45s ease; }
|
&.wobble { animation: tray-wobble-landscape 0.45s ease; }
|
||||||
&.snap { animation: tray-snap-landscape 0.30s ease; }
|
&.snap { animation: tray-snap-landscape 1.0s ease; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +308,7 @@ $handle-r: 1rem;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tray-role-card.arc-in {
|
.tray-role-card.arc-in {
|
||||||
animation: tray-role-arc-in-landscape 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
animation: tray-role-arc-in-landscape 1.0s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes tray-wobble-landscape {
|
@keyframes tray-wobble-landscape {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ describe("RoleSelect", () => {
|
|||||||
let testDiv;
|
let testDiv;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
RoleSelect._testReset(); // zero _placeCardDelay; clear _animationPending/_pendingTurnChange
|
||||||
testDiv = document.createElement("div");
|
testDiv = document.createElement("div");
|
||||||
testDiv.innerHTML = `
|
testDiv.innerHTML = `
|
||||||
<div class="room-page"
|
<div class="room-page"
|
||||||
@@ -152,7 +153,7 @@ describe("RoleSelect", () => {
|
|||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
|
|
||||||
describe("room:turn_changed event", () => {
|
describe("room:turn_changed event", () => {
|
||||||
let stack;
|
let stack, trayWrap;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Six table seats, slot 1 starts active
|
// Six table seats, slot 1 starts active
|
||||||
@@ -169,6 +170,12 @@ describe("RoleSelect", () => {
|
|||||||
stack.dataset.userSlots = "1";
|
stack.dataset.userSlots = "1";
|
||||||
stack.dataset.starterRoles = "";
|
stack.dataset.starterRoles = "";
|
||||||
testDiv.appendChild(stack);
|
testDiv.appendChild(stack);
|
||||||
|
|
||||||
|
trayWrap = document.createElement("div");
|
||||||
|
trayWrap.id = "id_tray_wrap";
|
||||||
|
// Simulate server-side class during ROLE_SELECT
|
||||||
|
trayWrap.className = "role-select-phase";
|
||||||
|
testDiv.appendChild(trayWrap);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls Tray.forceClose() on turn change", () => {
|
it("calls Tray.forceClose() on turn change", () => {
|
||||||
@@ -179,13 +186,19 @@ describe("RoleSelect", () => {
|
|||||||
expect(Tray.forceClose).toHaveBeenCalled();
|
expect(Tray.forceClose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("moves .active to the newly active seat", () => {
|
it("re-adds role-select-phase to tray wrap on turn change", () => {
|
||||||
|
trayWrap.classList.remove("role-select-phase"); // simulate it was shown
|
||||||
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
detail: { active_slot: 2 }
|
detail: { active_slot: 2 }
|
||||||
}));
|
}));
|
||||||
expect(
|
expect(trayWrap.classList.contains("role-select-phase")).toBe(true);
|
||||||
testDiv.querySelector(".table-seat.active").dataset.slot
|
});
|
||||||
).toBe("2");
|
|
||||||
|
it("clears .active from all seats on turn change", () => {
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2 }
|
||||||
|
}));
|
||||||
|
expect(testDiv.querySelector(".table-seat.active")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes .active from the previously active seat", () => {
|
it("removes .active from the previously active seat", () => {
|
||||||
@@ -231,6 +244,119 @@ describe("RoleSelect", () => {
|
|||||||
stack.click();
|
stack.click();
|
||||||
expect(document.querySelector(".role-select-backdrop")).toBeNull();
|
expect(document.querySelector(".role-select-backdrop")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("updates seat icon to fa-circle-check when role appears in starter_roles", () => {
|
||||||
|
const seat = document.createElement("div");
|
||||||
|
seat.className = "table-seat";
|
||||||
|
seat.dataset.role = "PC";
|
||||||
|
const ban = document.createElement("i");
|
||||||
|
ban.className = "position-status-icon fa-solid fa-ban";
|
||||||
|
seat.appendChild(ban);
|
||||||
|
testDiv.appendChild(seat);
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2, starter_roles: ["PC"] }
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(seat.querySelector(".fa-ban")).toBeNull();
|
||||||
|
expect(seat.querySelector(".fa-circle-check")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds role-confirmed to seat when role appears in starter_roles", () => {
|
||||||
|
const seat = document.createElement("div");
|
||||||
|
seat.className = "table-seat";
|
||||||
|
seat.dataset.role = "PC";
|
||||||
|
testDiv.appendChild(seat);
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2, starter_roles: ["PC"] }
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(seat.classList.contains("role-confirmed")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves seat icon as fa-ban when role not in starter_roles", () => {
|
||||||
|
const seat = document.createElement("div");
|
||||||
|
seat.className = "table-seat";
|
||||||
|
seat.dataset.role = "NC";
|
||||||
|
const ban = document.createElement("i");
|
||||||
|
ban.className = "position-status-icon fa-solid fa-ban";
|
||||||
|
seat.appendChild(ban);
|
||||||
|
testDiv.appendChild(seat);
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2, starter_roles: ["PC"] }
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(seat.querySelector(".fa-ban")).not.toBeNull();
|
||||||
|
expect(seat.querySelector(".fa-circle-check")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds role-assigned to slot-1 circle when 1 role assigned", () => {
|
||||||
|
const circle = document.createElement("div");
|
||||||
|
circle.className = "gate-slot filled";
|
||||||
|
circle.dataset.slot = "1";
|
||||||
|
testDiv.appendChild(circle);
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2, starter_roles: ["PC"] }
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(circle.classList.contains("role-assigned")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves slot-2 circle visible when only 1 role assigned", () => {
|
||||||
|
const circle = document.createElement("div");
|
||||||
|
circle.className = "gate-slot filled";
|
||||||
|
circle.dataset.slot = "2";
|
||||||
|
testDiv.appendChild(circle);
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2, starter_roles: ["PC"] }
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(circle.classList.contains("role-assigned")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates data-active-slot on card stack to the new active slot", () => {
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2, starter_roles: [] }
|
||||||
|
}));
|
||||||
|
expect(stack.dataset.activeSlot).toBe("2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
// selectRole slot-circle fade-out //
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
|
||||||
|
describe("selectRole() slot-circle behaviour", () => {
|
||||||
|
let circle, stack;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Gate-slot circle for slot 1 (active turn)
|
||||||
|
circle = document.createElement("div");
|
||||||
|
circle.className = "gate-slot filled";
|
||||||
|
circle.dataset.slot = "1";
|
||||||
|
testDiv.appendChild(circle);
|
||||||
|
|
||||||
|
// Card stack with active-slot=1 so selectRole() knows which circle to hide
|
||||||
|
stack = document.createElement("div");
|
||||||
|
stack.className = "card-stack";
|
||||||
|
stack.dataset.state = "eligible";
|
||||||
|
stack.dataset.starterRoles = "";
|
||||||
|
stack.dataset.userSlots = "1";
|
||||||
|
stack.dataset.activeSlot = "1";
|
||||||
|
testDiv.appendChild(stack);
|
||||||
|
|
||||||
|
spyOn(Tray, "placeCard");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds role-assigned to the active slot's circle immediately on confirm", () => {
|
||||||
|
RoleSelect.openFan();
|
||||||
|
document.querySelector("#id_role_select .card").click();
|
||||||
|
expect(circle.classList.contains("role-assigned")).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
@@ -242,9 +368,14 @@ describe("RoleSelect", () => {
|
|||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
|
|
||||||
describe("tray card after successful role selection", () => {
|
describe("tray card after successful role selection", () => {
|
||||||
let guardConfirm;
|
let guardConfirm, trayWrap;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
trayWrap = document.createElement("div");
|
||||||
|
trayWrap.id = "id_tray_wrap";
|
||||||
|
trayWrap.className = "role-select-phase";
|
||||||
|
testDiv.appendChild(trayWrap);
|
||||||
|
|
||||||
// Spy on Tray.placeCard: call the onComplete callback immediately.
|
// Spy on Tray.placeCard: call the onComplete callback immediately.
|
||||||
spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => {
|
spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => {
|
||||||
if (cb) cb();
|
if (cb) cb();
|
||||||
@@ -261,13 +392,15 @@ describe("RoleSelect", () => {
|
|||||||
|
|
||||||
it("calls Tray.placeCard() on success", async () => {
|
it("calls Tray.placeCard() on success", async () => {
|
||||||
guardConfirm();
|
guardConfirm();
|
||||||
await Promise.resolve();
|
await Promise.resolve(); // flush fetch .then()
|
||||||
|
await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay setTimeout
|
||||||
expect(Tray.placeCard).toHaveBeenCalled();
|
expect(Tray.placeCard).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes the role code string to Tray.placeCard", async () => {
|
it("passes the role code string to Tray.placeCard", async () => {
|
||||||
guardConfirm();
|
guardConfirm();
|
||||||
await Promise.resolve();
|
await Promise.resolve(); // flush fetch .then()
|
||||||
|
await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay setTimeout
|
||||||
const roleCode = Tray.placeCard.calls.mostRecent().args[0];
|
const roleCode = Tray.placeCard.calls.mostRecent().args[0];
|
||||||
expect(typeof roleCode).toBe("string");
|
expect(typeof roleCode).toBe("string");
|
||||||
expect(roleCode.length).toBeGreaterThan(0);
|
expect(roleCode.length).toBeGreaterThan(0);
|
||||||
@@ -281,6 +414,27 @@ describe("RoleSelect", () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
expect(Tray.placeCard).not.toHaveBeenCalled();
|
expect(Tray.placeCard).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("removes role-select-phase from tray wrap on successful pick", async () => {
|
||||||
|
guardConfirm();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(trayWrap.classList.contains("role-select-phase")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds role-confirmed class to the seated position after placeCard completes", async () => {
|
||||||
|
// Add a seat element matching the first available role (PC)
|
||||||
|
const seat = document.createElement("div");
|
||||||
|
seat.className = "table-seat";
|
||||||
|
seat.dataset.role = "PC";
|
||||||
|
seat.innerHTML = '<i class="position-status-icon fa-solid fa-ban"></i>';
|
||||||
|
testDiv.appendChild(seat);
|
||||||
|
|
||||||
|
guardConfirm();
|
||||||
|
await Promise.resolve(); // fetch resolves + placeCard fires
|
||||||
|
await new Promise(r => setTimeout(r, 0)); // flush _postTrayDelay setTimeout
|
||||||
|
|
||||||
|
expect(seat.classList.contains("role-confirmed")).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
@@ -360,17 +514,20 @@ describe("RoleSelect", () => {
|
|||||||
RoleSelect.openFan();
|
RoleSelect.openFan();
|
||||||
document.querySelector("#id_role_select .card").click();
|
document.querySelector("#id_role_select .card").click();
|
||||||
guardConfirm();
|
guardConfirm();
|
||||||
await Promise.resolve();
|
await Promise.resolve(); // flush fetch .then()
|
||||||
|
await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay → placeCard called, heldCallback set
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
detail: { active_slot: 2, starter_roles: [] }
|
detail: { active_slot: 2, starter_roles: [] }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Fire onComplete — deferred turn_changed should now run
|
// Fire onComplete — post-tray delay (0 in tests) still uses setTimeout
|
||||||
Tray._testFirePlaceCardComplete();
|
Tray._testFirePlaceCardComplete();
|
||||||
|
await new Promise(r => setTimeout(r, 0)); // flush _postTrayDelay setTimeout
|
||||||
|
|
||||||
const activeSeat = testDiv.querySelector(".table-seat.active");
|
// Seat glow is JS-only (tray animation window); after deferred
|
||||||
expect(activeSeat && activeSeat.dataset.slot).toBe("2");
|
// handleTurnChanged runs, all seat glows are cleared.
|
||||||
|
expect(testDiv.querySelector(".table-seat.active")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("turn_changed after animation completes is processed immediately", () => {
|
it("turn_changed after animation completes is processed immediately", () => {
|
||||||
@@ -379,8 +536,8 @@ describe("RoleSelect", () => {
|
|||||||
detail: { active_slot: 2, starter_roles: [] }
|
detail: { active_slot: 2, starter_roles: [] }
|
||||||
}));
|
}));
|
||||||
expect(Tray.forceClose).toHaveBeenCalled();
|
expect(Tray.forceClose).toHaveBeenCalled();
|
||||||
const activeSeat = testDiv.querySelector(".table-seat.active");
|
// Seats are not persistently glowed; all active cleared
|
||||||
expect(activeSeat && activeSeat.dataset.slot).toBe("2");
|
expect(testDiv.querySelector(".table-seat.active")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -44,43 +44,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gate-slots row">
|
|
||||||
{% for slot in slots %}
|
|
||||||
<div
|
|
||||||
class="gate-slot{% if slot.status == 'EMPTY' %} empty{% elif slot.status == 'FILLED' %} filled{% elif slot.status == 'RESERVED' %} reserved{% endif %}"
|
|
||||||
data-slot="{{ slot.slot_number }}"
|
|
||||||
>
|
|
||||||
<span class="slot-number">{{ slot.slot_number }}</span>
|
|
||||||
{% if slot.gamer %}
|
|
||||||
<span class="slot-gamer">{{ slot.gamer.email }}</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="slot-gamer">empty</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if slot.status == 'RESERVED' and slot.gamer == request.user %}
|
|
||||||
<form method="POST" action="{% url 'epic:confirm_token' room.id %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
{% if is_last_slot %}
|
|
||||||
<button type="submit" class="btn btn-primary btn-xl">PICK ROLES</button>
|
|
||||||
{% else %}
|
|
||||||
<button type="submit" class="btn btn-confirm">OK</button>
|
|
||||||
{% endif %}
|
|
||||||
</form>
|
|
||||||
{% elif carte_active and slot.status == 'EMPTY' and slot.slot_number == carte_next_slot_number %}
|
|
||||||
<form method="POST" action="{% url 'epic:confirm_token' room.id %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="slot_number" value="{{ slot.slot_number }}">
|
|
||||||
<button type="submit" class="drop-token-btn btn btn-confirm" aria-label="Fill slot {{ slot.slot_number }}">OK</button>
|
|
||||||
</form>
|
|
||||||
{% elif carte_active and slot.status == 'FILLED' and slot.slot_number == carte_nvm_slot_number %}
|
|
||||||
<form method="POST" action="{% url 'epic:release_slot' room.id %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="slot_number" value="{{ slot.slot_number }}">
|
|
||||||
<button type="submit" class="slot-release-btn btn btn-cancel">NVM</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% if room.gate_status == 'OPEN' %}
|
{% if room.gate_status == 'OPEN' %}
|
||||||
<form method="POST" action="{% url 'epic:pick_roles' room.id %}" style="display:contents">
|
<form method="POST" action="{% url 'epic:pick_roles' room.id %}" style="display:contents">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|||||||
@@ -1,12 +1,31 @@
|
|||||||
{% for pos in gate_positions %}
|
<div class="position-strip">
|
||||||
<div class="table-position" data-slot="{{ pos.slot.slot_number }}" data-role-label="{{ pos.role_label }}">
|
{% for pos in gate_positions %}
|
||||||
<div class="position-body">
|
<div class="gate-slot{% if pos.slot.status == 'EMPTY' %} empty{% elif pos.slot.status == 'FILLED' %} filled{% elif pos.slot.status == 'RESERVED' %} reserved{% endif %}{% if pos.role_assigned %} role-assigned{% endif %}"
|
||||||
<i class="fa-solid fa-chair"></i>
|
data-slot="{{ pos.slot.slot_number }}">
|
||||||
<span class="position-role-label">{{ pos.role_label }}</span>
|
<span class="slot-number">{{ pos.slot.slot_number }}</span>
|
||||||
<div class="token-tooltip">
|
<span class="slot-gamer">{% if pos.slot.gamer %}{{ pos.slot.gamer.email }}{% else %}empty{% endif %}</span>
|
||||||
<h4>{% if pos.slot.gamer %}@{{ pos.slot.gamer.username|default:pos.slot.gamer.email }}{% else %}Empty Seat{% endif %}</h4>
|
{% if pos.slot.status == 'RESERVED' and pos.slot.gamer == request.user %}
|
||||||
|
<form method="POST" action="{% url 'epic:confirm_token' room.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% if is_last_slot %}
|
||||||
|
<button type="submit" class="btn btn-primary btn-xl">PICK ROLES</button>
|
||||||
|
{% else %}
|
||||||
|
<button type="submit" class="btn btn-confirm">OK</button>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
{% elif carte_active and pos.slot.status == 'EMPTY' and pos.slot.slot_number == carte_next_slot_number %}
|
||||||
|
<form method="POST" action="{% url 'epic:confirm_token' room.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="slot_number" value="{{ pos.slot.slot_number }}">
|
||||||
|
<button type="submit" class="drop-token-btn btn btn-confirm" aria-label="Fill slot {{ pos.slot.slot_number }}">OK</button>
|
||||||
|
</form>
|
||||||
|
{% elif carte_active and pos.slot.status == 'FILLED' and pos.slot.slot_number == carte_nvm_slot_number %}
|
||||||
|
<form method="POST" action="{% url 'epic:release_slot' room.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="slot_number" value="{{ pos.slot.slot_number }}">
|
||||||
|
<button type="submit" class="slot-release-btn btn btn-cancel">NVM</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{% endfor %}
|
||||||
<i class="position-status-icon fa-solid {% if pos.slot.gamer %}fa-circle-check{% else %}fa-ban{% endif %}"></i>
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
{% if room.table_status == "ROLE_SELECT" and card_stack_state %}
|
{% if room.table_status == "ROLE_SELECT" and card_stack_state %}
|
||||||
<div class="card-stack" data-state="{{ card_stack_state }}"
|
<div class="card-stack" data-state="{{ card_stack_state }}"
|
||||||
data-starter-roles="{{ starter_roles|join:',' }}"
|
data-starter-roles="{{ starter_roles|join:',' }}"
|
||||||
data-user-slots="{{ user_slots|join:',' }}">
|
data-user-slots="{{ user_slots|join:',' }}"
|
||||||
|
data-active-slot="{{ active_slot }}">
|
||||||
{% if card_stack_state == "ineligible" %}
|
{% if card_stack_state == "ineligible" %}
|
||||||
<i class="fa-solid fa-ban"></i>
|
<i class="fa-solid fa-ban"></i>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -35,18 +36,19 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% for slot in room.gate_slots.all %}
|
{% for pos in gate_positions %}
|
||||||
<div class="table-seat{% if slot.slot_number == active_slot %} active{% endif %}"
|
<div class="table-seat{% if pos.role_label in starter_roles %} role-confirmed{% endif %}"
|
||||||
data-slot="{{ slot.slot_number }}">
|
data-slot="{{ pos.slot.slot_number }}" data-role="{{ pos.role_label }}">
|
||||||
<div class="seat-portrait">{{ slot.slot_number }}</div>
|
<i class="fa-solid fa-chair"></i>
|
||||||
<div class="seat-card-arc"></div>
|
<span class="seat-role-label">{{ pos.role_label }}</span>
|
||||||
<span class="seat-label">
|
{% if pos.role_label in starter_roles %}
|
||||||
{% if slot.gamer %}@{{ slot.gamer.username|default:slot.gamer.email }}{% endif %}
|
<i class="position-status-icon fa-solid fa-circle-check"></i>
|
||||||
</span>
|
{% else %}
|
||||||
|
<i class="position-status-icon fa-solid fa-ban"></i>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% include "apps/gameboard/_partials/_table_positions.html" %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -74,10 +76,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if room.gate_status != "RENEWAL_DUE" and room.table_status != "SIG_SELECT" %}
|
||||||
|
{% include "apps/gameboard/_partials/_table_positions.html" %}
|
||||||
|
{% endif %}
|
||||||
{% if not room.table_status and room.gate_status != "RENEWAL_DUE" %}
|
{% if not room.table_status and room.gate_status != "RENEWAL_DUE" %}
|
||||||
{% include "apps/gameboard/_partials/_gatekeeper.html" %}
|
{% include "apps/gameboard/_partials/_gatekeeper.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div id="id_tray_wrap">
|
{% if room.table_status %}
|
||||||
|
<div id="id_tray_wrap"{% if room.table_status == "ROLE_SELECT" %} class="role-select-phase"{% endif %}>
|
||||||
<div id="id_tray_handle">
|
<div id="id_tray_handle">
|
||||||
<div id="id_tray_grip"></div>
|
<div id="id_tray_grip"></div>
|
||||||
<button id="id_tray_btn" aria-label="Open seat tray">
|
<button id="id_tray_btn" aria-label="Open seat tray">
|
||||||
@@ -86,6 +92,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="id_tray" style="display:none"><div id="id_tray_grid">{% for i in "12345678" %}<div class="tray-cell"></div>{% endfor %}</div></div>
|
<div id="id_tray" style="display:none"><div id="id_tray_grid">{% for i in "12345678" %}<div class="tray-cell"></div>{% endfor %}</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% include "apps/gameboard/_partials/_room_gear.html" %}
|
{% include "apps/gameboard/_partials/_room_gear.html" %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
Reference in New Issue
Block a user