// Host tests for grants.h — the shared-control engine — with a fake clock.
// g++ -std=c++17 -Wall -Wextra -I.. grants_test.cpp -o grants_test && ./grants_test
#include "grants.h"
#include <stdio.h>
#include <string.h>
using namespace cth;
static int failures = 0, checks = 0;
#define CHECK(cond) do { checks++; if (!(cond)) { failures++; \
printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); } } while (0)
static Engine fresh(uint32_t epoch = 7) {
Engine e;
e.begin(epoch, true);
return e;
}
static int drain(Engine &e, Event *out, int max) {
int n = 0;
Event ev;
while (e.popEvent(ev)) if (n < max) out[n++] = ev;
return n;
}
static void test_first_come() {
Engine e = fresh();
int busy;
CHECK(e.grant(1, 0, "alice", 1000, busy) == Engine::Granted);
CHECK(e.grant(1, 1, "bob", 1001, busy) == Engine::Busy);
CHECK(busy == 1);
CHECK(e.grant(2, 1, "bob", 1002, busy) == Engine::Granted); // other slot: fine
const char *who; uint32_t n;
CHECK(e.held(1, who, n) && strcmp(who, "alice") == 0 && n == 1);
CHECK(e.held(2, who, n) && strcmp(who, "bob") == 0 && n == 2);
Event ev[8];
int k = drain(e, ev, 8);
CHECK(k == 2 && ev[0].kind == Event::Held && ev[0].slot == 1 && ev[0].epoch == 7);
}
static void test_grant_all_is_atomic() {
Engine e = fresh();
int busy;
CHECK(e.grant(2, 1, "bob", 0, busy) == Engine::Granted);
CHECK(e.grant(ALL, 0, "alice", 0, busy) == Engine::Busy);
CHECK(busy == 2);
const char *who; uint32_t n;
CHECK(!e.held(0, who, n)); // nothing taken by the failed `all`
CHECK(e.release(2, 1, 0));
CHECK(e.grant(ALL, 0, "alice", 0, busy) == Engine::Granted);
for (int s = 0; s < SLOTS; s++) CHECK(e.held(s, who, n) && strcmp(who, "alice") == 0);
}
static void test_set_needs_holder_and_grant() {
Engine e = fresh();
int busy;
e.grant(0, 3, "alice", 0, busy);
const char *who; uint32_t n;
e.held(0, who, n);
CHECK(e.set(0, 4, 7, n, 1, 2, 3, 0, 10) == Engine::NotHeld); // wrong socket
CHECK(e.set(0, 3, 7, n + 1, 1, 2, 3, 0, 10) == Engine::NotHeld); // wrong grant
CHECK(e.set(0, 3, 6, n, 1, 2, 3, 0, 10) == Engine::NotHeld); // wrong epoch
CHECK(e.set(0, 3, 7, n, 1, 2, RED_MAX + 1, 0, 10) == Engine::BadValue);
CHECK(e.set(0, 3, 7, n, 100, 200, 300, 0, 10) == Engine::SetOk);
CHECK(e.value(0) == 100 && e.value(1) == 200 && e.value(2) == 300);
CHECK(e.set(1, 3, 7, n, 1, 1, 1, 0, 10) == Engine::NotHeld); // slot not held
}
static void test_idle_expiry_reports_final_value() {
Engine e = fresh();
int busy;
e.grant(3, 0, "alice", 0, busy);
const char *who; uint32_t n;
e.held(3, who, n);
e.set(3, 0, 7, n, 10, 20, 30, 0, 1500); // movement resets the idle clock
e.tick(3400);
CHECK(e.held(3, who, n)); // 1900 ms since the last move
e.tick(3500);
CHECK(!e.held(3, who, n)); // 2000 ms: freed
Event ev[8];
int k = drain(e, ev, 8);
CHECK(k == 2);
CHECK(ev[1].kind == Event::Free && ev[1].why == Why::Idle && ev[1].slot == 3);
CHECK(ev[1].b == 10 && ev[1].g == 20 && ev[1].r == 30 && ev[1].n == n);
CHECK(strcmp(ev[1].holder, "alice") == 0);
}
static void test_max_expiry_even_while_moving() {
Engine e = fresh();
int busy;
e.grant(0, 0, "alice", 0, busy);
const char *who; uint32_t n;
e.held(0, who, n);
uint32_t t = 0;
for (; t < MAX_MS; t += 500) { e.set(0, 0, 7, n, t % 1000, 0, 0, 0, t); e.tick(t); }
e.tick(MAX_MS);
CHECK(!e.held(0, who, n));
Event ev[80];
int k = drain(e, ev, 80);
CHECK(ev[k - 1].kind == Event::Free && ev[k - 1].why == Why::Max);
// A re-grab can't extend a drag: re-granting a held slot keeps its clock.
Engine f = fresh();
f.grant(0, 0, "alice", 0, busy);
f.grant(0, 0, "alice", 29000, busy);
f.tick(29500);
f.grant(0, 0, "alice", 29600, busy); // re-grab mid-drag
f.tick(30000);
CHECK(!f.held(0, who, n));
}
static void test_release_and_disconnect() {
Engine e = fresh();
int busy;
e.grant(0, 0, "alice", 0, busy);
e.grant(1, 1, "bob", 0, busy);
CHECK(!e.release(0, 1, 5)); // bob can't release alice's slot
CHECK(e.release(0, 0, 5));
e.socketClosed(1);
const char *who; uint32_t n;
CHECK(!e.held(0, who, n) && !e.held(1, who, n));
Event ev[8];
int k = drain(e, ev, 8);
CHECK(k == 4 && ev[2].why == Why::Release && ev[3].why == Why::Disconnect);
}
static void test_stopall_overrides_drags() {
Engine e = fresh();
int busy;
e.grant(ALL, 0, "alice", 0, busy);
e.stopAll();
const char *who; uint32_t n;
for (int s = 0; s < SLOTS; s++) CHECK(!e.held(s, who, n));
Event ev[16];
int k = drain(e, ev, 16);
CHECK(k == 8 && ev[7].why == Why::Blackout);
}
static void test_legacy_skips_held_slots() {
Engine e = fresh();
int busy;
e.grant(1, 0, "alice", 0, busy);
uint32_t v[CHANNELS];
for (int c = 0; c < CHANNELS; c++) v[c] = 1000 + c;
CHECK(e.legacy(v, CHANNELS, 0, 0) == 9);
CHECK(e.value(0) == 1000 && e.value(3) == 0 && e.value(5) == 0 && e.value(6) == 1006);
}
static void test_per_channel_fades_independent() {
Engine e = fresh();
int busy;
e.grant(0, 0, "alice", 0, busy);
e.grant(1, 1, "bob", 0, busy);
const char *who; uint32_t na, nb;
e.held(0, who, na);
e.held(1, who, nb);
e.set(0, 0, 7, na, 0, 0, 40000, 1000, 0); // alice fades slot 0 over 1 s
e.tick(500);
e.set(1, 1, 7, nb, 30000, 0, 0, 0, 500); // bob writes slot 1 mid-fade
e.tick(500);
CHECK(e.value(2) > 15000 && e.value(2) < 25000); // alice's fade continued (linear, gamma 1)
CHECK(e.value(3) == 30000);
e.tick(1000);
CHECK(e.value(2) == 40000);
CHECK(!e.anyFading());
}
static void test_fade_reports_target_on_release() {
Engine e = fresh();
int busy;
e.grant(0, 0, "alice", 0, busy);
const char *who; uint32_t n;
e.held(0, who, n);
e.set(0, 0, 7, n, 60000, 0, 0, 5000, 0);
e.tick(100);
e.release(0, 0, 100);
Event ev[4];
int k = drain(e, ev, 4);
CHECK(ev[k - 1].b == 60000); // ends on the target, not the mid-fade value
}
static void test_wraparound() {
Engine e = fresh();
int busy;
uint32_t t0 = 0xFFFFFF00u; // just before millis() wraps
e.grant(0, 0, "alice", t0, busy);
const char *who; uint32_t n;
e.tick(t0 + 1000); // wrapped past zero
CHECK(e.held(0, who, n));
e.tick(t0 + 2000);
CHECK(!e.held(0, who, n));
}
static void test_disabled_without_epoch() {
Engine e;
e.begin(0, false);
int busy;
CHECK(e.grant(0, 0, "alice", 0, busy) == Engine::Disabled);
uint32_t v[3] = {1, 2, 3};
CHECK(e.legacy(v, 3, 0, 0) == 3); // legacy control still works
}
static void test_values_broadcast_pacing() {
Engine e = fresh();
uint32_t v[CHANNELS] = {0};
v[0] = 5;
e.legacy(v, CHANNELS, 0, 0);
CHECK(e.valuesDue(0));
v[0] = 6;
e.legacy(v, CHANNELS, 0, 100);
CHECK(!e.valuesDue(100)); // inside the 250 ms window
CHECK(e.valuesDue(250));
CHECK(!e.valuesDue(600)); // nothing changed since
}
static void test_free_message_format() {
Event ev;
memset(&ev, 0, sizeof(ev));
ev.kind = Event::Free;
ev.slot = 2;
ev.epoch = 9;
ev.n = 41;
ev.b = 1; ev.g = 2; ev.r = 45000;
char buf[160];
freeMessage(buf, sizeof(buf), "studiolight", ev);
CHECK(strcmp(buf, "cthuloid/free/1 studiolight 2 9 41 1 2 45000") == 0);
}
static void test_pick_slot() {
bool occ[3] = {true, true, true};
uint32_t last[3] = {100, 50, 10};
bool holds[3] = {false, false, true};
CHECK(pickSlot(occ, last, holds, 3, 200) == 1); // oldest WITHOUT a grant
occ[0] = false;
CHECK(pickSlot(occ, last, holds, 3, 200) == 0); // a free slot first
occ[0] = true;
bool all[3] = {true, true, true};
CHECK(pickSlot(occ, last, all, 3, 200) == -1); // everyone holds: refuse
}
static void test_set_epoch_live() {
// epochfloor recovery: a reflashed unit raises its epoch while running. Grants
// under the old epoch end (blackout semantics), numbering restarts under the new.
Engine e = fresh(3);
int busy;
e.grant(0, 0, "alice", 0, busy);
e.setEpoch(12);
const char *who; uint32_t n;
CHECK(!e.held(0, who, n));
CHECK(e.epoch() == 12);
Event ev[4];
int k = drain(e, ev, 4);
CHECK(k == 2 && ev[1].kind == Event::Free && ev[1].epoch == 3); // freed under the OLD epoch
e.grant(0, 0, "alice", 0, busy);
e.held(0, who, n);
CHECK(n == 1);
k = drain(e, ev, 4);
CHECK(k == 1 && ev[0].epoch == 12);
}
static void test_rate_cap() {
RateCap r;
int ok = 0;
for (int i = 0; i < 100; i++) ok += r.allow(500);
CHECK(ok == 60 && r.dropped == 40);
CHECK(r.allow(1500)); // new window
}
int main() {
test_first_come();
test_grant_all_is_atomic();
test_set_needs_holder_and_grant();
test_idle_expiry_reports_final_value();
test_max_expiry_even_while_moving();
test_release_and_disconnect();
test_stopall_overrides_drags();
test_legacy_skips_held_slots();
test_per_channel_fades_independent();
test_fade_reports_target_on_release();
test_wraparound();
test_disabled_without_epoch();
test_values_broadcast_pacing();
test_free_message_format();
test_pick_slot();
test_set_epoch_live();
test_rate_cap();
printf("%d/%d checks passed\n", checks - failures, checks);
return failures ? 1 : 0;
}