From badf7c0d0f2c026a51f88201b60384ccb833843e Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sat, 14 Mar 2026 13:11:30 +0100 Subject: [PATCH] [fix](trx-frontend-http): improve scheduler entry controls Add previous/next scheduler entry controls for overlapping\ntime-span slots and fix interleave timing calculations so\nthe active slot and countdown align with the overlap window.\n\nVerification: cargo test -p trx-frontend-http scheduler\nVerification: node --check assets/web/plugins/scheduler.js\n\nCo-authored-by: OpenAI Codex Signed-off-by: Stan Grams --- .../trx-frontend-http/assets/web/index.html | 4 + .../assets/web/plugins/scheduler.js | 152 +++++++- .../trx-frontend-http/assets/web/style.css | 8 + .../trx-frontend/trx-frontend-http/src/api.rs | 1 + .../trx-frontend-http/src/scheduler.rs | 361 +++++++++++------- 5 files changed, 376 insertions(+), 150 deletions(-) diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index f733d17..8ba26fa 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -266,6 +266,10 @@
+
+ + +
Scheduler is controlling the rig.
Interleaving: --
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/scheduler.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/scheduler.js index f409c84..eed6112 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/scheduler.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/scheduler.js @@ -16,6 +16,7 @@ let bookmarkList = []; // [{id, name, freq_hz, mode}, ...] let statusInterval = null; let interleaveTicker = null; + let schedulerStepPending = false; // ------------------------------------------------------------------------- // Init @@ -91,6 +92,17 @@ ); } + function apiActivateSchedulerEntry(rigId, entryId) { + return fetch("/scheduler/" + encodeURIComponent(rigId) + "/activate", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ entry_id: entryId }), + }).then(function (r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }); + } + function apiGetBookmarks() { return fetch("/bookmarks").then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); @@ -156,15 +168,38 @@ return nowMin >= start || nowMin < end; } - function schedulerInterleaveSummary(config) { - if (!config || config.mode !== "time_span") return "Interleaving: off"; + function schedulerEntryCurrentWindowStart(entry, nowMin) { + const start = Number(entry && entry.start_min); + const end = Number(entry && entry.end_min); + if (!Number.isFinite(start) || !Number.isFinite(end)) return Number.NEGATIVE_INFINITY; + if (start === end) return 0; + if (start < end) return start; + return nowMin >= start ? start : (start - 1440); + } + + function schedulerEntryDisplayName(entry) { + if (!entry) return "Scheduler entry"; + if (entry.label) return String(entry.label); + const bookmarkName = bmName(entry.bookmark_id); + return bookmarkName || "Scheduler entry"; + } + + function schedulerInterleaveState(config) { + if (!config || config.mode !== "time_span") { + return { activeEntries: [], currentIndex: -1, remainingSec: 0, cycleMin: 0 }; + } const entries = Array.isArray(config.entries) ? config.entries : []; const minuteInfo = schedulerUtcMinuteInfo(); const nowMin = minuteInfo.minuteOfDay; const active = entries.filter(function (entry) { return schedulerEntryIsActive(entry, nowMin); }); - if (active.length <= 1) return "Interleaving: off"; + if (active.length === 0) { + return { activeEntries: [], currentIndex: -1, remainingSec: 0, cycleMin: 0 }; + } + if (active.length === 1) { + return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; + } const defaultInterleave = Number(config.interleave_min); const durations = active.map(function (entry) { const own = Number(entry && entry.interleave_min); @@ -173,27 +208,76 @@ return 0; }); const cycleMin = durations.reduce(function (sum, value) { return sum + value; }, 0); - if (!(cycleMin > 0)) return "Interleaving: off"; - const posMin = minuteInfo.minuteOfDay % cycleMin; + if (!(cycleMin > 0)) { + return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; + } + const overlapStart = active.reduce(function (maxStart, entry) { + return Math.max(maxStart, schedulerEntryCurrentWindowStart(entry, nowMin)); + }, Number.NEGATIVE_INFINITY); + if (!Number.isFinite(overlapStart)) { + return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; + } + const nowMinPrecise = minuteInfo.minuteOfDay + (minuteInfo.secondOfMinute / 60); + const posMin = ((nowMinPrecise - overlapStart) % cycleMin + cycleMin) % cycleMin; let cumulative = 0; + let slotStart = 0; + let currentIndex = 0; let currentDuration = 0; for (let i = 0; i < durations.length; i += 1) { - cumulative += durations[i]; - if (posMin < cumulative) { + const nextCumulative = cumulative + durations[i]; + if (posMin < nextCumulative) { + slotStart = cumulative; + cumulative = nextCumulative; + currentIndex = i; currentDuration = durations[i]; break; } + cumulative = nextCumulative; } - if (!(currentDuration > 0)) return "Interleaving: off"; - const remainingMin = cumulative - posMin; - const remainingSec = Math.max(1, (remainingMin * 60) - minuteInfo.secondOfMinute); - return "Interleaving: next switch in " + remainingSec + "s (" + cycleMin + " min cycle)"; + if (!(currentDuration > 0)) { + return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; + } + const elapsedSlotSec = Math.max(0, Math.floor((posMin - slotStart) * 60)); + const remainingSec = Math.max(1, (currentDuration * 60) - elapsedSlotSec); + return { + activeEntries: active, + currentIndex: currentIndex, + remainingSec: remainingSec, + cycleMin: cycleMin, + }; + } + + function schedulerInterleaveSummary(config) { + const state = schedulerInterleaveState(config); + if (state.activeEntries.length <= 1 || !(state.cycleMin > 0)) return "Interleaving: off"; + const activeName = schedulerEntryDisplayName(state.activeEntries[state.currentIndex]); + return "Interleaving: " + activeName + " · next switch in " + state.remainingSec + "s (" + state.cycleMin + " min cycle)"; } function renderSchedulerInterleaveStatus() { const el = document.getElementById("scheduler-cycle-status"); if (!el) return; el.textContent = schedulerInterleaveSummary(currentConfig); + renderSchedulerStepControls(); + } + + function renderSchedulerStepControls() { + const prevBtn = document.getElementById("scheduler-prev-btn"); + const nextBtn = document.getElementById("scheduler-next-btn"); + if (!prevBtn || !nextBtn) return; + const state = schedulerInterleaveState(currentConfig); + const enabled = + schedulerRole === "control" && + !!currentRigId && + !schedulerStepPending && + state.activeEntries.length > 1; + prevBtn.disabled = !enabled; + nextBtn.disabled = !enabled; + const hint = enabled + ? "Select a different active scheduler entry" + : "Available only when multiple scheduler entries are active"; + prevBtn.title = hint; + nextBtn.title = hint; } function pollStatus() { @@ -350,7 +434,7 @@ '' + (allDay ? "All day" : minToHHMM(entry.start_min)) + '' + '' + (allDay ? "—" : minToHHMM(entry.end_min)) + '' + '' + centerCell + '' + - '' + bmName(entry.bookmark_id) + '' + + '' + escHtml(bmName(entry.bookmark_id)) + '' + '' + extraCell + '' + '' + escHtml(entry.label || "") + '' + '' + il + '' + @@ -366,7 +450,7 @@ function bmName(id) { const bm = bookmarkList.find(function (b) { return b.id === id; }); - return bm ? escHtml(bm.name) : escHtml(id); + return bm ? bm.name : String(id || ""); } function minToHHMM(min) { @@ -388,6 +472,38 @@ .replace(/"/g, """); } + function schedulerSelectRelativeEntry(delta) { + const state = schedulerInterleaveState(currentConfig); + if (!currentRigId || schedulerStepPending || state.activeEntries.length <= 1) return; + const count = state.activeEntries.length; + const currentIndex = state.currentIndex >= 0 ? state.currentIndex : 0; + const targetIndex = (currentIndex + delta + count) % count; + const target = state.activeEntries[targetIndex]; + if (!target || !target.id) return; + + schedulerStepPending = true; + renderSchedulerStepControls(); + + Promise.resolve(typeof vchanTakeSchedulerControl === "function" ? vchanTakeSchedulerControl() : null) + .then(function () { + return apiActivateSchedulerEntry(currentRigId, target.id); + }) + .then(function (status) { + renderStatus(status); + renderSchedulerInterleaveStatus(); + showSchedulerToast("Selected " + schedulerEntryDisplayName(target) + "."); + pollStatus(); + }) + .catch(function (e) { + console.error("scheduler entry selection failed", e); + showSchedulerToast("Scheduler entry selection failed: " + e.message, true); + }) + .finally(function () { + schedulerStepPending = false; + renderSchedulerStepControls(); + }); + } + function removeEntry(idx) { if (!currentConfig || !currentConfig.entries) return; currentConfig.entries.splice(idx, 1); @@ -566,6 +682,16 @@ const addBtn = document.getElementById("scheduler-ts-add-btn"); if (addBtn) addBtn.addEventListener("click", addEntry); + const prevBtn = document.getElementById("scheduler-prev-btn"); + if (prevBtn) prevBtn.addEventListener("click", function () { + schedulerSelectRelativeEntry(-1); + }); + + const nextBtn = document.getElementById("scheduler-next-btn"); + if (nextBtn) nextBtn.addEventListener("click", function () { + schedulerSelectRelativeEntry(1); + }); + wireExtraBmAdd(); } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css index b89936a..e547e80 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css @@ -396,6 +396,14 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem; border-color: var(--accent-yellow); color: var(--accent-yellow); } +.scheduler-step-controls { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} +.scheduler-step-controls button { + min-width: 7.6rem; +} .scheduler-release-status { color: var(--text-muted); font-size: 0.78rem; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs index ffa9902..db94ad8 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs @@ -1322,6 +1322,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .service(crate::server::scheduler::put_scheduler) .service(crate::server::scheduler::delete_scheduler) .service(crate::server::scheduler::get_scheduler_status) + .service(crate::server::scheduler::put_scheduler_activate_entry) .service(crate::server::scheduler::get_scheduler_control) .service(crate::server::scheduler::put_scheduler_control) .service(crate::server::background_decode::get_background_decode) diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/scheduler.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/scheduler.rs index bcc347a..0d380c7 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/scheduler.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/scheduler.rs @@ -302,39 +302,79 @@ fn entry_is_active(entry: &ScheduleEntry, now_min: f64) -> bool { } } +fn entry_current_window_start(entry: &ScheduleEntry, now_min: f64) -> f64 { + let start = entry.start_min as f64; + let end = entry.end_min as f64; + if start == end { + return 0.0; + } + if start < end { + return start; + } + if now_min >= start { + start + } else { + start - 1440.0 + } +} + +fn timespan_active_entries(entries: &[ScheduleEntry], now_min: f64) -> Vec<&ScheduleEntry> { + entries + .iter() + .filter(|entry| entry_is_active(entry, now_min)) + .collect() +} + +fn timespan_cycle_slot( + active: &[&ScheduleEntry], + now_min: f64, + default_interleave: Option, +) -> Option { + if active.len() <= 1 { + return None; + } + + let durations: Vec = active + .iter() + .map(|entry| entry.interleave_min.or(default_interleave).unwrap_or(0)) + .collect(); + let cycle: u32 = durations.iter().sum(); + if cycle == 0 { + return None; + } + + let overlap_start = active + .iter() + .map(|entry| entry_current_window_start(entry, now_min)) + .fold(f64::NEG_INFINITY, f64::max); + if !overlap_start.is_finite() { + return None; + } + + let elapsed = (now_min - overlap_start).rem_euclid(cycle as f64); + let mut cumulative = 0.0; + for (idx, duration) in durations.iter().enumerate() { + cumulative += *duration as f64; + if elapsed < cumulative { + return Some(idx); + } + } + durations.len().checked_sub(1) +} + fn timespan_active_entry( entries: &[ScheduleEntry], now_min: f64, default_interleave: Option, ) -> Option<&ScheduleEntry> { - let active: Vec<&ScheduleEntry> = entries - .iter() - .filter(|e| entry_is_active(e, now_min)) - .collect(); + let active = timespan_active_entries(entries, now_min); if active.is_empty() { return None; } - // With interleaving and more than one active entry, use a weighted cycle. - // Each entry's effective duration is its own interleave_min, falling back - // to the config-level default. The cycle length is the sum of all durations. - if active.len() > 1 { - let durations: Vec = active - .iter() - .map(|e| e.interleave_min.or(default_interleave).unwrap_or(0)) - .collect(); - let cycle: u32 = durations.iter().sum(); - if cycle > 0 { - let pos = (now_min as u64) % (cycle as u64); - let mut cum = 0u64; - for (entry, &dur) in active.iter().zip(durations.iter()) { - cum += dur as u64; - if pos < cum { - return Some(*entry); - } - } - } + if let Some(idx) = timespan_cycle_slot(&active, now_min, default_interleave) { + return Some(active[idx]); } // Default: first matching entry wins. @@ -346,8 +386,8 @@ fn utc_minutes_now() -> f64 { let secs = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() - .as_secs(); - ((secs % 86400) as f64) / 60.0 + .as_secs_f64(); + (secs % 86400.0) / 60.0 } // ============================================================================ @@ -369,6 +409,73 @@ pub struct SchedulerStatus { pub last_bookmark_ids: Vec, } +async fn apply_scheduler_target( + rig_tx: &mpsc::Sender, + rig_id: &str, + status_map: &SchedulerStatusMap, + bookmarks: &BookmarkStore, + bookmark_id: &str, + center_hz: Option, + extra_bm_ids: &[String], +) -> Result { + let bookmark = bookmarks + .get(bookmark_id) + .ok_or_else(|| format!("bookmark '{bookmark_id}' not found for rig '{rig_id}'"))?; + + let extra_bookmarks: Vec<_> = extra_bm_ids + .iter() + .filter_map(|id| bookmarks.get(id)) + .collect(); + + if let Some(chz) = center_hz { + scheduler_send( + rig_tx, + RigCommand::SetCenterFreq(Freq { hz: chz }), + rig_id.to_string(), + ) + .await?; + } + + scheduler_send( + rig_tx, + RigCommand::SetFreq(Freq { + hz: bookmark.freq_hz, + }), + rig_id.to_string(), + ) + .await?; + + scheduler_send( + rig_tx, + RigCommand::SetMode(trx_protocol::parse_mode(&bookmark.mode)), + rig_id.to_string(), + ) + .await?; + + apply_scheduler_decoders(rig_tx, rig_id, &bookmark, &extra_bookmarks).await; + + let status = SchedulerStatus { + active: true, + last_bookmark_id: Some(bookmark_id.to_string()), + last_bookmark_name: Some(bookmark.name.clone()), + last_applied_utc: Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64, + ), + last_center_hz: center_hz, + last_bookmark_ids: extra_bm_ids.to_vec(), + }; + + { + let mut map = status_map.write().unwrap_or_else(|e| e.into_inner()); + map.insert(rig_id.to_string(), status.clone()); + } + + Ok(status) +} + /// Shared mutable state for scheduler status (one entry per rig). pub type SchedulerStatusMap = Arc>>; @@ -524,91 +631,27 @@ pub fn spawn_scheduler_task( continue; }; - let extra_bookmarks: Vec<_> = extra_bm_ids - .iter() - .filter_map(|id| bookmarks.get(id)) - .collect(); - info!( "scheduler: rig '{}' → bookmark '{}' ({} Hz {})", config.rig_id, bm.name, bm.freq_hz, bm.mode ); - // Apply SetCenterFreq first if this is a multi-channel SDR slot. - if let Some(chz) = center_hz { - if let Err(e) = scheduler_send( - &rig_tx, - RigCommand::SetCenterFreq(Freq { hz: chz }), - config.rig_id.clone(), - ) - .await - { - warn!( - "scheduler: SetCenterFreq failed for '{}': {:?}", - config.rig_id, e - ); - } - } - - // Apply SetFreq. - if let Err(e) = scheduler_send( + if let Err(e) = apply_scheduler_target( &rig_tx, - RigCommand::SetFreq(Freq { hz: bm.freq_hz }), - config.rig_id.clone(), + &config.rig_id, + &status_map, + &bookmarks, + &bm_id, + center_hz, + &extra_bm_ids, ) .await { - warn!("scheduler: SetFreq failed for '{}': {:?}", config.rig_id, e); + warn!("scheduler: failed to apply target for '{}': {e}", config.rig_id); continue; } - // Apply SetMode. - { - let mode = trx_protocol::parse_mode(&bm.mode); - if let Err(e) = scheduler_send( - &rig_tx, - RigCommand::SetMode(mode), - config.rig_id.clone(), - ) - .await - { - warn!( - "scheduler: SetMode failed for '{}': {:?}", - config.rig_id, e - ); - } - } - - apply_scheduler_decoders( - &rig_tx, - &config.rig_id, - &bm, - &extra_bookmarks, - ) - .await; - last_applied.insert(config.rig_id.clone(), target); - - // Update status map (includes center_hz + extra bookmark_ids - // so the JS frontend can set up virtual channels on connect). - let now_ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64; - { - let mut map = status_map.write().unwrap_or_else(|e| e.into_inner()); - map.insert( - config.rig_id.clone(), - SchedulerStatus { - active: true, - last_bookmark_id: Some(bm_id), - last_bookmark_name: Some(bm.name), - last_applied_utc: Some(now_ts), - last_center_hz: center_hz, - last_bookmark_ids: extra_bm_ids, - }, - ); - } } } }); @@ -675,57 +718,27 @@ async fn apply_last_scheduler_cycle( let Some(bookmark_id) = status.last_bookmark_id else { return; }; - let Some(bookmark) = bookmarks.get(&bookmark_id) else { + if bookmarks.get(&bookmark_id).is_none() { warn!( "scheduler: last bookmark '{}' not found for rig '{}'", bookmark_id, rig_id ); return; - }; - - let extra_bookmarks: Vec<_> = status - .last_bookmark_ids - .iter() - .filter_map(|id| bookmarks.get(id)) - .collect(); - - if let Some(center_hz) = status.last_center_hz { - if let Err(e) = scheduler_send( - rig_tx, - RigCommand::SetCenterFreq(Freq { hz: center_hz }), - rig_id.to_string(), - ) - .await - { - warn!( - "scheduler: restore SetCenterFreq failed for '{}': {:?}", - rig_id, e - ); - } } - if let Err(e) = scheduler_send( + if let Err(e) = apply_scheduler_target( rig_tx, - RigCommand::SetFreq(Freq { hz: bookmark.freq_hz }), - rig_id.to_string(), + rig_id, + status_map, + bookmarks, + &bookmark_id, + status.last_center_hz, + &status.last_bookmark_ids, ) .await { - warn!("scheduler: restore SetFreq failed for '{}': {:?}", rig_id, e); - return; + warn!("scheduler: restore failed for '{}': {e}", rig_id); } - - if let Err(e) = scheduler_send( - rig_tx, - RigCommand::SetMode(trx_protocol::parse_mode(&bookmark.mode)), - rig_id.to_string(), - ) - .await - { - warn!("scheduler: restore SetMode failed for '{}': {:?}", rig_id, e); - } - - apply_scheduler_decoders(rig_tx, rig_id, &bookmark, &extra_bookmarks).await; } /// Send a single RigCommand from the scheduler context (fire-and-forget style). @@ -809,6 +822,53 @@ pub async fn get_scheduler_status( HttpResponse::Ok().json(status) } +#[derive(Deserialize)] +pub struct SchedulerActivateEntryRequest { + pub entry_id: String, +} + +/// PUT /scheduler/{rig_id}/activate +#[put("/scheduler/{rig_id}/activate")] +pub async fn put_scheduler_activate_entry( + path: web::Path, + body: web::Json, + store: web::Data>, + status_map: web::Data, + bookmarks: web::Data>, + rig_tx: web::Data>, +) -> impl Responder { + let rig_id = path.into_inner(); + let Some(config) = store.get(&rig_id) else { + return HttpResponse::NotFound().body("scheduler config not found"); + }; + if config.mode != SchedulerMode::TimeSpan { + return HttpResponse::BadRequest().body("scheduler mode is not time_span"); + } + + let entry_id = body.entry_id.trim(); + let Some(entry) = config.entries.iter().find(|entry| entry.id == entry_id) else { + return HttpResponse::NotFound().body("scheduler entry not found"); + }; + if entry.bookmark_id.trim().is_empty() { + return HttpResponse::BadRequest().body("scheduler entry has no primary bookmark"); + } + + match apply_scheduler_target( + rig_tx.get_ref(), + &rig_id, + status_map.get_ref(), + bookmarks.get_ref().as_ref(), + &entry.bookmark_id, + entry.center_hz, + &entry.bookmark_ids, + ) + .await + { + Ok(status) => HttpResponse::Ok().json(status), + Err(err) => HttpResponse::InternalServerError().body(err), + } +} + #[derive(Deserialize)] pub struct SchedulerControlQuery { pub session_id: Option, @@ -856,7 +916,7 @@ pub async fn put_scheduler_control( #[cfg(test)] mod tests { - use super::{timespan_active_entry, ScheduleEntry}; + use super::{timespan_active_entry, timespan_cycle_slot, timespan_active_entries, ScheduleEntry}; fn entry( id: &str, @@ -901,4 +961,31 @@ mod tests { assert_eq!(active.id, "slot-a"); assert_eq!(active.center_hz, Some(14_100_000)); } + + #[test] + fn timespan_cycle_is_anchored_to_overlap_start() { + let entries = vec![ + entry("slot-a", 60, 180, "bm-a", Some(14_100_000), Some(10)), + entry("slot-b", 90, 180, "bm-b", Some(14_200_000), Some(10)), + ]; + + let active = timespan_active_entry(&entries, 100.0, None).expect("active entry"); + assert_eq!(active.id, "slot-b"); + } + + #[test] + fn timespan_cycle_handles_overlap_spanning_midnight() { + let entries = vec![ + entry("slot-a", 1380, 120, "bm-a", Some(14_100_000), Some(10)), + entry("slot-b", 0, 120, "bm-b", Some(14_200_000), Some(10)), + ]; + + let active_entries = timespan_active_entries(&entries, 5.0); + assert_eq!(active_entries.len(), 2); + let slot = timespan_cycle_slot(&active_entries, 5.0, None).expect("cycle slot"); + assert_eq!(slot, 0); + + let active = timespan_active_entry(&entries, 10.0, None).expect("active entry"); + assert_eq!(active.id, "slot-b"); + } }