GateSlot seat-occupancy clock: cost_current / renewal-grace / grace_expired derived from filled_at + renewal_period — TDD

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) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-31 22:47:51 -04:00
parent 1e70ffabd6
commit 6fd515bc6d
2 changed files with 122 additions and 0 deletions

View File

@@ -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"

View File

@@ -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")