From a5e1ed985c6ea70e2402ef7760beed3effdd8453 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sat, 16 May 2026 21:07:36 -0700 Subject: [PATCH] feat(door): P3-B pure slot allocator (lowest-free, range-generic) + tests pickLowestFreeSlot fills gaps before extending (deterministic, compact, predictable for admins reading the controller). Range is a parameter (HeatSync passes 0-200) so it stays adapter-generic. Ignores duplicate/ out-of-range/non-integer used slots; reports slot_exhausted when full. slotCapacity for 'controller full N/N' messaging. 8 unit tests. --- __tests__/door-slots-logic.test.ts | 50 ++++++++++++++++++++++++++++++ lib/door-slots-logic.ts | 43 +++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 __tests__/door-slots-logic.test.ts create mode 100644 lib/door-slots-logic.ts diff --git a/__tests__/door-slots-logic.test.ts b/__tests__/door-slots-logic.test.ts new file mode 100644 index 0000000..e730a60 --- /dev/null +++ b/__tests__/door-slots-logic.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest' +import { + pickLowestFreeSlot, + slotCapacity, + HEATSYNC_SLOT_MIN, + HEATSYNC_SLOT_MAX, +} from '@/lib/door-slots-logic' + +describe('pickLowestFreeSlot', () => { + it('picks 0 on an empty controller (default HeatSync range)', () => { + expect(pickLowestFreeSlot([])).toEqual({ ok: true, slot: 0 }) + }) + + it('returns the lowest free slot, filling gaps before extending', () => { + expect(pickLowestFreeSlot([0, 1, 3])).toEqual({ ok: true, slot: 2 }) + expect(pickLowestFreeSlot([0, 1, 2, 3])).toEqual({ ok: true, slot: 4 }) + }) + + it('ignores duplicates, out-of-range and non-integer entries', () => { + expect(pickLowestFreeSlot([0, 0, 1, 1, 999, -5, 2.5])).toEqual({ ok: true, slot: 2 }) + }) + + it('reports slot_exhausted when every slot in range is taken', () => { + const full = Array.from({ length: 201 }, (_, i) => i) // 0..200 + expect(pickLowestFreeSlot(full)).toEqual({ ok: false, reason: 'slot_exhausted' }) + }) + + it('frees a revoked slot for reuse (lowest-first reclaims it)', () => { + const full = Array.from({ length: 201 }, (_, i) => i) + const afterRevoke = full.filter(s => s !== 57) + expect(pickLowestFreeSlot(afterRevoke)).toEqual({ ok: true, slot: 57 }) + }) + + it('honours a custom range for non-HeatSync controllers', () => { + expect(pickLowestFreeSlot([10, 11], 10, 12)).toEqual({ ok: true, slot: 12 }) + expect(pickLowestFreeSlot([10, 11, 12], 10, 12)).toEqual({ ok: false, reason: 'slot_exhausted' }) + }) +}) + +describe('slotCapacity', () => { + it('counts the HeatSync range as 201 inclusive slots', () => { + expect(slotCapacity()).toBe(201) + expect(slotCapacity(HEATSYNC_SLOT_MIN, HEATSYNC_SLOT_MAX)).toBe(201) + }) + it('handles custom and degenerate ranges', () => { + expect(slotCapacity(1, 200)).toBe(200) + expect(slotCapacity(5, 5)).toBe(1) + expect(slotCapacity(10, 9)).toBe(0) + }) +}) diff --git a/lib/door-slots-logic.ts b/lib/door-slots-logic.ts new file mode 100644 index 0000000..1054252 --- /dev/null +++ b/lib/door-slots-logic.ts @@ -0,0 +1,43 @@ +// Pure, dependency-free slot allocation for the Door epic. No Supabase/React. +// Some controllers key cards by an integer "slot" (the verified HeatSync/23b +// firmware: 0-200). The slot space is per door connection. This decides which +// slot a newly granted card should take; the action layer holds the actual +// allocation state (door_card_slots) and the DB unique constraint arbitrates +// concurrent grants. Kept adapter-generic: the range is a parameter, not a +// HeatSync constant, even though HeatSync is the first caller. + +// Default range matches the HeatSync firmware (slots 0-200 inclusive, 201 +// usable). The user chose the full range with no reserved slots. +export const HEATSYNC_SLOT_MIN = 0 +export const HEATSYNC_SLOT_MAX = 200 + +export type SlotPick = + | { ok: true; slot: number } + | { ok: false; reason: 'slot_exhausted' } + +// Lowest free slot first: deterministic (testable), compact, and predictable +// for an admin reading the controller's user list. `used` may contain +// duplicates or out-of-range values; both are ignored. +export function pickLowestFreeSlot( + used: number[], + min: number = HEATSYNC_SLOT_MIN, + max: number = HEATSYNC_SLOT_MAX, +): SlotPick { + const taken = new Set() + for (const n of used) { + if (Number.isInteger(n)) taken.add(n) + } + for (let slot = min; slot <= max; slot++) { + if (!taken.has(slot)) return { ok: true, slot } + } + return { ok: false, reason: 'slot_exhausted' } +} + +// Total addressable slots for a range, for capacity messaging +// ("controller is full, 201/201 slots used"). +export function slotCapacity( + min: number = HEATSYNC_SLOT_MIN, + max: number = HEATSYNC_SLOT_MAX, +): number { + return max < min ? 0 : max - min + 1 +}