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.
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<number>()
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user