diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index 71981df..d413cf2 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -85,6 +85,52 @@ class GateSlot(models.Model): debited_token_type = models.CharField(max_length=8, null=True, blank=True) debited_token_expires_at = models.DateTimeField(null=True, blank=True) + # ── Seat-occupancy / renewal clock (sprint 2026-05-31) ──────────────── + # A filled seat's token cost is "current" for one renewal span after + # `filled_at`, then sits in a renewal-grace span of equal length before + # auto-BYE. Uniform across token types (no exceptions) — keyed on + # `filled_at` only; the per-token `debit_token` rules are untouched. A + # NULL `filled_at` (ORM fixtures / RESERVED slots) reads current / + # never-expired so nothing built without a fill timestamp gets evicted. + @property + def renewal_span(self): + return self.room.renewal_period or timedelta(days=7) + + @property + def cost_current_until(self): + """End of the cost-current window [A, A+S). None if not filled.""" + if self.filled_at is None: + return None + return self.filled_at + self.renewal_span + + @property + def grace_expires_at(self): + """End of the renewal-grace window [A+S, A+2S) — the auto-BYE + threshold. None if not filled.""" + if self.filled_at is None: + return None + return self.filled_at + 2 * self.renewal_span + + @property + def cost_current(self): + """True in [A, A+S). NULL filled_at → True (never-filled / fixtures).""" + until = self.cost_current_until + return until is None or timezone.now() < until + + @property + def in_renewal_grace(self): + """True in [A+S, A+2S) — cost lapsed but the seat is still held for + renewal. False before the span and after grace expires.""" + if self.filled_at is None: + return False + return self.cost_current_until <= timezone.now() < self.grace_expires_at + + @property + def grace_expired(self): + """True at/after A+2S — past renewal grace, eligible for auto-BYE.""" + exp = self.grace_expires_at + return exp is not None and timezone.now() >= exp + class RoomInvite(models.Model): PENDING = "PENDING" diff --git a/src/apps/epic/tests/integrated/test_models.py b/src/apps/epic/tests/integrated/test_models.py index 6e67b60..618e5f2 100644 --- a/src/apps/epic/tests/integrated/test_models.py +++ b/src/apps/epic/tests/integrated/test_models.py @@ -80,6 +80,82 @@ class DebitTokenTest(TestCase): self.assertEqual(self.room.gate_status, Room.OPEN) +class GateSlotCostCurrentTest(TestCase): + """Seat-occupancy / renewal clock (sprint 2026-05-31). A filled seat's + token cost is "current" for `filled_at + renewal_period` (the cost-current + window), then sits in a renewal-grace window of equal length before its + occupant is auto-BYE'd. All derived from `filled_at` + `renewal_period` + only — uniform across token types, no new fields. A NULL `filled_at` + (ORM fixtures / RESERVED slots) reads as current / never-expired.""" + + def setUp(self): + self.owner = User.objects.create(email="founder@example.com") + self.room = Room.objects.create( + name="Test Room", owner=self.owner, renewal_period=timedelta(days=7), + ) + self.slot = self.room.gate_slots.get(slot_number=1) + + def _fill(self, filled_at): + self.slot.status = GateSlot.FILLED + self.slot.gamer = self.owner + self.slot.filled_at = filled_at + self.slot.save() + return self.slot + + def test_cost_current_true_within_span(self): + self._fill(timezone.now()) + self.assertTrue(self.slot.cost_current) + + def test_cost_current_false_after_span(self): + self._fill(timezone.now() - timedelta(days=8)) + self.assertFalse(self.slot.cost_current) + + def test_cost_current_true_when_filled_at_null(self): + # RESERVED / ORM-built fixtures never expire (filled_at is None). + self.slot.status = GateSlot.FILLED + self.slot.filled_at = None + self.slot.save() + self.assertTrue(self.slot.cost_current) + + def test_cost_current_until_equals_filled_plus_renewal_period(self): + self._fill(timezone.now()) + self.assertEqual( + self.slot.cost_current_until, self.slot.filled_at + timedelta(days=7), + ) + + def test_in_renewal_grace_true_between_S_and_2S(self): + self._fill(timezone.now() - timedelta(days=8)) # past S=7d, before 2S=14d + self.assertTrue(self.slot.in_renewal_grace) + + def test_in_renewal_grace_false_before_S(self): + self._fill(timezone.now()) + self.assertFalse(self.slot.in_renewal_grace) + + def test_in_renewal_grace_false_after_2S(self): + self._fill(timezone.now() - timedelta(days=15)) + self.assertFalse(self.slot.in_renewal_grace) + + def test_grace_expired_at_2S(self): + self._fill(timezone.now() - timedelta(days=15)) # past 2S=14d + self.assertTrue(self.slot.grace_expired) + + def test_grace_expired_false_within_grace(self): + self._fill(timezone.now() - timedelta(days=8)) + self.assertFalse(self.slot.grace_expired) + + def test_grace_expired_false_for_null_filled_at(self): + self.slot.status = GateSlot.FILLED + self.slot.filled_at = None + self.slot.save() + self.assertFalse(self.slot.grace_expired) + + def test_renewal_span_falls_back_to_7d_when_period_null(self): + self.room.renewal_period = None + self.room.save(update_fields=["renewal_period"]) + self.slot.refresh_from_db() + self.assertEqual(self.slot.renewal_span, timedelta(days=7)) + + class CoinTokenInUseTest(TestCase): def setUp(self): self.owner = User.objects.create(email="founder@example.com")