use std::collections::HashSet;
use openlogi_core::device::{
DeviceInventory, DeviceKind, DeviceModelInfo, DeviceTransports, PairedDevice, ReceiverInfo,
};
use super::cache::{
CACHE_MISS_GRACE, CacheKey, CacheOutcome, Cached, REFRESH_TICKS, backfill_identity, is_stale,
};
use super::probe::{NodeProbe, assemble_bolt_probe, parse_codename_unifying};
use super::{Enumerator, ONESHOT_ATTEMPTS, one_shot_should_stop};
use crate::inventory::features::ProbedFeatures;
fn cache_entry(probed_tick: u64) -> Cached {
Cached {
probe: ProbedFeatures::default(),
battery_index: None,
probed_tick,
}
}
#[test]
fn cache_entry_survives_grace_then_evicts() {
let mut e = Enumerator::default();
let key = CacheKey::Bolt {
unit_id: [1, 2, 3, 4],
};
e.cache.insert(key.clone(), cache_entry(0));
let nobody = HashSet::new();
for _ in 0..CACHE_MISS_GRACE {
e.evict_unseen(&nobody);
assert!(
e.cache.contains_key(&key),
"evicted inside the grace window"
);
}
e.evict_unseen(&nobody);
assert!(
!e.cache.contains_key(&key),
"should evict past the grace window"
);
}
#[test]
fn being_seen_resets_the_miss_counter() {
let mut e = Enumerator::default();
let key = CacheKey::Bolt { unit_id: [9; 4] };
e.cache.insert(key.clone(), cache_entry(0));
let nobody = HashSet::new();
let seen: HashSet<CacheKey> = std::iter::once(key.clone()).collect();
e.evict_unseen(&nobody); e.evict_unseen(&seen); for _ in 0..CACHE_MISS_GRACE {
e.evict_unseen(&nobody);
}
assert!(
e.cache.contains_key(&key),
"counter reset by a sighting, so still within grace"
);
}
#[test]
fn cached_probe_is_reused_until_refresh_ticks() {
let cached = Cached {
probe: ProbedFeatures::default(),
battery_index: None,
probed_tick: 10,
};
assert!(!is_stale(&cached, 10), "same tick is fresh");
assert!(
!is_stale(&cached, 10 + REFRESH_TICKS - 1),
"just under the window is still fresh"
);
assert!(
is_stale(&cached, 10 + REFRESH_TICKS),
"at the window the probe is refreshed"
);
}
fn inventory(slots: &[u8]) -> Vec<DeviceInventory> {
vec![DeviceInventory {
receiver: ReceiverInfo {
name: "Unifying Receiver".to_string(),
vendor_id: 0x046d,
product_id: 0xc52b,
unique_id: Some("receiver-1".to_string()),
},
paired: slots
.iter()
.copied()
.map(|slot| PairedDevice {
slot,
codename: Some(format!("device-{slot}")),
wpid: Some(0xb000 + u16::from(slot)),
kind: DeviceKind::Mouse,
online: true,
battery: None,
model_info: None,
capabilities: None,
})
.collect(),
}]
}
#[test]
fn one_shot_retry_stops_when_first_attempt_is_complete() {
let current = inventory(&[1, 2]);
assert!(
one_shot_should_stop(None, ¤t, true, true, 1),
"complete inventories keep the one-pass happy path"
);
}
#[test]
fn one_shot_retry_waits_for_healthy_incomplete_inventory_to_stabilize() {
let partial = inventory(&[1]);
let full = inventory(&[1, 2]);
assert!(
!one_shot_should_stop(None, &partial, false, true, 1),
"the first incomplete pass has no previous inventory to compare"
);
assert!(
!one_shot_should_stop(Some(partial.as_slice()), &full, false, true, 2),
"a changed inventory should get another retry window"
);
assert!(
one_shot_should_stop(Some(full.as_slice()), &full, false, true, 3),
"once the returned inventory stabilizes, retrying stops"
);
}
#[test]
fn one_shot_retry_stops_on_unchanged_incomplete_inventory() {
let partial = inventory(&[1]);
assert!(
one_shot_should_stop(Some(partial.as_slice()), &partial, false, true, 2),
"stable partial inventories should not burn every retry attempt"
);
}
#[test]
fn one_shot_retry_keeps_unchanged_inventory_after_unhealthy_probe() {
let partial = inventory(&[1]);
assert!(
!one_shot_should_stop(Some(partial.as_slice()), &partial, false, false, 2),
"unchanged replay after a failed probe must keep retrying before the cap"
);
}
#[test]
fn one_shot_retry_stops_at_attempt_cap_when_inventory_keeps_changing() {
let previous = inventory(&[1]);
let current = inventory(&[1, 2]);
assert!(
one_shot_should_stop(
Some(previous.as_slice()),
¤t,
false,
false,
ONESHOT_ATTEMPTS
),
"the retry loop must remain bounded even if the inventory changes every time"
);
}
fn bolt_receiver_info() -> ReceiverInfo {
ReceiverInfo {
name: "Logi Bolt Receiver".to_string(),
vendor_id: 0x046d,
product_id: 0xc548,
unique_id: Some("bolt-1".to_string()),
}
}
fn bolt_slot(slot: u8) -> (PairedDevice, CacheOutcome) {
(
PairedDevice {
slot,
codename: Some(format!("device-{slot}")),
wpid: None,
kind: DeviceKind::Mouse,
online: true,
battery: None,
model_info: None,
capabilities: None,
},
CacheOutcome::Seen(CacheKey::Bolt {
unit_id: [0, 0, 0, slot],
}),
)
}
fn paired_slots(probe: &NodeProbe) -> Vec<u8> {
let Some(inventory) = probe.inventory.as_ref() else {
panic!("expected an inventory");
};
inventory.paired.iter().map(|d| d.slot).collect()
}
#[test]
fn bolt_probe_is_complete_when_count_matches_readable_slots() {
let probe = assemble_bolt_probe(
bolt_receiver_info(),
Some(2),
vec![bolt_slot(1), bolt_slot(2)],
);
assert!(probe.complete, "count matches the readable slots");
assert!(probe.healthy, "a complete Bolt walk is authoritative");
assert_eq!(paired_slots(&probe), vec![1, 2], "slots surface in order");
assert_eq!(
probe.outcomes.len(),
2,
"one cache outcome per readable slot"
);
}
#[test]
fn bolt_probe_is_incomplete_when_a_counted_slot_is_unreadable() {
let probe = assemble_bolt_probe(bolt_receiver_info(), Some(2), vec![bolt_slot(1)]);
assert_eq!(
paired_slots(&probe),
vec![1],
"only the readable slot surfaces"
);
assert!(!probe.complete, "a count shortfall is not complete");
assert!(
!probe.healthy,
"an incomplete Bolt walk is not authoritative"
);
}
#[test]
fn bolt_probe_is_incomplete_when_the_count_register_is_unanswered() {
let probe = assemble_bolt_probe(bolt_receiver_info(), None, vec![bolt_slot(1), bolt_slot(2)]);
assert_eq!(paired_slots(&probe), vec![1, 2]);
assert!(
!probe.complete,
"no count register means we couldn't fully check"
);
assert!(!probe.healthy);
}
fn model(unit_id: [u8; 4], serial: Option<&str>) -> DeviceModelInfo {
DeviceModelInfo {
entity_count: 1,
serial_number: serial.map(str::to_string),
unit_id,
transports: DeviceTransports::default(),
model_ids: [0xc09d, 0, 0],
extended_model_id: 1,
}
}
fn probed(model_info: Option<DeviceModelInfo>, identity_incomplete: bool) -> ProbedFeatures {
ProbedFeatures {
model_info,
identity_incomplete,
kind: Some(DeviceKind::Mouse),
..ProbedFeatures::default()
}
}
#[test]
fn failed_device_info_read_backfills_from_cache() {
let mut fresh = probed(None, true);
let cached = probed(Some(model([0x46, 0, 0x2e, 0], None)), false);
backfill_identity(&mut fresh, &cached);
assert_eq!(fresh.model_info, cached.model_info);
assert!(
!fresh.identity_incomplete,
"a backfilled identity is complete and may be cached"
);
}
#[test]
fn failed_serial_read_backfills_only_the_serial() {
let mut fresh = probed(Some(model([1, 2, 3, 4], None)), true);
let cached = probed(Some(model([9, 9, 9, 9], Some("abc123"))), false);
backfill_identity(&mut fresh, &cached);
let Some(info) = fresh.model_info else {
panic!("model info kept");
};
assert_eq!(info.serial_number.as_deref(), Some("abc123"));
assert_eq!(info.unit_id, [1, 2, 3, 4], "fresh unit id wins");
assert!(!fresh.identity_incomplete);
}
#[test]
fn complete_probe_is_never_overwritten_by_cache() {
let mut fresh = probed(Some(model([1, 2, 3, 4], None)), false);
let cached = probed(Some(model([9, 9, 9, 9], Some("stale"))), false);
backfill_identity(&mut fresh, &cached);
let Some(info) = fresh.model_info else {
panic!("model info kept");
};
assert_eq!(info.unit_id, [1, 2, 3, 4]);
assert!(
info.serial_number.is_none(),
"no serial was read, none faked"
);
}
#[test]
fn incomplete_probe_without_cached_identity_stays_incomplete() {
let mut fresh = probed(None, true);
let cached = probed(None, false);
backfill_identity(&mut fresh, &cached);
assert!(
fresh.identity_incomplete,
"nothing to backfill from — the caller must not memoize this probe"
);
}
#[test]
fn failed_kind_read_is_carried_forward() {
let mut fresh = ProbedFeatures::default();
let cached = probed(None, false);
backfill_identity(&mut fresh, &cached);
assert_eq!(fresh.kind, Some(DeviceKind::Mouse));
}
#[test]
fn codename_reads_len_prefixed_name() {
let mut buf = vec![0x40, 0x0c];
buf.extend_from_slice(b"MX Master 2S");
buf.extend_from_slice(&[0u8; 2]); assert_eq!(
parse_codename_unifying(&buf).as_deref(),
Some("MX Master 2S")
);
}
#[test]
fn codename_clamps_overlong_len() {
let buf = [0x40, 0xff, b'h', b'i'];
assert_eq!(parse_codename_unifying(&buf).as_deref(), Some("hi"));
}
#[test]
fn codename_rejects_short_response() {
assert_eq!(parse_codename_unifying(&[0x40]), None);
}