added PICK SKY ready gate: SigReservation.ready + countdown_remaining fields, Room.SKY_SELECT status + sig_select_started_at, sig_ready + sig_confirm views, WS notifiers for countdown_start/cancel/polarity_room_done/pick_sky_available, migration 0031, PICK SKY btn in hex center at SKY_SELECT, tray cell 2 sig card placeholder; FTs SRG1-8 written (pending JS/consumer)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
33
src/apps/epic/migrations/0031_sig_ready_sky_select.py
Normal file
33
src/apps/epic/migrations/0031_sig_ready_sky_select.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-04-09 04:53
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0030_sigreservation_seat_fk'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='room',
|
||||||
|
name='sig_select_started_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sigreservation',
|
||||||
|
name='countdown_remaining',
|
||||||
|
field=models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sigreservation',
|
||||||
|
name='ready',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='room',
|
||||||
|
name='table_status',
|
||||||
|
field=models.CharField(blank=True, choices=[('ROLE_SELECT', 'Role Select'), ('SIG_SELECT', 'Significator Select'), ('SKY_SELECT', 'Sky Select'), ('IN_GAME', 'In Game')], max_length=20, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -33,10 +33,12 @@ class Room(models.Model):
|
|||||||
|
|
||||||
ROLE_SELECT = "ROLE_SELECT"
|
ROLE_SELECT = "ROLE_SELECT"
|
||||||
SIG_SELECT = "SIG_SELECT"
|
SIG_SELECT = "SIG_SELECT"
|
||||||
|
SKY_SELECT = "SKY_SELECT"
|
||||||
IN_GAME = "IN_GAME"
|
IN_GAME = "IN_GAME"
|
||||||
TABLE_STATUS_CHOICES = [
|
TABLE_STATUS_CHOICES = [
|
||||||
(ROLE_SELECT, "Role Select"),
|
(ROLE_SELECT, "Role Select"),
|
||||||
(SIG_SELECT, "Significator Select"),
|
(SIG_SELECT, "Significator Select"),
|
||||||
|
(SKY_SELECT, "Sky Select"),
|
||||||
(IN_GAME, "In Game"),
|
(IN_GAME, "In Game"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -50,6 +52,7 @@ class Room(models.Model):
|
|||||||
table_status = models.CharField(
|
table_status = models.CharField(
|
||||||
max_length=20, choices=TABLE_STATUS_CHOICES, null=True, blank=True
|
max_length=20, choices=TABLE_STATUS_CHOICES, null=True, blank=True
|
||||||
)
|
)
|
||||||
|
sig_select_started_at = models.DateTimeField(null=True, blank=True)
|
||||||
renewal_period = models.DurationField(null=True, blank=True, default=timedelta(days=7))
|
renewal_period = models.DurationField(null=True, blank=True, default=timedelta(days=7))
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
board_state = models.JSONField(default=dict)
|
board_state = models.JSONField(default=dict)
|
||||||
@@ -369,6 +372,8 @@ class SigReservation(models.Model):
|
|||||||
role = models.CharField(max_length=2)
|
role = models.CharField(max_length=2)
|
||||||
polarity = models.CharField(max_length=7, choices=POLARITY_CHOICES)
|
polarity = models.CharField(max_length=7, choices=POLARITY_CHOICES)
|
||||||
reserved_at = models.DateTimeField(auto_now_add=True)
|
reserved_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
ready = models.BooleanField(default=False)
|
||||||
|
countdown_remaining = models.IntegerField(null=True, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
constraints = [
|
constraints = [
|
||||||
|
|||||||
62
src/apps/epic/static/apps/epic/icons/cards-sigs/Blank.svg
Normal file
62
src/apps/epic/static/apps/epic/icons/cards-sigs/Blank.svg
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288 560">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #ead08e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #e1bc70;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-3 {
|
||||||
|
fill: #c8a363;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-4 {
|
||||||
|
fill: #d2ab67;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-5 {
|
||||||
|
fill: #e7c278;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-6 {
|
||||||
|
fill: none;
|
||||||
|
stroke: #381507;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 2.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-7 {
|
||||||
|
fill: #dfbc6d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-8 {
|
||||||
|
fill: #cfa864;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-9 {
|
||||||
|
fill: #f4dfa9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-10 {
|
||||||
|
fill: #d0a965;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path class="cls-9" d="M176.79,209.43l-2.01.07-2.45,11.42c.4,3.9-.63,6.69-1.9,10.36l-3.61,10.39c.59.78.74,1.77,1.37.7l7.89-16.39,7.62-10.14c2.27.08,3.91.46,5.82.02-.48,1.32-.34,1.86-.19,3.24,2.27-.16,4.39.54,6.42,1.88,1.42-1.07,2.79-1.65,4.6-1.66h3.49c.68.88-.13,2.84-1.05,3.79-.72.74.87,3.35,1.92,3.32l6.17-.17c1.75,1.96,1.05,4.36.98,6.82-.21,7.02-6.11,2.65-10.61,6.97-.68.65-.91,2.18-.23,2.68.84.62,4.26-1.19,5.18,1.09.05.12-2.19,4.54-4.01,7.24l3.5,4.78c.67.91,1.01,1.73,1.7,1.94,2.5.74,3.42-1.07,5.16.15l-.16-6.2c5.06-.66.3,10.94,7.05,9.19-.39,1.97-.65,4.21-1.67,6.09l-.88,1.61-23.67,3.92-26.09,5.6,28.81-1.28,21.9,1.79,2.66,21.59c.23,1.91.61,4.63,0,6.52-.9,2.75-4.75,3.45-6.85,4.52-.11-.6-.55-.77-.29-.96.27-.2.75-.49.46-.76-1.69-1.58-3.7-.88-5.42-1.5l-25.61-9.14c-2.01-.72-4.18-2.11-5.81.36,9.47,1.99,17.53,6.65,23.36,13.99,3.22,1.6,5.73,3.69,8.19,6.23,2.46,1.69,4.62,3.8,5.34,6.49,1.07-.18.92.5,1.05.83.15.38-.61.67-.62,1-.12,4.43-3.89,4.36-7.11,9.5-2.55,4.08-7.38,4.35-9.21,6.15l-.94.92c-.16.16.8.55.24.65-.67.12-1.28.23-1.51.37-.2.12.32.78-.29.87-1.03.15-2.52-.07-3.24.26-.61.28-1.54,1.08-1.53,2.3.01,1.24,3.21,1.69,4.07.81-.22,1.01.07,1.8.09,2.48l-6.88-.51c-3.33-.25-6.05-1.19-8.14-3.56l1.22-1.88c.25-.38-.05-1.81-.78-2.58l-7.92-8.31-1.79-6.55c1.07-.94.71-1.26-.44-1.1.06,2.16-1.07,5.3.01,7.63,2.52,5.41,2.99,11.11,2.18,16.92l-13.46,1.33c-2.91.29-5.41-.37-7.43-2.26l-.56-.52c.46-.08,1.32-.85.89-.92-.9-.15-.17-.72-.3-1.29-2.17-9.59-2.64-19.05-3.8-28.66l-2.19-18.19c-.03-.27-.5-.91-.37-1.27.06-.16-.25-.18-.89-.27.05,8.57-.38,17.06-1.41,25.88l-2.92,25.11c-2.05-1.12-1.73-2.64-2.66-3.77-2.42-1.3-5.35-.57-7.72.19-.82-1.15-2.95-.7-3.79-1.24l-7.41-4.74c-1.04-.66-1.4-2.15-1.42-3.1-.05-2.5,5.64-4.26,5.39-8.93l-4.61,2.69c-2.5,1.46-5.16,2.36-7.42,4.49,1.1,1.73,2.39,6.23.43,7.73-2.2,1.69-4.11-1.83-4.14-3.67-.04-2.86-4.05-4.49-6.8-4.27-2.19-1.72-4.47-2.69-7.41-2.81,2.92-5.58,6.84-9.71,11.07-13.92l9.26-9.22c-4.84,1.85-8.54,5.02-12.9,7.76l-13.73,8.66c-2.37-3.34-2.85-6.15-7.7-5.29-.62-.03-1.45-1.19-2.31-.96-.3-2.98.95-6.63,3.72-8.23,2.3-1.71,4.51-3.28,7.48-2.66l9.81-4.84-3.17-6.89c-.54-1.17-2.95-1.86-4.17-1.27-1.03.5-3.22,1.58-2.74,3.27.24.84,2.09,1.72,1.17,2.66-1.77,1.82-4.46,1.82-7,1.48-3.12-5.27-4.99-4.03-6.2-5.44-1.11-1.28-.17-2.47-1.16-4.71-.95-.19-2.17.03-3.09-.52l.47-8.25c5.41-1.54,11-1.66,16.65-2.11l13.76-1.11,23.43-2.65c.72-.08,1.55-.2,1.17-1.12l-34.67-.55-10.78-.52-6.8-1.66c.07.32.09.89.3,1.07.4.35-.32.87-.61,1.11-.78-.59-2.15-1.21-2.35-2.59l-1.83-12.66c-.25-1.72.79-3.24,1.79-4.12.41-.03,1.02.46.89.93-.09.34-.92.63-.78,1.42l24.02-6.49,13.85-1.37,22.02-.39-2.6-11.6c1.4-.26,3.69-1.85,4.49-2.9.89-1.16-.7-2.67-.56-3.76l1.42-10.88c2.44.37,3.7.45,5.42.41l.57,3.78,1.87,20.67c.26,2.86-.56,5.85,1.49,8.37.13-9.58.68-18.19,1.32-27.3l1.78-21.14c-.18-.34.15-.71.44-.92.45-.32.07-.8-.36-1.29.1.71-.38.67-.55.8.16-.14.65-.09.55-.8l26.18-2.61c2.68-.27,3.68,4.3,4.17,6.14Z"/>
|
||||||
|
<path class="cls-2" d="M70.64,293.55c.93.55,2.14.33,3.09.52.99,2.25.05,3.43,1.16,4.71,1.21,1.41,3.09.17,6.2,5.44,2.54.34,5.22.34,7-1.48.92-.95-.93-1.82-1.17-2.66-.48-1.69,1.71-2.77,2.74-3.27,1.22-.6,3.63.1,4.17,1.27l3.17,6.89-9.81,4.84c-2.97-.61-5.17.95-7.48,2.66-2.77,1.6-4.02,5.25-3.72,8.23.86-.23,1.7.94,2.31.96,4.85-.86,5.33,1.94,7.7,5.29l13.73-8.66c4.36-2.75,8.06-5.91,12.9-7.76l-9.26,9.22c-4.23,4.21-8.15,8.34-11.07,13.92,2.94.13,5.21,1.09,7.41,2.81,2.75-.22,6.76,1.41,6.8,4.27.03,1.84,1.94,5.36,4.14,3.67,1.95-1.5.67-6-.43-7.73,2.26-2.13,4.92-3.03,7.42-4.49l4.61-2.69c.25,4.67-5.44,6.43-5.39,8.93.02.95.39,2.43,1.42,3.1l7.41,4.74c.84.54,2.97.08,3.79,1.24,2.37-.76,5.3-1.48,7.72-.19.94,1.13.61,2.65,2.66,3.77l2.92-25.11c1.03-8.82,1.46-17.32,1.41-25.88.64.09.95.11.89.27-.13.37.34,1,.37,1.27l2.19,18.19c1.16,9.61,1.63,19.07,3.8,28.66.13.57-.6,1.14.3,1.29.43.07-.43.84-.89.92l.56.52-.56-.52c-1-.94-2.21-2.22-3.88-2.29-1.16,1.06-1.06,3.19-2.51,4-2.11,1.17-23.76,4.92-29.64,4.22l-26.91-3.21c-4.28-.51-8.3-1.54-12.64-.83-1.01.17-1.84.08-2.48-.35-1.08-.72-2.51-11.79-5.03-19.97-.63-2.06-.71-4.42-.42-6.58l2.23-17.05,1.07-15.1Z"/>
|
||||||
|
<path class="cls-1" d="M138.74,206.71c-.44,1.03-1.52,1.13-2.79.95l2.89,11.57.5,4.51c-1.72.04-2.98-.04-5.42-.41l-1.42,10.88c-.14,1.09,1.45,2.61.56,3.76-.8,1.04-3.08,2.63-4.49,2.9-2.41-10.77-4.78-21.47-4.96-32.84-.01-.81-.89-1-1.61-.47-.4.29-.56-.28-.69.82-.91,7.34-.87,14.61.45,22.21l1.81,10.39c.2,1.16-.14,1.74-.66,2.45-.66.89-1.51.05-2.58.15l-6.62.66c-11.9,1.2-23.41,4.28-34.02,9.94-1.49.79-2.59,1.69-4.04,1.19l-.45-.56-.41.43-.35.37.35-.37.41-.43,47.79-50.52c5.56-.03,11.1.34,15.74,2.42Z"/>
|
||||||
|
<path class="cls-7" d="M219.43,260.9c-6.75,1.75-1.99-9.85-7.05-9.19l.16,6.2c-1.73-1.22-2.65.59-5.16-.15-.69-.2-1.03-1.02-1.7-1.94l-3.5-4.78c1.82-2.7,4.06-7.11,4.01-7.24-.92-2.28-4.34-.47-5.18-1.09-.68-.5-.45-2.03.23-2.68,4.5-4.32,10.41.05,10.61-6.97.07-2.46.77-4.86-.98-6.82l-6.17.17c-1.05.03-2.64-2.58-1.92-3.32.92-.95,1.73-2.91,1.05-3.8h-3.49c-1.81.02-3.18.6-4.6,1.67-2.03-1.33-4.15-2.04-6.42-1.88-.15-1.38-.29-1.92.19-3.24-1.91.44-3.55.07-5.82-.02l-7.62,10.14-7.89,16.39c-.62,1.07-.78.08-1.37-.7l3.61-10.39c1.27-3.66,2.3-6.46,1.9-10.36l2.45-11.42,2.01-.07c.39,1.47,1.68,1.42,2.71,3.07l2.72-3.52c1.28-1.66,2.13-2.36,4.21-2.02,4.76.8,11.51-5.31,15.84-3.2l5.09.33,6.04.66c1.5.16,3.24-.48,4.43,1.23.92,1.32.41,2.84.7,4.58l2.01,12.15c.75,4.53-.52,9.03-.78,12.69l-.41,5.63-.11,12.64c-.02,2.58.65,4.91.18,7.23Z"/>
|
||||||
|
<path class="cls-5" d="M194.84,352.09c-.02-.68-.3-1.47-.09-2.48-.86.88-4.06.43-4.07-.81,0-1.21.92-2.02,1.53-2.3.72-.33,2.21-.11,3.24-.26.61-.09.09-.75.29-.87.23-.14.84-.25,1.51-.37.55-.1-.4-.49-.24-.65l.94-.92c1.83-1.8,6.66-2.07,9.21-6.15,3.22-5.14,6.99-5.06,7.11-9.5,0-.33.77-.62.62-1-.13-.33.02-1.01-1.05-.83-.72-2.69-2.89-4.8-5.34-6.49-2.46-2.53-4.96-4.63-8.19-6.23-5.84-7.33-13.89-12-23.36-13.99,1.63-2.47,3.8-1.08,5.81-.36l25.61,9.14c1.72.61,3.72-.09,5.42,1.5.29.27-.19.56-.46.76-.25.19.18.36.29.96l-2.06,1.95c1.07.86,1.21,1.79,2.05,2.05,1.4.43,2.37,1.45,3.11,2.99.49,1.04-.89,2.78-.4,4.19l3.22,9.28-1.02,21.79-10.21-.23-13.46-1.16Z"/>
|
||||||
|
<path class="cls-3" d="M128.56,240.87l2.6,11.6-22.02.39-13.85,1.37-24.02,6.49c-.14-.79.69-1.08.78-1.42.12-.46-.48-.95-.89-.93l3.19-2.78.1.02.35-.37.41-.43.45.56c1.45.49,2.55-.4,4.04-1.19,10.61-5.66,22.12-8.74,34.02-9.94l6.62-.66c1.07-.11,1.92.74,2.58-.15.52-.71.86-1.29.66-2.45l-1.81-10.39c-1.32-7.6-1.36-14.87-.45-22.21.14-1.1.29-.53.69-.82.72-.53,1.6-.34,1.61.47.18,11.37,2.55,22.07,4.96,32.84Z"/>
|
||||||
|
<path class="cls-8" d="M216.88,268.6l-1.26,4.05c1.5,1.91,1.96,3.93,2.21,5.96l-21.9-1.79-28.81,1.28,26.09-5.6,23.67-3.92Z"/>
|
||||||
|
<path class="cls-8" d="M73.54,277.75c.29-.24,1.01-.76.61-1.11-.21-.18-.23-.75-.3-1.07l6.8,1.66,10.78.52,34.67.55c.38.92-.45,1.04-1.17,1.12l-23.43,2.65-13.76,1.11c-5.64.46-11.23.57-16.65,2.11.22-3.96,6.26-5.09,2.43-7.55Z"/>
|
||||||
|
<path class="cls-10" d="M145.9,206.7c.16-.14.65-.09.55-.8.43.49.82.97.36,1.29-.29.21-.61.58-.44.92l-1.78,21.14c-.64,9.11-1.2,17.72-1.32,27.3-2.05-2.51-1.23-5.51-1.49-8.37l-1.87-20.67-.57-3.78-.5-4.51-2.89-11.57c1.27.18,2.35.08,2.79-.95,1.34.6,2.38,2.57,3.92,2.39s2.25-1.55,3.24-2.4Z"/>
|
||||||
|
<path class="cls-4" d="M179.83,348.02c-3.45-2.21-1.4,3.53-7.52,4.14.81-5.81.34-11.5-2.18-16.92-1.09-2.33.04-5.47-.01-7.63,1.15-.16,1.51.16.44,1.1l1.79,6.55,7.92,8.31c.73.77,1.03,2.2.78,2.58l-1.22,1.88Z"/>
|
||||||
|
<path class="cls-6" d="M176.79,209.43c-.49-1.84-1.49-6.41-4.17-6.14l-26.18,2.61c.1.71-.38.67-.55.8-.99.85-1.73,2.22-3.24,2.4s-2.58-1.79-3.92-2.39c-4.64-2.08-10.18-2.45-15.74-2.42l-47.79,50.52-.41.43-.45.35-3.19,2.78c-1,.87-2.04,2.4-1.79,4.12l1.83,12.66c.2,1.38,1.57,2.01,2.35,2.59,3.83,2.46-2.21,3.6-2.43,7.55l-.47,8.25-1.07,15.1-2.23,17.05c-.28,2.16-.21,4.53.42,6.58,2.52,8.18,3.95,19.25,5.03,19.97.64.42,1.47.51,2.48.35,4.34-.71,8.36.32,12.64.83l26.91,3.21c5.88.7,27.53-3.05,29.64-4.22,1.45-.81,1.35-2.94,2.51-4,1.67.07,2.88,1.35,3.88,2.29l.56.52c2.02,1.89,4.52,2.55,7.43,2.26l13.46-1.33c6.12-.6,4.07-6.35,7.52-4.14,2.09,2.37,4.82,3.31,8.14,3.56l6.88.51,13.46,1.16,10.21.23,1.02-21.79-3.22-9.28c-.49-1.42.9-3.16.4-4.19-.73-1.54-1.71-2.56-3.11-2.99"/>
|
||||||
|
<path class="cls-6" d="M121.3,208.37c-.91,7.34-.87,14.61.45,22.21l1.81,10.39c.2,1.16-.14,1.74-.66,2.45-.66.89-1.51.05-2.58.15l-6.62.66c-11.9,1.2-23.41,4.28-34.02,9.94-1.49.79-2.59,1.69-4.04,1.19-.41-.14-.87-.19-1.2.24"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.8 KiB |
@@ -530,3 +530,66 @@ class TarotCardCautionsTest(TestCase):
|
|||||||
for caution in schizo.cautions:
|
for caution in schizo.cautions:
|
||||||
self.assertIn("reverse", caution)
|
self.assertIn("reverse", caution)
|
||||||
self.assertNotIn("transform", caution)
|
self.assertNotIn("transform", caution)
|
||||||
|
|
||||||
|
|
||||||
|
# ── SigReservation ready gate ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class SigReservationReadyGateTest(TestCase):
|
||||||
|
"""SigReservation.ready and countdown_remaining fields."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
|
owner = User.objects.create(email="owner@test.io")
|
||||||
|
room = Room.objects.create(name="R", owner=owner)
|
||||||
|
card = TarotCard.objects.get(
|
||||||
|
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11
|
||||||
|
)
|
||||||
|
self.res = SigReservation.objects.create(
|
||||||
|
room=room, gamer=owner, card=card, role="PC", polarity="levity"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ready_defaults_to_false(self):
|
||||||
|
self.assertFalse(self.res.ready)
|
||||||
|
|
||||||
|
def test_countdown_remaining_defaults_to_none(self):
|
||||||
|
self.assertIsNone(self.res.countdown_remaining)
|
||||||
|
|
||||||
|
def test_ready_can_be_set_true(self):
|
||||||
|
self.res.ready = True
|
||||||
|
self.res.save()
|
||||||
|
self.res.refresh_from_db()
|
||||||
|
self.assertTrue(self.res.ready)
|
||||||
|
|
||||||
|
def test_countdown_remaining_can_be_saved(self):
|
||||||
|
self.res.countdown_remaining = 7
|
||||||
|
self.res.save()
|
||||||
|
self.res.refresh_from_db()
|
||||||
|
self.assertEqual(self.res.countdown_remaining, 7)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Room SKY_SELECT status ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class RoomSkySelectStatusTest(TestCase):
|
||||||
|
"""Room.SKY_SELECT constant and sig_select_started_at field."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
owner = User.objects.create(email="owner@test.io")
|
||||||
|
self.room = Room.objects.create(name="R", owner=owner)
|
||||||
|
|
||||||
|
def test_sky_select_constant_value(self):
|
||||||
|
self.assertEqual(Room.SKY_SELECT, "SKY_SELECT")
|
||||||
|
|
||||||
|
def test_sky_select_is_valid_table_status_choice(self):
|
||||||
|
choices = [c[0] for c in Room.TABLE_STATUS_CHOICES]
|
||||||
|
self.assertIn(Room.SKY_SELECT, choices)
|
||||||
|
|
||||||
|
def test_sig_select_started_at_defaults_to_none(self):
|
||||||
|
self.assertIsNone(self.room.sig_select_started_at)
|
||||||
|
|
||||||
|
def test_sig_select_started_at_can_be_set(self):
|
||||||
|
from django.utils import timezone
|
||||||
|
now = timezone.now()
|
||||||
|
self.room.sig_select_started_at = now
|
||||||
|
self.room.save()
|
||||||
|
self.room.refresh_from_db()
|
||||||
|
self.assertIsNotNone(self.room.sig_select_started_at)
|
||||||
|
|||||||
@@ -1306,3 +1306,307 @@ class SigReserveViewTest(TestCase):
|
|||||||
args, kwargs = mock_notify.call_args
|
args, kwargs = mock_notify.call_args
|
||||||
self.assertEqual(args[1], self.card.pk) # card_id must not be None
|
self.assertEqual(args[1], self.card.pk) # card_id must not be None
|
||||||
self.assertFalse(kwargs['reserved']) # reserved=False
|
self.assertFalse(kwargs['reserved']) # reserved=False
|
||||||
|
|
||||||
|
|
||||||
|
# ── sig_ready view ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _make_levity_reservations(room, gamers, earthman, ready=False):
|
||||||
|
"""Create SigReservations for the three levity gamers (PC, NC, SC).
|
||||||
|
Returns the three reservations in PC→NC→SC order."""
|
||||||
|
cards = [
|
||||||
|
TarotCard.objects.get(deck_variant=earthman, arcana="MIDDLE", suit="BRANDS", number=n)
|
||||||
|
for n in (11, 12, 13)
|
||||||
|
]
|
||||||
|
roles = ["PC", "NC", "SC"]
|
||||||
|
# gamers[0]=PC, gamers[1]=NC, gamers[3]=SC
|
||||||
|
gamer_indices = [0, 1, 3]
|
||||||
|
reservations = []
|
||||||
|
for gamer_idx, role, card in zip(gamer_indices, roles, cards):
|
||||||
|
seat = TableSeat.objects.get(room=room, role=role)
|
||||||
|
res = SigReservation.objects.create(
|
||||||
|
room=room, gamer=gamers[gamer_idx], card=card,
|
||||||
|
role=role, polarity="levity", seat=seat, ready=ready,
|
||||||
|
)
|
||||||
|
reservations.append(res)
|
||||||
|
return reservations
|
||||||
|
|
||||||
|
|
||||||
|
class SigReadyViewTest(TestCase):
|
||||||
|
"""sig_ready — toggle ready/unready for the polarity-room countdown."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
||||||
|
self.reservations = _make_levity_reservations(self.room, self.gamers, self.earthman)
|
||||||
|
self.url = reverse("epic:sig_ready", kwargs={"room_id": self.room.id})
|
||||||
|
|
||||||
|
def _post(self, action="ready", seconds_remaining=None, client=None):
|
||||||
|
c = client or self.client
|
||||||
|
data = {"action": action}
|
||||||
|
if seconds_remaining is not None:
|
||||||
|
data["seconds_remaining"] = seconds_remaining
|
||||||
|
return c.post(self.url, data=data)
|
||||||
|
|
||||||
|
# ── guards ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_sig_ready_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self._post()
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn("/accounts/login/", response.url)
|
||||||
|
|
||||||
|
def test_sig_ready_requires_seated_gamer(self):
|
||||||
|
outsider = User.objects.create(email="outsider@test.io")
|
||||||
|
outsider_client = self.client.__class__()
|
||||||
|
outsider_client.force_login(outsider)
|
||||||
|
response = self._post(client=outsider_client)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_sig_ready_wrong_phase_returns_400(self):
|
||||||
|
self.room.table_status = Room.ROLE_SELECT
|
||||||
|
self.room.save()
|
||||||
|
response = self._post()
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_sig_ready_without_reservation_returns_400(self):
|
||||||
|
"""Can't go ready without an OK'd card."""
|
||||||
|
SigReservation.objects.filter(room=self.room, gamer=self.gamers[0]).delete()
|
||||||
|
response = self._post()
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
# ── happy-path ready ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_sig_ready_sets_ready_true_on_reservation(self):
|
||||||
|
self._post(action="ready")
|
||||||
|
res = SigReservation.objects.get(room=self.room, gamer=self.gamers[0])
|
||||||
|
self.assertTrue(res.ready)
|
||||||
|
|
||||||
|
def test_sig_ready_returns_200(self):
|
||||||
|
response = self._post(action="ready")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# ── unready ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_sig_unready_sets_ready_false(self):
|
||||||
|
self.reservations[0].ready = True
|
||||||
|
self.reservations[0].save()
|
||||||
|
self._post(action="unready")
|
||||||
|
res = SigReservation.objects.get(room=self.room, gamer=self.gamers[0])
|
||||||
|
self.assertFalse(res.ready)
|
||||||
|
|
||||||
|
def test_sig_unready_when_not_ready_is_harmless(self):
|
||||||
|
response = self._post(action="unready")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# ── countdown mechanics ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_sig_ready_broadcasts_countdown_start_when_all_three_polarity_ready(self):
|
||||||
|
"""When all three levity gamers are ready, countdown_start broadcasts."""
|
||||||
|
# Make NC and SC ready first
|
||||||
|
for res in self.reservations[1:]:
|
||||||
|
res.ready = True
|
||||||
|
res.save()
|
||||||
|
# PC (founder) goes ready — triggers all-three condition
|
||||||
|
with patch("apps.epic.views._notify_countdown_start") as mock_notify:
|
||||||
|
self._post(action="ready")
|
||||||
|
mock_notify.assert_called_once()
|
||||||
|
args = mock_notify.call_args[0]
|
||||||
|
self.assertIn("levity", args) # polarity in call
|
||||||
|
|
||||||
|
def test_sig_ready_does_not_broadcast_countdown_when_only_two_ready(self):
|
||||||
|
self.reservations[1].ready = True
|
||||||
|
self.reservations[1].save()
|
||||||
|
with patch("apps.epic.views._notify_countdown_start") as mock_notify:
|
||||||
|
self._post(action="ready")
|
||||||
|
mock_notify.assert_not_called()
|
||||||
|
|
||||||
|
def test_sig_unready_saves_seconds_remaining_on_all_polarity_reservations(self):
|
||||||
|
for res in self.reservations:
|
||||||
|
res.ready = True
|
||||||
|
res.save()
|
||||||
|
self._post(action="unready", seconds_remaining=7)
|
||||||
|
for res in self.reservations:
|
||||||
|
res.refresh_from_db()
|
||||||
|
self.assertEqual(res.countdown_remaining, 7)
|
||||||
|
|
||||||
|
def test_sig_unready_broadcasts_countdown_cancel(self):
|
||||||
|
for res in self.reservations:
|
||||||
|
res.ready = True
|
||||||
|
res.save()
|
||||||
|
with patch("apps.epic.views._notify_countdown_cancel") as mock_notify:
|
||||||
|
self._post(action="unready", seconds_remaining=7)
|
||||||
|
mock_notify.assert_called_once()
|
||||||
|
|
||||||
|
def test_sig_ready_uses_saved_seconds_for_countdown_restart(self):
|
||||||
|
"""If countdown_remaining is saved (e.g. 7), countdown_start sends 7 not 12."""
|
||||||
|
for res in self.reservations:
|
||||||
|
res.ready = True
|
||||||
|
res.countdown_remaining = 7
|
||||||
|
res.save()
|
||||||
|
# One unreadied; now goes ready again — all 3 ready → start from 7
|
||||||
|
self.reservations[0].ready = False
|
||||||
|
self.reservations[0].save()
|
||||||
|
with patch("apps.epic.views._notify_countdown_start") as mock_notify:
|
||||||
|
self._post(action="ready")
|
||||||
|
mock_notify.assert_called_once()
|
||||||
|
args, kwargs = mock_notify.call_args
|
||||||
|
seconds_sent = kwargs.get("seconds") or args[1]
|
||||||
|
self.assertEqual(seconds_sent, 7)
|
||||||
|
|
||||||
|
|
||||||
|
# ── sig_confirm view ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _make_gravity_reservations(room, gamers, earthman, ready=False):
|
||||||
|
"""Create SigReservations for the three gravity gamers (EC, AC, BC)."""
|
||||||
|
cards = [
|
||||||
|
TarotCard.objects.get(deck_variant=earthman, arcana="MIDDLE", suit="GRAILS", number=n)
|
||||||
|
for n in (11, 12, 13)
|
||||||
|
]
|
||||||
|
roles = ["EC", "AC", "BC"]
|
||||||
|
# gamers[2]=EC, gamers[4]=AC, gamers[5]=BC
|
||||||
|
gamer_indices = [2, 4, 5]
|
||||||
|
reservations = []
|
||||||
|
for gamer_idx, role, card in zip(gamer_indices, roles, cards):
|
||||||
|
seat = TableSeat.objects.get(room=room, role=role)
|
||||||
|
res = SigReservation.objects.create(
|
||||||
|
room=room, gamer=gamers[gamer_idx], card=card,
|
||||||
|
role=role, polarity="gravity", seat=seat, ready=ready,
|
||||||
|
)
|
||||||
|
reservations.append(res)
|
||||||
|
return reservations
|
||||||
|
|
||||||
|
|
||||||
|
class SigConfirmViewTest(TestCase):
|
||||||
|
"""sig_confirm — finalize polarity group once countdown reaches zero."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
||||||
|
# All three levity gamers are ready
|
||||||
|
self.lev_res = _make_levity_reservations(
|
||||||
|
self.room, self.gamers, self.earthman, ready=True
|
||||||
|
)
|
||||||
|
# founder (PC) is already logged in from _full_sig_setUp
|
||||||
|
self.url = reverse("epic:sig_confirm", kwargs={"room_id": self.room.id})
|
||||||
|
|
||||||
|
def _post(self, polarity="levity", client=None):
|
||||||
|
c = client or self.client
|
||||||
|
return c.post(self.url, data={"polarity": polarity})
|
||||||
|
|
||||||
|
# ── guards ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_sig_confirm_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self._post()
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn("/accounts/login/", response.url)
|
||||||
|
|
||||||
|
def test_sig_confirm_requires_seated_gamer(self):
|
||||||
|
outsider = User.objects.create(email="outsider@test.io")
|
||||||
|
outsider_client = self.client.__class__()
|
||||||
|
outsider_client.force_login(outsider)
|
||||||
|
response = self._post(client=outsider_client)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_sig_confirm_wrong_phase_returns_400(self):
|
||||||
|
self.room.table_status = Room.ROLE_SELECT
|
||||||
|
self.room.save()
|
||||||
|
response = self._post()
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_sig_confirm_not_all_polarity_ready_returns_400(self):
|
||||||
|
"""If any of the three in the polarity group isn't ready, reject."""
|
||||||
|
self.lev_res[1].ready = False
|
||||||
|
self.lev_res[1].save()
|
||||||
|
response = self._post()
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
# ── happy-path ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_sig_confirm_sets_significator_on_seats_from_reservations(self):
|
||||||
|
self._post()
|
||||||
|
for res in self.lev_res:
|
||||||
|
seat = TableSeat.objects.get(room=self.room, role=res.role)
|
||||||
|
self.assertEqual(seat.significator, res.card)
|
||||||
|
|
||||||
|
def test_sig_confirm_returns_200(self):
|
||||||
|
response = self._post()
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_sig_confirm_broadcasts_polarity_room_done(self):
|
||||||
|
with patch("apps.epic.views._notify_polarity_room_done") as mock_notify:
|
||||||
|
self._post()
|
||||||
|
mock_notify.assert_called_once()
|
||||||
|
args = mock_notify.call_args[0]
|
||||||
|
self.assertIn("levity", args)
|
||||||
|
|
||||||
|
def test_sig_confirm_is_idempotent_if_significators_already_set(self):
|
||||||
|
"""Second call from another browser returns 200 without re-running logic."""
|
||||||
|
self._post()
|
||||||
|
response = self._post()
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# ── both polarities done ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_sig_confirm_broadcasts_pick_sky_available_when_both_polarities_done(self):
|
||||||
|
"""After both levity and gravity confirm, pick_sky_available fires."""
|
||||||
|
# Pre-set gravity seats to already have significators (simulating earlier confirm)
|
||||||
|
grav_cards = [
|
||||||
|
TarotCard.objects.get(deck_variant=self.earthman, arcana="MIDDLE", suit="GRAILS", number=n)
|
||||||
|
for n in (11, 12, 13)
|
||||||
|
]
|
||||||
|
for role, card in zip(["EC", "AC", "BC"], grav_cards):
|
||||||
|
seat = TableSeat.objects.get(room=self.room, role=role)
|
||||||
|
seat.significator = card
|
||||||
|
seat.save()
|
||||||
|
with patch("apps.epic.views._notify_pick_sky_available") as mock_notify:
|
||||||
|
self._post(polarity="levity")
|
||||||
|
mock_notify.assert_called_once()
|
||||||
|
|
||||||
|
def test_sig_confirm_sets_room_to_sky_select_when_both_polarities_done(self):
|
||||||
|
grav_cards = [
|
||||||
|
TarotCard.objects.get(deck_variant=self.earthman, arcana="MIDDLE", suit="GRAILS", number=n)
|
||||||
|
for n in (11, 12, 13)
|
||||||
|
]
|
||||||
|
for role, card in zip(["EC", "AC", "BC"], grav_cards):
|
||||||
|
seat = TableSeat.objects.get(room=self.room, role=role)
|
||||||
|
seat.significator = card
|
||||||
|
seat.save()
|
||||||
|
self._post(polarity="levity")
|
||||||
|
self.room.refresh_from_db()
|
||||||
|
self.assertEqual(self.room.table_status, Room.SKY_SELECT)
|
||||||
|
|
||||||
|
def test_sig_confirm_does_not_broadcast_pick_sky_available_when_only_one_polarity_done(self):
|
||||||
|
with patch("apps.epic.views._notify_pick_sky_available") as mock_notify:
|
||||||
|
self._post(polarity="levity")
|
||||||
|
mock_notify.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ── SKY_SELECT rendering ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class PickSkyRenderingTest(TestCase):
|
||||||
|
"""Room page at SKY_SELECT renders PICK SKY btn and sig card in tray cell 2."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
||||||
|
self.room.table_status = Room.SKY_SELECT
|
||||||
|
self.room.save()
|
||||||
|
self.sig_card = TarotCard.objects.get(
|
||||||
|
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11
|
||||||
|
)
|
||||||
|
pc_seat = TableSeat.objects.get(room=self.room, role="PC")
|
||||||
|
pc_seat.significator = self.sig_card
|
||||||
|
pc_seat.save()
|
||||||
|
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||||||
|
|
||||||
|
def test_pick_sky_btn_present_in_sky_select_phase(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertContains(response, "id_pick_sky_btn")
|
||||||
|
|
||||||
|
def test_tray_cell_2_contains_sig_card_icon_in_sky_select(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertContains(response, "tray-sig-card")
|
||||||
|
|
||||||
|
def test_pick_sky_btn_absent_during_sig_select(self):
|
||||||
|
self.room.table_status = Room.SIG_SELECT
|
||||||
|
self.room.save()
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertNotContains(response, "id_pick_sky_btn")
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ urlpatterns = [
|
|||||||
path('room/<uuid:room_id>/select-role', views.select_role, name='select_role'),
|
path('room/<uuid:room_id>/select-role', views.select_role, name='select_role'),
|
||||||
path('room/<uuid:room_id>/select-sig', views.select_sig, name='select_sig'),
|
path('room/<uuid:room_id>/select-sig', views.select_sig, name='select_sig'),
|
||||||
path('room/<uuid:room_id>/sig-reserve', views.sig_reserve, name='sig_reserve'),
|
path('room/<uuid:room_id>/sig-reserve', views.sig_reserve, name='sig_reserve'),
|
||||||
|
path('room/<uuid:room_id>/sig-ready', views.sig_ready, name='sig_ready'),
|
||||||
|
path('room/<uuid:room_id>/sig-confirm', views.sig_confirm, name='sig_confirm'),
|
||||||
path('room/<uuid:room_id>/gate/invite', views.invite_gamer, name='invite_gamer'),
|
path('room/<uuid:room_id>/gate/invite', views.invite_gamer, name='invite_gamer'),
|
||||||
path('room/<uuid:room_id>/gate/status', views.gate_status, name='gate_status'),
|
path('room/<uuid:room_id>/gate/status', views.gate_status, name='gate_status'),
|
||||||
path('room/<uuid:room_id>/delete', views.delete_room, name='delete_room'),
|
path('room/<uuid:room_id>/delete', views.delete_room, name='delete_room'),
|
||||||
|
|||||||
@@ -93,6 +93,34 @@ def _notify_sig_reserved(room_id, card_id, role, reserved):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_countdown_start(room_id, polarity, *, seconds):
|
||||||
|
async_to_sync(get_channel_layer().group_send)(
|
||||||
|
f'cursors_{room_id}_{polarity}',
|
||||||
|
{'type': 'countdown_start', 'polarity': polarity, 'seconds': seconds},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_countdown_cancel(room_id, polarity, *, seconds_remaining):
|
||||||
|
async_to_sync(get_channel_layer().group_send)(
|
||||||
|
f'cursors_{room_id}_{polarity}',
|
||||||
|
{'type': 'countdown_cancel', 'polarity': polarity, 'seconds_remaining': seconds_remaining},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_polarity_room_done(room_id, polarity):
|
||||||
|
async_to_sync(get_channel_layer().group_send)(
|
||||||
|
f'room_{room_id}',
|
||||||
|
{'type': 'polarity_room_done', 'polarity': polarity},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_pick_sky_available(room_id):
|
||||||
|
async_to_sync(get_channel_layer().group_send)(
|
||||||
|
f'room_{room_id}',
|
||||||
|
{'type': 'pick_sky_available'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}
|
SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}
|
||||||
|
|
||||||
_SIG_SEAT_ORDERING = Case(
|
_SIG_SEAT_ORDERING = Case(
|
||||||
@@ -260,6 +288,10 @@ def _role_select_context(room, user):
|
|||||||
"gate_positions": _gate_positions(room),
|
"gate_positions": _gate_positions(room),
|
||||||
"slots": room.gate_slots.order_by("slot_number"),
|
"slots": room.gate_slots.order_by("slot_number"),
|
||||||
}
|
}
|
||||||
|
# Tray cell 2: sig card (set once polarity group confirms)
|
||||||
|
_canonical_seat = _canonical_user_seat(room, user) if user.is_authenticated else None
|
||||||
|
ctx["my_tray_sig"] = _canonical_seat.significator if _canonical_seat else None
|
||||||
|
|
||||||
if room.table_status == Room.SIG_SELECT:
|
if room.table_status == Room.SIG_SELECT:
|
||||||
user_seat = _canonical_user_seat(room, user) if user.is_authenticated else None
|
user_seat = _canonical_user_seat(room, user) if user.is_authenticated else None
|
||||||
user_role = user_seat.role if user_seat else None
|
user_role = user_seat.role if user_seat else None
|
||||||
@@ -647,6 +679,118 @@ def sig_reserve(request, room_id):
|
|||||||
return HttpResponse(status=200)
|
return HttpResponse(status=200)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def sig_ready(request, room_id):
|
||||||
|
"""Toggle ready/unready for the polarity-room countdown.
|
||||||
|
POST body: action=ready|unready [, seconds_remaining=<int>]
|
||||||
|
"""
|
||||||
|
if request.method != "POST":
|
||||||
|
return HttpResponse(status=405)
|
||||||
|
room = Room.objects.get(id=room_id)
|
||||||
|
if room.table_status != Room.SIG_SELECT:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
user_seat = _canonical_user_seat(room, request.user)
|
||||||
|
if user_seat is None:
|
||||||
|
return HttpResponse(status=403)
|
||||||
|
|
||||||
|
action = request.POST.get("action", "ready")
|
||||||
|
reservation = SigReservation.objects.filter(room=room, gamer=request.user).first()
|
||||||
|
|
||||||
|
if action == "ready":
|
||||||
|
if reservation is None:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
reservation.ready = True
|
||||||
|
reservation.save(update_fields=["ready"])
|
||||||
|
|
||||||
|
# Check if all three in this polarity are now ready
|
||||||
|
polarity = reservation.polarity
|
||||||
|
polarity_roles = _LEVITY_ROLES if polarity == SigReservation.LEVITY else _GRAVITY_ROLES
|
||||||
|
ready_count = SigReservation.objects.filter(
|
||||||
|
room=room, polarity=polarity, ready=True
|
||||||
|
).count()
|
||||||
|
if ready_count == 3:
|
||||||
|
# Use saved countdown_remaining if a pause was recorded, else 12
|
||||||
|
saved = SigReservation.objects.filter(
|
||||||
|
room=room, polarity=polarity
|
||||||
|
).exclude(countdown_remaining__isnull=True).values_list(
|
||||||
|
"countdown_remaining", flat=True
|
||||||
|
).first()
|
||||||
|
seconds = saved if saved is not None else 12
|
||||||
|
_notify_countdown_start(room_id, polarity, seconds=seconds)
|
||||||
|
|
||||||
|
else: # unready
|
||||||
|
if reservation is not None:
|
||||||
|
reservation.ready = False
|
||||||
|
reservation.save(update_fields=["ready"])
|
||||||
|
polarity = reservation.polarity
|
||||||
|
|
||||||
|
# Save remaining seconds on all polarity reservations
|
||||||
|
try:
|
||||||
|
seconds_remaining = int(request.POST.get("seconds_remaining", 12))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
seconds_remaining = 12
|
||||||
|
SigReservation.objects.filter(room=room, polarity=polarity).update(
|
||||||
|
countdown_remaining=seconds_remaining
|
||||||
|
)
|
||||||
|
_notify_countdown_cancel(room_id, polarity, seconds_remaining=seconds_remaining)
|
||||||
|
|
||||||
|
return HttpResponse(status=200)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def sig_confirm(request, room_id):
|
||||||
|
"""Client posts this when the polarity-room countdown reaches zero.
|
||||||
|
POST body: polarity=levity|gravity
|
||||||
|
Sets significators on the three seats and broadcasts polarity_room_done.
|
||||||
|
When both polarities are confirmed, broadcasts pick_sky_available and
|
||||||
|
transitions the room to SKY_SELECT.
|
||||||
|
"""
|
||||||
|
if request.method != "POST":
|
||||||
|
return HttpResponse(status=405)
|
||||||
|
room = Room.objects.get(id=room_id)
|
||||||
|
if room.table_status != Room.SIG_SELECT:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
user_seat = _canonical_user_seat(room, request.user)
|
||||||
|
if user_seat is None:
|
||||||
|
return HttpResponse(status=403)
|
||||||
|
|
||||||
|
seat_polarity = SigReservation.LEVITY if user_seat.role in _LEVITY_ROLES else SigReservation.GRAVITY
|
||||||
|
polarity = request.POST.get("polarity", seat_polarity)
|
||||||
|
polarity_roles = _LEVITY_ROLES if polarity == SigReservation.LEVITY else _GRAVITY_ROLES
|
||||||
|
|
||||||
|
# Idempotency: if all seats in this polarity already have significators, skip
|
||||||
|
already_done = not room.table_seats.filter(
|
||||||
|
role__in=polarity_roles, significator__isnull=True
|
||||||
|
).exists()
|
||||||
|
if already_done:
|
||||||
|
return HttpResponse(status=200)
|
||||||
|
|
||||||
|
# Guard: all three must be ready
|
||||||
|
ready_reservations = list(
|
||||||
|
SigReservation.objects.filter(room=room, polarity=polarity, ready=True)
|
||||||
|
.select_related("seat", "card")
|
||||||
|
)
|
||||||
|
if len(ready_reservations) < 3:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
# Set significators from reservations
|
||||||
|
for res in ready_reservations:
|
||||||
|
if res.seat:
|
||||||
|
res.seat.significator = res.card
|
||||||
|
res.seat.save(update_fields=["significator"])
|
||||||
|
|
||||||
|
_notify_polarity_room_done(room_id, polarity)
|
||||||
|
|
||||||
|
# Check if both polarities are now confirmed
|
||||||
|
all_done = not room.table_seats.filter(significator__isnull=True).exists()
|
||||||
|
if all_done:
|
||||||
|
room.table_status = Room.SKY_SELECT
|
||||||
|
room.save(update_fields=["table_status"])
|
||||||
|
_notify_pick_sky_available(room_id)
|
||||||
|
|
||||||
|
return HttpResponse(status=200)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def select_sig(request, room_id):
|
def select_sig(request, room_id):
|
||||||
if request.method != "POST":
|
if request.method != "POST":
|
||||||
|
|||||||
@@ -370,3 +370,389 @@ class SigSelectThemeTest(FunctionalTest):
|
|||||||
))
|
))
|
||||||
corr = self.browser.find_element(By.CSS_SELECTOR, ".fan-card-correspondence")
|
corr = self.browser.find_element(By.CSS_SELECTOR, ".fan-card-correspondence")
|
||||||
self.assertEqual(corr.text, "")
|
self.assertEqual(corr.text, "")
|
||||||
|
|
||||||
|
|
||||||
|
# ── TAKE SIG / WAIT NO — ready gate ──────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# TAKE SIG (.btn.btn-primary) appears at the bottom-left corner of the card
|
||||||
|
# stage preview once a gamer has clicked OK on a card (SigReservation exists).
|
||||||
|
# Clicking it sets the gamer's status to ready and changes the btn to WAIT NO.
|
||||||
|
# WAIT NO cancels the ready status and reverts back to TAKE SIG.
|
||||||
|
#
|
||||||
|
# When all three gamers in a polarity WS room are ready, a 12-second countdown
|
||||||
|
# starts. Any WAIT NO during the countdown cancels it; the saved remaining time
|
||||||
|
# is resumed when all three are ready again. When the countdown completes
|
||||||
|
# (client POSTs sig_confirm) the polarity group returns to the table hex.
|
||||||
|
# When both polarity groups have confirmed, PICK SKY btn appears in the hex
|
||||||
|
# center for all six gamers.
|
||||||
|
#
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class SigReadyGateTest(FunctionalTest):
|
||||||
|
"""Single-browser tests for TAKE SIG / WAIT NO btn."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.browser.set_window_size(800, 1200)
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
|
||||||
|
)
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _setup_sig_room(self):
|
||||||
|
emails = [
|
||||||
|
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||||||
|
"pal@test.io", "dude@test.io", "bro@test.io",
|
||||||
|
]
|
||||||
|
founder, _ = User.objects.get_or_create(email=emails[0])
|
||||||
|
room = Room.objects.create(name="Ready Gate Test", owner=founder)
|
||||||
|
_fill_room_via_orm(room, emails)
|
||||||
|
_assign_all_roles(room)
|
||||||
|
return room
|
||||||
|
|
||||||
|
def _click_ok_on_any_card(self):
|
||||||
|
"""Click the first sig card to stage it, then click OK."""
|
||||||
|
card = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-card")
|
||||||
|
)
|
||||||
|
card.click()
|
||||||
|
ok_btn = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-ok-btn")
|
||||||
|
)
|
||||||
|
ok_btn.click()
|
||||||
|
|
||||||
|
# ── SRG1: TAKE SIG btn not visible before OK ──────────────────────── #
|
||||||
|
|
||||||
|
def test_take_sig_btn_not_visible_before_ok_click(self):
|
||||||
|
"""TAKE SIG must be absent until the gamer has OK'd a card."""
|
||||||
|
room = self._setup_sig_room()
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay"))
|
||||||
|
|
||||||
|
take_sig_btns = self.browser.find_elements(By.ID, "id_take_sig_btn")
|
||||||
|
self.assertEqual(len(take_sig_btns), 0)
|
||||||
|
|
||||||
|
# ── SRG2: TAKE SIG btn appears after OK ──────────────────────────── #
|
||||||
|
|
||||||
|
def test_take_sig_btn_appears_after_ok_click(self):
|
||||||
|
room = self._setup_sig_room()
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay"))
|
||||||
|
self._click_ok_on_any_card()
|
||||||
|
|
||||||
|
take_sig_btn = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.ID, "id_take_sig_btn")
|
||||||
|
)
|
||||||
|
self.assertIn("TAKE SIG", take_sig_btn.text.upper())
|
||||||
|
|
||||||
|
# ── SRG3: TAKE SIG → WAIT NO ─────────────────────────────────────── #
|
||||||
|
|
||||||
|
def test_take_sig_btn_becomes_wait_no_after_click(self):
|
||||||
|
room = self._setup_sig_room()
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay"))
|
||||||
|
self._click_ok_on_any_card()
|
||||||
|
|
||||||
|
take_sig_btn = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.ID, "id_take_sig_btn")
|
||||||
|
)
|
||||||
|
take_sig_btn.click()
|
||||||
|
|
||||||
|
wait_no_btn = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.ID, "id_take_sig_btn")
|
||||||
|
)
|
||||||
|
self.assertIn("WAIT NO", wait_no_btn.text.upper())
|
||||||
|
|
||||||
|
# ── SRG4: WAIT NO → TAKE SIG ─────────────────────────────────────── #
|
||||||
|
|
||||||
|
def test_wait_no_reverts_to_take_sig(self):
|
||||||
|
room = self._setup_sig_room()
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay"))
|
||||||
|
self._click_ok_on_any_card()
|
||||||
|
|
||||||
|
btn = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.ID, "id_take_sig_btn")
|
||||||
|
)
|
||||||
|
btn.click() # → WAIT NO
|
||||||
|
self.wait_for(lambda: "WAIT NO" in self.browser.find_element(
|
||||||
|
By.ID, "id_take_sig_btn").text.upper()
|
||||||
|
)
|
||||||
|
btn = self.browser.find_element(By.ID, "id_take_sig_btn")
|
||||||
|
btn.click() # → TAKE SIG again
|
||||||
|
|
||||||
|
reverted = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.ID, "id_take_sig_btn")
|
||||||
|
)
|
||||||
|
self.assertIn("TAKE SIG", reverted.text.upper())
|
||||||
|
|
||||||
|
|
||||||
|
@tag("channels")
|
||||||
|
class SigReadyCountdownChannelsTest(ChannelsFunctionalTest):
|
||||||
|
"""Multi-browser WebSocket tests for the polarity-room countdown and PICK SKY."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.browser.set_window_size(800, 1200)
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
|
||||||
|
)
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _make_browser_for(self, email):
|
||||||
|
session_key = create_pre_authenticated_session(email)
|
||||||
|
options = webdriver.FirefoxOptions()
|
||||||
|
if os.environ.get("HEADLESS"):
|
||||||
|
options.add_argument("--headless")
|
||||||
|
b = webdriver.Firefox(options=options)
|
||||||
|
b.get(self.live_server_url + "/404_no_such_url/")
|
||||||
|
b.add_cookie(dict(
|
||||||
|
name=django_settings.SESSION_COOKIE_NAME,
|
||||||
|
value=session_key,
|
||||||
|
path="/",
|
||||||
|
))
|
||||||
|
return b
|
||||||
|
|
||||||
|
def _setup_sig_select_room(self):
|
||||||
|
emails = [
|
||||||
|
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||||||
|
"pal@test.io", "dude@test.io", "bro@test.io",
|
||||||
|
]
|
||||||
|
founder, _ = User.objects.get_or_create(email=emails[0])
|
||||||
|
room = Room.objects.create(name="Countdown Test", owner=founder)
|
||||||
|
_fill_room_via_orm(room, emails)
|
||||||
|
_assign_all_roles(room)
|
||||||
|
return room, emails
|
||||||
|
|
||||||
|
# ── SRG5: countdown appears when all three polarity ready ─────────── #
|
||||||
|
|
||||||
|
@tag("channels")
|
||||||
|
def test_countdown_element_appears_when_all_three_levity_gamers_ready(self):
|
||||||
|
"""When PC, NC, and SC each click TAKE SIG the countdown becomes visible."""
|
||||||
|
room, emails = self._setup_sig_select_room()
|
||||||
|
levity_emails = [emails[0], emails[1], emails[3]] # PC, NC, SC
|
||||||
|
browsers = []
|
||||||
|
try:
|
||||||
|
for email in levity_emails:
|
||||||
|
b = self._make_browser_for(email)
|
||||||
|
b.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
|
||||||
|
browsers.append(b)
|
||||||
|
|
||||||
|
# Each levity gamer OK's a card then clicks TAKE SIG
|
||||||
|
for b in browsers:
|
||||||
|
self.wait_for(
|
||||||
|
lambda: b.find_element(By.CSS_SELECTOR, ".sig-card"), browser=b
|
||||||
|
)
|
||||||
|
b.find_element(By.CSS_SELECTOR, ".sig-card").click()
|
||||||
|
self.wait_for(
|
||||||
|
lambda: b.find_element(By.CSS_SELECTOR, ".sig-ok-btn"), browser=b
|
||||||
|
)
|
||||||
|
b.find_element(By.CSS_SELECTOR, ".sig-ok-btn").click()
|
||||||
|
self.wait_for(
|
||||||
|
lambda: b.find_element(By.ID, "id_take_sig_btn"), browser=b
|
||||||
|
)
|
||||||
|
b.find_element(By.ID, "id_take_sig_btn").click()
|
||||||
|
|
||||||
|
# All three browsers should now see the countdown
|
||||||
|
for b in browsers:
|
||||||
|
self.wait_for(
|
||||||
|
lambda: b.find_element(By.ID, "id_sig_countdown"), browser=b
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
for b in browsers:
|
||||||
|
b.quit()
|
||||||
|
|
||||||
|
# ── SRG6: countdown disappears when WAIT NO clicked ──────────────── #
|
||||||
|
|
||||||
|
@tag("channels")
|
||||||
|
def test_countdown_disappears_when_any_levity_gamer_clicks_wait_no(self):
|
||||||
|
"""Any WAIT NO during the countdown cancels it for all three browsers."""
|
||||||
|
room, emails = self._setup_sig_select_room()
|
||||||
|
levity_emails = [emails[0], emails[1], emails[3]]
|
||||||
|
browsers = []
|
||||||
|
try:
|
||||||
|
for email in levity_emails:
|
||||||
|
b = self._make_browser_for(email)
|
||||||
|
b.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
|
||||||
|
browsers.append(b)
|
||||||
|
|
||||||
|
# All go ready
|
||||||
|
for b in browsers:
|
||||||
|
self.wait_for(
|
||||||
|
lambda: b.find_element(By.CSS_SELECTOR, ".sig-card"), browser=b
|
||||||
|
)
|
||||||
|
b.find_element(By.CSS_SELECTOR, ".sig-card").click()
|
||||||
|
self.wait_for(
|
||||||
|
lambda: b.find_element(By.CSS_SELECTOR, ".sig-ok-btn"), browser=b
|
||||||
|
)
|
||||||
|
b.find_element(By.CSS_SELECTOR, ".sig-ok-btn").click()
|
||||||
|
self.wait_for(
|
||||||
|
lambda: b.find_element(By.ID, "id_take_sig_btn"), browser=b
|
||||||
|
)
|
||||||
|
b.find_element(By.ID, "id_take_sig_btn").click()
|
||||||
|
|
||||||
|
# Confirm countdown started for all
|
||||||
|
for b in browsers:
|
||||||
|
self.wait_for(
|
||||||
|
lambda: b.find_element(By.ID, "id_sig_countdown"), browser=b
|
||||||
|
)
|
||||||
|
|
||||||
|
# PC clicks WAIT NO
|
||||||
|
browsers[0].find_element(By.ID, "id_take_sig_btn").click()
|
||||||
|
|
||||||
|
# Countdown element should disappear for all three
|
||||||
|
for b in browsers:
|
||||||
|
self.wait_for(
|
||||||
|
lambda: len(b.find_elements(By.ID, "id_sig_countdown")) == 0,
|
||||||
|
browser=b,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
for b in browsers:
|
||||||
|
b.quit()
|
||||||
|
|
||||||
|
# ── SRG7: PICK SKY btn appears after both polarity groups confirm ─── #
|
||||||
|
|
||||||
|
@tag("channels")
|
||||||
|
def test_pick_sky_btn_appears_in_hex_after_both_groups_confirm(self):
|
||||||
|
"""Once both levity and gravity countdowns complete, all six browsers
|
||||||
|
see the PICK SKY btn in the table hex center."""
|
||||||
|
# This test drives the full flow end-to-end but uses ORM shortcuts
|
||||||
|
# to set all-ready state for one polarity, letting the other complete
|
||||||
|
# via the UI, to keep execution time manageable.
|
||||||
|
room, emails = self._setup_sig_select_room()
|
||||||
|
# Pre-confirm gravity via ORM: set significators on EC/AC/BC seats
|
||||||
|
from apps.epic.models import TarotCard, DeckVariant
|
||||||
|
earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
|
grav_roles = ["EC", "AC", "BC"]
|
||||||
|
grav_suits = ["GRAILS", "BLADES", "CROWNS"]
|
||||||
|
for role, suit in zip(grav_roles, grav_suits):
|
||||||
|
card = TarotCard.objects.get(
|
||||||
|
deck_variant=earthman, arcana="MIDDLE", suit=suit, number=11
|
||||||
|
)
|
||||||
|
seat = room.table_seats.get(role=role)
|
||||||
|
seat.significator = card
|
||||||
|
seat.save()
|
||||||
|
|
||||||
|
levity_emails = [emails[0], emails[1], emails[3]]
|
||||||
|
browsers = []
|
||||||
|
try:
|
||||||
|
for email in levity_emails:
|
||||||
|
b = self._make_browser_for(email)
|
||||||
|
b.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
|
||||||
|
browsers.append(b)
|
||||||
|
|
||||||
|
# All levity gamers OK and TAKE SIG
|
||||||
|
for b in browsers:
|
||||||
|
self.wait_for(
|
||||||
|
lambda: b.find_element(By.CSS_SELECTOR, ".sig-card"), browser=b
|
||||||
|
)
|
||||||
|
b.find_element(By.CSS_SELECTOR, ".sig-card").click()
|
||||||
|
self.wait_for(
|
||||||
|
lambda: b.find_element(By.CSS_SELECTOR, ".sig-ok-btn"), browser=b
|
||||||
|
)
|
||||||
|
b.find_element(By.CSS_SELECTOR, ".sig-ok-btn").click()
|
||||||
|
self.wait_for(
|
||||||
|
lambda: b.find_element(By.ID, "id_take_sig_btn"), browser=b
|
||||||
|
)
|
||||||
|
b.find_element(By.ID, "id_take_sig_btn").click()
|
||||||
|
|
||||||
|
# Wait for countdown to expire or be confirmed; PICK SKY appears in hex
|
||||||
|
for b in browsers:
|
||||||
|
self.wait_for(
|
||||||
|
lambda: b.find_element(By.ID, "id_pick_sky_btn"), browser=b
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
for b in browsers:
|
||||||
|
b.quit()
|
||||||
|
|
||||||
|
# ── SRG8: first-done group sees waiting message ───────────────────── #
|
||||||
|
|
||||||
|
@tag("channels")
|
||||||
|
def test_first_done_polarity_sees_other_group_settling_message(self):
|
||||||
|
"""After levity confirms but gravity hasn't yet, levity gamers see
|
||||||
|
'Gravity settling . . .' on the dormant hex."""
|
||||||
|
room, emails = self._setup_sig_select_room()
|
||||||
|
levity_emails = [emails[0], emails[1], emails[3]]
|
||||||
|
browsers = []
|
||||||
|
try:
|
||||||
|
for email in levity_emails:
|
||||||
|
b = self._make_browser_for(email)
|
||||||
|
b.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
|
||||||
|
browsers.append(b)
|
||||||
|
|
||||||
|
for b in browsers:
|
||||||
|
self.wait_for(
|
||||||
|
lambda: b.find_element(By.CSS_SELECTOR, ".sig-card"), browser=b
|
||||||
|
)
|
||||||
|
b.find_element(By.CSS_SELECTOR, ".sig-card").click()
|
||||||
|
self.wait_for(
|
||||||
|
lambda: b.find_element(By.CSS_SELECTOR, ".sig-ok-btn"), browser=b
|
||||||
|
)
|
||||||
|
b.find_element(By.CSS_SELECTOR, ".sig-ok-btn").click()
|
||||||
|
self.wait_for(
|
||||||
|
lambda: b.find_element(By.ID, "id_take_sig_btn"), browser=b
|
||||||
|
)
|
||||||
|
b.find_element(By.ID, "id_take_sig_btn").click()
|
||||||
|
|
||||||
|
# Wait for levity confirm → hex revealed, waiting message visible
|
||||||
|
for b in browsers:
|
||||||
|
self.wait_for(
|
||||||
|
lambda: "settling" in b.find_element(
|
||||||
|
By.ID, "id_hex_waiting_msg"
|
||||||
|
).text.lower(),
|
||||||
|
browser=b,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
for b in browsers:
|
||||||
|
b.quit()
|
||||||
|
|
||||||
|
|
||||||
|
# ── SKY OVERLAY (natal wheel) — DEFERRED / PENDING PYSWISS ──────────────────
|
||||||
|
#
|
||||||
|
# These FTs outline the sky overlay behavior but are left as stubs.
|
||||||
|
# The sky overlay will be built after the PySwiss microservice (step 18)
|
||||||
|
# and the D3 natal wheel implementation. A prototype already exists and
|
||||||
|
# will be reviewed before these tests are filled in.
|
||||||
|
#
|
||||||
|
# class PickSkyTrayFlowTest(FunctionalTest):
|
||||||
|
#
|
||||||
|
# def test_pick_sky_btn_opens_tray_with_sig_card_in_slot_2(self):
|
||||||
|
# """Clicking PICK SKY opens #id_tray; tray cell 2 shows the gamer's
|
||||||
|
# sig card icon (Blank.svg placeholder until card-specific icons land)."""
|
||||||
|
# ...
|
||||||
|
#
|
||||||
|
# def test_tray_close_dismisses_sig_overlay_and_reveals_hex(self):
|
||||||
|
# """After tray closes the sig select modal is gone and the table hex
|
||||||
|
# is visible again."""
|
||||||
|
# ...
|
||||||
|
#
|
||||||
|
# def test_sky_overlay_appears_over_hex_after_tray_closes(self):
|
||||||
|
# """The sky overlay (#id_sky_overlay) appears over the hex once the
|
||||||
|
# tray animation completes."""
|
||||||
|
# ...
|
||||||
|
#
|
||||||
|
# def test_sky_overlay_prompts_for_input_date(self):
|
||||||
|
# """The sky overlay contains a date input field for natal wheel
|
||||||
|
# calculation via the PySwiss microservice API."""
|
||||||
|
# ...
|
||||||
|
#
|
||||||
|
# def test_sky_overlay_renders_natal_wheel_for_given_date(self):
|
||||||
|
# """Submitting a date triggers a D3-drawn natal wheel (pyswisseph
|
||||||
|
# data). Each house/planet is individually navigable."""
|
||||||
|
# ...
|
||||||
|
#
|
||||||
|
# def test_sky_overlay_accessible_during_play_for_timeframe_changes(self):
|
||||||
|
# """During IN_GAME phase a gamer can reopen the sky overlay to change
|
||||||
|
# the timeframe or check aspects presiding over the current scene."""
|
||||||
|
# ...
|
||||||
|
#
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -34,6 +34,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if room.table_status == "SKY_SELECT" %}
|
||||||
|
<button id="id_pick_sky_btn" class="btn btn-primary">PICK<br>SKY</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +76,7 @@
|
|||||||
<i class="fa-solid fa-dice-d20"></i>
|
<i class="fa-solid fa-dice-d20"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="id_tray" style="display:none"><div id="id_tray_grid" data-role-icons-url="{% static 'apps/epic/icons/cards-roles/' %}">{% if my_tray_role %}<div class="tray-cell tray-role-card" data-role="{{ my_tray_role }}"><img src="{% static my_tray_scrawl_static_path %}" alt="{{ my_tray_role }}"></div>{% else %}<div class="tray-cell"></div>{% endif %}{% for i in "2345678" %}<div class="tray-cell"></div>{% endfor %}</div></div>
|
<div id="id_tray" style="display:none"><div id="id_tray_grid" data-role-icons-url="{% static 'apps/epic/icons/cards-roles/' %}">{% if my_tray_role %}<div class="tray-cell tray-role-card" data-role="{{ my_tray_role }}"><img src="{% static my_tray_scrawl_static_path %}" alt="{{ my_tray_role }}"></div>{% else %}<div class="tray-cell"></div>{% endif %}{% if my_tray_sig %}<div class="tray-cell tray-sig-card"><img src="{% static 'apps/epic/icons/cards-sigs/Blank.svg' %}" alt="{{ my_tray_sig.name }}"></div>{% else %}<div class="tray-cell"></div>{% endif %}{% for i in "345678" %}<div class="tray-cell"></div>{% endfor %}</div></div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% include "apps/gameboard/_partials/_room_gear.html" %}
|
{% include "apps/gameboard/_partials/_room_gear.html" %}
|
||||||
|
|||||||
Reference in New Issue
Block a user