From 6fd515bc6d5567eb6c5da42f3c79c12bda53ce8a Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Sun, 31 May 2026 22:47:51 -0400 Subject: [PATCH] =?UTF-8?q?GateSlot=20seat-occupancy=20clock:=20cost=5Fcur?= =?UTF-8?q?rent=20/=20renewal-grace=20/=20grace=5Fexpired=20derived=20from?= =?UTF-8?q?=20filled=5Fat=20+=20renewal=5Fperiod=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the room GATE VIEW + seat-renewal sprint. Pure model properties (no migration, no new fields) layering a uniform seat clock on top of the existing per-token debit rules (which stay untouched): [A, A+S) cost_current play normally (A = filled_at) [A+S, A+2S) in_renewal_grace cost lapsed, seat held (S = renewal_period) [A+2S, ∞) grace_expired eligible for auto-BYE Uniform across ALL token types per user-spec (PASS/BAND/CARTE included) — keyed on filled_at only. A NULL filled_at (RESERVED slots, ORM-built fixtures) reads cost_current=True / grace_expired=False so nothing without a fill timestamp is ever evicted (protects existing FILLED-slot tests that set status via the ORM). renewal_span falls back to 7d when room.renewal_period is None. Tests: GateSlotCostCurrentTest — 11 UTs covering within/after span, null filled_at, until==filled+period, grace boundaries [S,2S), expiry at 2S, and the 7d span fallback. 491 epic tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/apps/epic/models.py | 46 +++++++++++ src/apps/epic/tests/integrated/test_models.py | 76 +++++++++++++++++++ 2 files changed, 122 insertions(+) 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")