maybe don't delete collectstatic static/tests/ dir
This commit is contained in:
@@ -163,6 +163,9 @@ var RoleSelect = (function () {
|
||||
var invSlot = document.getElementById("id_inv_role_card");
|
||||
if (invSlot) invSlot.innerHTML = "";
|
||||
|
||||
// Force-close tray instantly so it never obscures the next player's card-stack.
|
||||
if (typeof Tray !== "undefined") Tray.forceClose();
|
||||
|
||||
var stack = document.querySelector(".card-stack[data-user-slots]");
|
||||
if (stack) {
|
||||
// Sync starter-roles from server so the fan reflects actual DB state
|
||||
|
||||
@@ -8,6 +8,11 @@ class JasmineTest(FunctionalTest):
|
||||
|
||||
def check_results():
|
||||
result = self.browser.find_element(By.CSS_SELECTOR, ".jasmine-overall-result")
|
||||
self.assertIn("0 failures", result.text)
|
||||
if "0 failures" not in result.text:
|
||||
failures = self.browser.find_elements(
|
||||
By.CSS_SELECTOR, ".jasmine-failed .jasmine-description"
|
||||
)
|
||||
detail = "\n".join(f.text for f in failures) if failures else "(no detail)"
|
||||
self.fail(f"{result.text}\nFailing specs:\n{detail}")
|
||||
|
||||
self.wait_for(check_results)
|
||||
|
||||
@@ -547,23 +547,14 @@ class RoleSelectTrayTest(FunctionalTest):
|
||||
"""After confirming a role pick, the role card enters the tray grid and
|
||||
the tray opens to reveal it.
|
||||
|
||||
Grid conventions:
|
||||
Portrait — grid-auto-flow:column, 8 explicit rows. Position 0 = row 1, col 1
|
||||
(topmost-leftmost). New items prepended → grid grows rightward.
|
||||
Landscape — grid-auto-flow:row, 8 explicit columns, anchored to bottom.
|
||||
Position 0 = row 1 (bottom), col 1. New items prepended → grid
|
||||
grows upward.
|
||||
|
||||
"Dummy objects" in T2/T3 are prior gamers' role cards already placed in the
|
||||
tray. They are injected via JS because no backend mechanism exists yet to
|
||||
populate the tray for a specific gamer's view.
|
||||
Portrait — card lands at the topmost grid square (first child, row 1 col 1).
|
||||
Landscape — card lands at the leftmost grid square (first child, row 1 col 1).
|
||||
"""
|
||||
|
||||
EMAILS = [
|
||||
"slot1@test.io", "slot2@test.io", "slot3@test.io",
|
||||
"slot4@test.io", "slot5@test.io", "slot6@test.io",
|
||||
]
|
||||
ALL_ROLES = ["PC", "BC", "SC", "AC", "NC", "EC"]
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
@@ -574,21 +565,17 @@ class RoleSelectTrayTest(FunctionalTest):
|
||||
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
|
||||
)
|
||||
|
||||
def _make_room(self, active_slot=1):
|
||||
"""Room in ROLE_SELECT with all 6 seats created. Seats 1..(active_slot-1)
|
||||
already have roles assigned so the active_slot gamer is eligible."""
|
||||
def _make_room(self):
|
||||
"""Room in ROLE_SELECT with all 6 seats created, slot 1 eligible."""
|
||||
founder, _ = User.objects.get_or_create(email=self.EMAILS[0])
|
||||
room = Room.objects.create(name="Tray Card Test", owner=founder)
|
||||
_fill_room_via_orm(room, self.EMAILS)
|
||||
room.table_status = Room.ROLE_SELECT
|
||||
room.save()
|
||||
for slot in room.gate_slots.order_by("slot_number"):
|
||||
ts = TableSeat.objects.create(
|
||||
TableSeat.objects.create(
|
||||
room=room, gamer=slot.gamer, slot_number=slot.slot_number
|
||||
)
|
||||
if slot.slot_number < active_slot:
|
||||
ts.role = self.ALL_ROLES[slot.slot_number - 1]
|
||||
ts.save()
|
||||
return room
|
||||
|
||||
def _select_role(self):
|
||||
@@ -602,199 +589,76 @@ class RoleSelectTrayTest(FunctionalTest):
|
||||
self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
|
||||
self.confirm_guard()
|
||||
|
||||
def _inject_prior_role_cards(self, roles):
|
||||
"""Prepend tray-role-card divs into #id_tray_grid to simulate cards
|
||||
placed by earlier gamers. roles is oldest-first; the final state has
|
||||
the most-recent card at position 0 (front of grid)."""
|
||||
self.browser.execute_script("""
|
||||
var grid = document.getElementById('id_tray_grid');
|
||||
var roles = arguments[0];
|
||||
roles.forEach(function(role) {
|
||||
var card = document.createElement('div');
|
||||
card.className = 'tray-cell tray-role-card';
|
||||
card.dataset.role = role;
|
||||
grid.insertBefore(card, grid.firstChild);
|
||||
});
|
||||
""", roles)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# T1 — Portrait, position 1: empty tray, card at row 1 col 1 #
|
||||
# T1 — Portrait: role card at topmost grid square, tray opens #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_portrait_first_role_card_enters_grid_position_zero(self):
|
||||
"""Portrait, slot 1: after confirming a role, a .tray-role-card element
|
||||
appears as the first child of #id_tray_grid (topmost-leftmost cell), and
|
||||
the tray wrap is at least partially open."""
|
||||
def test_portrait_role_card_enters_topmost_grid_square(self):
|
||||
"""Portrait: after confirming a role, a .tray-role-card is the first child
|
||||
of #id_tray_grid (topmost cell) and the tray is open."""
|
||||
self.browser.set_window_size(390, 844)
|
||||
room = self._make_room(active_slot=1)
|
||||
room = self._make_room()
|
||||
self.create_pre_authenticated_session("slot1@test.io")
|
||||
self.browser.get(f"{self.live_server_url}/gameboard/room/{room.id}/gate/")
|
||||
|
||||
wrap = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_wrap"))
|
||||
# Record closed position before selection.
|
||||
initial_left = self.browser.execute_script(
|
||||
"return parseInt(arguments[0].style.left, 10) || window.innerWidth", wrap
|
||||
)
|
||||
grid_before = self.browser.execute_script(
|
||||
"return document.getElementById('id_tray_grid').children.length"
|
||||
)
|
||||
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_wrap"))
|
||||
self._select_role()
|
||||
|
||||
# 1. A .tray-role-card is now in the grid.
|
||||
# Card appears in the grid.
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_tray_grid .tray-role-card"
|
||||
)
|
||||
)
|
||||
|
||||
# 2. It is the first child — topmost, leftmost in portrait.
|
||||
# It is the first child — topmost in portrait.
|
||||
is_first = self.browser.execute_script("""
|
||||
var card = document.querySelector('#id_tray_grid .tray-role-card');
|
||||
return card !== null && card === card.parentElement.firstElementChild;
|
||||
""")
|
||||
self.assertTrue(is_first, "Role card should be the first child of #id_tray_grid")
|
||||
|
||||
# 3. Exactly one item was prepended.
|
||||
grid_after = self.browser.execute_script(
|
||||
"return document.getElementById('id_tray_grid').children.length"
|
||||
# Tray is open.
|
||||
self.assertTrue(
|
||||
self.browser.execute_script("return Tray.isOpen()"),
|
||||
"Tray should be open after role selection"
|
||||
)
|
||||
self.assertEqual(grid_after, grid_before + 1)
|
||||
|
||||
# 4. Tray moved from closed position toward open.
|
||||
current_left = self.browser.execute_script(
|
||||
"return parseInt(arguments[0].style.left, 10)", wrap
|
||||
)
|
||||
self.assertLess(current_left, initial_left,
|
||||
"Tray should have moved left (toward open) after role selection")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# T2 — Portrait, position 2: col 1 full, 8th item overflows to col 2 #
|
||||
# T2 — Landscape: role card at leftmost grid square, tray opens #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_portrait_second_card_prepended_pushes_eighth_item_to_col_2(self):
|
||||
"""Portrait, slot 2: col 1 already holds slot 1's role card (position 0)
|
||||
plus 7 tray-cells (positions 1-7), filling the column. After slot 2
|
||||
confirms, the new card takes position 0; the old position-7 item
|
||||
(tray-cell 6) moves to col 2, row 1 (position 8)."""
|
||||
self.browser.set_window_size(390, 844)
|
||||
room = self._make_room(active_slot=2)
|
||||
self.create_pre_authenticated_session("slot2@test.io")
|
||||
self.browser.get(f"{self.live_server_url}/gameboard/room/{room.id}/gate/")
|
||||
|
||||
# Simulate slot 1's card already placed in the tray.
|
||||
# Grid starts with 8 tray-cells; injecting 1 role card → 9 items total.
|
||||
# Col 1: [PC-card, tray-0..tray-6] = 8 (full). Col 2: [tray-7].
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_wrap"))
|
||||
self._inject_prior_role_cards(["PC"])
|
||||
|
||||
grid_before = self.browser.execute_script(
|
||||
"return document.getElementById('id_tray_grid').children.length"
|
||||
)
|
||||
self.assertEqual(grid_before, 9, "9 items before: 1 prior card + 8 tray-cells")
|
||||
|
||||
self._select_role()
|
||||
|
||||
# 1. Wait for grid to grow (fetch .then() is async).
|
||||
self.wait_for(
|
||||
lambda: self.assertEqual(
|
||||
self.browser.execute_script(
|
||||
"return document.getElementById('id_tray_grid').children.length"
|
||||
),
|
||||
grid_before + 1,
|
||||
)
|
||||
)
|
||||
grid_after = grid_before + 1
|
||||
|
||||
# 2. New tray-role-card is the first child.
|
||||
is_first = self.browser.execute_script("""
|
||||
var card = document.querySelector('#id_tray_grid .tray-role-card');
|
||||
return card !== null && card === card.parentElement.firstElementChild;
|
||||
""")
|
||||
self.assertTrue(is_first, "Newest role card should be first child")
|
||||
|
||||
# 3. The item now at position 8 (col 2, row 1) is a tray-cell —
|
||||
# it was the 8th item in col 1 and has been displaced.
|
||||
displaced = self.browser.execute_script("""
|
||||
var grid = document.getElementById('id_tray_grid');
|
||||
var el = grid.children[8];
|
||||
return el ? el.className : null;
|
||||
""")
|
||||
self.assertIsNotNone(displaced)
|
||||
self.assertIn("tray-cell", displaced)
|
||||
|
||||
# 4. Tray open enough to reveal at least col 1 (left < initial closed pos).
|
||||
wrap = self.browser.find_element(By.ID, "id_tray_wrap")
|
||||
left = self.browser.execute_script("return parseInt(arguments[0].style.left, 10)", wrap)
|
||||
viewport_w = self.browser.execute_script("return window.innerWidth")
|
||||
self.assertLess(left, viewport_w,
|
||||
"Tray should be at least partially open after role selection")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# T3 — Landscape, position 3: row 1 full, rightmost item enters row 2 #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_landscape_third_card_at_bottom_left_rightmost_overflows_to_row_2(self):
|
||||
"""Landscape, slot 3: row 1 (bottom, 8 cols) already holds 2 prior role
|
||||
cards + 6 tray-cells. After slot 3 confirms, new card at position 0
|
||||
(bottommost-leftmost); old position-7 item enters row 2, col 1 (pos 8)."""
|
||||
@tag('two-browser')
|
||||
def test_landscape_role_card_enters_leftmost_grid_square(self):
|
||||
"""Landscape: after confirming a role, a .tray-role-card is the first child
|
||||
of #id_tray_grid (leftmost cell) and the tray is open."""
|
||||
self.browser.set_window_size(844, 390)
|
||||
room = self._make_room(active_slot=3)
|
||||
self.create_pre_authenticated_session("slot3@test.io")
|
||||
room = self._make_room()
|
||||
self.create_pre_authenticated_session("slot1@test.io")
|
||||
self.browser.get(f"{self.live_server_url}/gameboard/room/{room.id}/gate/")
|
||||
|
||||
# Inject 2 prior role cards (oldest first → newest at grid front).
|
||||
# Grid: [BC-card(0), PC-card(1), tray-0(2)..tray-7(9)] = 10 items.
|
||||
# Row 1 (bottom): positions 0-7 = full. Row 2: positions 8-9.
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_wrap"))
|
||||
self._inject_prior_role_cards(["PC", "BC"])
|
||||
|
||||
grid_before = self.browser.execute_script(
|
||||
"return document.getElementById('id_tray_grid').children.length"
|
||||
)
|
||||
self.assertEqual(grid_before, 10, "10 items before: 2 prior cards + 8 tray-cells")
|
||||
|
||||
wrap = self.browser.find_element(By.ID, "id_tray_wrap")
|
||||
initial_top = self.browser.execute_script(
|
||||
"return parseInt(arguments[0].style.top, 10)", wrap
|
||||
)
|
||||
|
||||
self._select_role()
|
||||
|
||||
# 1. Wait for grid to grow (fetch .then() is async).
|
||||
# Card appears in the grid.
|
||||
self.wait_for(
|
||||
lambda: self.assertEqual(
|
||||
self.browser.execute_script(
|
||||
"return document.getElementById('id_tray_grid').children.length"
|
||||
),
|
||||
grid_before + 1,
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_tray_grid .tray-role-card"
|
||||
)
|
||||
)
|
||||
grid_after = grid_before + 1
|
||||
|
||||
# 2. Newest tray-role-card is the first child — bottommost-leftmost in landscape.
|
||||
# It is the first child — leftmost in landscape.
|
||||
is_first = self.browser.execute_script("""
|
||||
var card = document.querySelector('#id_tray_grid .tray-role-card');
|
||||
return card !== null && card === card.parentElement.firstElementChild;
|
||||
""")
|
||||
self.assertTrue(is_first, "Newest role card should be first child")
|
||||
self.assertTrue(is_first, "Role card should be the first child of #id_tray_grid")
|
||||
|
||||
# 3. Item at position 8 (row 2, col 1) is a tray-cell — it was the
|
||||
# rightmost item in row 1 (position 7) and has been displaced upward.
|
||||
displaced = self.browser.execute_script("""
|
||||
var grid = document.getElementById('id_tray_grid');
|
||||
var el = grid.children[8];
|
||||
return el ? el.className : null;
|
||||
""")
|
||||
self.assertIsNotNone(displaced)
|
||||
self.assertIn("tray-cell", displaced)
|
||||
|
||||
# 4. Tray opened downward — top is less negative (closer to 0) than before.
|
||||
current_top = self.browser.execute_script(
|
||||
"return parseInt(arguments[0].style.top, 10)", wrap
|
||||
# Tray is open.
|
||||
self.assertTrue(
|
||||
self.browser.execute_script("return Tray.isOpen()"),
|
||||
"Tray should be open after role selection"
|
||||
)
|
||||
self.assertGreater(current_top, initial_top,
|
||||
"Tray should have moved down (toward open) after role selection")
|
||||
|
||||
|
||||
@tag('channels')
|
||||
@@ -877,11 +741,10 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest):
|
||||
return b
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Test 5 — Turn passes to next gamer via WebSocket after selection #
|
||||
# Test 5 — Tray closes on turn advance (portrait) #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@unittest.skip("tray obscures card-stack after role selection — needs tray-close-on-turn-change + grid ordering fixes first")
|
||||
def test_turn_passes_after_selection(self):
|
||||
def _make_turn_test_room(self):
|
||||
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||||
User.objects.get_or_create(email="friend@test.io")
|
||||
room = Room.objects.create(name="Turn Test", owner=founder)
|
||||
@@ -895,47 +758,80 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest):
|
||||
TableSeat.objects.create(
|
||||
room=room, gamer=slot.gamer, slot_number=slot.slot_number,
|
||||
)
|
||||
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||||
return f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||||
|
||||
# 1. Founder (slot 1) — eligible
|
||||
def test_portrait_tray_closes_on_turn_advance(self):
|
||||
"""Portrait: after selecting a role the tray opens and the role card lands
|
||||
in the topmost grid square. When turn_changed arrives via WS, the tray
|
||||
force-closes so the next player's card-stack is not obscured."""
|
||||
self.browser.set_window_size(390, 844)
|
||||
room_url = self._make_turn_test_room()
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
self.browser.get(room_url)
|
||||
self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
|
||||
))
|
||||
|
||||
# 2. Friend (slot 2) — ineligible in second browser
|
||||
self.browser2 = self._make_browser2("friend@test.io")
|
||||
try:
|
||||
self.browser2.get(room_url)
|
||||
self.wait_for(lambda: self.browser2.find_element(
|
||||
By.CSS_SELECTOR, ".card-stack[data-state='ineligible']"
|
||||
))
|
||||
# Select a role — tray opens and card lands in topmost square.
|
||||
self.browser.find_element(By.CSS_SELECTOR, ".card-stack").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()
|
||||
|
||||
# 3. Founder picks a role
|
||||
self.browser.find_element(By.CSS_SELECTOR, ".card-stack").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()
|
||||
self.wait_for(lambda: self.assertTrue(
|
||||
self.browser.execute_script("return Tray.isOpen()")
|
||||
))
|
||||
is_first = self.browser.execute_script("""
|
||||
var card = document.querySelector('#id_tray_grid .tray-role-card');
|
||||
return card !== null && card === card.parentElement.firstElementChild;
|
||||
""")
|
||||
self.assertTrue(is_first, "Role card should be first child (topmost) of grid")
|
||||
|
||||
# 4. Friend's stack becomes eligible via WebSocket — no page refresh
|
||||
self.wait_for(lambda: self.browser2.find_element(
|
||||
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
|
||||
))
|
||||
# Turn advances via WS — seat 2 becomes active.
|
||||
self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".table-seat.active[data-slot='2']"
|
||||
))
|
||||
|
||||
# 5. Founder's stack is STILL ineligible — WS must not re-enable it
|
||||
self.wait_for(lambda: self.assertEqual(
|
||||
self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".card-stack"
|
||||
).get_attribute("data-state"),
|
||||
"ineligible",
|
||||
))
|
||||
# Tray must be closed after turn_changed.
|
||||
self.assertFalse(
|
||||
self.browser.execute_script("return Tray.isOpen()"),
|
||||
"Tray should be closed after turn advances"
|
||||
)
|
||||
|
||||
def test_landscape_tray_closes_on_turn_advance(self):
|
||||
"""Landscape: same sequence — role card at leftmost grid square; tray
|
||||
closes when turn_changed arrives."""
|
||||
self.browser.set_window_size(844, 390)
|
||||
room_url = self._make_turn_test_room()
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
self.browser.get(room_url)
|
||||
self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
|
||||
))
|
||||
|
||||
self.browser.find_element(By.CSS_SELECTOR, ".card-stack").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()
|
||||
|
||||
self.wait_for(lambda: self.assertTrue(
|
||||
self.browser.execute_script("return Tray.isOpen()")
|
||||
))
|
||||
is_first = self.browser.execute_script("""
|
||||
var card = document.querySelector('#id_tray_grid .tray-role-card');
|
||||
return card !== null && card === card.parentElement.firstElementChild;
|
||||
""")
|
||||
self.assertTrue(is_first, "Role card should be first child (leftmost) of grid")
|
||||
|
||||
# Turn advances via WS — seat 2 becomes active.
|
||||
self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".table-seat.active[data-slot='2']"
|
||||
))
|
||||
|
||||
# Tray must be closed after turn_changed.
|
||||
self.assertFalse(
|
||||
self.browser.execute_script("return Tray.isOpen()"),
|
||||
"Tray should be closed after turn advances"
|
||||
)
|
||||
|
||||
# 6. Clicking founder's stack does not reopen the fan
|
||||
self.browser.find_element(By.CSS_SELECTOR, ".card-stack").click()
|
||||
self.wait_for(lambda: self.assertEqual(
|
||||
len(self.browser.find_elements(By.ID, "id_role_select")), 0
|
||||
))
|
||||
finally:
|
||||
self.browser2.quit()
|
||||
|
||||
|
||||
21
src/static/tests/LICENSE
Normal file
21
src/static/tests/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
Copyright (c) 2008-2019 Pivotal Labs
|
||||
Copyright (c) 2008-2026 The Jasmine developers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
477
src/static/tests/RoleSelectSpec.js
Normal file
477
src/static/tests/RoleSelectSpec.js
Normal file
@@ -0,0 +1,477 @@
|
||||
describe("RoleSelect", () => {
|
||||
let testDiv;
|
||||
|
||||
beforeEach(() => {
|
||||
testDiv = document.createElement("div");
|
||||
testDiv.innerHTML = `
|
||||
<div class="room-page"
|
||||
data-select-role-url="/epic/room/test-uuid/select-role">
|
||||
</div>
|
||||
<div id="id_inv_role_card"></div>
|
||||
`;
|
||||
document.body.appendChild(testDiv);
|
||||
window.fetch = jasmine.createSpy("fetch").and.returnValue(
|
||||
Promise.resolve({ ok: true })
|
||||
);
|
||||
// Default stub: auto-confirm so existing card-click tests pass unchanged.
|
||||
// The click-guard integration describe overrides this with a capturing spy.
|
||||
window.showGuard = (_anchor, _msg, onConfirm) => onConfirm && onConfirm();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
RoleSelect.closeFan();
|
||||
testDiv.remove();
|
||||
delete window.showGuard;
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// openFan() //
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
describe("openFan()", () => {
|
||||
it("creates .role-select-backdrop in the DOM", () => {
|
||||
RoleSelect.openFan();
|
||||
expect(document.querySelector(".role-select-backdrop")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("creates #id_role_select inside the backdrop", () => {
|
||||
RoleSelect.openFan();
|
||||
expect(document.getElementById("id_role_select")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders exactly 6 .card elements", () => {
|
||||
RoleSelect.openFan();
|
||||
const cards = document.querySelectorAll("#id_role_select .card");
|
||||
expect(cards.length).toBe(6);
|
||||
});
|
||||
|
||||
it("does not open a second backdrop if already open", () => {
|
||||
RoleSelect.openFan();
|
||||
RoleSelect.openFan();
|
||||
expect(document.querySelectorAll(".role-select-backdrop").length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// closeFan() //
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
describe("closeFan()", () => {
|
||||
it("removes .role-select-backdrop from the DOM", () => {
|
||||
RoleSelect.openFan();
|
||||
RoleSelect.closeFan();
|
||||
expect(document.querySelector(".role-select-backdrop")).toBeNull();
|
||||
});
|
||||
|
||||
it("removes #id_role_select from the DOM", () => {
|
||||
RoleSelect.openFan();
|
||||
RoleSelect.closeFan();
|
||||
expect(document.getElementById("id_role_select")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not throw if no fan is open", () => {
|
||||
expect(() => RoleSelect.closeFan()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Card interactions //
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
describe("card interactions", () => {
|
||||
beforeEach(() => {
|
||||
RoleSelect.openFan();
|
||||
});
|
||||
|
||||
it("mouseenter adds .flipped to the card", () => {
|
||||
const card = document.querySelector("#id_role_select .card");
|
||||
card.dispatchEvent(new MouseEvent("mouseenter"));
|
||||
expect(card.classList.contains("flipped")).toBe(true);
|
||||
});
|
||||
|
||||
it("mouseleave removes .flipped from the card", () => {
|
||||
const card = document.querySelector("#id_role_select .card");
|
||||
card.dispatchEvent(new MouseEvent("mouseenter"));
|
||||
card.dispatchEvent(new MouseEvent("mouseleave"));
|
||||
expect(card.classList.contains("flipped")).toBe(false);
|
||||
});
|
||||
|
||||
it("clicking a card closes the fan", () => {
|
||||
const card = document.querySelector("#id_role_select .card");
|
||||
card.click();
|
||||
expect(document.getElementById("id_role_select")).toBeNull();
|
||||
});
|
||||
|
||||
it("clicking a card appends a .card to #id_inv_role_card", () => {
|
||||
const card = document.querySelector("#id_role_select .card");
|
||||
card.click();
|
||||
expect(document.querySelector("#id_inv_role_card .card")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("clicking a card POSTs to the select_role URL", () => {
|
||||
const card = document.querySelector("#id_role_select .card");
|
||||
card.click();
|
||||
expect(window.fetch).toHaveBeenCalledWith(
|
||||
"/epic/room/test-uuid/select-role",
|
||||
jasmine.objectContaining({ method: "POST" })
|
||||
);
|
||||
});
|
||||
|
||||
it("clicking a card results in exactly one card in inventory", () => {
|
||||
const card = document.querySelector("#id_role_select .card");
|
||||
card.click();
|
||||
expect(document.querySelectorAll("#id_inv_role_card .card").length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Backdrop click //
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
describe("backdrop click", () => {
|
||||
it("closes the fan", () => {
|
||||
RoleSelect.openFan();
|
||||
document.querySelector(".role-select-backdrop").click();
|
||||
expect(document.getElementById("id_role_select")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not add a card to inventory", () => {
|
||||
RoleSelect.openFan();
|
||||
document.querySelector(".role-select-backdrop").click();
|
||||
expect(document.querySelector("#id_inv_role_card .card")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// room:roles_revealed event //
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
describe("room:roles_revealed event", () => {
|
||||
let reloadCalled;
|
||||
|
||||
beforeEach(() => {
|
||||
reloadCalled = false;
|
||||
RoleSelect.setReload(() => { reloadCalled = true; });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
RoleSelect.setReload(() => { window.location.reload(); });
|
||||
});
|
||||
|
||||
it("triggers a page reload", () => {
|
||||
window.dispatchEvent(new CustomEvent("room:roles_revealed", { detail: {} }));
|
||||
expect(reloadCalled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// room:turn_changed event //
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
describe("room:turn_changed event", () => {
|
||||
let stack;
|
||||
|
||||
beforeEach(() => {
|
||||
// Six table seats, slot 1 starts active
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
const seat = document.createElement("div");
|
||||
seat.className = "table-seat" + (i === 1 ? " active" : "");
|
||||
seat.dataset.slot = String(i);
|
||||
seat.innerHTML = '<div class="seat-card-arc"></div>';
|
||||
testDiv.appendChild(seat);
|
||||
}
|
||||
stack = document.createElement("div");
|
||||
stack.className = "card-stack";
|
||||
stack.dataset.state = "ineligible";
|
||||
stack.dataset.userSlots = "1";
|
||||
stack.dataset.starterRoles = "";
|
||||
testDiv.appendChild(stack);
|
||||
});
|
||||
|
||||
it("calls Tray.forceClose() on turn change", () => {
|
||||
spyOn(Tray, "forceClose");
|
||||
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||
detail: { active_slot: 2 }
|
||||
}));
|
||||
expect(Tray.forceClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves .active to the newly active seat", () => {
|
||||
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||
detail: { active_slot: 2 }
|
||||
}));
|
||||
expect(
|
||||
testDiv.querySelector(".table-seat.active").dataset.slot
|
||||
).toBe("2");
|
||||
});
|
||||
|
||||
it("removes .active from the previously active seat", () => {
|
||||
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||
detail: { active_slot: 2 }
|
||||
}));
|
||||
expect(
|
||||
testDiv.querySelector(".table-seat[data-slot='1']").classList.contains("active")
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("sets data-state to eligible when active_slot matches user slot", () => {
|
||||
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||
detail: { active_slot: 1 }
|
||||
}));
|
||||
expect(stack.dataset.state).toBe("eligible");
|
||||
});
|
||||
|
||||
it("sets data-state to ineligible when active_slot does not match", () => {
|
||||
stack.dataset.state = "eligible";
|
||||
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||
detail: { active_slot: 2 }
|
||||
}));
|
||||
expect(stack.dataset.state).toBe("ineligible");
|
||||
});
|
||||
|
||||
it("clicking stack opens fan when newly eligible", () => {
|
||||
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||
detail: { active_slot: 1 }
|
||||
}));
|
||||
stack.click();
|
||||
expect(document.querySelector(".role-select-backdrop")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("clicking stack does not open fan when ineligible", () => {
|
||||
// Make eligible first (adds listener), then flip back to ineligible
|
||||
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||
detail: { active_slot: 1 }
|
||||
}));
|
||||
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||
detail: { active_slot: 2 }
|
||||
}));
|
||||
stack.click();
|
||||
expect(document.querySelector(".role-select-backdrop")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Tray card placement after successful role selection //
|
||||
// ------------------------------------------------------------------ //
|
||||
// The tray-role-card is created in the fetch .then() callback, so //
|
||||
// these tests are async — await Promise.resolve() flushes the //
|
||||
// microtask queue before asserting. //
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
describe("tray card after successful role selection", () => {
|
||||
let grid, guardConfirm;
|
||||
|
||||
beforeEach(() => {
|
||||
// Minimal tray grid matching room.html structure
|
||||
grid = document.createElement("div");
|
||||
grid.id = "id_tray_grid";
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "tray-cell";
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
testDiv.appendChild(grid);
|
||||
|
||||
spyOn(Tray, "open");
|
||||
|
||||
// Capturing guard spy — holds onConfirm so we can fire it per-test
|
||||
window.showGuard = jasmine.createSpy("showGuard").and.callFake(
|
||||
(anchor, message, onConfirm) => { guardConfirm = onConfirm; }
|
||||
);
|
||||
|
||||
RoleSelect.openFan();
|
||||
document.querySelector("#id_role_select .card").click();
|
||||
});
|
||||
|
||||
it("prepends a .tray-role-card to #id_tray_grid on success", async () => {
|
||||
guardConfirm();
|
||||
await Promise.resolve();
|
||||
expect(grid.querySelector(".tray-role-card")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("tray-role-card is the first child of #id_tray_grid", async () => {
|
||||
guardConfirm();
|
||||
await Promise.resolve();
|
||||
expect(grid.firstElementChild.classList.contains("tray-role-card")).toBe(true);
|
||||
});
|
||||
|
||||
it("tray-role-card carries the selected role as data-role", async () => {
|
||||
guardConfirm();
|
||||
await Promise.resolve();
|
||||
const trayCard = grid.querySelector(".tray-role-card");
|
||||
expect(trayCard.dataset.role).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls Tray.open() on success", async () => {
|
||||
guardConfirm();
|
||||
await Promise.resolve();
|
||||
expect(Tray.open).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not prepend a tray-role-card on server rejection", async () => {
|
||||
window.fetch = jasmine.createSpy("fetch").and.returnValue(
|
||||
Promise.resolve({ ok: false })
|
||||
);
|
||||
guardConfirm();
|
||||
await Promise.resolve();
|
||||
expect(grid.querySelector(".tray-role-card")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not call Tray.open() on server rejection", async () => {
|
||||
window.fetch = jasmine.createSpy("fetch").and.returnValue(
|
||||
Promise.resolve({ ok: false })
|
||||
);
|
||||
guardConfirm();
|
||||
await Promise.resolve();
|
||||
expect(Tray.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("grid grows by exactly 1 on success", async () => {
|
||||
const before = grid.children.length;
|
||||
guardConfirm();
|
||||
await Promise.resolve();
|
||||
expect(grid.children.length).toBe(before + 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// click-guard integration //
|
||||
// ------------------------------------------------------------------ //
|
||||
// NOTE: cascade prevention (outside-click on backdrop not closing the //
|
||||
// fan while the guard is active) relies on the guard portal's capture- //
|
||||
// phase stopPropagation, which lives in base.html and requires //
|
||||
// integration testing. The callback contract is fully covered below. //
|
||||
// ------------------------------------------------------------------ //
|
||||
|
||||
describe("click-guard integration", () => {
|
||||
let guardAnchor, guardMessage, guardConfirm, guardDismiss;
|
||||
|
||||
beforeEach(() => {
|
||||
window.showGuard = jasmine.createSpy("showGuard").and.callFake(
|
||||
(anchor, message, onConfirm, onDismiss) => {
|
||||
guardAnchor = anchor;
|
||||
guardMessage = message;
|
||||
guardConfirm = onConfirm;
|
||||
guardDismiss = onDismiss;
|
||||
}
|
||||
);
|
||||
RoleSelect.openFan();
|
||||
});
|
||||
|
||||
describe("clicking a card", () => {
|
||||
let card;
|
||||
|
||||
beforeEach(() => {
|
||||
card = document.querySelector("#id_role_select .card");
|
||||
card.click();
|
||||
});
|
||||
|
||||
it("calls window.showGuard", () => {
|
||||
expect(window.showGuard).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes the card element as the anchor", () => {
|
||||
expect(guardAnchor).toBe(card);
|
||||
});
|
||||
|
||||
it("message contains the role name", () => {
|
||||
const roleName = card.querySelector(".card-role-name").textContent.trim();
|
||||
expect(guardMessage).toContain(roleName);
|
||||
});
|
||||
|
||||
it("message contains the role code", () => {
|
||||
expect(guardMessage).toContain(card.dataset.role);
|
||||
});
|
||||
|
||||
it("message contains a <br>", () => {
|
||||
expect(guardMessage).toContain("<br>");
|
||||
});
|
||||
|
||||
it("does not immediately close the fan", () => {
|
||||
expect(document.querySelector(".role-select-backdrop")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does not immediately POST to the select_role URL", () => {
|
||||
expect(window.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds .flipped to the card", () => {
|
||||
expect(card.classList.contains("flipped")).toBe(true);
|
||||
});
|
||||
|
||||
it("adds .guard-active to the card", () => {
|
||||
expect(card.classList.contains("guard-active")).toBe(true);
|
||||
});
|
||||
|
||||
it("mouseleave does not remove .flipped while guard is active", () => {
|
||||
card.dispatchEvent(new MouseEvent("mouseleave"));
|
||||
expect(card.classList.contains("flipped")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirming the guard (OK)", () => {
|
||||
let card;
|
||||
|
||||
beforeEach(() => {
|
||||
card = document.querySelector("#id_role_select .card");
|
||||
card.click();
|
||||
guardConfirm();
|
||||
});
|
||||
|
||||
it("removes .guard-active from the card", () => {
|
||||
expect(card.classList.contains("guard-active")).toBe(false);
|
||||
});
|
||||
|
||||
it("closes the fan", () => {
|
||||
expect(document.querySelector(".role-select-backdrop")).toBeNull();
|
||||
});
|
||||
|
||||
it("POSTs to the select_role URL", () => {
|
||||
expect(window.fetch).toHaveBeenCalledWith(
|
||||
"/epic/room/test-uuid/select-role",
|
||||
jasmine.objectContaining({ method: "POST" })
|
||||
);
|
||||
});
|
||||
|
||||
it("appends a .card to #id_inv_role_card", () => {
|
||||
expect(document.querySelector("#id_inv_role_card .card")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dismissing the guard (NVM or outside click)", () => {
|
||||
let card;
|
||||
|
||||
beforeEach(() => {
|
||||
card = document.querySelector("#id_role_select .card");
|
||||
card.click();
|
||||
guardDismiss();
|
||||
});
|
||||
|
||||
it("removes .guard-active from the card", () => {
|
||||
expect(card.classList.contains("guard-active")).toBe(false);
|
||||
});
|
||||
|
||||
it("removes .flipped from the card", () => {
|
||||
expect(card.classList.contains("flipped")).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves the fan open", () => {
|
||||
expect(document.querySelector(".role-select-backdrop")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does not POST to the select_role URL", () => {
|
||||
expect(window.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not add a card to inventory", () => {
|
||||
expect(document.querySelector("#id_inv_role_card .card")).toBeNull();
|
||||
});
|
||||
|
||||
it("restores normal mouseleave behaviour on the card", () => {
|
||||
card.dispatchEvent(new MouseEvent("mouseenter"));
|
||||
card.dispatchEvent(new MouseEvent("mouseleave"));
|
||||
expect(card.classList.contains("flipped")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
57
src/static/tests/Spec.js
Normal file
57
src/static/tests/Spec.js
Normal file
@@ -0,0 +1,57 @@
|
||||
console.log("Spec.js is loading");
|
||||
|
||||
describe("GameArray JavaScript", () => {
|
||||
const inputId= "id_text";
|
||||
const errorClass = "invalid-feedback";
|
||||
const inputSelector = `#${inputId}`;
|
||||
const errorSelector = `.${errorClass}`;
|
||||
let testDiv;
|
||||
let textInput;
|
||||
let errorMsg;
|
||||
|
||||
beforeEach(() => {
|
||||
console.log("beforeEach");
|
||||
testDiv = document.createElement("div");
|
||||
testDiv.innerHTML = `
|
||||
<form>
|
||||
<input
|
||||
id="${inputId}"
|
||||
name="text"
|
||||
class="form-control form-control-lg is-invalid"
|
||||
placeholder="Enter a to-do item"
|
||||
value="Value as submitted"
|
||||
aria-describedby="id_text_feedback"
|
||||
required
|
||||
/>
|
||||
<div id="id_text_feedback" class="${errorClass}">An error message</div>
|
||||
</form>
|
||||
`;
|
||||
document.body.appendChild(testDiv);
|
||||
textInput = document.querySelector(inputSelector);
|
||||
errorMsg = document.querySelector(errorSelector);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
testDiv.remove();
|
||||
});
|
||||
|
||||
it("should have a useful html fixture", () => {
|
||||
console.log("in test 1");
|
||||
expect(errorMsg.checkVisibility()).toBe(true);
|
||||
});
|
||||
|
||||
it("should hide error message on input", () => {
|
||||
console.log("in test 2");
|
||||
initialize(inputSelector);
|
||||
textInput.dispatchEvent(new InputEvent("input"));
|
||||
|
||||
expect(errorMsg.checkVisibility()).toBe(false);
|
||||
});
|
||||
|
||||
it("should not hide error message before event is fired", () => {
|
||||
console.log("in test 3");
|
||||
initialize(inputSelector);
|
||||
|
||||
expect(errorMsg.checkVisibility()).toBe(true);
|
||||
});
|
||||
});
|
||||
38
src/static/tests/SpecRunner.html
Normal file
38
src/static/tests/SpecRunner.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="author" content="Disco DeDisco">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
|
||||
<!-- Bootstrap CSS -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.8/css/bootstrap.min.css"/>
|
||||
<link rel="stylesheet" href="lib/jasmine-6.0.1/jasmine.css">
|
||||
|
||||
<title>Jasmine Spec Runner</title>
|
||||
<link rel="stylesheet" href="lib/jasmine.css">
|
||||
|
||||
<!-- Jasmine -->
|
||||
<script src="lib/jasmine-6.0.1/jasmine.js"></script>
|
||||
<script src="lib/jasmine-6.0.1/jasmine-html.js"></script>
|
||||
<script src="lib/jasmine-6.0.1/boot0.js"></script>
|
||||
<!-- spec files -->
|
||||
<script src="Spec.js"></script>
|
||||
<script src="RoleSelectSpec.js"></script>
|
||||
<script src="TraySpec.js"></script>
|
||||
<!-- src files -->
|
||||
<script src="/static/apps/dashboard/dashboard.js"></script>
|
||||
<script src="/static/apps/epic/role-select.js"></script>
|
||||
<script src="/static/apps/epic/tray.js"></script>
|
||||
<!-- Jasmine env config (optional) -->
|
||||
<script src="lib/jasmine-6.0.1/boot1.js"></script>
|
||||
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
425
src/static/tests/TraySpec.js
Normal file
425
src/static/tests/TraySpec.js
Normal file
@@ -0,0 +1,425 @@
|
||||
// ── TraySpec.js ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Unit specs for tray.js — the per-seat, per-room slide-out panel anchored
|
||||
// to the right edge of the viewport.
|
||||
//
|
||||
// DOM contract assumed by the module:
|
||||
// #id_tray_wrap — outermost container; JS sets style.left for positioning
|
||||
// #id_tray_btn — the drawer-handle button
|
||||
// #id_tray — the tray panel (hidden by default)
|
||||
//
|
||||
// Public API under test:
|
||||
// Tray.init() — compute bounds, apply vertical bounds, attach listeners
|
||||
// Tray.open() — reveal tray, animate wrap to minLeft
|
||||
// Tray.close() — hide tray, animate wrap to maxLeft
|
||||
// Tray.isOpen() — state predicate
|
||||
// Tray.reset() — restore initial state (for afterEach)
|
||||
//
|
||||
// Drag model: tray follows pointer in real-time; position persists on release.
|
||||
// Any leftward drag opens the tray.
|
||||
// Drag > 10px suppresses the subsequent click event.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Tray", () => {
|
||||
let btn, tray, wrap;
|
||||
|
||||
beforeEach(() => {
|
||||
wrap = document.createElement("div");
|
||||
wrap.id = "id_tray_wrap";
|
||||
|
||||
btn = document.createElement("button");
|
||||
btn.id = "id_tray_btn";
|
||||
|
||||
tray = document.createElement("div");
|
||||
tray.id = "id_tray";
|
||||
tray.style.display = "none";
|
||||
|
||||
wrap.appendChild(btn);
|
||||
document.body.appendChild(wrap);
|
||||
document.body.appendChild(tray);
|
||||
|
||||
Tray._testSetLandscape(false); // force portrait regardless of window size
|
||||
Tray.init();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Tray.reset();
|
||||
wrap.remove();
|
||||
tray.remove();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------- //
|
||||
// open() //
|
||||
// ---------------------------------------------------------------------- //
|
||||
|
||||
describe("open()", () => {
|
||||
it("makes #id_tray visible", () => {
|
||||
Tray.open();
|
||||
expect(tray.style.display).not.toBe("none");
|
||||
});
|
||||
|
||||
it("adds .open to #id_tray_btn", () => {
|
||||
Tray.open();
|
||||
expect(btn.classList.contains("open")).toBe(true);
|
||||
});
|
||||
|
||||
it("sets wrap left to minLeft (0)", () => {
|
||||
Tray.open();
|
||||
expect(wrap.style.left).toBe("0px");
|
||||
});
|
||||
|
||||
it("calling open() twice does not duplicate .open", () => {
|
||||
Tray.open();
|
||||
Tray.open();
|
||||
const openCount = btn.className.split(" ").filter(c => c === "open").length;
|
||||
expect(openCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------- //
|
||||
// close() //
|
||||
// ---------------------------------------------------------------------- //
|
||||
|
||||
describe("close()", () => {
|
||||
beforeEach(() => Tray.open());
|
||||
|
||||
it("hides #id_tray after slide + snap both complete", () => {
|
||||
Tray.close();
|
||||
wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "left" }));
|
||||
wrap.dispatchEvent(new Event("animationend"));
|
||||
expect(tray.style.display).toBe("none");
|
||||
});
|
||||
|
||||
it("adds .snap to wrap after slide transition completes", () => {
|
||||
Tray.close();
|
||||
wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "left" }));
|
||||
expect(wrap.classList.contains("snap")).toBe(true);
|
||||
});
|
||||
|
||||
it("removes .snap from wrap once animationend fires", () => {
|
||||
Tray.close();
|
||||
wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "left" }));
|
||||
wrap.dispatchEvent(new Event("animationend"));
|
||||
expect(wrap.classList.contains("snap")).toBe(false);
|
||||
});
|
||||
|
||||
it("removes .open from #id_tray_btn", () => {
|
||||
Tray.close();
|
||||
expect(btn.classList.contains("open")).toBe(false);
|
||||
});
|
||||
|
||||
it("sets wrap left to maxLeft", () => {
|
||||
Tray.close();
|
||||
expect(parseInt(wrap.style.left, 10)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not throw if already closed", () => {
|
||||
Tray.close();
|
||||
expect(() => Tray.close()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------- //
|
||||
// isOpen() //
|
||||
// ---------------------------------------------------------------------- //
|
||||
|
||||
describe("isOpen()", () => {
|
||||
it("returns false by default", () => {
|
||||
expect(Tray.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true after open()", () => {
|
||||
Tray.open();
|
||||
expect(Tray.isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false after close()", () => {
|
||||
Tray.open();
|
||||
Tray.close();
|
||||
expect(Tray.isOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------- //
|
||||
// Click when closed — wobble wrap, do not open //
|
||||
// ---------------------------------------------------------------------- //
|
||||
|
||||
describe("clicking btn when closed", () => {
|
||||
it("adds .wobble to wrap", () => {
|
||||
btn.click();
|
||||
expect(wrap.classList.contains("wobble")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not open the tray", () => {
|
||||
btn.click();
|
||||
expect(Tray.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("removes .wobble once animationend fires on wrap", () => {
|
||||
btn.click();
|
||||
wrap.dispatchEvent(new Event("animationend"));
|
||||
expect(wrap.classList.contains("wobble")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------- //
|
||||
// Click when open — close, no wobble //
|
||||
// ---------------------------------------------------------------------- //
|
||||
|
||||
describe("clicking btn when open", () => {
|
||||
beforeEach(() => Tray.open());
|
||||
|
||||
it("closes the tray", () => {
|
||||
btn.click();
|
||||
expect(Tray.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("does not add .wobble", () => {
|
||||
btn.click();
|
||||
expect(wrap.classList.contains("wobble")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------- //
|
||||
// Drag interaction — continuous positioning //
|
||||
// ---------------------------------------------------------------------- //
|
||||
|
||||
describe("drag interaction", () => {
|
||||
function simulateDrag(deltaX) {
|
||||
const startX = 800;
|
||||
btn.dispatchEvent(new PointerEvent("pointerdown", { clientX: startX, bubbles: true }));
|
||||
btn.dispatchEvent(new PointerEvent("pointermove", { clientX: startX + deltaX, bubbles: true }));
|
||||
btn.dispatchEvent(new PointerEvent("pointerup", { clientX: startX + deltaX, bubbles: true }));
|
||||
}
|
||||
|
||||
it("dragging left opens the tray", () => {
|
||||
simulateDrag(-60);
|
||||
expect(Tray.isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it("any leftward drag opens the tray", () => {
|
||||
simulateDrag(-20);
|
||||
expect(Tray.isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it("dragging right does not open the tray", () => {
|
||||
simulateDrag(100);
|
||||
expect(Tray.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("drag > 10px suppresses the subsequent click", () => {
|
||||
simulateDrag(-60);
|
||||
btn.click(); // should be swallowed — tray stays open
|
||||
expect(Tray.isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it("does not add .wobble during drag", () => {
|
||||
simulateDrag(-60);
|
||||
expect(wrap.classList.contains("wobble")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------- //
|
||||
// Landscape mode — Y-axis drag, top-positioned wrap //
|
||||
// ---------------------------------------------------------------------- //
|
||||
|
||||
describe("landscape mode", () => {
|
||||
// Re-init in landscape after the portrait init from outer beforeEach.
|
||||
beforeEach(() => {
|
||||
Tray.reset();
|
||||
Tray._testSetLandscape(true);
|
||||
Tray.init();
|
||||
});
|
||||
|
||||
function simulateDragY(deltaY) {
|
||||
const startY = 50;
|
||||
btn.dispatchEvent(new PointerEvent("pointerdown", { clientY: startY, clientX: 0, bubbles: true }));
|
||||
btn.dispatchEvent(new PointerEvent("pointermove", { clientY: startY + deltaY, clientX: 0, bubbles: true }));
|
||||
btn.dispatchEvent(new PointerEvent("pointerup", { clientY: startY + deltaY, clientX: 0, bubbles: true }));
|
||||
}
|
||||
|
||||
// ── open() in landscape ─────────────────────────────────────────── //
|
||||
|
||||
describe("open()", () => {
|
||||
it("makes #id_tray visible", () => {
|
||||
Tray.open();
|
||||
expect(tray.style.display).not.toBe("none");
|
||||
});
|
||||
|
||||
it("adds .open to #id_tray_btn", () => {
|
||||
Tray.open();
|
||||
expect(btn.classList.contains("open")).toBe(true);
|
||||
});
|
||||
|
||||
it("positions wrap via style.top, not style.left", () => {
|
||||
Tray.open();
|
||||
expect(wrap.style.top).not.toBe("");
|
||||
expect(wrap.style.left).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ── close() in landscape ────────────────────────────────────────── //
|
||||
|
||||
describe("close()", () => {
|
||||
beforeEach(() => Tray.open());
|
||||
|
||||
it("closes the tray (display not toggled in landscape)", () => {
|
||||
Tray.close();
|
||||
expect(Tray.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("removes .open from #id_tray_btn", () => {
|
||||
Tray.close();
|
||||
expect(btn.classList.contains("open")).toBe(false);
|
||||
});
|
||||
|
||||
it("closed top is less than open top (wrap slides up to close)", () => {
|
||||
const openTop = parseInt(wrap.style.top, 10);
|
||||
Tray.close();
|
||||
const closedTop = parseInt(wrap.style.top, 10);
|
||||
expect(closedTop).toBeLessThan(openTop);
|
||||
});
|
||||
|
||||
it("adds .snap to wrap after top transition completes", () => {
|
||||
Tray.close();
|
||||
wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "top" }));
|
||||
expect(wrap.classList.contains("snap")).toBe(true);
|
||||
});
|
||||
|
||||
it("removes .snap from wrap once animationend fires", () => {
|
||||
Tray.close();
|
||||
wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "top" }));
|
||||
wrap.dispatchEvent(new Event("animationend"));
|
||||
expect(wrap.classList.contains("snap")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── drag — Y axis ──────────────────────────────────────────────── //
|
||||
|
||||
describe("drag interaction", () => {
|
||||
it("dragging down opens the tray", () => {
|
||||
simulateDragY(100);
|
||||
expect(Tray.isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it("dragging up does not open the tray", () => {
|
||||
simulateDragY(-100);
|
||||
expect(Tray.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("drag > 10px downward suppresses subsequent click", () => {
|
||||
simulateDragY(100);
|
||||
btn.click(); // should be swallowed — tray stays open
|
||||
expect(Tray.isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it("does not set style.left (Y axis only)", () => {
|
||||
simulateDragY(100);
|
||||
expect(wrap.style.left).toBe("");
|
||||
});
|
||||
|
||||
it("does not add .wobble during drag", () => {
|
||||
simulateDragY(100);
|
||||
expect(wrap.classList.contains("wobble")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── click when closed — wobble, no open ───────────────────────── //
|
||||
|
||||
describe("clicking btn when closed", () => {
|
||||
it("adds .wobble to wrap", () => {
|
||||
btn.click();
|
||||
expect(wrap.classList.contains("wobble")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not open the tray", () => {
|
||||
btn.click();
|
||||
expect(Tray.isOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── click when open — close ────────────────────────────────────── //
|
||||
|
||||
describe("clicking btn when open", () => {
|
||||
beforeEach(() => Tray.open());
|
||||
|
||||
it("closes the tray", () => {
|
||||
btn.click();
|
||||
expect(Tray.isOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── init positions wrap at closed (top) ────────────────────────── //
|
||||
|
||||
it("init sets wrap to closed position (top < 0 or = maxTop)", () => {
|
||||
// After landscape init with no real elements, _maxTop = -(wrapH_fallback - handleH_fallback)
|
||||
// which will be negative. Wrap starts off-screen above.
|
||||
const top = parseInt(wrap.style.top, 10);
|
||||
expect(top).toBeLessThan(0);
|
||||
});
|
||||
|
||||
// ── resize closes landscape tray ─────────────────────────────── //
|
||||
|
||||
describe("resize closes the tray", () => {
|
||||
it("closes when landscape tray is open", () => {
|
||||
Tray.open();
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
expect(Tray.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("removes .open from btn on resize", () => {
|
||||
Tray.open();
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
expect(btn.classList.contains("open")).toBe(false);
|
||||
});
|
||||
|
||||
it("resets wrap to closed top position on resize", () => {
|
||||
Tray.open();
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
expect(parseInt(wrap.style.top, 10)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("does not re-open a closed tray on resize", () => {
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
expect(Tray.isOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------- //
|
||||
// window resize — portrait //
|
||||
// ---------------------------------------------------------------------- //
|
||||
|
||||
describe("window resize (portrait)", () => {
|
||||
it("closes the tray when open", () => {
|
||||
Tray.open();
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
expect(Tray.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it("removes .open from btn on resize", () => {
|
||||
Tray.open();
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
expect(btn.classList.contains("open")).toBe(false);
|
||||
});
|
||||
|
||||
it("hides the tray panel on resize", () => {
|
||||
Tray.open();
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
expect(tray.style.display).toBe("none");
|
||||
});
|
||||
|
||||
it("resets wrap to closed left position on resize", () => {
|
||||
Tray.open();
|
||||
expect(wrap.style.left).toBe("0px");
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
expect(parseInt(wrap.style.left, 10)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not re-open a closed tray on resize", () => {
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
expect(Tray.isOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
68
src/static/tests/lib/jasmine-6.0.1/boot0.js
Normal file
68
src/static/tests/lib/jasmine-6.0.1/boot0.js
Normal file
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
Copyright (c) 2008-2019 Pivotal Labs
|
||||
Copyright (c) 2008-2026 The Jasmine developers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
This file starts the process of "booting" Jasmine. It initializes Jasmine,
|
||||
makes its globals available, and creates the env. This file should be loaded
|
||||
after `jasmine.js` and `jasmine_html.js`, but before `boot1.js` or any project
|
||||
source files or spec files are loaded.
|
||||
*/
|
||||
(function() {
|
||||
const jasmineRequire = window.jasmineRequire || require('./jasmine.js');
|
||||
|
||||
/**
|
||||
* ## Require & Instantiate
|
||||
*
|
||||
* Require Jasmine's core files. Specifically, this requires and attaches all of Jasmine's code to the `jasmine` reference.
|
||||
*/
|
||||
const jasmine = jasmineRequire.core(jasmineRequire),
|
||||
global = jasmine.getGlobal();
|
||||
global.jasmine = jasmine;
|
||||
|
||||
/**
|
||||
* Since this is being run in a browser and the results should populate to an HTML page, require the HTML-specific Jasmine code, injecting the same reference.
|
||||
*/
|
||||
jasmineRequire.html(jasmine);
|
||||
|
||||
/**
|
||||
* Create the Jasmine environment. This is used to run all specs in a project.
|
||||
*/
|
||||
const env = jasmine.getEnv();
|
||||
|
||||
/**
|
||||
* ## The Global Interface
|
||||
*
|
||||
* Build up the functions that will be exposed as the Jasmine public interface. A project can customize, rename or alias any of these functions as desired, provided the implementation remains unchanged.
|
||||
*/
|
||||
const jasmineInterface = jasmineRequire.interface(jasmine, env);
|
||||
|
||||
/**
|
||||
* Add all of the Jasmine global/public interface to the global scope, so a project can use the public interface directly. For example, calling `describe` in specs instead of `jasmine.getEnv().describe`.
|
||||
*/
|
||||
for (const property in jasmineInterface) {
|
||||
global[property] = jasmineInterface[property];
|
||||
}
|
||||
})();
|
||||
64
src/static/tests/lib/jasmine-6.0.1/boot1.js
Normal file
64
src/static/tests/lib/jasmine-6.0.1/boot1.js
Normal file
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
Copyright (c) 2008-2019 Pivotal Labs
|
||||
Copyright (c) 2008-2026 The Jasmine developers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
This file finishes 'booting' Jasmine, performing all of the necessary
|
||||
initialization before executing the loaded environment and all of a project's
|
||||
specs. This file should be loaded after `boot0.js` but before any project
|
||||
source files or spec files are loaded. Thus this file can also be used to
|
||||
customize Jasmine for a project.
|
||||
|
||||
If a project is using Jasmine via the standalone distribution, this file can
|
||||
be customized directly. If you only wish to configure the Jasmine env, you
|
||||
can load another file that calls `jasmine.getEnv().configure({...})`
|
||||
after `boot0.js` is loaded and before this file is loaded.
|
||||
*/
|
||||
|
||||
(function() {
|
||||
const env = jasmine.getEnv();
|
||||
const urls = new jasmine.HtmlReporterV2Urls();
|
||||
|
||||
/**
|
||||
* Configures Jasmine based on the current set of query parameters. This
|
||||
* supports all parameters set by the HTML reporter as well as
|
||||
* spec=partialPath, which filters out specs whose paths don't contain the
|
||||
* parameter.
|
||||
*/
|
||||
env.configure(urls.configFromCurrentUrl());
|
||||
|
||||
const currentWindowOnload = window.onload;
|
||||
window.onload = function() {
|
||||
if (currentWindowOnload) {
|
||||
currentWindowOnload();
|
||||
}
|
||||
|
||||
// The HTML reporter needs to be set up here so it can access the DOM. Other
|
||||
// reporters can be added at any time before env.execute() is called.
|
||||
const htmlReporter = new jasmine.HtmlReporterV2({ env, urls });
|
||||
env.addReporter(htmlReporter);
|
||||
env.execute();
|
||||
};
|
||||
})();
|
||||
1863
src/static/tests/lib/jasmine-6.0.1/jasmine-html.js
Normal file
1863
src/static/tests/lib/jasmine-6.0.1/jasmine-html.js
Normal file
File diff suppressed because it is too large
Load Diff
351
src/static/tests/lib/jasmine-6.0.1/jasmine.css
Normal file
351
src/static/tests/lib/jasmine-6.0.1/jasmine.css
Normal file
File diff suppressed because one or more lines are too long
12412
src/static/tests/lib/jasmine-6.0.1/jasmine.js
Normal file
12412
src/static/tests/lib/jasmine-6.0.1/jasmine.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -188,6 +188,14 @@ describe("RoleSelect", () => {
|
||||
testDiv.appendChild(stack);
|
||||
});
|
||||
|
||||
it("calls Tray.forceClose() on turn change", () => {
|
||||
spyOn(Tray, "forceClose");
|
||||
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||
detail: { active_slot: 2 }
|
||||
}));
|
||||
expect(Tray.forceClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves .active to the newly active seat", () => {
|
||||
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||
detail: { active_slot: 2 }
|
||||
|
||||
Reference in New Issue
Block a user