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:
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user