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");
+ }
}