diff --git a/firmware/obc-app/src/device_core/connections.rs b/firmware/obc-app/src/device_core/connections.rs index d3c605fdd..65f7d1bb4 100644 --- a/firmware/obc-app/src/device_core/connections.rs +++ b/firmware/obc-app/src/device_core/connections.rs @@ -8,14 +8,17 @@ //! | Producer | Consumer | Type | Delivery | //! |---|---|---|---| //! | `UiRuntime` | `CatalogMachine` | [`CatalogIntent`] | same pass | -//! | `UiRuntime` | `Recorder` | [`RecorderIntent`] | same pass | //! | `RetentionMachine` | `CatalogMachine` | [`CatalogIntent`] (an expiry) | same pass | //! | `CatalogMachine` | `Navigator` | [`ActiveRouteRemoved`] | same pass | //! | `CatalogMachine` | `RetentionMachine` | [`CatalogIdentityChanged`] | next pass | -//! | `Recorder` | `RetentionMachine` | [`RideClosed`] | next pass | //! | `Navigator` | `RetentionMachine` | [`RouteActivated`] | next pass | //! | any domain | `FaultState` | [`FaultNotices`] | same pass, producer is earlier | //! +//! There is deliberately **no** `UiRuntime` → `Recorder` row and no ride-closed row. Recorder has no +//! machine in Phase 1, so a connection into it could only take the rider's finish one-shot away from +//! the legacy drain that still performs it — provisioning for a lifecycle nobody owns, at the cost of +//! destroying a rider request. #1397 S6 brings the connection back with the domain that needs it. +//! //! ## Which direction decides the timing //! //! The pass order (see [`pass`](super::pass)) is fixed, so a connection's timing is not a policy @@ -32,12 +35,14 @@ //! Every slot holds **one** value. What a *second* value of the same kind means is different per //! connection, so each one states its rule rather than sharing a default: //! -//! - **Intents and one-shots** ([`Slot`], [`Merge::KeepFirst`]): the first value stands and the -//! second is handed back. The producer keeps it and offers it again next pass — backpressure, -//! never a silent drop. This is why a producer checks [`Slot::is_empty`] *before* consuming its -//! own one-shot: an intent it cannot deliver must stay where the rider left it. -//! - **Identity changes** ([`Merge::KeepLatest`]): a level, not an event. The newest revision -//! replaces the older one, because acting on a superseded identity is worse than acting late. +//! - **Intents and one-shots** ([`Slot`]): the first value stands and the second is handed back. The +//! producer keeps it and offers it again next pass — backpressure, never a silent drop. This is +//! why a producer checks [`Slot::is_empty`] *before* consuming its own one-shot: an intent it +//! cannot deliver must stay where the rider left it. +//! - **Later-to-earlier deposits** ([`Deferred`]): the newest value replaces the older one. Both of +//! them are levels — which identity the catalog holds, which route is active — and acting on a +//! superseded level is worse than acting late. A deposit that must *queue* rather than replace +//! arrives with the first connection that needs one. //! - **Fault notices** ([`FaultNotices`]): accumulate. Two domains raising a warning in one pass //! both reach the rider; a bit set is the only shape that cannot lose one. //! @@ -49,11 +54,10 @@ #![allow(dead_code)] use crate::catalog_state::CatalogIntent; -use crate::recorder::RecorderIntent; use crate::screen::WarningFlags; use crate::CatalogObjectId; -use super::slots::{Slot, SlotFull}; +use super::slots::Slot; use super::Revision; // ==================== the connection payloads ==================== @@ -75,28 +79,15 @@ pub struct ActiveRouteRemoved { /// changed, so it re-discovers rather than draining candidates against a picture that is gone. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct CatalogIdentityChanged { - /// The store revision the catalog now reflects. [`Merge::KeepLatest`]: an older revision never - /// displaces a newer one. + /// The store revision the catalog now reflects. A newer revision replaces an older deposit — + /// an older one never displaces it. pub revision: Revision, } -/// `Recorder` → `RetentionMachine`, **next pass**: the open ride was closed. -/// -/// The epic's table calls this row "ride finalized or synced". The *synced* half reaches retention -/// today as a fact about the ride inventory (it stamps `synced_at` eagerly from its own view), so -/// what the recorder itself has to say is the half only it knows: the ride is over, and the -/// inventory is about to change. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RideClosed { - /// Whether the ride was thrown away rather than kept. - pub discarded: bool, -} - /// `Navigator` → `RetentionMachine`, **next pass**: a route became the active one. /// /// An active route must not expire underneath the ride it is guiding, so retention stamps it once -/// per activation. [`Merge::KeepLatest`]: two activations in one pass leave the route that is -/// actually active. +/// per activation. Two activations before the next pass leave the route that is actually active. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RouteActivated { /// The durable identity of the newly active route. @@ -105,16 +96,6 @@ pub struct RouteActivated { // ==================== the deferred slot ==================== -/// What a second value in an occupied slot means. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Merge { - /// The first value stands; the second is handed back to its producer, which keeps it pending. - KeepFirst, - /// The newest value replaces the held one — for a *level*, where the older value is simply - /// wrong once a newer one exists. - KeepLatest, -} - /// A **later-to-earlier** connection: one value, deposited in one pass and consumed in the next. /// /// Two halves, because "deposited" and "visible" are different states. [`defer`](Self::defer) fills @@ -124,34 +105,23 @@ pub enum Merge { /// backwards edge the pass order forbids is impossible rather than merely discouraged. #[derive(Debug, PartialEq, Eq)] pub struct Deferred { - merge: Merge, pending: Option, ready: Option, } impl Deferred { - /// An empty slot with the given merge rule. - pub const fn new(merge: Merge) -> Self { - Deferred { merge, pending: None, ready: None } + /// An empty slot. + pub const fn new() -> Self { + Deferred { pending: None, ready: None } } - /// Deposit a value for the next pass. + /// Deposit a value for the next pass, replacing any deposit not yet promoted. /// - /// Under [`Merge::KeepFirst`] a full slot refuses and hands `value` back — its producer keeps it - /// and offers it again once the slot drains. Under [`Merge::KeepLatest`] it replaces, so this - /// never fails. - pub fn defer(&mut self, value: T) -> Result<(), SlotFull> { - match self.merge { - Merge::KeepLatest => { - self.pending = Some(value); - Ok(()) - } - Merge::KeepFirst if self.pending.is_some() => Err(SlotFull { rejected: value }), - Merge::KeepFirst => { - self.pending = Some(value); - Ok(()) - } - } + /// Both connections that use this carry a *level* — which identity the catalog holds, which + /// route is active — so a newer deposit makes an older one wrong rather than second in line. + /// A connection whose deposits must queue instead arrives with the domain that needs one. + pub fn defer(&mut self, value: T) { + self.pending = Some(value); } /// Make a value deposited on an earlier pass visible to its consumer. Called once per pass, @@ -217,8 +187,6 @@ impl FaultNotices { pub struct Connections { /// `UiRuntime` → `CatalogMachine`: the rider's delete, resolved to a durable identity. pub ui_catalog: Slot, - /// `UiRuntime` → `Recorder`: save or discard the open ride. - pub ui_recorder: Slot, /// `RetentionMachine` → `CatalogMachine`: an expiry, as the same intent a rider's delete uses, /// so an auto-expired object leaves by exactly the path a deleted one does. pub expiry: Slot, @@ -228,8 +196,6 @@ pub struct Connections { pub faults: FaultNotices, /// `CatalogMachine` → `RetentionMachine`, next pass. pub catalog_identity: Deferred, - /// `Recorder` → `RetentionMachine`, next pass. - pub ride_closed: Deferred, /// `Navigator` → `RetentionMachine`, next pass. pub route_activated: Deferred, } @@ -239,16 +205,11 @@ impl Connections { pub const fn new() -> Self { Connections { ui_catalog: Slot::new(), - ui_recorder: Slot::new(), expiry: Slot::new(), active_route_removed: Slot::new(), faults: FaultNotices::NONE, - // An identity is a level: the newest one is the only one worth acting on. - catalog_identity: Deferred::new(Merge::KeepLatest), - // A closed ride is an event: the second one waits rather than erasing the first. - ride_closed: Deferred::new(Merge::KeepFirst), - // An activation is a level: what matters is which route is active now. - route_activated: Deferred::new(Merge::KeepLatest), + catalog_identity: Deferred::new(), + route_activated: Deferred::new(), } } @@ -256,14 +217,13 @@ impl Connections { /// before any new gesture, sensor reading or fact is applied. pub fn promote_deferred(&mut self) { self.catalog_identity.promote(); - self.ride_closed.promote(); self.route_activated.promote(); } /// Whether any deferred connection still holds a value — the pass's "run again before sleep" /// test. pub fn has_deferred(&self) -> bool { - self.catalog_identity.is_pending() || self.ride_closed.is_pending() || self.route_activated.is_pending() + self.catalog_identity.is_pending() || self.route_activated.is_pending() } } @@ -277,10 +237,9 @@ impl Default for Connections { // identity, a revision or a flag — a growth means bulk crept into a message. const _: () = assert!(core::mem::size_of::() <= 8, "one durable identity"); const _: () = assert!(core::mem::size_of::() <= 8, "one revision"); -const _: () = assert!(core::mem::size_of::() <= 1, "one flag"); const _: () = assert!(core::mem::size_of::() <= 8, "one durable identity"); const _: () = assert!(core::mem::size_of::() <= 4, "a bit set"); -const _: () = assert!(core::mem::size_of::() <= 192, "eight bounded slots"); +const _: () = assert!(core::mem::size_of::() <= 160, "six bounded slots"); #[cfg(test)] mod tests { @@ -295,11 +254,11 @@ mod tests { /// impossible rather than merely discouraged. #[test] fn a_deferred_value_is_invisible_until_the_next_pass_promotes_it() { - let mut slot: Deferred = Deferred::new(Merge::KeepLatest); + let mut slot: Deferred = Deferred::new(); assert!(!slot.is_pending()); // Pass 1, a late stage deposits. - slot.defer(RouteActivated { route: 7 }).unwrap(); + slot.defer(RouteActivated { route: 7 }); assert!(slot.take().is_none(), "an earlier stage of the same pass cannot see it"); assert!(slot.is_pending(), "…and it is what asks for another pass"); @@ -311,34 +270,13 @@ mod tests { assert!(slot.take().is_none(), "promotion invents nothing"); } - /// `KeepFirst`: a full slot refuses and hands the value back, so its producer keeps it. Nothing - /// is overwritten and nothing is lost. - #[test] - fn a_full_keep_first_slot_hands_the_second_value_back() { - let mut slot: Deferred = Deferred::new(Merge::KeepFirst); - let first = RideClosed { discarded: false }; - let second = RideClosed { discarded: true }; - - slot.defer(first).unwrap(); - let err = slot.defer(second).expect_err("a full slot refuses"); - assert_eq!(err.rejected, second, "the producer gets its value back unchanged"); - - slot.promote(); - assert_eq!(slot.take(), Some(first), "the first value is what the consumer sees"); - - // The producer offers the retained value again, and now it fits. - slot.defer(second).unwrap(); - slot.promote(); - assert_eq!(slot.take(), Some(second)); - } - - /// `KeepLatest`: an identity is a level, so a newer revision replaces an older deposit rather - /// than queueing behind it. + /// An identity is a level, so a newer revision replaces an older deposit rather than queueing + /// behind it. #[test] - fn a_keep_latest_slot_holds_the_newest_identity() { - let mut slot: Deferred = Deferred::new(Merge::KeepLatest); - slot.defer(identity(4)).unwrap(); - slot.defer(identity(9)).unwrap(); + fn a_deferred_slot_holds_the_newest_identity() { + let mut slot: Deferred = Deferred::new(); + slot.defer(identity(4)); + slot.defer(identity(9)); slot.promote(); assert_eq!(slot.take(), Some(identity(9)), "acting on a superseded identity is the worse failure"); @@ -349,12 +287,12 @@ mod tests { /// through promotion, so the deposit simply waits its turn. #[test] fn promotion_never_displaces_an_unconsumed_value() { - let mut slot: Deferred = Deferred::new(Merge::KeepLatest); - slot.defer(identity(1)).unwrap(); + let mut slot: Deferred = Deferred::new(); + slot.defer(identity(1)); slot.promote(); // The consumer skipped its stage this pass; a newer deposit arrives. - slot.defer(identity(2)).unwrap(); + slot.defer(identity(2)); slot.promote(); assert_eq!(slot.take(), Some(identity(1)), "the promoted value stands"); slot.promote(); @@ -399,7 +337,7 @@ mod tests { wires.faults.raise(WarningFlags::NO_GPS); assert!(!wires.has_deferred(), "a same-pass slot is drained by the pass that filled it"); - wires.route_activated.defer(RouteActivated { route: 1 }).unwrap(); + wires.route_activated.defer(RouteActivated { route: 1 }); assert!(wires.has_deferred()); wires.promote_deferred(); assert!(wires.has_deferred(), "promoted but unconsumed still counts"); diff --git a/firmware/obc-app/src/device_core/pass.rs b/firmware/obc-app/src/device_core/pass.rs index 9a02ead9a..c5421341b 100644 --- a/firmware/obc-app/src/device_core/pass.rs +++ b/firmware/obc-app/src/device_core/pass.rs @@ -63,10 +63,9 @@ use obc_ports::{InputClock, RideClock, Sensors}; use obc_route::RouteReader; use crate::catalog_state::CatalogIntent; -use crate::device_core::connections::{ActiveRouteRemoved, CatalogIdentityChanged, RideClosed, RouteActivated}; +use crate::device_core::connections::{ActiveRouteRemoved, CatalogIdentityChanged, RouteActivated}; use crate::dirty::Dirty; use crate::input::Gesture; -use crate::recorder::RecorderIntent; use crate::retention::SweepKind; use crate::App; @@ -411,6 +410,10 @@ impl App { /// [`CatalogIntent`], not a store operation. Every intent is offered into a slot that is /// **checked first** — an intent that cannot be delivered leaves the rider's one-shot exactly /// where it was, so nothing is lost by a busy pass. + /// + /// The rider's ride *close* is deliberately not taken here. Recorder has no machine yet, so + /// there is no domain to name it to; taking the one-shot anyway would only remove it from the + /// legacy drain that still performs it, and the rider's save would be destroyed on the way. fn stage_ui(&mut self, now: PassClock) { self.pass.record(PassStage::Ui); self.advance_animations(now.ui); @@ -429,21 +432,11 @@ impl App { let _ = self.pass.connections.ui_catalog.try_put(intent); } } - if self.pass.connections.ui_recorder.is_empty() { - if let Some(action) = self.activity.take_track_action() { - let intent = match action { - crate::TrackAction::Save => RecorderIntent::Save, - crate::TrackAction::Discard => RecorderIntent::Discard, - }; - let _ = self.pass.connections.ui_recorder.try_put(intent); - } - } } /// Stage 5 — advance `RetentionMachine`. /// - /// Its deferred inbox first: what Navigator, Recorder and the catalog decided *after* it ran - /// last pass. Then the domain's own advance, then the one expiry intent and the one sidecar + /// Its deferred inbox first: what Navigator and the catalog decided *after* it ran last pass. Then the domain's own advance, then the one expiry intent and the one sidecar /// write it may have this pass. The expiry goes into a same-pass slot because the catalog runs /// next — an auto-expired object leaves by exactly the path a rider-deleted one does. /// @@ -458,9 +451,6 @@ impl App { if let Some(activated) = self.pass.connections.route_activated.take() { self.with_retention(|retention, view| retention.note_route_activated(activated.route, view)); } - if self.pass.connections.ride_closed.take().is_some() { - self.retention.force_next_sweep(); - } if self.pass.connections.catalog_identity.take().is_some() { self.retention.force_next_sweep(); } @@ -478,6 +468,7 @@ impl App { for kind in [SweepKind::StampRoute, SweepKind::StampRide] { if let Some(effect) = self.with_retention(|retention, view| retention.next_metadata_effect(kind, view)) { + self.mirror_stamp(effect); let _ = effects.retention.try_put(effect); break; } @@ -485,6 +476,31 @@ impl App { } } + /// Mirror a decided sidecar stamp into the resident view, the moment the effect leaves. + /// + /// Retention re-derives its candidates from that view, so without this the *same* stamp is + /// rediscovered on the pass after the executor answers it — an endless sidecar write, one per + /// pass, for the rest of the boot, on a device whose whole power budget is not waking up. + /// + /// The value written is the one the effect carries, not a guess: the durable write is still the + /// executor's, and a failure re-queues the stamp through + /// [`apply_outcome`](crate::retention::RetentionMachine::apply_outcome) as before. The legacy + /// drain has always done this at `App::retention_stamp_command`; this is the same mirror on the + /// path that replaces it. + fn mirror_stamp(&mut self, effect: crate::retention::RetentionEffect) { + match effect { + crate::retention::RetentionEffect::WriteRouteMetadata { id, meta, .. } => { + self.catalogs.stamp_route_last_used(id, meta.last_used_utc); + } + crate::retention::RetentionEffect::WriteRideMetadata { id, synced_at, .. } => { + // Both, because a ride outside the newest-32 display catalog must stop re-enqueuing + // its stamp too (finding #876-2). + self.catalogs.stamp_ride_synced_at(id, synced_at); + self.catalogs.stamp_inventory_synced_at(id, synced_at); + } + } + } + /// Stage 6 — advance `CatalogMachine`. /// /// The rider's own request outranks an expiry, exactly as the legacy drain has it: a hold-to- @@ -509,8 +525,7 @@ impl App { if let Some(store) = self.pass.store { if self.pass.announced != Some(store.revision) { self.pass.announced = Some(store.revision); - let _ = - self.pass.connections.catalog_identity.defer(CatalogIdentityChanged { revision: store.revision }); + self.pass.connections.catalog_identity.defer(CatalogIdentityChanged { revision: store.revision }); } } if let Some(effect) = self.catalogs.next_effect() { @@ -532,18 +547,17 @@ impl App { /// Stage 7 — advance `Recorder`. /// - /// The ride session itself accumulates with the fix in stage 3, and the close reaches the - /// platform on the legacy path until Recorder's machine lands (#1397). What the pass owns is the - /// connection: the ride inventory is about to change and retention hears about it next pass. A - /// full deferred slot leaves the intent where it was, and the immediate next wake is what brings - /// it back. + /// Nothing yet: Recorder's machine arrives with #1397 S6, and until it does the ride lifecycle + /// stays entirely on the legacy path — the rider's finish one-shot is left in + /// [`Activity`](crate::Activity) for `drain_host_commands` to turn into a + /// [`FinishTrack`](crate::HostCommand::FinishTrack), exactly as it always was. + /// + /// The stage is a *position*, held so the order is fixed before the machines land. It is + /// deliberately not a connection into a domain that cannot act: such a connection destroys what + /// it carries — the pass would take the rider's Save at stage 4 and have nowhere to put it here, + /// so the ride would never be finalized and no executor would be told. fn stage_recorder(&mut self) { self.pass.record(PassStage::Recorder); - let Some(intent) = self.pass.connections.ui_recorder.take() else { return }; - let closed = RideClosed { discarded: matches!(intent, RecorderIntent::Discard) }; - if self.pass.connections.ride_closed.defer(closed).is_err() { - let _ = self.pass.connections.ui_recorder.try_put(intent); - } } /// Stage 8 — advance `Navigator`. @@ -564,7 +578,7 @@ impl App { if active != self.pass.active_route { self.pass.active_route = active; if let Some(route) = active { - let _ = self.pass.connections.route_activated.defer(RouteActivated { route }); + self.pass.connections.route_activated.defer(RouteActivated { route }); } } } @@ -937,32 +951,54 @@ mod tests { /// A deferred value in flight makes the pass ask for another one **before sleep**: the work is /// already decided, and parking on it would leave it sitting until the next rider input. /// - /// The other half of the rule — a *full* slot handing the value back so its producer keeps it — - /// is the [`Deferred`](super::super::connections::Deferred) contract, exercised in - /// [`connections`](super::super::connections). It cannot arise from this wiring, because every - /// consumer's stage runs on every pass. + /// Written on the activation, which is the deferred producer this wiring actually has: Navigator + /// runs after retention, so an activation cannot reach backwards and waits a pass. #[test] fn a_deferred_value_forces_another_pass_before_sleep() { - let mut app = App::new(AppState::new(0, 0, 1.0)); + let mut app = navigating(); // route 0 is active before the first pass app.activity.mode = Mode::Riding; - quiet(&mut app, 10); - app.activity.request_track(crate::TrackAction::Save); - let plan = quiet(&mut app, 20); - assert!(app.pass.connections.ride_closed.is_pending(), "the close waits for retention"); + let plan = quiet(&mut app, 10); + assert!(app.pass.connections.route_activated.is_pending(), "the activation waits for retention"); assert!(plan.immediate && plan.next_wake_ms == Some(0), "so the runtime comes straight back"); // The next pass consumes it before anything else, and then there is nothing to hurry for. - let plan = quiet(&mut app, 30); - assert!(!app.pass.connections.ride_closed.is_pending()); + let plan = quiet(&mut app, 20); + assert!(!app.pass.connections.route_activated.is_pending()); assert!(!plan.immediate && plan.next_wake_ms != Some(0)); - // A second close right behind the first is delivered just the same — nothing was lost to - // the pass that was already carrying one. - app.activity.request_track(crate::TrackAction::Discard); - let plan = quiet(&mut app, 40); - assert!(app.pass.connections.ride_closed.is_pending() && plan.immediate); - assert!(app.pass.connections.ui_recorder.is_empty(), "the intent reached its domain"); + // A second activation right behind the first is deposited just the same. + app.activate_route(1); + let plan = quiet(&mut app, 30); + assert!(app.pass.connections.route_activated.is_pending() && plan.immediate); + } + + /// The rider's ride close stays on the legacy path, untouched by the pass. + /// + /// A stage-4 take with nowhere to put it at stage 7 leaves the ride unfinalized and no executor + /// told, so there is no connection to take it — and this is what "the close reaches the platform + /// on the legacy path" has to mean to be true. + #[test] + fn a_ride_close_survives_the_pass_for_the_legacy_drain() { + let mut app = App::new(AppState::new(0, 0, 1.0)); + app.activity.mode = Mode::Riding; + quiet(&mut app, 10); + + app.activity.request_track(crate::TrackAction::Save); + let plan = quiet(&mut app, 20); + assert!(app.activity.has_track_action(), "the pass left the rider's finish where it was"); + assert!(plan.effects.recorder.is_empty(), "and emitted no recorder effect it cannot answer"); + + let mut mail: crate::HostMailbox = crate::HostMailbox::new(); + let _ = app.drain_host_commands(&mut mail); + let mut drained = Vec::new(); + while let Some(command) = mail.pop() { + drained.push(command); + } + assert!( + drained.contains(&crate::HostCommand::FinishTrack(crate::TrackAction::Save)), + "so the executor that performs it still gets it: {drained:?}" + ); } /// The backpressure rule, end to end: two intents reach the catalog in one pass, it can admit diff --git a/firmware/tools/resource_baseline.json b/firmware/tools/resource_baseline.json index 06955f670..145c75c41 100644 --- a/firmware/tools/resource_baseline.json +++ b/firmware/tools/resource_baseline.json @@ -57,10 +57,11 @@ "_arena_render_note_1213": "**#1213 (WX10) — the rain overlay's per-frame decoded-tile cache joins `RenderScratch`: `arena_render` 117,408 → 120,544 B (+3,136 B = 12 tile slots × 256 B decoded cells + keys/ok flags/cursor + padding; `MCU_SCRATCH_BYTES` moves 117,376 → 120,512 and the 32 B delta is the same struct padding it always carried).** This is the arena-arm *composition* figure moving, not resident RAM: the arena is max(arms) and the USB arm (131,072 B) is still the maximum — `ARENA_BYTES == USB_ARM_BYTES` is compile-asserted in arena.rs — so `arena_total`, `measured_resident`, `uninit_max` and `resident_ram_max` are all unchanged, +0 B on glass. Render-arm headroom under the max-of-arms cliff is now 131,072 − 120,544 = 10,528 B; growth past that becomes 1:1 resident and moves the cliff (re-read arena.rs's growth-asymmetry notes before spending it). The slot count is 12 because the rain zoom regime's 45°-worst-case scanline crosses ~10 tiles (`RAIN_MAX_CELL_STEP` in obc-render/src/rain.rs); 8 slots measurably re-fetched at the regime edge. Identical in both profiles — the render path is the same image either way.", "_compile_note_dc4_1437": "**DC4 #1437 domain boundaries: `app` 50,520 -> 50,680 B (+160 B), both profiles.** Itemised on the CI `embedded` run for this branch against its base (099bd1be), all four items structural and none of them a buffer: (1) +72 B for the three derived cache keys in `CatalogState` (`ride_profile_for`, `ride_preview_for`, `nav_preview_route`) widening from `Option` (8 B) to `Option` / `Option` (32 B) — a durable object identity plus a source and a view revision, which is what removes the catalog-index remap and with it the late-answer and replaced-bytes staleness holes; (2) +24 B for the three `Revision` counters those keys read (`source_revision`, `ride_track_view`, `nav_preview_view`); (3) +40 B for the new `WeatherDomain` field on `App` (a token source, the installed `WeatherData` identity/revision, the in-flight refresh token, the request flag and the last terminal result); (4) +24 B inside `RetentionMachine` (its own token source and the in-flight sidecar-write slot, so a failed metadata write can be re-queued for the id it was actually about). Linked resident clears the 320,688 B `resident_ram_max` ceiling by ~16 KB on both profiles and `.uninit` is untouched at 132,096 B, so nothing moves on glass. Re-verified against S3 #1446's placement-constructor merge: `app` reads 50,680 B on both profiles there too, and `init_idle`'s own frame is 76 B of the 4,096 B ceiling. `measured_resident` (320,616 B) is a record, not a gate, and is left as it stands: it predates this branch.", "_compile_note_dc5_1438": "**DC5 #1438 pass coordinator: `app` 50,680 -> 50,920 B (+240 B), both profiles.** Two structural items, measured on the thumbv8m target, neither of them a buffer: (1) +224 B for the new `PassState` field on `App` \u2014 136 B of it the `Connections` set (eight named cross-domain slots: two UI intents, the expiry intent, the active-route notice, the fault bit set, and the three deferred slots whose two halves are what make a later-to-earlier value land on the *next* pass), and 88 B the levels a stage detects an edge against (the last store revision seen, the revision the catalog announced, the last link state, the transfer state, the active route identity, the 12 B `Capabilities` the admission stage recalculates, and the re-entrancy flag); (2) +16 B in `CatalogState` for the catalog domain's admitted-intent slot (`Option` is 16 B \u2014 one identity and a tag), with its 4 B token source and the in-flight flag landing in existing padding. The test-only stage recorder is `#[cfg(test)]` and is not in this figure. Linked resident and `.uninit` are unchanged (304,824 B / 132,096 B on the pinned host, against the 320,688 B ceiling), the arena is untouched, and `init_idle`'s construction frame is unmoved at 76 B against its 4,096 B limit \u2014 the field is written straight into its `.bss` slot like every other.", + "_compile_note_dc7_1440": "**DC7 #1440 conformance gate: `app` 50,920 -> 50,904 B (-16 B), both profiles.** A saving, from deleting the `UiRuntime` -> `Recorder` connection the gate proved was destroying a rider request: the pass took the ride-close one-shot at stage 4 and dropped it at stage 7, where Recorder has no machine to act on it, so the ride was never finalized and no executor was told. Out of `Connections` go the `Slot` and the `Deferred`; with the last `KeepFirst` connection gone, `Merge` goes with them and both remaining deferred slots lose their merge field. The close is back on the legacy drain that performs it, and returns with Recorder's machine at #1397 S6. Linked resident and `.uninit` are unchanged within the ceilings (304,808 B / 132,096 B on the pinned host, against 320,688 B), the arena is untouched, and `init_idle`'s construction frame is unmoved at 76 B of its 4,096 B limit.", "compile_time_allocations": { "framebuffer": 76800, "row_diff": 1284, - "app": 50920, + "app": 50904, "map_cache": 37084, "map_tables": 4416, "route_cache": 9260, @@ -197,10 +198,11 @@ "_arena_render_note_1213": "**#1213 (WX10) — the rain overlay's per-frame decoded-tile cache joins `RenderScratch`: `arena_render` 117,408 → 120,544 B (+3,136 B = 12 tile slots × 256 B decoded cells + keys/ok flags/cursor + padding; `MCU_SCRATCH_BYTES` moves 117,376 → 120,512 and the 32 B delta is the same struct padding it always carried).** This is the arena-arm *composition* figure moving, not resident RAM: the arena is max(arms) and the USB arm (131,072 B) is still the maximum — `ARENA_BYTES == USB_ARM_BYTES` is compile-asserted in arena.rs — so `arena_total`, `measured_resident`, `uninit_max` and `resident_ram_max` are all unchanged, +0 B on glass. Render-arm headroom under the max-of-arms cliff is now 131,072 − 120,544 = 10,528 B; growth past that becomes 1:1 resident and moves the cliff (re-read arena.rs's growth-asymmetry notes before spending it). The slot count is 12 because the rain zoom regime's 45°-worst-case scanline crosses ~10 tiles (`RAIN_MAX_CELL_STEP` in obc-render/src/rain.rs); 8 slots measurably re-fetched at the regime edge. Identical in both profiles — the render path is the same image either way.", "_compile_note_dc4_1437": "**DC4 #1437 domain boundaries: `app` 50,520 -> 50,680 B (+160 B), both profiles.** Itemised on the CI `embedded` run for this branch against its base (099bd1be), all four items structural and none of them a buffer: (1) +72 B for the three derived cache keys in `CatalogState` (`ride_profile_for`, `ride_preview_for`, `nav_preview_route`) widening from `Option` (8 B) to `Option` / `Option` (32 B) — a durable object identity plus a source and a view revision, which is what removes the catalog-index remap and with it the late-answer and replaced-bytes staleness holes; (2) +24 B for the three `Revision` counters those keys read (`source_revision`, `ride_track_view`, `nav_preview_view`); (3) +40 B for the new `WeatherDomain` field on `App` (a token source, the installed `WeatherData` identity/revision, the in-flight refresh token, the request flag and the last terminal result); (4) +24 B inside `RetentionMachine` (its own token source and the in-flight sidecar-write slot, so a failed metadata write can be re-queued for the id it was actually about). Linked resident clears the 320,688 B `resident_ram_max` ceiling by ~16 KB on both profiles and `.uninit` is untouched at 132,096 B, so nothing moves on glass. Re-verified against S3 #1446's placement-constructor merge: `app` reads 50,680 B on both profiles there too, and `init_idle`'s own frame is 76 B of the 4,096 B ceiling. `measured_resident` (320,616 B) is a record, not a gate, and is left as it stands: it predates this branch.", "_compile_note_dc5_1438": "**DC5 #1438 pass coordinator: `app` 50,680 -> 50,920 B (+240 B), both profiles.** Two structural items, measured on the thumbv8m target, neither of them a buffer: (1) +224 B for the new `PassState` field on `App` \u2014 136 B of it the `Connections` set (eight named cross-domain slots: two UI intents, the expiry intent, the active-route notice, the fault bit set, and the three deferred slots whose two halves are what make a later-to-earlier value land on the *next* pass), and 88 B the levels a stage detects an edge against (the last store revision seen, the revision the catalog announced, the last link state, the transfer state, the active route identity, the 12 B `Capabilities` the admission stage recalculates, and the re-entrancy flag); (2) +16 B in `CatalogState` for the catalog domain's admitted-intent slot (`Option` is 16 B \u2014 one identity and a tag), with its 4 B token source and the in-flight flag landing in existing padding. The test-only stage recorder is `#[cfg(test)]` and is not in this figure. Linked resident and `.uninit` are unchanged (304,824 B / 132,096 B on the pinned host, against the 320,688 B ceiling), the arena is untouched, and `init_idle`'s construction frame is unmoved at 76 B against its 4,096 B limit \u2014 the field is written straight into its `.bss` slot like every other.", + "_compile_note_dc7_1440": "**DC7 #1440 conformance gate: `app` 50,920 -> 50,904 B (-16 B), both profiles.** A saving, from deleting the `UiRuntime` -> `Recorder` connection the gate proved was destroying a rider request: the pass took the ride-close one-shot at stage 4 and dropped it at stage 7, where Recorder has no machine to act on it, so the ride was never finalized and no executor was told. Out of `Connections` go the `Slot` and the `Deferred`; with the last `KeepFirst` connection gone, `Merge` goes with them and both remaining deferred slots lose their merge field. The close is back on the legacy drain that performs it, and returns with Recorder's machine at #1397 S6. Linked resident and `.uninit` are unchanged within the ceilings (304,808 B / 132,096 B on the pinned host, against 320,688 B), the arena is untouched, and `init_idle`'s construction frame is unmoved at 76 B of its 4,096 B limit.", "compile_time_allocations": { "framebuffer": 76800, "row_diff": 1284, - "app": 50920, + "app": 50904, "map_cache": 37084, "map_tables": 4416, "route_cache": 9260, diff --git a/host/obc-host-core/tests/device_core_conformance.rs b/host/obc-host-core/tests/device_core_conformance.rs new file mode 100644 index 000000000..ebfb400a0 --- /dev/null +++ b/host/obc-host-core/tests/device_core_conformance.rs @@ -0,0 +1,1865 @@ +//! DC7 — the DeviceCore Phase 1 conformance gate (#1440, epic #1433 §13). +//! +//! Every DC1 scenario, run through five runners, compared on what the rider can see: +//! +//! | Runner | Frame | Executor | +//! |---|---|---| +//! | `legacy-immediate` | `HostLoop::reconcile_commands_traced` | the shipping host, answers in the same call | +//! | `legacy-delayed` | the same | the same, answers a pass later | +//! | `core-immediate` | [`App::run_pass`] | typed effects in, typed outcomes back, same call | +//! | `core-delayed` | the same | the same, on a scripted delay | +//! | `compatibility` | the same | [`LegacyAdapter`] — effects out as `HostCommand`s, `HostEvent`s back | +//! +//! The comparison is **rider-visible state**, not command sequences: a domain whose lifecycle moved +//! into DeviceCore no longer speaks the legacy vocabulary, and requiring it to would pin the +//! migration in place. What must not change is what the device shows and holds. +//! +//! ## The three dispositions +//! +//! Every difference this file finds is either [`Disposition::Corrected`] — DeviceCore is right and +//! the old behaviour was a defect — or [`Disposition::Accepted`], with the reason and the slice that +//! removes it. The third disposition the epic names, a *blocking* conformance failure, is not a row: +//! it is this file failing, because a difference with no approved row is one nothing may ship over. +//! At the time of writing there is none. +//! +//! ## Two production defects came out of this gate +//! +//! **The rider's ride close was destroyed.** The pass took the finish one-shot at stage 4 and +//! dropped it at stage 7, where Recorder has no machine to act on it — so the ride was never +//! finalized and no executor was told. The fix was to delete the `UiRuntime` → `Recorder` +//! connection rather than document the loss: it provisioned for a lifecycle nobody owns, and its +//! only effect was destroying a rider request. The close is back on the legacy drain, where it is +//! performed, and [`a_ride_finalize_failure_after_the_last_checkpoint_reaches_the_rider`] runs the +//! mandated trace for real instead of pinning a gap. +//! +//! **A decided sidecar stamp must be mirrored into the resident view.** Retention re-derives its +//! candidates from that view — the eager ride stamp on every trusted tick — so an unmirrored stamp +//! is rediscovered and re-issued on every later pass. +//! [`a_stamp_that_was_answered_is_not_enqueued_again`] pins the ride arm. +//! +//! ## What Phase 1 does and does not own +//! +//! Three domains have a state machine today — the catalog, retention and weather — and those are +//! the three whose effects a pass emits and whose outcomes it consumes. Two of them can be reached +//! from outside `obc-app`: weather's refresh intent has no public door until #1401 lands the request +//! cutover, so this executor serves the catalog and retention, and asserts the rest stays empty. +//! +//! The other six domains speak the legacy protocol still, so both DeviceCore runners drain the +//! legacy mailbox for them. [`store_owned`] and [`derived_level`] are the line between the two, and +//! every class still on the old protocol has a [`LegacyOwned`] row naming the slice that moves it. + +mod device_core_corpus; + +use std::collections::BTreeSet; + +use obc_app::ble::BondEffect; +use obc_app::catalog_state::{CatalogEffect, CatalogError, CatalogOutcome}; +use obc_app::device_core::compat::{event_reply, LegacyOwned, LegacyReply}; +use obc_app::device_core::derived::{ + DerivedInput, DerivedInputs, DerivedNeeds, DerivedResult, DerivedTargets, NavPreviewKey, RideTrackKey, +}; +use obc_app::device_core::storage_info::StorageInfoEffect; +use obc_app::device_core::{ + Capabilities, DeviceFacts, EffectSlots, LegacyAdapter, LegacyInputs, NavigatorTag, OutcomeSlots, PassClock, + PassInputs, PassPlan, PlatformSupport, Revision, SettingsTag, StoreIdentity, StoreRevision, TokenSource, + TransferState, +}; +use obc_app::dfu::DfuEffect; +use obc_app::navigator::{NavigatorEffect, NavigatorError, NavigatorOutcome, PlannerWork}; +use obc_app::recorder::RecorderEffect; +use obc_app::retention::{Retention, RetentionEffect, RetentionError, RetentionOutcome, RouteRetentionMeta}; +use obc_app::screen::Screen; +use obc_app::settings::{SettingsEffect, SettingsOutcome}; +use obc_app::weather::WeatherEffect; +use obc_app::{ + App, AppState, DetourRequest, DfuAction, Gesture, HostCommand, HostEvent, HostMailbox, NavRequest, + RideRetentionRecord, TrackAction, WarningFlags, +}; +use obc_host_core::trace::{run_scenario_seeded, RunnerMode, Trace, TraceHarness, TraceInput, TraceRecorder}; +use obc_ports::{Fix, InputClock, LocationSource, RideClock, Sensors, SettingsSaveError}; +use obc_route::NavError; + +use device_core_corpus::{ + clock_watermark, definition, normalization_seed, visible_state, Action, LegacyHarness, PendingSettingsResult, + Scenario, VisibleState, SCENARIOS, SETTINGS_FAILURE_RETRY_MS, +}; + +// ==================== the five runners ==================== + +/// One column of the conformance matrix. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Runner { + LegacyImmediate, + LegacyDelayed, + CoreImmediate, + CoreDelayed, + Compatibility, +} + +impl Runner { + const ALL: [Runner; 5] = [ + Runner::LegacyImmediate, + Runner::LegacyDelayed, + Runner::CoreImmediate, + Runner::CoreDelayed, + Runner::Compatibility, + ]; + + const fn name(self) -> &'static str { + match self { + Runner::LegacyImmediate => "legacy-immediate", + Runner::LegacyDelayed => "legacy-delayed", + Runner::CoreImmediate => "core-immediate", + Runner::CoreDelayed => "core-delayed", + Runner::Compatibility => "compatibility", + } + } + + /// When completed work is handed back. The scripted delay is deliberately uneven, so a runner + /// that only ever sees one cadence cannot pass by accident. + const fn mode(self) -> RunnerMode { + match self { + Runner::LegacyImmediate | Runner::CoreImmediate | Runner::Compatibility => RunnerMode::Immediate, + Runner::LegacyDelayed => RunnerMode::OnePassDelayed, + Runner::CoreDelayed => RunnerMode::ScriptedDelay(&[2, 0, 1]), + } + } + + /// Run one scenario, then let the runner settle, and report both. + fn run(self, scenario: &Scenario) -> Run { + let definition = definition(scenario); + match self { + Runner::LegacyImmediate | Runner::LegacyDelayed => { + let mut harness = LegacyHarness::new(); + let trace = run_scenario_seeded(&definition, self.mode(), &normalization_seed(), &mut harness); + Run::finish(self, scenario, trace, &mut harness) + } + Runner::CoreImmediate | Runner::CoreDelayed | Runner::Compatibility => { + let executor = if self == Runner::Compatibility { Executor::Compatibility } else { Executor::Typed }; + let mut harness = CoreHarness::new(executor); + let trace = run_scenario_seeded(&definition, self.mode(), &normalization_seed(), &mut harness); + Run::finish(self, scenario, trace, &mut harness) + } + } + } +} + +/// One runner's result: the recorded trace, and the state it comes to rest in. +/// +/// The two are different questions. A trace ends the moment the last delayed answer is delivered, +/// which for a *level* is one pass before the level can be consumed — so comparing traces at that +/// instant would compare delivery cadence rather than behaviour. The settled state is what "the same +/// device" means, and it is what the matrix compares (the same rule +/// `device_core_compat` follows: compare at rest, never mid-flight). +struct Run { + trace: Trace, + settled: VisibleState, +} + +impl Run { + fn finish(runner: Runner, scenario: &Scenario, trace: TraceResult, harness: &mut H) -> Run + where + H: TraceHarness, + { + let trace = trace.unwrap_or_else(|error| panic!("{} failed in {}: {error:?}", scenario.name, runner.name())); + let mut recorder = recorder(); + for _ in 0..SETTLE_PASSES { + for done in harness.run_pass(&mut recorder) { + harness.deliver(done, &mut recorder); + } + } + // At rest means at rest: a runner still producing work here would be compared mid-flight, + // and the matrix would be reading a snapshot of a device that had not finished. + let settled = harness.snapshot(); + assert!( + harness.run_pass(&mut recorder).is_empty(), + "{} has not settled in {} passes under {}", + scenario.name, + SETTLE_PASSES, + runner.name() + ); + assert_eq!(harness.snapshot(), settled, "and a quiet pass changes nothing"); + Run { settled, trace } + } +} + +type TraceResult = Result, obc_host_core::trace::TraceRunError>; + +/// Quiet passes after the scripted inputs end. Enough for a level to be answered and consumed, and +/// for a deferred value to reach the component behind it. +const SETTLE_PASSES: usize = 6; + +/// A platform that implements everything, so no capability hides a path the matrix means to run. +const EVERYTHING: PlatformSupport = PlatformSupport { + detour: true, + settings_persistence: true, + dfu: true, + weather: true, + bonding: true, + storage_space_report: true, +}; + +/// The rider-visible projection two runners must agree on. +/// +/// Two fields are dropped, and both for the same reason: they count *legacy* events rather than +/// anything the rider can see. `pending_host_command` asks the old protocol whether it has a command +/// queued, which a domain that no longer speaks it answers `false` to by construction. +/// `retention_delete_attempts` counts calls into `BorrowedRoutes::delete_by_id`, which counts every +/// call including one for an id already gone; the typed executor reaches the store by identity and +/// counts only the calls whose object is still catalogued, so the two count different events for the +/// same behaviour. Requiring either would be requiring the legacy command sequence. +fn rider_visible(mut state: VisibleState) -> VisibleState { + state.pending_host_command = false; + state.retention_delete_attempts = 0; + state +} + +// ==================== what the pass owns today ==================== + +/// The legacy classes DeviceCore's pass has taken over outright. +/// +/// A rider's delete is consumed at stage 4 and a retention stamp leaves as a `RetentionEffect`, so +/// neither pends on the old protocol any more. A DeviceCore runner asserts they do not appear rather +/// than filtering them: executing one beside the effect that already carries it would delete or +/// stamp twice, and a class that quietly came back would be the migration coming undone. +fn store_owned(command: &HostCommand) -> bool { + matches!( + command, + HostCommand::DeleteRoute { .. } + | HostCommand::DeleteRide { .. } + | HostCommand::StampRouteUsed { .. } + | HostCommand::StampRideSynced { .. } + ) +} + +/// The two derived cues, which are *levels* rather than one-shots: they are re-derived from state on +/// every drain, so they keep pending and a DeviceCore runner declines them every time — the plan's +/// keyed [`DerivedNeeds`] is what it answers instead (#1437). +fn derived_level(command: &HostCommand) -> Option<&'static str> { + match command { + HostCommand::LoadRideTrack { .. } => Some("LoadRideTrack"), + HostCommand::RefreshNavPreview => Some("RefreshNavPreview"), + _ => None, + } +} + +// ==================== the DeviceCore harness ==================== + +/// Which executor sits behind the pass. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Executor { + /// Bounded effects in, typed token-carrying outcomes back. What #1397 S6 builds for real. + Typed, + /// The same effects through [`LegacyAdapter`], executed as `HostCommand`s and answered with + /// `HostEvent`s. What a host that has not migrated yet can run today. + Compatibility, +} + +struct NoFix; +impl LocationSource for NoFix { + fn poll(&mut self) -> Option { + None + } +} + +/// Which resident catalog a completed store operation changed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Refeed { + None, + Routes, + Rides, +} + +/// One thing the executor finished, on its way back into the next pass. +#[derive(Debug)] +enum Done { + /// A typed catalog answer, plus the resident re-feed the store change implies. Bulk never + /// enters the protocol, so the catalog arrives through the same feeders it always did. + Catalog { + outcome: CatalogOutcome, + refeed: Refeed, + }, + Retention(RetentionOutcome), + /// A legacy answer, for a domain whose machine has not landed. + Event(HostEvent), + /// The ride the recorder just finalized — answered, as the legacy protocol answers it, by a + /// catalog re-feed rather than by a terminal ride identity (`LegacyOwned::RideCloseAck`). + RideSaved, + RideTrack(DerivedInput), + NavPreview(DerivedInput), +} + +/// The pass, one executor, and the shared scenario fixture. +/// +/// The fixture — the app, the catalogs and the scripted planner/DFU/settings answers — is +/// [`LegacyHarness`], unchanged: the two harnesses apply the *same* rider inputs and differ only in +/// what runs the frame. That is what makes a difference in the trace a difference in the runner. +struct CoreHarness { + state: LegacyHarness, + executor: Executor, + adapter: LegacyAdapter, + /// What the executor has handed back since the last pass. + inbox: LegacyInputs, + /// The pass's own monotonic clock. The legacy harness has none — its actions move the app's + /// animation clock directly — so this stays at or above every mark those actions set. + clock_ms: u32, + /// The bounded polylines a derived answer carries beside its key. + ride_preview: Vec<(i32, i32)>, + nav_preview: Vec<(i32, i32)>, + /// Legacy classes the pass took ownership of, so the run can prove it moved rather than dropped. + moved: BTreeSet<&'static str>, + /// Effects the executor served, by domain. + served: BTreeSet<&'static str>, + /// Effects the adapter could not express at all, by row. + left: BTreeSet, +} + +impl CoreHarness { + fn new(executor: Executor) -> Self { + CoreHarness { + state: LegacyHarness::new(), + executor, + adapter: LegacyAdapter::new(), + inbox: LegacyInputs::new(), + clock_ms: 0, + ride_preview: Vec::new(), + nav_preview: Vec::new(), + moved: BTreeSet::new(), + served: BTreeSet::new(), + left: BTreeSet::new(), + } + } + + fn app(&mut self) -> &mut App { + &mut self.state.app + } + + /// One DeviceCore frame: whatever the executor handed back, then fourteen stages, then a plan. + /// + /// The clock moves one millisecond per pass. The legacy harness has none — its actions drive the + /// app's animation clock directly, and time otherwise stands still — so a runner that ran the + /// clock faster would age cards and idle timers the legacy baseline never sees, and the matrix + /// would compare elapsed time rather than behaviour. The marks the actions do set are followed + /// exactly (see [`clock_watermark`]). + fn pass(&mut self) -> PassPlan { + self.clock_ms += 1; + let mut inputs = std::mem::take(&mut self.inbox); + let ride_preview = std::mem::take(&mut self.ride_preview); + let nav_preview = std::mem::take(&mut self.nav_preview); + let mut location = NoFix; + let plan = self.state.app.run_pass(PassInputs { + now: PassClock { ride: RideClock(self.clock_ms), ui: InputClock(self.clock_ms) }, + gestures: &[], + sensors: Sensors::new(&mut location), + route: None, + support: EVERYTHING, + outcomes: &mut inputs.outcomes, + facts: &mut inputs.facts, + derived: inputs.derived, + targets: DerivedTargets { ride_preview: &ride_preview, nav_preview: &nav_preview }, + }); + // A derived answer was either accepted or was about something else; either way it is spent. + // Outcomes and facts with no owner stay where the executor put them. + inputs.derived = DerivedInputs::NONE; + self.inbox = inputs; + plan + } + + // ---- the typed executor ---- + + /// Serve what a host outside `obc-app` can actually cause. + /// + /// The catalog and retention are the two domains whose effects a rider action or a retention + /// sweep produces. Weather has a machine too, but its refresh intent has no public door yet + /// (#1401 owns the request cutover), and the remaining six have no machine at all — so nothing + /// else may appear. Asserted rather than assumed: an effect this executor cannot serve turning + /// up would be a silent change of who decides, and the run must stop rather than skip it. + fn serve_typed(&mut self, effects: &mut EffectSlots, done: &mut Vec) { + if let Some(effect) = effects.catalog.take() { + self.served.insert("catalog"); + done.push(self.serve_catalog(effect)); + } + if let Some(effect) = effects.retention.take() { + self.served.insert("retention"); + done.push(Done::Retention(self.serve_retention(effect))); + } + assert!(!effects.has_pending(), "only catalog and retention effects are reachable from a host in Phase 1"); + } + + fn serve_catalog(&mut self, effect: CatalogEffect) -> Done { + match effect { + CatalogEffect::ReadCatalog { .. } => { + // A refresh needs `CatalogIntent::Refresh`, which the pass does not produce yet: a + // store commit still becomes the legacy `RescanStore` cue (LegacyOwned::StoreRevision). + panic!("no catalog refresh intent exists until #1397 S6 moves the store executor") + } + CatalogEffect::RemoveObject { token, object } => { + if let Some(index) = self.state.route_ids.iter().position(|&id| id == object) { + self.state.retention_delete_attempts = self.state.retention_delete_attempts.saturating_add(1); + if std::mem::take(&mut self.state.route_delete_fail_once) { + // The store refused the removal. Not `existed: false` — the object is still + // there, which is what makes retention re-queue its candidate. + return Done::Catalog { + outcome: CatalogOutcome::Failed { token, error: CatalogError::RemoveFailed }, + refeed: Refeed::None, + }; + } + self.state.routes.remove(index); + self.state.route_ids.remove(index); + return Done::Catalog { + outcome: CatalogOutcome::ObjectRemoved { token, object, existed: true }, + refeed: Refeed::Routes, + }; + } + if let Some(index) = self.state.ride_ids.iter().position(|&id| id == object) { + self.state.rides.remove(index); + self.state.ride_ids.remove(index); + return Done::Catalog { + outcome: CatalogOutcome::ObjectRemoved { token, object, existed: true }, + refeed: Refeed::Rides, + }; + } + // The subject vanished before the commit — a success for the goal state, and the + // one shape that must not read as a failure (epic §13). + Done::Catalog { + outcome: CatalogOutcome::ObjectRemoved { token, object, existed: false }, + refeed: Refeed::None, + } + } + CatalogEffect::ReadTripMembers { .. } => { + panic!("the trip cascade is refused at admission until #1397 lands the bounded member read") + } + } + } + + /// The sidecar writes. The fixture keeps no durable sidecar, so the answer *is* the write — + /// what matters here is that it carries the operation's token back, which the legacy protocol + /// has no way to do (`LegacyOwned::SidecarAck`). + fn serve_retention(&mut self, effect: RetentionEffect) -> RetentionOutcome { + match effect { + RetentionEffect::WriteRouteMetadata { token, id, .. } => { + RetentionOutcome::RouteMetadataWritten { token, id } + } + RetentionEffect::WriteRideMetadata { token, id, .. } => RetentionOutcome::RideMetadataWritten { token, id }, + } + } + + // ---- the compatibility executor ---- + + /// The same effects, through the adapter and out as legacy commands. + /// + /// The two counts this leaves behind are the whole point of running it beside the typed + /// executor: what the adapter *sent*, and what it could not express at all. + fn serve_compat( + &mut self, + effects: &mut EffectSlots, + needs: &DerivedNeeds, + done: &mut Vec, + trace: &mut TraceRecorder, + ) { + let mut mail: HostMailbox = HostMailbox::new(); + let report = self.adapter.effects_to_commands(effects, &mut mail); + for row in LegacyOwned::ALL { + if report.owned.contains(row) { + self.left.insert(row); + } + } + self.adapter.needs_to_commands(needs, &mut mail); + while let Some(command) = mail.pop() { + if let Some(level) = derived_level(&command) { + // The adapter re-emits the two levels as their old cues; this runner answers them + // from the plan's keys instead, which is the whole of #1437. + self.moved.insert(level); + continue; + } + self.served.insert("adapter"); + trace.record_command(&command); + self.serve_legacy(command, done, trace); + } + } + + // ---- the legacy half, for the six domains without a machine ---- + + fn serve_mailbox(&mut self, done: &mut Vec, trace: &mut TraceRecorder) { + let mut mail: HostMailbox = HostMailbox::new(); + let _ = self.state.app.drain_host_commands(&mut mail); + let mut persisted = None; + while let Some(command) = mail.pop() { + if let Some(level) = derived_level(&command) { + self.moved.insert(level); + continue; + } + assert!( + !store_owned(&command), + "{command:?} is DeviceCore's now — running it here would repeat the effect that carries it" + ); + if let HostCommand::PersistSettings { revision } = command { + // The first, as `LegacyHarness::run_pass`'s `find_map` takes it — a second in one + // drain would be a coalescing bug, and taking the last would hide it. + persisted.get_or_insert(revision); + } + trace.record_command(&command); + self.serve_legacy(command, done, trace); + } + // The scripted answers the legacy corpus arms at its protocol boundary, drained exactly as + // the legacy runner drains them — this half of both DeviceCore runners is unchanged by + // design, because these six domains have not migrated. + if let Some(result) = self.state.pending_nav_plan.take() { + done.push(Done::Event(HostEvent::NavPlanned(result))); + } + if std::mem::take(&mut self.state.commit_success_pending) { + done.push(Done::Event(HostEvent::DetourCommitted(Ok(10)))); + } + let ready = !matches!(self.state.pending_settings_result, Some(PendingSettingsResult::PersistLatest)) + || persisted.is_some(); + if ready { + if let Some(result) = self.state.pending_settings_result.take() { + let revision = match result { + PendingSettingsResult::PersistRevision(revision) => revision, + PendingSettingsResult::PersistLatest | PendingSettingsResult::FailLatest => { + persisted.unwrap_or(self.state.settings_revision) + } + }; + done.push(Done::Event(match result { + PendingSettingsResult::FailLatest => { + HostEvent::SettingsPersistFailed { revision, error: SettingsSaveError::Backend } + } + PendingSettingsResult::PersistLatest | PendingSettingsResult::PersistRevision(_) => { + HostEvent::SettingsPersisted { revision } + } + })); + } + } + } + + fn serve_legacy(&mut self, command: HostCommand, done: &mut Vec, trace: &mut TraceRecorder) { + match command { + HostCommand::RescanStore { .. } => { + self.state.feed_routes("core.routes", trace); + self.state.feed_trips("core.trips", trace); + self.state.feed_rides("core.rides", trace); + } + HostCommand::DeleteTrip { id } => { + // The legacy host runs the whole cascade inside one command + // (`LegacyOwned::TripCascade`); the bounded member read arrives with #1397. + if self.state.trip_present && id == 50 { + for member in self.state.trip_stage_ids.clone() { + if let Some(index) = self.state.route_ids.iter().position(|&id| id == member) { + self.state.routes.remove(index); + self.state.route_ids.remove(index); + } + } + self.state.trip_present = false; + self.state.feed_routes("core.cascade-routes", trace); + self.state.feed_trips("core.cascade-trips", trace); + } + } + HostCommand::Dfu(DfuAction::Scan) => { + if let Some(result) = self.state.pending_dfu_scan.take() { + done.push(Done::Event(HostEvent::DfuScanned(result))); + } + } + HostCommand::Dfu(DfuAction::Install) => { + if let Some(result) = self.state.pending_dfu_install.take() { + done.push(Done::Event(match result { + Ok(()) => HostEvent::DfuInstallBegan, + Err(error) => HostEvent::DfuInstallFailed(error), + })); + } + } + HostCommand::ScanCardFree => { + done.push(Done::Event(HostEvent::CardScanned { free_bytes: Some(8 * 1024 * 1024) })); + } + // The sidecar stamps are fire-and-forget in the old protocol: the write happens and + // nothing acknowledges it (`LegacyOwned::SidecarAck`), which is exactly why + // RetentionMachine stays in flight behind one under the compatibility executor. + HostCommand::StampRouteUsed { .. } | HostCommand::StampRideSynced { .. } => { + self.served.insert("legacy-stamp"); + } + // The ride close: still a legacy command, because Recorder has no machine — and the + // pass deliberately leaves the rider's one-shot here rather than taking it somewhere it + // cannot be acted on. + HostCommand::FinishTrack(TrackAction::Save) => { + if std::mem::take(&mut self.state.fail_next_finalize) { + // The legacy vocabulary has no recorder-finalize outcome; a host reports the + // failure through the generic warning, which is DC1's own recorded limitation. + done.push(Done::Event(HostEvent::Warning(WarningFlags::REC_ERROR))); + } else { + done.push(Done::RideSaved); + } + } + HostCommand::FinishTrack(TrackAction::Discard) => {} + // Cancels, plan requests and the detour commit are consumed without being started: the + // corpus scripts their completion at the protocol boundary, exactly as the legacy runner + // does (`PlanHold`, `hold_detour_commit`). + HostCommand::PersistSettings { .. } + | HostCommand::CancelRoutePlan + | HostCommand::CancelDetour + | HostCommand::PlanRoute(_) + | HostCommand::PlanDetour(_) + | HostCommand::CommitDetour + | HostCommand::ForgetBond => {} + other => panic!("{other:?} is pass-owned and must not reach the legacy executor"), + } + } + + // ---- the two derived levels ---- + + /// Answer each level with the key the need carried. Under a delayed runner the subject may have + /// moved by the time this lands, and the pass drops it — which is the corrected defect. + fn serve_derived(&mut self, needs: &DerivedNeeds, done: &mut Vec) { + if let Some(key) = needs.ride_track { + done.push(Done::RideTrack(DerivedInput::filled(key))); + } + if let Some(key) = needs.nav_preview { + done.push(Done::NavPreview(DerivedInput::filled(key))); + } + } + + /// Serve one plan and hand every answer straight back — the trace runner's frame, without the + /// delivery scheduling. + fn serve(&mut self, mut plan: PassPlan, trace: &mut TraceRecorder) { + let mut done = Vec::new(); + match self.executor { + Executor::Typed => self.serve_typed(&mut plan.effects, &mut done), + Executor::Compatibility => self.serve_compat(&mut plan.effects, &plan.derived_needs, &mut done, trace), + } + self.serve_mailbox(&mut done, trace); + self.serve_derived(&plan.derived_needs, &mut done); + for item in done { + self.deliver(item, trace); + } + } + + fn refeed(&mut self, refeed: Refeed, trace: &mut TraceRecorder) { + match refeed { + Refeed::None => {} + Refeed::Routes => self.state.feed_routes("core.routes", trace), + Refeed::Rides => self.state.feed_rides("core.rides", trace), + } + } +} + +impl TraceHarness for CoreHarness { + type State = VisibleState; + type Outcome = Done; + + fn snapshot(&self) -> Self::State { + visible_state(&self.state.app, self.state.settings_revision, self.state.retention_delete_attempts) + } + + fn apply_input(&mut self, action: &Action, trace: &mut TraceRecorder) { + // An action that moves the app's animation clock moves the pass's with it, so a bounded + // retry window cannot reopen behind the action that just closed it. + self.clock_ms = self.clock_ms.max(clock_watermark(*action)); + TraceHarness::apply_input(&mut self.state, action, trace); + } + + fn run_pass(&mut self, trace: &mut TraceRecorder) -> Vec { + let mut plan = self.pass(); + let mut done = Vec::new(); + match self.executor { + Executor::Typed => self.serve_typed(&mut plan.effects, &mut done), + Executor::Compatibility => self.serve_compat(&mut plan.effects, &plan.derived_needs, &mut done, trace), + } + self.serve_mailbox(&mut done, trace); + self.serve_derived(&plan.derived_needs, &mut done); + done + } + + fn deliver(&mut self, done: Self::Outcome, trace: &mut TraceRecorder) { + match done { + Done::Catalog { outcome, refeed } => { + self.refeed(refeed, trace); + let _ = self.inbox.outcomes.catalog.try_put(outcome); + } + Done::Retention(outcome) => { + let _ = self.inbox.outcomes.retention.try_put(outcome); + } + Done::Event(event) => { + trace.record_event(&event); + let settings_failed = matches!(event, HostEvent::SettingsPersistFailed { .. }); + // The adapter answers what the *adapter* asked for. An event that answers a command + // the App's own mailbox produced has no correlation slot — the domain that asked has + // no machine and asked over the old protocol — so it goes back through the old door. + // Handing it to the adapter would be an unrequested reply, which it refuses rather + // than forging a token for. + let correlated = matches!(self.executor, Executor::Compatibility) + && event_reply(&event).is_some_and(|class| self.adapter.pending().holds(class)); + if correlated { + self.adapter.event_to_inputs(event, &mut self.inbox).expect("the adapter asked for it"); + } else { + self.state.app.apply_event(event); + } + if settings_failed && self.state.settings_retry_requested { + self.clock_ms = self.clock_ms.max(SETTINGS_FAILURE_RETRY_MS); + } + } + Done::RideSaved => self.state.feed_rides("core.recorder-saved", trace), + Done::RideTrack(input) => { + self.ride_preview = vec![(0, 0), (1, 1)]; + self.inbox.derived.ride_track = Some(input); + } + Done::NavPreview(input) => { + self.nav_preview = vec![(0, 0), (1, 1)]; + self.inbox.derived.nav_preview = Some(input); + } + } + } +} + +// ==================== the disposition table ==================== + +/// What a difference between two runners means. +/// +/// The epic names three dispositions. Only two of them are ever *written down*: a blocking +/// conformance failure is not a row someone writes, it is +/// [`every_scenario_agrees_or_has_an_approved_disposition`] failing on a difference with no row. +/// That is why there is no `Blocking` variant here — an unexplained difference cannot be recorded +/// and shrugged at, it fails the gate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Disposition { + /// DeviceCore is right and the legacy behaviour was a defect. `why` states the expected target. + Corrected, + /// A real difference Phase 1 accepts, with the reason and the slice that removes it. + Accepted, +} + +/// One approved difference between the legacy baseline and one or more DeviceCore runners. +/// +/// `runners` is what makes the table exact at the level the result is reported at: a difference that +/// *moved* from one runner to another — the typed executor regressing into the compatibility +/// executor's shape, say — changes the set of `(scenario, runner)` cells even when the scenario list +/// and the cell count are unchanged. +#[derive(Debug, Clone, Copy)] +struct Difference { + scenario: &'static str, + /// Exactly the runners this difference appears in. + runners: &'static [Runner], + disposition: Disposition, + /// The legacy row that owns the difference, when one does. Cross-checked against the rows the + /// compatibility executor actually reports, so a citation cannot be prose that stopped being + /// true. `None` says no legacy row owns it, and `why` then has to say what does. + owner: Option, + /// What differs. + what: &'static str, + /// The expected target (`Corrected`) or the reason and later owner (`Accepted`). + why: &'static str, +} + +impl Difference { + /// The `(scenario, runner)` cells this row accounts for. + fn cells(&self) -> impl Iterator + '_ { + self.runners.iter().map(|runner| (self.scenario, runner.name())) + } +} + +/// Every difference this gate found, with its disposition and the cells it appears in. +const DIFFERENCES: &[Difference] = &[ + Difference { + scenario: "derived-data.repeats-until-matching-fill", + runners: &[Runner::CoreImmediate, Runner::CoreDelayed, Runner::Compatibility], + disposition: Disposition::Corrected, + owner: None, + what: "every DeviceCore runner settles one screen shallower than the legacy baseline", + why: "no legacy row owns a corrected defect. The legacy bulk feeders carry no subject, so a \ + fill for the route the rider *was* previewing satisfies the need for the one they are \ + previewing now and leaves an extra overview on the stack — DC1 records that stack \ + depth moving with delivery cadence as a known defect. DeviceCore keys every derived \ + read (#1437), drops an answer about something else, and reaches the same state \ + immediate or delayed. The shallower stack is the expected target.", + }, + Difference { + scenario: "catalog.route-delete", + runners: &[Runner::Compatibility], + disposition: Disposition::Accepted, + owner: Some(LegacyOwned::ObjectNamespace), + what: "the compatibility runner keeps the object", + why: "CatalogEffect::RemoveObject is namespace-free because the flat store removes by \ + identity; the legacy deletes are namespaced, and the namespace cannot be recovered \ + from the effect. The adapter leaves it rather than guessing (#1397 S6). The typed \ + runner removes it, which is what that row costs until the store executor lands.", + }, + Difference { + scenario: "catalog.ride-delete", + runners: &[Runner::Compatibility], + disposition: Disposition::Accepted, + owner: Some(LegacyOwned::ObjectNamespace), + what: "the compatibility runner keeps the object", + why: "the same namespace-free removal, and the same row (#1397 S6).", + }, + Difference { + scenario: "retention.expiry-retry-and-trusted-clock", + runners: &[Runner::Compatibility], + disposition: Disposition::Accepted, + owner: Some(LegacyOwned::ObjectNamespace), + what: "the compatibility runner expires nothing", + why: "an expiry reaches the catalog as the same namespace-free removal, so it hits the same \ + row — and the catalog then stays in flight, because no legacy event can build a \ + CatalogOutcome. That is the documented one-operation cost of running the pass before \ + the executors migrate (#1439, closed by #1397 S6).", + }, +]; + +// ==================== the matrix ==================== + +/// Every applicable DC1 scenario, through all five runners. +/// +/// The gate: every runner reaches a rider-visible terminal state that either matches the legacy +/// baseline or is named in [`DIFFERENCES`] with a disposition. Nothing may differ silently. +#[test] +fn every_scenario_agrees_or_has_an_approved_disposition() { + let mut compared = 0usize; + let mut differing: BTreeSet<(&str, &str)> = BTreeSet::new(); + + for scenario in SCENARIOS { + let runs: Vec<_> = Runner::ALL.iter().map(|runner| (*runner, runner.run(scenario))).collect(); + let baseline = rider_visible(runs[0].1.settled.clone()); + + for (runner, run) in &runs { + assert_eq!(run.trace.scenario, scenario.name); + assert!(!run.trace.steps.is_empty(), "{} produced no steps in {}", scenario.name, runner.name()); + compared += 1; + if rider_visible(run.settled.clone()) != baseline { + differing.insert((scenario.name, runner.name())); + } + } + + // Immediate and delayed reach the same place *within* a family — the executor conformance + // rule of #1433 §13. A difference here would be timing sensitivity, never policy. + assert_eq!( + rider_visible(runs[1].1.settled.clone()), + baseline, + "{}: the legacy runner changed terminal state when delayed", + scenario.name + ); + assert_eq!( + rider_visible(runs[3].1.settled.clone()), + rider_visible(runs[2].1.settled.clone()), + "{}: DeviceCore changed terminal state under a scripted delay", + scenario.name + ); + } + + assert_eq!(compared, SCENARIOS.len() * Runner::ALL.len(), "every scenario runs in every runner"); + + // Exact at the level the result is reported at: one approved `(scenario, runner)` cell per + // difference. A difference that moves between runners inside a listed scenario changes this set + // even though the scenario list and the cell count do not. + let approved: BTreeSet<(&str, &str)> = DIFFERENCES.iter().flat_map(Difference::cells).collect(); + assert_eq!( + differing, approved, + "the disposition table is exact per cell: every difference is approved in the runner it \ + appears in, and no row documents one that no longer exists" + ); +} + +/// Every row of the disposition table is usable, and its citation is checked rather than read. +/// +/// An `owner` is not prose: for a compatibility-runner row it must be a row the compatibility +/// executor **actually reports leaving** on that scenario, so a citation that stopped being true +/// fails here. A row with no owner has to say what owns the difference instead. +#[test] +fn every_difference_carries_a_verified_disposition() { + assert!(!DIFFERENCES.is_empty()); + let names: BTreeSet<&str> = SCENARIOS.iter().map(|scenario| scenario.name).collect(); + + for row in DIFFERENCES { + assert!(!row.what.is_empty() && !row.why.is_empty(), "{row:?}"); + assert!(names.contains(row.scenario), "{} is not a scenario", row.scenario); + assert!(!row.runners.is_empty(), "a difference appears in at least one runner: {row:?}"); + assert!(row.why.contains('#'), "every disposition names the slice that owns its target: {row:?}"); + + match (row.disposition, row.owner) { + // A corrected defect is DeviceCore being right; no legacy row owns it. + (Disposition::Corrected, owner) => { + assert!(owner.is_none(), "a corrected defect is not owned by a legacy row: {row:?}"); + assert!(row.why.contains("no legacy row owns"), "and it has to say so: {row:?}"); + } + (Disposition::Accepted, Some(owner)) => { + assert!(owner.deletes_in().starts_with('#'), "{owner:?} must name its deletion slice"); + if row.runners.contains(&Runner::Compatibility) { + let left = compatibility_rows_left(named(row.scenario)); + assert!( + left.contains(&owner), + "{}: cites {owner:?}, but the compatibility executor reported {left:?}", + row.scenario + ); + } + } + (Disposition::Accepted, None) => assert!( + row.why.contains("no legacy row owns"), + "an accepted difference with no owning row has to say what owns it instead: {row:?}" + ), + } + } +} + +/// The `LegacyOwned` rows the compatibility executor reports leaving on one scenario — the evidence +/// an `owner` citation is checked against. +fn compatibility_rows_left(scenario: &Scenario) -> BTreeSet { + let mut harness = CoreHarness::new(Executor::Compatibility); + run_scenario_seeded(&definition(scenario), RunnerMode::Immediate, &normalization_seed(), &mut harness) + .expect("the scenario runs"); + harness.left +} + +/// Every `LegacyOwned` row has a later owner and a deletion slice — the epic's Phase 1 gate on the +/// inventory itself, checked from outside `obc-app` so the table is a seam rather than an internal. +#[test] +fn every_legacy_owned_row_names_the_slice_that_deletes_it() { + for row in LegacyOwned::ALL { + let owner = row.deletes_in(); + assert!(owner.starts_with('#'), "{row:?} must name an issue"); + assert!(owner.contains('—'), "{row:?} must say what takes it over"); + } +} + +/// The classes the pass took over really did move: a DeviceCore run observes them in the mailbox, +/// declines to execute them, and serves the same request as a typed effect or a keyed level. +#[test] +fn the_pass_owns_the_classes_it_took_over() { + // A rider's delete: the pass takes the request at stage 4, so the legacy class never pends at + // all, and the removal happens as one bounded catalog operation. + let mut harness = CoreHarness::new(Executor::Typed); + let trace = run_scenario_seeded( + &definition(named("catalog.route-delete")), + RunnerMode::Immediate, + &normalization_seed(), + &mut harness, + ) + .expect("the scenario runs"); + assert_eq!(trace.final_state.route_ids.len(), 2, "the rider's delete removed one route"); + assert!(harness.served.contains("catalog"), "and it was the catalog effect that did it"); + // `serve_mailbox` asserts the class never appears at all, so reaching here is the proof. + + // The retention stamps: the same, one layer down — the sweep's candidate leaves as a + // `RetentionEffect`, so the legacy stamp class is not pending either. + let mut harness = CoreHarness::new(Executor::Typed); + run_scenario_seeded( + &definition(named("retention.route-and-ride-stamps")), + RunnerMode::Immediate, + &normalization_seed(), + &mut harness, + ) + .expect("the scenario runs"); + assert!(harness.served.contains("retention"), "the stamps left as retention effects"); + assert!( + !harness.moved.contains("StampRouteUsed") && !harness.moved.contains("StampRideSynced"), + "and no legacy stamp was left pending beside them" + ); + + // An auto-expiry reaches the catalog by the same path a rider's delete does, and the legacy + // delete class does not come back beside it. + let mut harness = CoreHarness::new(Executor::Typed); + run_scenario_seeded( + &definition(named("retention.expiry-retry-and-trusted-clock")), + RunnerMode::Immediate, + &normalization_seed(), + &mut harness, + ) + .expect("the scenario runs"); + assert!(harness.served.contains("catalog"), "the expiry was served as one bounded removal"); + + // The two derived levels are levels, so they re-derive every drain and are declined every time. + let mut harness = CoreHarness::new(Executor::Typed); + run_scenario_seeded( + &definition(named("derived-data.repeats-until-matching-fill")), + RunnerMode::Immediate, + &normalization_seed(), + &mut harness, + ) + .expect("the scenario runs"); + assert!(harness.moved.contains("LoadRideTrack"), "the ride-track cue is answered from the plan's key"); + assert!(harness.moved.contains("RefreshNavPreview"), "and so is the nav preview"); +} + +fn named(name: &'static str) -> &'static Scenario { + SCENARIOS.iter().find(|scenario| scenario.name == name).unwrap_or_else(|| panic!("no scenario {name}")) +} + +/// What the compatibility executor cannot do, measured rather than asserted from the doc comment. +/// +/// The adapter can build a navigator, settings, DFU or storage outcome, because those four legacy +/// commands have a terminal event. It can build no catalog, retention or weather outcome, because +/// the old protocol answers those with a bulk re-feed, a store-changed edge, or nothing at all — so +/// a domain that latches in flight when it emits stays latched. Two of the accepted rows above are +/// this fact reaching the rider. +#[test] +fn the_compatibility_executor_leaves_what_the_old_protocol_cannot_say() { + let mut harness = CoreHarness::new(Executor::Compatibility); + run_scenario_seeded( + &definition(named("retention.expiry-retry-and-trusted-clock")), + RunnerMode::Immediate, + &normalization_seed(), + &mut harness, + ) + .expect("the scenario runs"); + assert!( + harness.left.contains(&LegacyOwned::ObjectNamespace), + "the removal has no legacy expression, and is left with its owner rather than dropped" + ); + assert_eq!(harness.state.route_ids.len(), 3, "so nothing was removed, and nothing pretended it had been"); + + // The same run under the typed executor completes it — the difference is the executor, and + // nothing else about the device. + let mut typed = CoreHarness::new(Executor::Typed); + run_scenario_seeded( + &definition(named("retention.expiry-retry-and-trusted-clock")), + RunnerMode::Immediate, + &normalization_seed(), + &mut typed, + ) + .expect("the scenario runs"); + assert_eq!(typed.state.route_ids.len(), 2, "the expired object is gone"); +} + +// ==================== the mandatory traces (#1440) ==================== + +/// One of the sixteen traces #1440 requires, bound to the test that runs it. +struct MandatoryTrace { + /// The row, in the issue's words. + row: &'static str, + /// The `#[test]` that runs it. + test: &'static str, + /// Set when the test exercises a *different* situation than the row names, saying why the row's + /// own situation is unreachable in Phase 1 and which slice makes it reachable. A row that + /// silently tested something else would let the issue's checklist read as covered when it is not. + substitution: Option<&'static str>, +} + +/// All sixteen. The binding is checked rather than written down: the test below looks each name up +/// in this file's own source as a real `#[test]`, so a renamed, deleted or un-attributed trace fails +/// the gate instead of quietly leaving a row of the issue uncovered. +const MANDATORY_TRACES: [MandatoryTrace; 16] = [ + MandatoryTrace { + row: "outcome after cancellation", + test: "an_outcome_after_cancellation_changes_nothing", + substitution: None, + }, + MandatoryTrace { + row: "outcome after a replacement request", + test: "an_outcome_after_a_replacement_request_changes_nothing", + substitution: None, + }, + MandatoryTrace { + row: "store change during catalog refresh", + test: "a_store_change_during_a_catalog_operation_is_not_lost", + substitution: Some( + "a catalog *refresh* cannot be in flight in Phase 1: the pass produces no CatalogIntent::Refresh, so a store commit still becomes the legacy RescanStore cue (LegacyOwned::StoreRevision). The trace runs the store-revision fact against an in-flight catalog **removal** — the same one-operation-in-flight rule, on the only catalog operation the pass can produce. The refresh arrives with the store executor at #1397 S6, and this row becomes literal then.", + ), + }, + MandatoryTrace { + row: "transfer start during route planning", + test: "a_transfer_during_planning_withdraws_heavy_capability", + substitution: None, + }, + MandatoryTrace { + row: "route-plan completion after active-route change", + test: "a_route_plan_that_lands_after_the_active_route_changed_is_refused", + substitution: None, + }, + MandatoryTrace { + row: "settings result with an old revision", + test: "a_settings_result_with_an_old_revision_is_refused", + substitution: None, + }, + MandatoryTrace { + row: "ride finalize failure after the last checkpoint", + test: "a_ride_finalize_failure_after_the_last_checkpoint_reaches_the_rider", + substitution: None, + }, + MandatoryTrace { + row: "trip member disappearance before delete commit", + test: "an_object_that_vanished_before_the_commit_is_a_success", + substitution: Some( + "the trip cascade never becomes an effect — CatalogState::admit_intent refuses it (LegacyOwned::TripCascade), so there is no bounded member read to race with. The trace runs the same disappearance against a route removal, which is where the rule lives: an object already gone is `existed: false`, a success, never a failure the rider sees. The cascade's own member read lands with #1397 S6.", + ), + }, + MandatoryTrace { + row: "capability change after a new map mounts", + test: "capabilities_follow_the_mounted_data_and_the_platform", + substitution: None, + }, + MandatoryTrace { + row: "detour without a path", + test: "a_detour_without_a_path_is_a_failure_and_not_an_absent_capability", + substitution: None, + }, + MandatoryTrace { + row: "device without detour capability", + test: "capabilities_follow_the_mounted_data_and_the_platform", + substitution: None, + }, + MandatoryTrace { + row: "active-route deletion with same-pass Navigator delivery", + test: "deleting_the_active_route_drops_it_in_the_same_pass", + substitution: None, + }, + MandatoryTrace { + row: "Navigator activation with next-pass Retention delivery", + test: "an_activation_reaches_retention_on_the_next_pass", + substitution: None, + }, + MandatoryTrace { + row: "full effect slot and full outcome slot", + test: "a_full_slot_preserves_work_on_both_sides_of_the_seam", + substitution: None, + }, + MandatoryTrace { + row: "deferred slot which forces a pass before sleep", + test: "a_deferred_value_forces_a_pass_before_sleep", + substitution: None, + }, + MandatoryTrace { + row: "stale derived input after a subject change", + test: "a_stale_derived_fill_is_dropped_and_the_level_asks_again", + substitution: None, + }, +]; + +#[test] +fn every_mandatory_trace_has_a_test_that_runs_it() { + let source = include_str!("device_core_conformance.rs"); + for trace in MANDATORY_TRACES { + // `#[test]` and not merely `fn`: a private helper of the same name, or a trace that lost its + // attribute, would otherwise satisfy the binding without ever running. + assert!( + source.contains(&format!("#[test]\nfn {}(", trace.test)), + "{}: no `#[test] fn {}` in this file", + trace.row, + trace.test + ); + if let Some(substitution) = trace.substitution { + assert!( + substitution.contains('#'), + "{}: a substituted situation names the slice that makes the row literal", + trace.row + ); + } + } + let substituted = MANDATORY_TRACES.iter().filter(|trace| trace.substitution.is_some()).count(); + assert_eq!(substituted, 2, "two rows are unreachable in Phase 1, and both say so"); +} + +fn typed() -> CoreHarness { + CoreHarness::new(Executor::Typed) +} + +/// Three routes under a trusted clock, `expired` of them long past their deadline, so the retention +/// sweep decides to expire them and the catalog turns each into one bounded removal. This is the one +/// path that reaches `CatalogEffect` from a public app surface, which is why the catalog traces +/// below are written on it rather than on the rider's hold-to-delete. +fn expiring(expired: usize) -> CoreHarness { + let mut harness = typed(); + harness.app().stamp_clock_ble(1_720_000_000, 60); + let now = harness.state.app.wall_unix_now(); + let old = now.saturating_sub(30 * 24 * 3600); + let meta: Vec<_> = (0..3) + .map(|index| { + if index < expired { + RouteRetentionMeta::new(Retention::Week1, old) + } else { + RouteRetentionMeta::new(Retention::Never, 0) + } + }) + .collect(); + harness.app().set_route_meta(&meta); + harness.app().force_retention_sweep(); + harness +} + +impl CoreHarness { + /// Apply one corpus action outside the scenario runner. + fn apply(&mut self, action: Action) { + let mut trace = recorder(); + self.clock_ms = self.clock_ms.max(clock_watermark(action)); + TraceHarness::apply_input(&mut self.state, &action, &mut trace); + } + + /// Serve one catalog effect and hand the answer straight back. + fn answer_catalog(&mut self, effect: CatalogEffect) -> CatalogOutcome { + let Done::Catalog { outcome, refeed } = self.serve_catalog(effect) else { panic!("a catalog answer") }; + self.deliver(Done::Catalog { outcome, refeed }, &mut recorder()); + outcome + } + + /// The next catalog effect, running passes until one appears. + fn next_catalog_effect(&mut self) -> CatalogEffect { + for _ in 0..8 { + let mut plan = self.pass(); + if let Some(effect) = plan.effects.catalog.take() { + return effect; + } + } + panic!("no catalog effect within eight passes") + } +} + +/// **Outcome after cancellation.** A terminal answer invalidates the domain's token, so a repeat of +/// it is no longer current and starts nothing — the rule a cancellation applies, reached through the +/// one terminal event every operation ends with. +#[test] +fn an_outcome_after_cancellation_changes_nothing() { + let mut harness = expiring(1); + let effect = harness.next_catalog_effect(); + let outcome = harness.answer_catalog(effect); + harness.pass(); + assert_eq!(harness.state.route_ids.len(), 2, "the expiry removed one route"); + + // The same answer again: the operation is over, so nothing reopens and nothing is retried. + let _ = harness.inbox.outcomes.catalog.try_put(outcome); + let plan = harness.pass(); + assert!(plan.effects.catalog.is_empty(), "a repeat of a terminal answer starts no work"); + assert_eq!(harness.state.route_ids.len(), 2, "and removes nothing a second time"); + + // The navigator half of the same rule, through the adapter: a cancelled plan does not accept its + // own result, and it is the *domain* that refuses it — the adapter hands the token straight back. + let mut navigator: TokenSource = TokenSource::new(); + let token = navigator.issue(); + let mut adapter = LegacyAdapter::new(); + let mut mail: HostMailbox = HostMailbox::new(); + let mut effects = EffectSlots::new(); + let work = PlannerWork::Route(NavRequest::new((0, 0), (1, 1), "goal")); + effects.navigator.try_put(NavigatorEffect::Acquire { token, work }).unwrap(); + assert_eq!(adapter.effects_to_commands(&mut effects, &mut mail).translated, 1); + navigator.invalidate(); // the rider pressed Back + let mut inbox = LegacyInputs::new(); + adapter.event_to_inputs(HostEvent::NavPlanned(Ok(9)), &mut inbox).unwrap(); + let outcome = inbox.outcomes.navigator.take().expect("delivered, not swallowed"); + assert_eq!(outcome, NavigatorOutcome::PlanFinished { token, route: 9 }); + assert!(!navigator.is_current(outcome.token()), "the cancelled operation does not accept it"); +} + +/// **Outcome after a replacement request.** The next operation supersedes the last; an answer that +/// belongs to the finished one changes nothing about the live one. +#[test] +fn an_outcome_after_a_replacement_request_changes_nothing() { + let mut harness = expiring(2); + let first = harness.next_catalog_effect(); + harness.answer_catalog(first); + let second = harness.next_catalog_effect(); + assert_ne!(first.token(), second.token(), "a new operation, a new token"); + + // The finished operation's answer, arriving again against the live one. + let _ = harness.inbox.outcomes.catalog.try_put(CatalogOutcome::ObjectRemoved { + token: first.token(), + object: 10, + existed: true, + }); + let plan = harness.pass(); + assert!(plan.effects.catalog.is_empty(), "the stale answer did not free the live operation"); + + harness.answer_catalog(second); + harness.pass(); + assert_eq!(harness.state.route_ids.len(), 1, "both removals landed, neither twice"); +} + +/// **A store change during a catalog operation.** The commit is an edge the pass records once, the +/// domain keeps one operation in flight, and neither loses the other. +#[test] +fn a_store_change_during_a_catalog_operation_is_not_lost() { + let mut harness = expiring(1); + let effect = harness.next_catalog_effect(); + + // The store moves underneath us while that removal is unanswered. + harness.inbox.facts.note_store_revision(StoreRevision { store: StoreIdentity::new(1), revision: Revision::new(4) }); + let plan = harness.pass(); + assert!(plan.effects.catalog.is_empty(), "one catalog operation at a time"); + assert!(harness.state.app.store_changed_pending() > 0, "the commit became the refresh cue"); + + // The same revision again is the same edge, not a second one. + let before = harness.state.app.store_changed_pending(); + harness.inbox.facts.note_store_revision(StoreRevision { store: StoreIdentity::new(1), revision: Revision::new(4) }); + harness.pass(); + assert_eq!(harness.state.app.store_changed_pending(), before, "one commit, one cue"); + + harness.answer_catalog(effect); + harness.pass(); + assert_eq!(harness.state.route_ids.len(), 2, "and the removal completed"); +} + +/// **A transfer starts during route planning.** Heavy work is withdrawn while a transfer holds the +/// store, so a *new* plan is never started — and the one already running is not failed by it either. +/// +/// The distinction is the rule: admission decides what may *begin*, and a capability going away is +/// not a reason to cancel an operation that is already owed an answer. +#[test] +fn a_transfer_during_planning_withdraws_heavy_capability() { + // A plan is admitted and goes out under Navigator's live token. + let mut navigator: TokenSource = TokenSource::new(); + let token = navigator.issue(); + let mut adapter = LegacyAdapter::new(); + let mut mail: HostMailbox = HostMailbox::new(); + let mut effects = EffectSlots::new(); + let work = PlannerWork::Route(NavRequest::new((0, 0), (1, 1), "goal")); + effects.navigator.try_put(NavigatorEffect::Acquire { token, work }).unwrap(); + adapter.effects_to_commands(&mut effects, &mut mail); + assert!(matches!(mail.pop(), Some(HostCommand::PlanRoute(_))), "the planner was asked"); + assert!(adapter.pending().holds(LegacyReply::RoutePlan), "and is owed an answer"); + + // The transfer starts. Admission is a level, recalculated from what is true now. + let facts = |heavy| DeviceFacts { + store_writable: true, + nav_graph: true, + weather_data: false, + link_connected: true, + ride_recording: false, + heavy_operations: heavy, + }; + let idle = Capabilities::calculate(EVERYTHING, facts(true)); + let streaming = Capabilities::calculate(EVERYTHING, facts(false)); + assert!(idle.navigator.plan_route && idle.navigator.plan_detour && idle.dfu.install); + assert!(!streaming.navigator.plan_route, "a second route plan cannot start"); + assert!(!streaming.navigator.plan_detour, "nor a detour"); + assert!(!streaming.dfu.install, "nor an install"); + assert!(streaming.catalog.mutate, "but the store is still writable — this is admission, not a fault"); + + // The pass sees the transfer and starts nothing; it also fails nothing. + let mut harness = expiring(0); + harness.inbox.facts.note_transfer(TransferState::Active); + let plan = harness.pass(); + assert!(!plan.effects.has_pending(), "no work is admitted while the transfer holds the store"); + assert!(adapter.pending().holds(LegacyReply::RoutePlan), "and the running plan is untouched"); + assert!(navigator.is_current(token), "its operation is still the current one"); + + // So when the planner finally answers, it is still this operation's answer. + let mut inbox = LegacyInputs::new(); + adapter.event_to_inputs(HostEvent::NavPlanned(Ok(10)), &mut inbox).unwrap(); + let outcome = inbox.outcomes.navigator.take().expect("the answer is delivered"); + assert!(navigator.is_current(outcome.token()), "a withdrawn capability never cancelled the running plan"); + + // …and the capability comes straight back when the transfer ends. + harness.inbox.facts.note_transfer(TransferState::Idle); + harness.pass(); + assert!(Capabilities::calculate(EVERYTHING, facts(true)).navigator.plan_route); +} + +/// **A route plan completes after the active route changed.** The answer carries the token the +/// request went out with; a Navigator that has moved on refuses it. +#[test] +fn a_route_plan_that_lands_after_the_active_route_changed_is_refused() { + let mut navigator: TokenSource = TokenSource::new(); + let token = navigator.issue(); + let mut adapter = LegacyAdapter::new(); + let mut mail: HostMailbox = HostMailbox::new(); + let mut effects = EffectSlots::new(); + let work = PlannerWork::Route(NavRequest::new((0, 0), (1, 1), "first")); + effects.navigator.try_put(NavigatorEffect::Acquire { token, work }).unwrap(); + adapter.effects_to_commands(&mut effects, &mut mail); + + // The rider activates a different route: Navigator replaces its operation. + navigator.invalidate(); + let replacement = navigator.issue(); + + let mut inbox = LegacyInputs::new(); + adapter.event_to_inputs(HostEvent::NavPlanned(Ok(10)), &mut inbox).unwrap(); + let outcome = inbox.outcomes.navigator.take().expect("the answer is delivered"); + assert_eq!(outcome.token(), token, "carrying the token it went out with"); + assert!(!navigator.is_current(outcome.token()), "which is no longer the current operation"); + assert!(navigator.is_current(replacement), "the replacement is"); +} + +/// **A settings result with an old revision.** The token and the revision are independent guards, +/// and the rider-visible half is identical in the legacy and DeviceCore runners. +#[test] +fn a_settings_result_with_an_old_revision_is_refused() { + let mut settings: TokenSource = TokenSource::new(); + let token = settings.issue(); + let mut adapter = LegacyAdapter::new(); + let mut mail: HostMailbox = HostMailbox::new(); + let mut effects = EffectSlots::new(); + effects.settings.try_put(SettingsEffect::PersistRevision { token, revision: 4 }).unwrap(); + adapter.effects_to_commands(&mut effects, &mut mail); + assert_eq!(mail.pop(), Some(HostCommand::PersistSettings { revision: 4 })); + + settings.invalidate(); // the rider edited again — a newer revision is the latest now + let mut inbox = LegacyInputs::new(); + adapter.event_to_inputs(HostEvent::SettingsPersisted { revision: 4 }, &mut inbox).unwrap(); + let outcome = inbox.outcomes.settings.take().expect("delivered, not swallowed"); + assert_eq!(outcome, SettingsOutcome::Persisted { token, revision: 4 }); + assert!(!settings.is_current(outcome.token()), "the superseded write does not clear the dirty state"); + + // …and the legacy half is real behaviour rather than only a token: the stale ack leaves the + // newer content pending, identically under both frames. + let scenario = named("settings.revision-success-and-stale-result"); + let legacy = Runner::LegacyImmediate.run(scenario); + let core = Runner::CoreImmediate.run(scenario); + assert_eq!(rider_visible(core.settled.clone()), rider_visible(legacy.settled.clone())); +} + +/// **A ride finalize failure after the last checkpoint.** The ride close survives the pass, the +/// executor that performs it fails it, and the rider is told. +/// +/// This trace is why the `ui_recorder` → `ride_closed` wiring is gone. It used to take the rider's +/// finish one-shot at stage 4 and drop it at stage 7, so the ride was never finalized and no +/// executor was told — a rider request destroyed to serve a lifecycle Recorder does not own yet. +/// The pass now leaves it alone, which is what "the close reaches the platform on the legacy path" +/// has to mean to be true. +#[test] +fn a_ride_finalize_failure_after_the_last_checkpoint_reaches_the_rider() { + let mut harness = expiring(0); + harness.app().activity.start_session(); + harness.pass(); + + // The last checkpoint is behind us; the finalize is the one that fails. + harness.state.fail_next_finalize = true; + harness.app().activity.request_track(TrackAction::Save); + let plan = harness.pass(); + assert!(plan.effects.recorder.is_empty(), "Recorder has no machine, so the pass emits no effect"); + assert!(harness.state.app.activity.has_track_action(), "and it leaves the rider's finish for the drain"); + + let mut done = Vec::new(); + let mut trace = recorder(); + harness.serve_mailbox(&mut done, &mut trace); + // The legacy vocabulary has no recorder-finalize outcome, so a host reports the failure through + // the generic warning — DC1's own recorded compatibility limitation, unchanged here. + assert!( + matches!(done.as_slice(), [Done::Event(HostEvent::Warning(flags))] if flags.contains(WarningFlags::REC_ERROR)), + "the finalize failed and said so: {done:?}" + ); + for item in done { + harness.deliver(item, &mut trace); + } + harness.pass(); + assert!( + matches!(harness.state.app.top_screen(), Screen::Warning(card) + if card.flags().contains(WarningFlags::REC_ERROR)), + "and the rider is told rather than left believing the ride was saved" + ); + + // The typed replacement is already mapped: when Recorder emits the effect, the adapter knows + // where it goes, so what is missing is one domain's machine and nothing in the protocol. + let mut recorder_ops = TokenSource::new(); + let mut effects = EffectSlots::new(); + effects.recorder.try_put(RecorderEffect::Finalize { token: recorder_ops.issue() }).unwrap(); + let mut adapter = LegacyAdapter::new(); + let mut mail: HostMailbox = HostMailbox::new(); + assert_eq!(adapter.effects_to_commands(&mut effects, &mut mail).translated, 1); + assert_eq!(mail.pop(), Some(HostCommand::FinishTrack(TrackAction::Save))); + assert!(LegacyOwned::RideCloseAck.deletes_in().contains("#1397"), "the acknowledgement is still owed"); +} + +/// **A trip member disappears before the delete commit.** The goal state holds, so the removal is a +/// success with `existed: false` — never a failure the rider is shown. +#[test] +fn an_object_that_vanished_before_the_commit_is_a_success() { + let mut harness = expiring(1); + let effect = harness.next_catalog_effect(); + let CatalogEffect::RemoveObject { object, .. } = effect else { panic!("a removal") }; + + // Something else removed it first. + let index = harness.state.route_ids.iter().position(|&id| id == object).expect("still catalogued"); + harness.state.routes.remove(index); + harness.state.route_ids.remove(index); + + let Done::Catalog { outcome, refeed } = harness.serve_catalog(effect) else { panic!() }; + assert_eq!(outcome, CatalogOutcome::ObjectRemoved { token: effect.token(), object, existed: false }); + assert_eq!(refeed, Refeed::None, "nothing changed, so nothing is re-fed"); + harness.deliver(Done::Catalog { outcome, refeed }, &mut recorder()); + let plan = harness.pass(); + assert!(plan.effects.catalog.is_empty(), "the operation is over — nothing retried, nothing failed"); +} + +/// **A capability changes after a new map mounts**, and **a device without the detour capability**. +/// +/// A capability is a level recomputed from what the image implements and what is true now. A missing +/// graph or missing support withdraws the operation entirely, so "unsupported" never reaches the +/// rider as a planning failure. +#[test] +fn capabilities_follow_the_mounted_data_and_the_platform() { + let facts = |nav_graph| DeviceFacts { + store_writable: true, + nav_graph, + weather_data: false, + link_connected: false, + ride_recording: false, + heavy_operations: true, + }; + let before = Capabilities::calculate(EVERYTHING, facts(false)); + let after = Capabilities::calculate(EVERYTHING, facts(true)); + assert!(!before.navigator.plan_route && !before.navigator.plan_detour, "no graph, no planning"); + assert!(after.navigator.plan_route && after.navigator.plan_detour, "a mounted routing graph turns both on"); + assert!(before.catalog.mutate && after.catalog.mutate, "and the rest of the device is unaffected"); + + // A device the detour was never built for. + let limited = Capabilities::calculate(NO_DETOUR, facts(true)); + assert!(limited.navigator.plan_route, "route planning is unaffected"); + assert!(!limited.navigator.plan_detour && !limited.navigator.commit_detour, "the detour is simply absent"); +} + +/// A platform without the detour, for the capability traces. +const NO_DETOUR: PlatformSupport = PlatformSupport { detour: false, ..EVERYTHING }; + +/// **A detour without a path** is a planning failure, and it is a *different* value from the absence +/// of the capability — the epic's "unsupported must not appear as NoPath". +#[test] +fn a_detour_without_a_path_is_a_failure_and_not_an_absent_capability() { + let mut navigator: TokenSource = TokenSource::new(); + let token = navigator.issue(); + let mut adapter = LegacyAdapter::new(); + let mut mail: HostMailbox = HostMailbox::new(); + let mut effects = EffectSlots::new(); + let work = PlannerWork::Detour(DetourRequest { route: 0, from: (0, 0), progress_m: 0, target_m: 500 }); + effects.navigator.try_put(NavigatorEffect::Acquire { token, work }).unwrap(); + adapter.effects_to_commands(&mut effects, &mut mail); + assert!(matches!(mail.pop(), Some(HostCommand::PlanDetour(_))), "a supported device asks the planner"); + + let mut inbox = LegacyInputs::new(); + adapter.event_to_inputs(HostEvent::DetourPlanned(Err(NavError::NoPath)), &mut inbox).unwrap(); + let outcome = inbox.outcomes.navigator.take().expect("the failure is an answer"); + assert_eq!(outcome, NavigatorOutcome::Failed { token, error: NavigatorError::Plan(NavError::NoPath) }); + assert!(navigator.is_current(outcome.token()), "and it is this operation's answer"); + + // The unsupported device never gets here: the capability is absent, so nothing is requested and + // there is no failure to report at all. + let facts = DeviceFacts { + store_writable: true, + nav_graph: true, + weather_data: false, + link_connected: false, + ride_recording: false, + heavy_operations: true, + }; + assert!(!Capabilities::calculate(NO_DETOUR, facts).navigator.plan_detour); +} + +/// **Deleting the active route, with same-pass Navigator delivery.** The rider is not left being +/// guided along a route the device has decided to remove. +#[test] +fn deleting_the_active_route_drops_it_in_the_same_pass() { + let mut harness = typed(); + harness.app().activate_route(0); + assert_eq!(harness.state.app.active_route_index(), Some(0)); + + harness.apply(Action::DeleteRoute); + let mut plan = harness.pass(); + assert!( + matches!(plan.effects.catalog.take(), Some(CatalogEffect::RemoveObject { object: 10, .. })), + "the rider's delete left as one bounded catalog operation" + ); + assert_eq!(harness.state.app.active_route_index(), None, "and Navigator heard about it in that pass"); +} + +/// **Navigator activation, with next-pass Retention delivery.** Navigator runs after retention, so +/// the activation waits one pass — and the wait is bounded by the immediate wake, not by input. +#[test] +fn an_activation_reaches_retention_on_the_next_pass() { + let mut harness = typed(); + harness.app().stamp_clock_ble(1_720_000_000, 60); + let now = harness.state.app.wall_unix_now(); + // A fresh `last_used`, so the hourly sweep has nothing of its own to say and the only stamp in + // this trace is the activation's. + harness.app().set_route_meta(&[ + RouteRetentionMeta::new(Retention::Week1, now), + RouteRetentionMeta::new(Retention::Never, 0), + RouteRetentionMeta::new(Retention::Never, 0), + ]); + harness.app().activate_route(0); + + let mut plan = harness.pass(); + assert!(plan.immediate, "Navigator runs after retention, so the activation is deposited, not delivered"); + assert!( + matches!(plan.effects.retention.take(), Some(RetentionEffect::WriteRouteMetadata { id: 10, .. })), + "the active route's use stamp goes out" + ); + + let plan = harness.pass(); + assert!(!plan.immediate, "retention consumed it on the next pass, and nothing is left waiting"); + assert!(plan.effects.retention.is_empty(), "the delivery is idempotent — no second sidecar write"); +} + +/// **A full effect slot and a full outcome slot.** Both preserve the value already there, and a +/// refused one comes back to its owner rather than being dropped. +#[test] +fn a_full_slot_preserves_work_on_both_sides_of_the_seam() { + // The effect side: two objects expire together, the domain admits one, and the other is not + // queued in the slot — it stays with its producer and goes out once the answer frees the domain. + let mut harness = expiring(2); + let first = harness.next_catalog_effect(); + let plan = harness.pass(); + assert!(plan.effects.catalog.is_empty(), "one catalog operation in flight at a time"); + harness.answer_catalog(first); + let second = harness.next_catalog_effect(); + assert_ne!(first.token(), second.token(), "nothing was lost to the busy pass"); + + // The outcome side: a second answer cannot displace one the pass has not consumed yet. + let mut outcomes = OutcomeSlots::new(); + let mut tokens = TokenSource::new(); + let held = CatalogOutcome::Cancelled { token: tokens.issue() }; + let intruder = CatalogOutcome::Cancelled { token: tokens.issue() }; + outcomes.catalog.try_put(held).unwrap(); + let refused = outcomes.catalog.try_put(intruder).expect_err("a full slot refuses"); + assert_eq!(refused.rejected, intruder, "and hands the value back to its owner"); + assert_eq!(outcomes.catalog.take(), Some(held), "the first answer is what the domain gets"); +} + +/// **A deferred slot forces a pass before sleep.** Work that is already decided must not sit until +/// the next rider input. +/// +/// Written on the route activation, which is the deferred producer the wiring actually has: +/// Navigator runs after retention, so an activation cannot reach backwards and waits a pass. +#[test] +fn a_deferred_value_forces_a_pass_before_sleep() { + let mut harness = typed(); + harness.app().activate_route(0); + + let plan = harness.pass(); + assert!(plan.immediate && plan.next_wake_ms == Some(0), "the runtime comes straight back"); + + let plan = harness.pass(); + assert!(!plan.immediate && plan.next_wake_ms != Some(0), "consumed, and nothing is left to hurry for"); + + harness.app().activate_route(1); + let plan = harness.pass(); + assert!(plan.immediate, "a second activation is deposited just the same"); +} + +/// **A stale derived input after a subject change** — the corrected defect of this gate. +/// +/// The legacy feeders carry no subject, so a delayed fill for the ride the rider *was* looking at +/// satisfies the need for the one they are looking at now. DeviceCore keys the read, so the same +/// delayed answer is dropped and the level asks again. +#[test] +fn a_stale_derived_fill_is_dropped_and_the_level_asks_again() { + let mut harness = typed(); + open_ride_detail(&mut harness); + let plan = harness.pass(); + let key = plan.derived_needs.ride_track.expect("the open detail wants its track"); + + // An answer about a different ride is an answer to a question nobody asked. + let other = RideTrackKey { ride: key.ride + 1, source: key.source, view: key.view }; + harness.inbox.derived.ride_track = Some(DerivedInput { key: other, result: DerivedResult::Filled }); + let plan = harness.pass(); + assert_eq!(plan.derived_needs.ride_track, Some(key), "the need is untouched"); + + // A failure for the *right* key is an answer, so a dead source costs one read and not one per + // pass. + harness.inbox.derived.ride_track = Some(DerivedInput { key, result: DerivedResult::Failed }); + let plan = harness.pass(); + assert!(plan.derived_needs.ride_track.is_none(), "a failure answers the key"); + + // And the scenario-level consequence: DeviceCore's terminal state stops depending on cadence. + let scenario = named("derived-data.repeats-until-matching-fill"); + let immediate = Runner::CoreImmediate.run(scenario); + let delayed = Runner::CoreDelayed.run(scenario); + assert_eq!( + rider_visible(delayed.settled.clone()), + rider_visible(immediate.settled.clone()), + "DeviceCore's terminal state no longer depends on delivery cadence" + ); + assert!( + DIFFERENCES + .iter() + .any(|row| row.scenario == scenario.name && matches!(row.disposition, Disposition::Corrected)), + "and the difference from the legacy runner is recorded as corrected" + ); +} + +fn open_ride_detail(harness: &mut CoreHarness) { + for gesture in [Gesture::Press, Gesture::Step(1), Gesture::Press, Gesture::Press] { + harness.app().apply_gesture(gesture); + } +} + +/// A throwaway recorder for the direct pass-level traces, which assert on state rather than on a +/// trace timeline. One open step, never finished — the feeder calls a re-feed makes have somewhere +/// to land. +fn recorder() -> TraceRecorder { + let mut recorder = TraceRecorder::new("direct", RunnerMode::Immediate, blank_state()); + recorder.begin_step(TraceInput::Named("direct")); + recorder +} + +fn blank_state() -> VisibleState { + visible_state(&App::new_idle(AppState::new(0, 0, 1.0)), 0, 0) +} + +/// The one warning path the pass owns end to end: a fault raised by any producer reaches the rider +/// in the pass it was raised in, and several producers coalesce onto one card. +#[test] +fn a_fact_raised_this_pass_reaches_the_rider_in_it() { + let mut harness = expiring(0); + harness.inbox.facts.raise_warnings(WarningFlags::NO_GPS); + harness.inbox.facts.raise_warnings(WarningFlags::MAP_SLOW); + harness.pass(); + assert!( + matches!(harness.state.app.top_screen(), Screen::Warning(card) + if card.flags().contains(WarningFlags::NO_GPS) && card.flags().contains(WarningFlags::MAP_SLOW)), + "both notices reached one card" + ); +} + +/// A failed sidecar write re-queues its candidate — the retry the legacy protocol cannot express at +/// all, because it never acknowledges a stamp (`LegacyOwned::SidecarAck`). +#[test] +fn a_failed_retention_write_is_retried() { + let mut harness = typed(); + harness.app().stamp_clock_ble(1_720_000_000, 60); + let now = harness.state.app.wall_unix_now(); + harness.app().set_route_meta(&[ + RouteRetentionMeta::new(Retention::Week1, now), + RouteRetentionMeta::new(Retention::Never, 0), + RouteRetentionMeta::new(Retention::Never, 0), + ]); + harness.pass(); + harness.app().activate_route(0); + + let mut effect = None; + for _ in 0..8 { + let mut plan = harness.pass(); + if let Some(found) = plan.effects.retention.take() { + effect = Some(found); + break; + } + } + let effect = effect.expect("the activation's use stamp goes out"); + let _ = harness + .inbox + .outcomes + .retention + .try_put(RetentionOutcome::Failed { token: effect.token(), error: RetentionError::WriteFailed }); + + let mut retried = false; + for _ in 0..16 { + let mut plan = harness.pass(); + if plan.effects.retention.take().is_some() { + retried = true; + break; + } + } + assert!(retried, "a failed write keeps its candidate and offers it again"); +} + +/// A decided sidecar stamp is mirrored into the resident view, so it is not rediscovered. +/// +/// The eager ride stamp runs on every trusted tick and re-enqueues any resident ride that is +/// `synced` with a `synced_at` of 0; only the mirror clears that 0, so an unmirrored stamp comes +/// back on every pass after the executor answers it. Both the legacy drain +/// (`App::retention_stamp_command`) and the pass mirror into the full ride inventory as well as +/// the display catalog, because a ride outside the newest-32 menu re-enqueues just the same +/// (finding #876-2). +/// +/// Without the mirror this fails on the first pass after the answer. +#[test] +fn a_stamp_that_was_answered_is_not_enqueued_again() { + let mut harness = typed(); + harness.app().stamp_clock_ble(1_720_000_000, 60); + let id = harness.state.ride_ids[0]; + harness.app().set_ride_retention_inventory(&[RideRetentionRecord { id, synced: true, synced_at_utc: 0 }]); + harness.app().force_retention_sweep(); + + let mut effect = None; + for _ in 0..8 { + let mut plan = harness.pass(); + if let Some(found) = plan.effects.retention.take() { + effect = Some(found); + break; + } + harness.serve(plan, &mut recorder()); + } + let effect = effect.expect("an acked ride with no synced_at stamp gets one"); + let outcome = harness.serve_retention(effect); + harness.deliver(Done::Retention(outcome), &mut recorder()); + + for step in 0..8 { + let mut plan = harness.pass(); + assert!( + plan.effects.retention.take().is_none(), + "the answered stamp came back on settle pass {step} — an endless sidecar write" + ); + harness.serve(plan, &mut recorder()); + } +} + +/// The conformance replay's wake profile and pass cost — #1440's last two resource rows. +/// +/// The wake counts are deterministic, so they are asserted: a pass that starts polling, or a +/// deferred connection that stops settling, moves them and this fails. The times are +/// machine-dependent, so they are printed under `--nocapture` rather than gated. What the test +/// guarantees is that both figures are reproducible from one command, instead of from a measurement +/// harness someone ran once and deleted. +#[test] +fn the_conformance_replay_wake_profile_and_pass_cost() { + let mut passes = 0u32; + let mut immediate = 0u32; + let mut timed = 0u32; + let mut sleeps = 0u32; + let mut total = std::time::Duration::ZERO; + let mut worst = std::time::Duration::ZERO; + + for scenario in SCENARIOS { + for executor in [Executor::Typed, Executor::Compatibility] { + let mut harness = CoreHarness::new(executor); + let mut trace = recorder(); + let actions = scenario.actions.iter().chain(std::iter::repeat_n(&Action::Settle, SETTLE_PASSES)); + for action in actions { + harness.apply(*action); + let start = std::time::Instant::now(); + let plan = harness.pass(); + let elapsed = start.elapsed(); + total += elapsed; + worst = worst.max(elapsed); + passes += 1; + match plan.next_wake_ms { + Some(0) => immediate += 1, + Some(_) => timed += 1, + None => sleeps += 1, + } + harness.serve(plan, &mut trace); + } + } + } + + println!("passes {passes}: {immediate} immediate, {timed} timed, {sleeps} sleep-until-event"); + println!( + "pass time: mean {:.3} us, worst {:.3} us", + total.as_secs_f64() * 1e6 / f64::from(passes), + worst.as_secs_f64() * 1e6 + ); + + assert_eq!(passes, WAKE_PROFILE.0, "the replay's pass count is fixed by the scenario table"); + assert_eq!(immediate, WAKE_PROFILE.1, "an immediate wake is decided work that has not reached its consumer"); + assert_eq!(timed, WAKE_PROFILE.2); + assert_eq!(sleeps, WAKE_PROFILE.3); + assert!(immediate * 10 < passes, "immediate wakes stay a small minority — nothing here polls"); +} + +/// `(passes, immediate, timed, sleep-until-event)` for the replay above. A ratchet, not a budget: +/// the numbers move when the pass's wake decisions do, and #1397 compares against them. +const WAKE_PROFILE: (u32, u32, u32, u32) = (366, 6, 236, 124); + +// ==================== the resource gate ==================== + +/// The pass protocol's size budget, re-asserted from outside `obc-app`. +/// +/// The compile-time assertions inside the crate gate the same values; this one exists because a +/// *host* is what puts both structs on its stack every pass, and because the gate's resource table +/// has to be reproducible from a command anyone can run. +#[test] +fn the_pass_protocol_stays_within_its_budget() { + use std::mem::size_of; + + assert!(size_of::() <= 160, "nine bounded effects: {}", size_of::()); + assert!(size_of::() <= 224, "nine bounded outcomes: {}", size_of::()); + assert!(size_of::() <= 64); + assert!(size_of::() <= 80); + + // The largest single message per direction — what a payload creeping into the protocol would + // show up as first. + let largest_effect = [ + size_of::(), + size_of::(), + size_of::(), + size_of::(), + size_of::(), + size_of::(), + size_of::(), + size_of::(), + size_of::(), + ] + .into_iter() + .max() + .unwrap(); + assert!(largest_effect <= 96, "the planner request is the largest effect: {largest_effect}"); +} diff --git a/host/obc-host-core/tests/device_core_corpus/mod.rs b/host/obc-host-core/tests/device_core_corpus/mod.rs new file mode 100644 index 000000000..aeddab8b1 --- /dev/null +++ b/host/obc-host-core/tests/device_core_corpus/mod.rs @@ -0,0 +1,1370 @@ +//! The shared DeviceCore behaviour corpus (#1434 DC1, #1440 DC7). +//! +//! One scenario table, one set of fixtures and one legacy harness, used by two test binaries: +//! `device_core_legacy_traces` pins what the legacy protocol does with them, and +//! `device_core_conformance` runs the same definitions through five runners. Keeping the corpus +//! here is what makes "the same scenario, a different runner" a fact about the code rather than +//! about two hand-kept copies of a table. +//! +//! Nothing here decides policy: the harness applies real `App` operations and real gestures, and the +//! runner in `obc_host_core::trace` only controls *when* completed outcomes are delivered. + +// A shared test corpus is compiled into every binary that includes it, and each uses a subset. +#![allow(dead_code)] + +use obc_app::dfu::{clamp, DfuFailure, DfuInstallError, DfuScanError, DfuScanReport}; +use obc_app::screen::Screen; +use obc_app::{ + App, AppState, DetourPreview, Gesture, HostCommand, HostEvent, Mode, RideRetentionRecord, RideSummary, + RouteSummary, TrackAction, TripInput, WarningFlags, +}; +use obc_formats::io::{ByteSink, SliceSource}; +use obc_host_core::trace::{ + run_scenario_seeded, CommandTag, EventTag, FeederCall, FeederKind, NormalizationSeed, ObjectKey, ObjectKind, + RevisionKey, RunnerMode, ScenarioStep, TimeKey, Trace, TraceHarness, TraceInput, TraceOutput, TraceRecorder, + TraceScenario, TraceSink, +}; +use obc_host_core::{HostLoop, PlanHold, RideRepository, RouteRepository, TripCatalog}; +use obc_map_scene::BBox; +use obc_ports::{Fix, InputClock, LocationSource, RideClock, Sensors, SettingsSaveError}; +use obc_route::{gpx_to_obcr, NavError, RouteIndex, RouteReader}; + +/// Every behavior row locked by DC1. Keeping the inventory typed makes adding a scenario without +/// the corresponding acceptance row (or silently dropping a row during later refactors) fail. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Requirement { + CatalogStoreChange, + CatalogRefresh, + CatalogRouteDelete, + CatalogRideDelete, + CatalogTripCascade, + CatalogUploadOrder, + CatalogIdentityRemap, + NavigationPlan, + NavigationCancel, + NavigationLateResult, + NavigationReplacement, + NavigationDetourPlan, + NavigationDetourCancel, + NavigationDetourCommit, + NavigationNoPath, + RecorderStart, + RecorderSave, + RecorderDiscard, + RecorderFinalizeFailure, + RecorderSessionReplacement, + SettingsDirtyRevision, + SettingsSuccess, + SettingsStaleResult, + SettingsFailure, + SettingsRetry, + RetentionRouteUseStamp, + RetentionRideSyncStamp, + RetentionExpiryDelete, + RetentionRetry, + RetentionTrustedClockGate, + DfuScanSuccess, + DfuScanFailure, + DfuInstallStart, + DfuInstallRefusal, + DfuConfirmedUpdate, + DfuFailedUpdate, + PlatformForgetBond, + PlatformCardSpaceScan, + DerivedRideTrackRepeatedUntilFill, + DerivedNavPreviewRepeatedUntilFill, + WeatherRefreshState, + WeatherInstalledDataChange, + WeatherStaleData, + WeatherAlertDelivery, +} + +pub const ALL_REQUIREMENTS: &[Requirement] = &[ + Requirement::CatalogStoreChange, + Requirement::CatalogRefresh, + Requirement::CatalogRouteDelete, + Requirement::CatalogRideDelete, + Requirement::CatalogTripCascade, + Requirement::CatalogUploadOrder, + Requirement::CatalogIdentityRemap, + Requirement::NavigationPlan, + Requirement::NavigationCancel, + Requirement::NavigationLateResult, + Requirement::NavigationReplacement, + Requirement::NavigationDetourPlan, + Requirement::NavigationDetourCancel, + Requirement::NavigationDetourCommit, + Requirement::NavigationNoPath, + Requirement::RecorderStart, + Requirement::RecorderSave, + Requirement::RecorderDiscard, + Requirement::RecorderFinalizeFailure, + Requirement::RecorderSessionReplacement, + Requirement::SettingsDirtyRevision, + Requirement::SettingsSuccess, + Requirement::SettingsStaleResult, + Requirement::SettingsFailure, + Requirement::SettingsRetry, + Requirement::RetentionRouteUseStamp, + Requirement::RetentionRideSyncStamp, + Requirement::RetentionExpiryDelete, + Requirement::RetentionRetry, + Requirement::RetentionTrustedClockGate, + Requirement::DfuScanSuccess, + Requirement::DfuScanFailure, + Requirement::DfuInstallStart, + Requirement::DfuInstallRefusal, + Requirement::DfuConfirmedUpdate, + Requirement::DfuFailedUpdate, + Requirement::PlatformForgetBond, + Requirement::PlatformCardSpaceScan, + Requirement::DerivedRideTrackRepeatedUntilFill, + Requirement::DerivedNavPreviewRepeatedUntilFill, + Requirement::WeatherRefreshState, + Requirement::WeatherInstalledDataChange, + Requirement::WeatherStaleData, + Requirement::WeatherAlertDelivery, +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + Settle, + StoreChanged, + RefreshCatalogs, + UploadRoutesThenTrip, + RemapCatalogIdentity, + DeleteRoute, + DeleteRide, + CascadeDeleteTrip, + StartRoutePlan, + CancelRoutePlan, + DeliverLateRouteResult, + ReplaceRoutePlan, + PlanDetour, + CancelDetour, + CommitDetour, + RouteNoPath, + StartRecorder, + SaveRecorder, + DiscardRecorder, + FailRecorderFinalize, + ReplaceRecorderSession, + DirtySettings, + PersistSettings, + DeliverStaleSettingsResult, + DeliverMatchingSettingsResult, + FailSettingsPersist, + RetrySettingsPersist, + StampRouteUse, + StampRideSync, + DeleteExpiredObject, + RetryExpiredDelete, + GateExpiryUntilClockTrusted, + ScanDfuSuccess, + ScanDfuFailure, + StartDfuInstall, + AdmitDfuInstall, + RefuseDfuInstall, + ConfirmUpdate, + FailUpdate, + ForgetBond, + ScanCardSpace, + NeedRideTrack, + RemapRideIdentity, + ReplaceRideTrackNeed, + FillRideTrack, + NeedNavPreview, + ReplaceNavPreviewNeed, + FillNavPreview, + RefreshWeather, + InstallWeatherData, + MarkWeatherStale, + DeliverWeatherAlert, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScreenState { + Home, + Menu, + Routes, + RouteOverview, + Rides, + RideDetail, + Map, + Detour, + Planning, + DetourPreview, + DfuCheck, + DfuConfirm, + DfuProgress, + DfuInstalling, + DfuError, + Warning, + WeatherAlert, + /// Any screen the projection does not name, carrying its variant name so two runners resting on + /// *different* unnamed screens still compare unequal. + Other(&'static str), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VisibleState { + pub screen: ScreenState, + pub stack_depth: usize, + pub mode: Mode, + pub route_names: Vec, + pub route_ids: Vec, + pub ride_names: Vec, + pub ride_ids: Vec, + pub trip_names: Vec, + pub trip_ids: Vec, + pub active_route_name: Option, + pub active_route_id: Option, + pub requested_ride_id: Option, + pub recording: bool, + pub clock_trusted: bool, + pub rain_steps_ahead: u8, + pub settings_revision: Option, + pub settings_utc_offset_min: i16, + pub pending_host_command: bool, + pub nav_preview_missing: bool, + pub warning: Option, + pub retention_delete_attempts: u16, +} + +#[derive(Debug)] +pub enum Outcome { + Event(HostEvent), + FinishSave, + FinalizeFailed, + CardScanned, + RideTrack { id: u64 }, + NavPreview { generation: u16 }, + DetourCommitted, +} + +#[derive(Debug, Clone, Copy)] +pub enum PendingSettingsResult { + PersistLatest, + FailLatest, + PersistRevision(u16), +} + +/// The legacy adapter uses the real `App` protocol doors and `HostLoop`'s passive trace observer. +/// Inputs are real public app operations or UI gestures; each pass runs the real command dispatcher; +/// deliveries call the real `set_*` feeders and `apply_event`. Planner, detour-commit, recorder-finalize, +/// and derived-fill completions are scripted at that protocol boundary because the legacy interfaces +/// do not expose a deterministic completion seam. The fixture-backed `board_parity` suite separately +/// exercises the real planner/repository path; this fast corpus must not be read as a second planner. +pub struct LegacyHarness { + pub app: App, + pub routes: Vec, + pub route_ids: Vec, + pub rides: Vec, + pub ride_ids: Vec, + pub trip_stage_ids: Vec, + pub trip_present: bool, + pub nav_generation: u16, + pub host: HostLoop, + pub fail_next_finalize: bool, + pub commit_success_pending: bool, + pub pending_nav_plan: Option>, + pub pending_dfu_scan: Option>, + pub pending_dfu_install: Option>, + pub pending_settings_result: Option, + pub settings_revision: u16, + pub route_delete_fail_once: bool, + pub retention_delete_attempts: u16, + pub settings_retry_requested: bool, +} + +impl LegacyHarness { + pub fn new() -> Self { + let routes = vec![route("Alpha"), route("Beta"), route("Gamma")]; + let route_ids = vec![10, 20, 30]; + let rides = vec![ride("Morning"), ride("Evening")]; + let ride_ids = vec![70, 90]; + let trip_stage_ids = vec![10, 20]; + let mut app = App::new_idle(AppState::new(8_330_000, 46_570_000, 1.0)); + app.set_routes_with_ids(&routes, &route_ids); + app.set_rides(&rides, &ride_ids); + app.set_trips(&[TripInput { id: 50, name: "Alps", stage_ids: &trip_stage_ids }]); + Self { + app, + routes, + route_ids, + rides, + ride_ids, + trip_stage_ids, + trip_present: true, + nav_generation: 0, + host: HostLoop::new(), + fail_next_finalize: false, + commit_success_pending: false, + pending_nav_plan: None, + pending_dfu_scan: None, + pending_dfu_install: None, + pending_settings_result: None, + settings_revision: 0, + route_delete_fail_once: false, + retention_delete_attempts: 0, + settings_retry_requested: false, + } + } + + pub fn event(&mut self, event: HostEvent, trace: &mut TraceRecorder) { + trace.record_event(&event); + self.app.apply_event(event); + } + + pub fn feed_routes(&mut self, key: &'static str, trace: &mut TraceRecorder) { + self.app.set_routes_with_ids(&self.routes, &self.route_ids); + trace.record_feeder(FeederCall::new(FeederKind::RouteCatalog, key, self.routes.len())); + } + + pub fn feed_rides(&mut self, key: &'static str, trace: &mut TraceRecorder) { + self.app.set_rides(&self.rides, &self.ride_ids); + trace.record_feeder(FeederCall::new(FeederKind::RideCatalog, key, self.rides.len())); + } + + pub fn feed_trips(&mut self, key: &'static str, trace: &mut TraceRecorder) { + if self.trip_present { + self.app.set_trips(&[TripInput { id: 50, name: "Alps", stage_ids: &self.trip_stage_ids }]); + } else { + self.app.set_trips(&[]); + } + trace.record_feeder(FeederCall::new(FeederKind::TripCatalog, key, usize::from(self.trip_present))); + } + + fn reset_to_riding_map(&mut self) { + self.app = App::new_idle(AppState::new(7_500_000, 43_500_000, 1.0)); + self.app.set_routes_with_ids(&self.routes, &self.route_ids); + self.app.set_rides(&self.rides, &self.ride_ids); + self.app.set_map_nav_graph(true); + self.app.state.user_fix = Some(road_fix(0.0)); + self.app.apply_gesture(Gesture::BackHold); + self.app.apply_gesture(Gesture::Press); + // The first row is the trip folder; enter it, open the first stage, then start the ride. + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Press); + let bytes = road_obcr(); + let source = SliceSource(&bytes); + let index = RouteIndex::read(&source).unwrap(); + let reader = RouteReader::new(&index, &source); + struct OneFix(Option); + impl LocationSource for OneFix { + fn poll(&mut self) -> Option { + self.0.take() + } + } + let mut location = OneFix(Some(road_fix(0.31))); + self.app.tick(RideClock(0), Sensors::new(&mut location), Some(&reader)); + } + + fn tick_without_fix(&mut self) { + struct NoFix; + impl LocationSource for NoFix { + fn poll(&mut self) -> Option { + None + } + } + let mut location = NoFix; + self.app.tick(RideClock(0), Sensors::new(&mut location), None); + } + + fn open_detour_plan(&mut self) { + if !matches!(self.app.top_screen(), Screen::Detour(_)) { + self.reset_to_riding_map(); + self.app.apply_gesture(Gesture::BackHold); + self.app.apply_gesture(Gesture::Step(1)); + self.app.apply_gesture(Gesture::Press); + } + self.app.apply_gesture(Gesture::Press); + } + + pub fn snapshot_state(&self) -> VisibleState { + visible_state(&self.app, self.settings_revision, self.retention_delete_attempts) + } +} + +/// The normalized rider-visible state every runner is compared on. +pub fn visible_state(app: &App, settings_revision: u16, retention_delete_attempts: u16) -> VisibleState { + let screen = match app.top_screen() { + Screen::Home(_) => ScreenState::Home, + Screen::Menu(_) => ScreenState::Menu, + Screen::RouteMenu(_) => ScreenState::Routes, + Screen::RouteOverview(_) => ScreenState::RouteOverview, + Screen::Rides(_) => ScreenState::Rides, + Screen::RideDetail(_) => ScreenState::RideDetail, + Screen::Map(_) => ScreenState::Map, + Screen::Detour(_) => ScreenState::Detour, + Screen::NavPlanning(_) => ScreenState::Planning, + Screen::DetourPreview(_) => ScreenState::DetourPreview, + Screen::DfuCheck(_) => ScreenState::DfuCheck, + Screen::DfuConfirm(_) => ScreenState::DfuConfirm, + Screen::DfuProgress(_) => ScreenState::DfuProgress, + Screen::DfuInstalling(_) => ScreenState::DfuInstalling, + Screen::DfuError(_) => ScreenState::DfuError, + Screen::Warning(_) => ScreenState::Warning, + Screen::WeatherAlert(_) => ScreenState::WeatherAlert, + other => ScreenState::Other(other.name()), + }; + VisibleState { + screen, + stack_depth: app.debug_stack_len(), + mode: app.mode(), + route_names: app.routes().iter().map(|item| item.name.as_str().to_owned()).collect(), + route_ids: app.route_ids().iter().map(|&id| fixture_object_key(ObjectKind::Route, id)).collect(), + ride_names: app.rides().iter().map(|item| item.name.as_str().to_owned()).collect(), + ride_ids: app.ride_ids().iter().map(|&id| fixture_object_key(ObjectKind::Ride, id)).collect(), + trip_names: app.trips().iter().map(|item| item.name.as_str().to_owned()).collect(), + trip_ids: app.trips().iter().map(|trip| fixture_object_key(ObjectKind::Trip, trip.id)).collect(), + active_route_name: app + .active_route_index() + .and_then(|index| app.routes().get(index)) + .map(|item| item.name.as_str().to_owned()), + active_route_id: app + .active_route_index() + .and_then(|index| app.route_ids().get(index)) + .map(|&id| fixture_object_key(ObjectKind::Route, id)), + requested_ride_id: app.ride_track_request().map(|id| fixture_object_key(ObjectKind::Ride, id)), + recording: app.activity.is_tracking(), + clock_trusted: app.clock_trusted(), + rain_steps_ahead: app.state.rain_steps_ahead, + settings_revision: match settings_revision { + 0 => None, + 1 => Some(RevisionKey(0)), + 2 => Some(RevisionKey(1)), + other => panic!("unseeded fixture settings revision {other}"), + }, + settings_utc_offset_min: app.settings().utc_offset_min, + pending_host_command: app.has_pending_host_command(), + nav_preview_missing: app.nav_preview_missing(), + warning: match app.top_screen() { + Screen::Warning(card) => Some(card.flags()), + _ => None, + }, + retention_delete_attempts, + } +} + +impl TraceHarness for LegacyHarness { + type State = VisibleState; + type Outcome = Outcome; + + fn snapshot(&self) -> Self::State { + self.snapshot_state() + } + + fn apply_input(&mut self, action: &Action, trace: &mut TraceRecorder) { + match action { + Action::Settle => {} + Action::StoreChanged => self.event(HostEvent::StoreChanged, trace), + Action::RefreshCatalogs => {} + Action::UploadRoutesThenTrip => { + self.feed_routes("upload.routes", trace); + self.event(HostEvent::RouteUploaded { id: 10, replaced: false, elevation: None }, trace); + self.event(HostEvent::RouteUploaded { id: 20, replaced: false, elevation: None }, trace); + self.feed_trips("upload.trip", trace); + self.event(HostEvent::TripUploaded { id: 50, replaced: false }, trace); + } + Action::RemapCatalogIdentity => { + self.routes.swap(0, 2); + self.route_ids.swap(0, 2); + self.feed_routes("catalog.remap", trace); + self.feed_trips("catalog.remap-trips", trace); + } + Action::DeleteRoute => { + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Press); + // The trip folders are first. Open the first stage, then its first route overview. + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Step(1)); + self.app.apply_gesture(Gesture::Hold); + } + Action::DeleteRide => { + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Step(1)); + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Hold); + } + Action::CascadeDeleteTrip => { + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Hold); + self.app.apply_gesture(Gesture::Step(1)); + self.app.apply_gesture(Gesture::Hold); + } + Action::StartRoutePlan => { + assert!(self.app.debug_start_nav((0, 0), (1_000, 1_000), "First plan")); + } + Action::CancelRoutePlan => self.app.apply_gesture(Gesture::Back), + Action::DeliverLateRouteResult => { + self.feed_routes("nav.old-publication", trace); + self.event(HostEvent::NavPlanned(Ok(10)), trace); + } + Action::ReplaceRoutePlan => { + assert!(self.app.debug_start_nav((0, 0), (2_000, 2_000), "Replacement")); + } + Action::PlanDetour => self.open_detour_plan(), + Action::CancelDetour => self.app.apply_gesture(Gesture::Back), + Action::CommitDetour => { + self.app.set_detour_preview(&[(0, 0), (100, 100)]); + trace.record_feeder(FeederCall::new(FeederKind::DetourPreview, "detour.preview", 2)); + self.event( + HostEvent::DetourPlanned(Ok(DetourPreview { + cost_delta_m: 100, + total_distance_m: 900, + rejoin_m: 1_000, + ascent_m: Some(30), + })), + trace, + ); + self.app.apply_gesture(Gesture::Press); + self.commit_success_pending = true; + } + Action::RouteNoPath => { + assert!(self.app.debug_start_nav((0, 0), (1_000, 1_000), "No path")); + self.pending_nav_plan = Some(Err(NavError::NoPath)); + } + Action::StartRecorder => self.app.activity.start_session(), + Action::SaveRecorder => { + self.app.activity.end_session(); + self.app.activity.request_track(TrackAction::Save); + } + Action::DiscardRecorder => { + self.app.activity.end_session(); + self.app.activity.request_track(TrackAction::Discard); + } + Action::FailRecorderFinalize => { + self.app.activity.request_track(TrackAction::Save); + // Compatibility limitation: the legacy vocabulary has no recorder-finalize outcome; + // hosts report the failure through the generic warning event. + self.fail_next_finalize = true; + } + Action::ReplaceRecorderSession => { + self.app.activity.end_session(); + self.app.activity.start_session(); + } + Action::DirtySettings => { + let settings = *self.app.settings(); + self.app.set_settings(settings); + trace.feeder_revision(FeederKind::Settings, "settings.boot", 0, 1); + self.app.stamp_clock_ble(1_720_000_000, 60); + self.settings_revision = 1; + } + Action::PersistSettings => {} + Action::DeliverStaleSettingsResult => { + self.app.stamp_clock_ble(1_720_000_060, 120); + self.settings_revision = 2; + self.pending_settings_result = Some(PendingSettingsResult::PersistRevision(1)); + } + Action::DeliverMatchingSettingsResult => { + self.pending_settings_result = Some(PendingSettingsResult::PersistRevision(self.settings_revision)); + } + Action::FailSettingsPersist => self.pending_settings_result = Some(PendingSettingsResult::FailLatest), + Action::RetrySettingsPersist => { + // The failed revision is retained through the real bounded retry window. + self.settings_retry_requested = true; + self.app.advance_animations(InputClock(4_002)); + self.pending_settings_result = Some(PendingSettingsResult::PersistLatest); + } + Action::StampRouteUse => { + self.app.stamp_clock_ble(1_720_000_000, 60); + self.event(HostEvent::RouteUploaded { id: 10, replaced: false, elevation: None }, trace); + } + Action::StampRideSync => { + self.app.stamp_clock_ble(1_720_000_000, 60); + let mut stamped = self.rides[0].clone(); + stamped.synced = true; + stamped.synced_at_utc = 0; + self.rides[0] = stamped; + self.feed_rides("retention.synced", trace); + self.app.set_ride_retention_inventory(&[RideRetentionRecord { + id: self.ride_ids[0], + synced: true, + synced_at_utc: 0, + }]); + trace.record_feeder(FeederCall::new(FeederKind::RideRetention, "retention.rides", 1)); + self.app.force_retention_sweep(); + self.tick_without_fix(); + } + Action::DeleteExpiredObject => { + self.app.stamp_clock_ble(1_720_000_000, 60); + self.app.set_route_meta(&[ + obc_app::RouteRetentionMeta::new(obc_app::Retention::Day1, 1), + obc_app::RouteRetentionMeta::new(obc_app::Retention::Never, 0), + obc_app::RouteRetentionMeta::new(obc_app::Retention::Never, 0), + ]); + trace.record_feeder(FeederCall::new(FeederKind::RouteRetention, "retention.routes", 3)); + self.route_delete_fail_once = true; + self.app.force_retention_sweep(); + self.tick_without_fix(); + } + Action::RetryExpiredDelete => { + // The original failed candidate owns its retry; no new discovery sweep is needed. + self.app.advance_animations(InputClock(5_002)); + self.tick_without_fix(); + } + Action::GateExpiryUntilClockTrusted => { + assert!(!self.app.clock_trusted()); + self.app.force_retention_sweep(); + self.tick_without_fix(); + } + Action::ScanDfuSuccess => { + assert!(self.app.open_remote_dfu_check()); + self.pending_dfu_scan = Some(Ok(DfuScanReport::new("v1", "v2", false))); + } + Action::ScanDfuFailure => { + // Finish the preceding flow, then open a fresh real scan wait. + self.app.apply_gesture(Gesture::Back); + assert!(self.app.open_remote_dfu_check()); + self.pending_dfu_scan = Some(Err(DfuScanError::Damaged)); + } + Action::StartDfuInstall => { + assert!(self.app.open_remote_dfu_check()); + self.pending_dfu_scan = Some(Ok(DfuScanReport::new("v1", "v2", false))); + } + Action::AdmitDfuInstall => { + self.app.apply_gesture(Gesture::Press); + self.pending_dfu_install = Some(Ok(())); + } + Action::RefuseDfuInstall => { + self.app.apply_gesture(Gesture::Press); + self.pending_dfu_install = Some(Err(DfuInstallError::Recording)); + } + Action::ConfirmUpdate => self.event(HostEvent::UpdateConfirmed(clamp("v2")), trace), + Action::FailUpdate => { + self.event(HostEvent::UpdateFailed { why: DfuFailure::Reverted, staged: Some(clamp("v3")) }, trace) + } + Action::ForgetBond => self.app.state.ble_forget_pending = true, + Action::ScanCardSpace => { + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Step(-1)); + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Step(-1)); + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Step(3)); + self.app.apply_gesture(Gesture::Press); + } + Action::NeedRideTrack => { + if !matches!(self.app.top_screen(), Screen::RideDetail(_)) { + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Step(1)); + self.app.apply_gesture(Gesture::Press); + self.app.apply_gesture(Gesture::Press); + } + } + Action::RemapRideIdentity => { + self.rides.swap(0, 1); + self.ride_ids.swap(0, 1); + self.feed_rides("ride.remap", trace); + } + Action::ReplaceRideTrackNeed => { + self.app.apply_gesture(Gesture::Back); + self.app.apply_gesture(Gesture::Step(1)); + self.app.apply_gesture(Gesture::Press); + } + Action::FillRideTrack => {} + Action::NeedNavPreview => { + if !self.app.nav_preview_missing() { + assert!(self.app.debug_start_nav((0, 0), (1_000, 1_000), "Preview")); + self.feed_routes("nav.preview-route", trace); + self.event(HostEvent::NavPlanned(Ok(10)), trace); + self.nav_generation = self.nav_generation.wrapping_add(1); + } + } + Action::ReplaceNavPreviewNeed => { + self.app.apply_gesture(Gesture::Back); + assert!(self.app.debug_start_nav((0, 0), (2_000, 2_000), "New preview")); + self.feed_routes("nav.preview-replacement", trace); + self.event(HostEvent::NavPlanned(Ok(10)), trace); + self.nav_generation = self.nav_generation.wrapping_add(1); + } + Action::FillNavPreview => {} + Action::RefreshWeather => { + self.app.set_rain_view(3, 0.5); + trace.record_feeder(FeederCall::new(FeederKind::RainView, "weather.refresh", 3)); + } + Action::InstallWeatherData => { + self.app.weather_feed_changed(); + trace.record_feeder(FeederCall::new(FeederKind::WeatherSnapshot, "weather.installed", 1)); + trace.record_feeder(FeederCall::new(FeederKind::WeatherFeedChanged, "weather.installed", 1)); + } + Action::MarkWeatherStale => { + self.app.set_rain_view(0, 0.0); + trace.record_feeder(FeederCall::new(FeederKind::RainView, "weather.stale", 0)); + } + Action::DeliverWeatherAlert => { + assert!(self.app.show_weather_alert(obc_app::WeatherAlertKind::Storm, 12)); + } + } + } + + fn run_pass(&mut self, trace: &mut TraceRecorder) -> Vec { + let ride_track_need = self.app.ride_track_request(); + let mut route_repo = BorrowedRoutes { + catalog: &mut self.routes, + ids: &mut self.route_ids, + fail_delete_once: &mut self.route_delete_fail_once, + delete_attempts: &mut self.retention_delete_attempts, + }; + let mut ride_repo = BorrowedRides { catalog: &mut self.rides, ids: &mut self.ride_ids }; + let mut trip_repo = BorrowedTrips { present: &mut self.trip_present, stage_ids: &self.trip_stage_ids }; + let mut platform = Vec::new(); + let finish = self.host.reconcile_commands_traced( + &mut self.app, + &mut route_repo, + &mut ride_repo, + &mut trip_repo, + PlanHold::new(true, true), + true, + true, + |_app, command| platform.push(command), + trace, + ); + let mut outcomes = Vec::new(); + if let Some(result) = self.pending_nav_plan.take() { + outcomes.push(Outcome::Event(HostEvent::NavPlanned(result))); + } + if matches!(finish, Some(TrackAction::Save)) { + if std::mem::take(&mut self.fail_next_finalize) { + outcomes.push(Outcome::FinalizeFailed); + } else { + outcomes.push(Outcome::FinishSave); + } + } + if platform.iter().any(|command| matches!(command, HostCommand::ScanCardFree)) { + outcomes.push(Outcome::CardScanned); + } + let persisted_revision = platform.iter().find_map(|command| match command { + HostCommand::PersistSettings { revision } => Some(*revision), + _ => None, + }); + let ready_settings_result = !matches!(self.pending_settings_result, Some(PendingSettingsResult::PersistLatest)) + || persisted_revision.is_some(); + if ready_settings_result { + if let Some(result) = self.pending_settings_result.take() { + let revision = match result { + PendingSettingsResult::PersistRevision(revision) => revision, + PendingSettingsResult::PersistLatest | PendingSettingsResult::FailLatest => { + persisted_revision.unwrap_or(self.settings_revision) + } + }; + outcomes.push(Outcome::Event(match result { + PendingSettingsResult::FailLatest => { + HostEvent::SettingsPersistFailed { revision, error: SettingsSaveError::Backend } + } + PendingSettingsResult::PersistLatest | PendingSettingsResult::PersistRevision(_) => { + HostEvent::SettingsPersisted { revision } + } + })); + } + } + for command in platform { + match command { + HostCommand::Dfu(obc_app::DfuAction::Scan) => { + if let Some(result) = self.pending_dfu_scan.take() { + outcomes.push(Outcome::Event(HostEvent::DfuScanned(result))); + } + } + HostCommand::Dfu(obc_app::DfuAction::Install) => { + if let Some(result) = self.pending_dfu_install.take() { + outcomes.push(Outcome::Event(match result { + Ok(()) => HostEvent::DfuInstallBegan, + Err(error) => HostEvent::DfuInstallFailed(error), + })); + } + } + _ => {} + } + } + if let Some(id) = ride_track_need { + outcomes.push(Outcome::RideTrack { id }); + } + if self.app.nav_preview_missing() { + outcomes.push(Outcome::NavPreview { generation: self.nav_generation }); + } + if std::mem::take(&mut self.commit_success_pending) { + outcomes.push(Outcome::DetourCommitted); + } + outcomes + } + + fn deliver(&mut self, outcome: Self::Outcome, trace: &mut TraceRecorder) { + match outcome { + Outcome::Event(event) => { + let settings_failed = matches!(event, HostEvent::SettingsPersistFailed { .. }); + self.event(event, trace); + if settings_failed && self.settings_retry_requested { + self.app.advance_animations(InputClock(SETTINGS_FAILURE_RETRY_MS)); + } + } + Outcome::FinishSave => self.feed_rides("recorder.saved", trace), + Outcome::FinalizeFailed => self.event(HostEvent::Warning(WarningFlags::REC_ERROR), trace), + Outcome::CardScanned => self.event(HostEvent::CardScanned { free_bytes: Some(8 * 1024 * 1024) }, trace), + Outcome::RideTrack { id } => { + let current = self.app.ride_track_request(); + let data = ride_delivery_key(id, current); + // Legacy bulk feeders are not request-keyed. Record and apply even stale attempts; + // accepting this fill for a replacement view is a characterized compatibility defect. + self.app.set_ride_profile(None); + self.app.set_ride_preview(&[(0, 0), (1, 1)]); + trace.record_feeder(FeederCall::new(FeederKind::RideProfile, data, 0)); + trace.record_feeder(FeederCall::new(FeederKind::RidePreview, data, 2)); + } + Outcome::NavPreview { generation } => { + let data = nav_delivery_key(generation, self.nav_generation); + // The legacy preview feeder has no generation argument, so delayed old data is + // attempted against the current request and may satisfy it incorrectly. + self.app.set_nav_preview(&[(0, 0), (1, 1)]); + trace.record_feeder(FeederCall::new(FeederKind::NavPreview, data, 2)); + } + Outcome::DetourCommitted => { + self.feed_routes("detour.commit", trace); + self.event(HostEvent::DetourCommitted(Ok(10)), trace); + } + } + } +} + +struct BorrowedRoutes<'a> { + catalog: &'a mut Vec, + ids: &'a mut Vec, + fail_delete_once: &'a mut bool, + delete_attempts: &'a mut u16, +} + +impl RouteRepository for BorrowedRoutes<'_> { + fn catalog(&self) -> &[RouteSummary] { + self.catalog + } + + fn ids(&self) -> &[u64] { + self.ids + } + + fn delete_by_id(&mut self, id: u64) -> bool { + *self.delete_attempts = self.delete_attempts.saturating_add(1); + if std::mem::take(self.fail_delete_once) { + return false; + } + let Some(index) = self.ids.iter().position(|candidate| *candidate == id) else { return false }; + self.ids.remove(index); + self.catalog.remove(index); + true + } + + fn write_nav_route(&mut self, _bytes: &[u8]) -> Option { + None + } + + fn sync_active(&mut self, _want: Option) -> bool { + false + } + + fn active_source(&self) -> Option> { + None + } + + fn invalidate_active(&mut self) {} +} + +struct BorrowedRides<'a> { + catalog: &'a mut Vec, + ids: &'a mut Vec, +} + +impl RideRepository for BorrowedRides<'_> { + fn catalog(&self) -> &[RideSummary] { + self.catalog + } + + fn ids(&self) -> &[u64] { + self.ids + } + + fn delete_by_id(&mut self, id: u64) -> bool { + let Some(index) = self.ids.iter().position(|candidate| *candidate == id) else { return false }; + self.ids.remove(index); + self.catalog.remove(index); + true + } + + fn profile_by_id(&self, _id: u64) -> Option { + None + } + + fn preview_by_id(&self, _id: u64) -> Vec<(i32, i32)> { + vec![(0, 0), (1, 1)] + } +} + +struct BorrowedTrips<'a> { + present: &'a mut bool, + stage_ids: &'a [u64], +} + +impl TripCatalog for BorrowedTrips<'_> { + fn member_route_ids(&self, id: u64) -> Vec { + if *self.present && id == 50 { + self.stage_ids.to_vec() + } else { + Vec::new() + } + } + + fn delete_by_id(&mut self, id: u64) -> bool { + if *self.present && id == 50 { + *self.present = false; + true + } else { + false + } + } + + fn refeed(&self, app: &mut App) { + if *self.present { + app.set_trips(&[TripInput { id: 50, name: "Alps", stage_ids: self.stage_ids }]); + } else { + app.set_trips(&[]); + } + } +} + +fn ride_delivery_key(requested: u64, current: Option) -> &'static str { + match (requested, current) { + (70, Some(70)) => "ride.track.requested-morning.current-morning", + (70, Some(90)) => "ride.track.requested-morning.current-evening", + (90, Some(70)) => "ride.track.requested-evening.current-morning", + (90, Some(90)) => "ride.track.requested-evening.current-evening", + (70, None) => "ride.track.requested-morning.current-none", + (90, None) => "ride.track.requested-evening.current-none", + (_, Some(70)) => "ride.track.requested-other.current-morning", + (_, Some(90)) => "ride.track.requested-other.current-evening", + (_, Some(_)) => "ride.track.requested-other.current-other", + (_, None) => "ride.track.requested-other.current-none", + } +} + +fn nav_delivery_key(requested: u16, current: u16) -> &'static str { + match (requested, current) { + (0, 0) => "nav.preview.requested-g0.current-g0", + (0, 1) => "nav.preview.requested-g0.current-g1", + (0, 2) => "nav.preview.requested-g0.current-g2", + (1, 1) => "nav.preview.requested-g1.current-g1", + (1, 2) => "nav.preview.requested-g1.current-g2", + (2, 2) => "nav.preview.requested-g2.current-g2", + _ if requested == current => "nav.preview.requested-other.current-matching", + _ => "nav.preview.requested-other.current-replacement", + } +} + +pub fn fixture_object_key(kind: ObjectKind, id: u64) -> ObjectKey { + match (kind, id) { + (ObjectKind::Route, 10) => ObjectKey(0), + (ObjectKind::Route, 20) => ObjectKey(1), + (ObjectKind::Route, 30) => ObjectKey(2), + (ObjectKind::Ride, 70) => ObjectKey(3), + (ObjectKind::Ride, 90) => ObjectKey(4), + (ObjectKind::Trip, 50) => ObjectKey(5), + _ => panic!("unseeded fixture identity {kind:?}:{id}"), + } +} + +pub fn route(name: &str) -> RouteSummary { + let mut summary = RouteSummary { + name: Default::default(), + distance_km: 10, + climb_m: 100, + bbox: BBox { min_lon: 0, min_lat: 0, max_lon: 1_000, max_lat: 1_000 }, + start_lon: 0, + start_lat: 0, + }; + summary.name.push_str(name).unwrap(); + summary +} + +pub fn ride(name: &str) -> RideSummary { + let mut summary = RideSummary { + name: Default::default(), + start_time: 1_720_000_000, + distance_m: 1_000, + moving_time_s: 600, + climb_m: 10, + synced: false, + synced_at_utc: 0, + }; + summary.name.push_str(name).unwrap(); + summary +} + +#[derive(Default)] +struct TestSink(Vec); + +impl ByteSink for TestSink { + fn write(&mut self, bytes: &[u8]) -> Result<(), obc_formats::io::Error> { + self.0.extend_from_slice(bytes); + Ok(()) + } + + fn patch_at(&mut self, offset: u32, bytes: &[u8]) -> Result<(), obc_formats::io::Error> { + let start = offset as usize; + self.0[start..start + bytes.len()].copy_from_slice(bytes); + Ok(()) + } +} + +pub fn road_obcr() -> Vec { + let mut gpx = String::from(""); + for index in 0..=10 { + let lon = 7.50 + 0.04 * index as f64 / 10.0; + gpx.push_str(&format!("100")); + } + gpx.push_str(""); + let mut sink = TestSink::default(); + gpx_to_obcr(&SliceSource(gpx.as_bytes()), "Trace road", &mut sink).unwrap(); + sink.0 +} + +pub fn road_fix(fraction: f64) -> Fix { + Fix::at(43_500_000, ((7.50 + 0.04 * fraction) * 1e6) as i32) +} + +pub struct Scenario { + pub name: &'static str, + pub requirements: &'static [Requirement], + pub actions: &'static [Action], +} + +pub const SCENARIOS: &[Scenario] = &[ + Scenario { + name: "catalog.refresh-upload-remap", + requirements: &[ + Requirement::CatalogStoreChange, + Requirement::CatalogRefresh, + Requirement::CatalogUploadOrder, + Requirement::CatalogIdentityRemap, + ], + actions: &[ + Action::StoreChanged, + Action::RefreshCatalogs, + Action::UploadRoutesThenTrip, + Action::RemapCatalogIdentity, + ], + }, + Scenario { + name: "catalog.route-delete", + requirements: &[Requirement::CatalogRouteDelete], + actions: &[Action::DeleteRoute], + }, + Scenario { + name: "catalog.ride-delete", + requirements: &[Requirement::CatalogRideDelete], + actions: &[Action::DeleteRide], + }, + Scenario { + name: "catalog.trip-cascade", + requirements: &[Requirement::CatalogTripCascade], + actions: &[Action::CascadeDeleteTrip], + }, + Scenario { + name: "navigation.plan-cancel-late-replacement", + requirements: &[ + Requirement::NavigationPlan, + Requirement::NavigationCancel, + Requirement::NavigationLateResult, + Requirement::NavigationReplacement, + ], + actions: &[ + Action::StartRoutePlan, + Action::CancelRoutePlan, + Action::ReplaceRoutePlan, + Action::DeliverLateRouteResult, + ], + }, + Scenario { + name: "navigation.detour-lifecycle", + requirements: &[ + Requirement::NavigationDetourPlan, + Requirement::NavigationDetourCancel, + Requirement::NavigationDetourCommit, + ], + actions: &[Action::PlanDetour, Action::CancelDetour, Action::PlanDetour, Action::CommitDetour], + }, + Scenario { + name: "navigation.no-path", + requirements: &[Requirement::NavigationNoPath], + actions: &[Action::RouteNoPath], + }, + Scenario { + name: "recorder.start-save-discard", + requirements: &[Requirement::RecorderStart, Requirement::RecorderSave, Requirement::RecorderDiscard], + actions: &[Action::StartRecorder, Action::SaveRecorder, Action::StartRecorder, Action::DiscardRecorder], + }, + Scenario { + name: "recorder.failure-and-session-replacement", + requirements: &[Requirement::RecorderFinalizeFailure, Requirement::RecorderSessionReplacement], + actions: &[Action::StartRecorder, Action::FailRecorderFinalize, Action::ReplaceRecorderSession], + }, + Scenario { + name: "settings.revision-success-and-stale-result", + requirements: &[ + Requirement::SettingsDirtyRevision, + Requirement::SettingsSuccess, + Requirement::SettingsStaleResult, + ], + actions: &[ + Action::DirtySettings, + Action::PersistSettings, + Action::DeliverStaleSettingsResult, + Action::DeliverMatchingSettingsResult, + ], + }, + Scenario { + name: "settings.failure-and-retry", + requirements: &[Requirement::SettingsFailure, Requirement::SettingsRetry], + actions: &[Action::DirtySettings, Action::FailSettingsPersist, Action::RetrySettingsPersist], + }, + Scenario { + name: "retention.route-and-ride-stamps", + requirements: &[Requirement::RetentionRouteUseStamp, Requirement::RetentionRideSyncStamp], + actions: &[Action::StampRouteUse, Action::StampRideSync], + }, + Scenario { + name: "retention.expiry-retry-and-trusted-clock", + requirements: &[ + Requirement::RetentionExpiryDelete, + Requirement::RetentionRetry, + Requirement::RetentionTrustedClockGate, + ], + actions: &[Action::GateExpiryUntilClockTrusted, Action::DeleteExpiredObject, Action::RetryExpiredDelete], + }, + Scenario { + name: "dfu.scan-outcomes", + requirements: &[Requirement::DfuScanSuccess, Requirement::DfuScanFailure], + actions: &[Action::ScanDfuSuccess, Action::Settle, Action::Settle, Action::ScanDfuFailure], + }, + Scenario { + name: "dfu.install-start", + requirements: &[Requirement::DfuInstallStart], + actions: &[Action::StartDfuInstall, Action::Settle, Action::Settle, Action::AdmitDfuInstall], + }, + Scenario { + name: "dfu.install-refusal", + requirements: &[Requirement::DfuInstallRefusal], + actions: &[Action::StartDfuInstall, Action::Settle, Action::Settle, Action::RefuseDfuInstall], + }, + Scenario { + name: "dfu.boot-outcomes", + requirements: &[Requirement::DfuConfirmedUpdate, Requirement::DfuFailedUpdate], + actions: &[Action::ConfirmUpdate, Action::FailUpdate], + }, + Scenario { + name: "platform.bond-and-card-space", + requirements: &[Requirement::PlatformForgetBond, Requirement::PlatformCardSpaceScan], + actions: &[Action::ForgetBond, Action::ScanCardSpace], + }, + Scenario { + name: "derived-data.repeats-until-matching-fill", + requirements: &[ + Requirement::DerivedRideTrackRepeatedUntilFill, + Requirement::DerivedNavPreviewRepeatedUntilFill, + ], + actions: &[ + Action::NeedRideTrack, + Action::RemapRideIdentity, + Action::ReplaceRideTrackNeed, + Action::FillRideTrack, + Action::NeedNavPreview, + Action::ReplaceNavPreviewNeed, + Action::NeedNavPreview, + Action::FillNavPreview, + ], + }, + Scenario { + name: "weather.refresh-install-stale-alert", + requirements: &[ + Requirement::WeatherRefreshState, + Requirement::WeatherInstalledDataChange, + Requirement::WeatherStaleData, + Requirement::WeatherAlertDelivery, + ], + actions: &[ + Action::RefreshWeather, + Action::InstallWeatherData, + Action::MarkWeatherStale, + Action::DeliverWeatherAlert, + ], + }, +]; + +/// The animation clock a delivered settings failure moves the app to, so the bounded retry window +/// has elapsed by the time the retry action runs. Used by [`LegacyHarness::deliver`] and by any +/// runner that owns a pass clock of its own. +pub const SETTINGS_FAILURE_RETRY_MS: u32 = 6_003; + +/// The `InputClock` an action advances the app's animation clock to, or `0` for one that does not. +/// +/// The legacy harness has no clock of its own — a handful of actions drive the app's directly, to +/// step past a bounded retry window. A runner that *does* own a pass clock keeps it at or above +/// these marks, so the window it just stepped past cannot reopen behind it. +pub fn clock_watermark(action: Action) -> u32 { + match action { + Action::RetrySettingsPersist => 4_002, + Action::RetryExpiredDelete => 5_002, + _ => 0, + } +} + +pub fn action_name(action: Action) -> &'static str { + match action { + Action::Settle => "settle", + Action::StoreChanged => "store-changed", + Action::RefreshCatalogs => "refresh-catalogs", + Action::UploadRoutesThenTrip => "upload-routes-then-trip", + Action::RemapCatalogIdentity => "remap-catalog-identity", + Action::DeleteRoute => "delete-route", + Action::DeleteRide => "delete-ride", + Action::CascadeDeleteTrip => "cascade-delete-trip", + Action::StartRoutePlan => "start-route-plan", + Action::CancelRoutePlan => "cancel-route-plan", + Action::DeliverLateRouteResult => "deliver-old-route-result", + Action::ReplaceRoutePlan => "start-replacement-route-plan", + Action::PlanDetour => "plan-detour", + Action::CancelDetour => "cancel-detour", + Action::CommitDetour => "commit-detour", + Action::RouteNoPath => "route-no-path", + Action::StartRecorder => "start-recorder", + Action::SaveRecorder => "save-recorder", + Action::DiscardRecorder => "discard-recorder", + Action::FailRecorderFinalize => "fail-recorder-finalize", + Action::ReplaceRecorderSession => "replace-recorder-session", + Action::DirtySettings => "dirty-settings", + Action::PersistSettings => "persist-settings-pass", + Action::DeliverStaleSettingsResult => "deliver-stale-settings-result", + Action::DeliverMatchingSettingsResult => "deliver-matching-settings-result", + Action::FailSettingsPersist => "fail-settings-persist", + Action::RetrySettingsPersist => "retry-settings-persist", + Action::StampRouteUse => "stamp-route-use", + Action::StampRideSync => "stamp-ride-sync", + Action::DeleteExpiredObject => "delete-expired-object", + Action::RetryExpiredDelete => "retry-expired-delete", + Action::GateExpiryUntilClockTrusted => "gate-expiry-until-clock-trusted", + Action::ScanDfuSuccess => "scan-dfu-success", + Action::ScanDfuFailure => "scan-dfu-failure", + Action::StartDfuInstall => "start-dfu-install", + Action::AdmitDfuInstall => "admit-dfu-install", + Action::RefuseDfuInstall => "refuse-dfu-install", + Action::ConfirmUpdate => "confirm-update", + Action::FailUpdate => "fail-update", + Action::ForgetBond => "forget-bond", + Action::ScanCardSpace => "scan-card-space", + Action::NeedRideTrack => "need-ride-track", + Action::RemapRideIdentity => "remap-ride-identity", + Action::ReplaceRideTrackNeed => "replace-ride-track-need", + Action::FillRideTrack => "fill-ride-track", + Action::NeedNavPreview => "need-nav-preview", + Action::ReplaceNavPreviewNeed => "replace-nav-preview-need", + Action::FillNavPreview => "fill-nav-preview", + Action::RefreshWeather => "refresh-weather", + Action::InstallWeatherData => "install-weather-data", + Action::MarkWeatherStale => "mark-weather-stale", + Action::DeliverWeatherAlert => "deliver-weather-alert", + } +} + +pub fn definition(scenario: &Scenario) -> TraceScenario { + TraceScenario { + name: scenario.name, + steps: scenario + .actions + .iter() + .copied() + .map(|action| ScenarioStep::new(TraceInput::Named(action_name(action)), action)) + .collect(), + } +} + +pub fn normalization_seed() -> NormalizationSeed { + NormalizationSeed { + objects: vec![ + (ObjectKind::Route, 10, ObjectKey(0)), + (ObjectKind::Route, 20, ObjectKey(1)), + (ObjectKind::Route, 30, ObjectKey(2)), + (ObjectKind::Ride, 70, ObjectKey(3)), + (ObjectKind::Ride, 90, ObjectKey(4)), + (ObjectKind::Trip, 50, ObjectKey(5)), + ], + revisions: vec![(1, RevisionKey(0)), (2, RevisionKey(1))], + times: vec![(1_720_000_000, TimeKey(0)), (1_720_000_060, TimeKey(1)), (1_720_000_120, TimeKey(2))], + } +} + +pub fn run_legacy( + scenario: &TraceScenario, + mode: RunnerMode, + harness: &mut LegacyHarness, +) -> Trace { + run_scenario_seeded(scenario, mode, &normalization_seed(), harness) + .unwrap_or_else(|error| panic!("{} failed in {mode:?}: {error:?}", scenario.name)) +} + +pub fn run_matrix(mode: RunnerMode) -> Vec> { + SCENARIOS + .iter() + .map(|scenario| { + let mut harness = LegacyHarness::new(); + run_legacy(&definition(scenario), mode, &mut harness) + }) + .collect() +} + +pub fn step<'a>( + trace: &'a Trace, + name: &'static str, +) -> &'a obc_host_core::trace::TraceStep { + trace + .steps + .iter() + .find(|step| step.input == TraceInput::Named(name)) + .unwrap_or_else(|| panic!("{} has no {name} step", trace.scenario)) +} + +pub fn command_count(trace: &Trace, tag: CommandTag) -> usize { + trace.steps.iter().flat_map(|step| &step.commands).filter(|command| command.tag() == tag).count() +} + +pub fn event_count(trace: &Trace, tag: EventTag) -> usize { + trace.steps.iter().flat_map(|step| &step.events).filter(|event| event.tag() == tag).count() +} + +pub fn feeder_count(trace: &Trace, kind: FeederKind) -> usize { + trace.steps.iter().flat_map(|step| &step.feeder_calls).filter(|call| call.feeder == kind).count() +} + +pub fn output_position( + trace: &Trace, + predicate: impl Fn(&TraceOutput) -> bool, +) -> Option<(usize, usize)> { + trace.steps.iter().enumerate().find_map(|(step_index, step)| { + step.timeline.iter().position(&predicate).map(|output_index| (step_index, output_index)) + }) +} + +pub fn command_precedes_event(trace: &Trace, command: CommandTag, event: EventTag) -> bool { + let command = + output_position(trace, |output| matches!(output, TraceOutput::Command(value) if value.tag() == command)); + let event = output_position(trace, |output| matches!(output, TraceOutput::Event(value) if value.tag() == event)); + matches!((command, event), (Some(command), Some(event)) if command < event) +} + +pub fn output_precedes( + trace: &Trace, + before: impl Fn(&TraceOutput) -> bool, + after: impl Fn(&TraceOutput) -> bool, +) -> bool { + matches!((output_position(trace, before), output_position(trace, after)), (Some(before), Some(after)) if before < after) +} diff --git a/host/obc-host-core/tests/device_core_legacy_traces.rs b/host/obc-host-core/tests/device_core_legacy_traces.rs index 1b716867a..e2392fddc 100644 --- a/host/obc-host-core/tests/device_core_legacy_traces.rs +++ b/host/obc-host-core/tests/device_core_legacy_traces.rs @@ -5,1192 +5,21 @@ //! outcomes, bulk feeder identities, and visible state. DC7 can run the same scenario definitions //! against DeviceCore and compare the normalized traces. +mod device_core_corpus; + use std::collections::BTreeSet; -use obc_app::dfu::{clamp, DfuFailure, DfuInstallError, DfuScanError, DfuScanReport}; -use obc_app::screen::Screen; -use obc_app::{ - App, AppState, DetourPreview, Gesture, HostCommand, HostEvent, Mode, RideRetentionRecord, RideSummary, - RouteSummary, TrackAction, TripInput, WarningFlags, -}; -use obc_formats::io::{ByteSink, SliceSource}; +use obc_app::{DfuFailure, TrackAction, WarningFlags}; use obc_host_core::trace::{ - run_scenario_seeded, CommandTag, DataKey, EventTag, FeederCall, FeederKind, NormalizationSeed, NormalizedCommand, - NormalizedError, NormalizedEvent, ObjectKey, ObjectKind, RevisionKey, RunnerMode, ScenarioStep, TimeKey, Trace, - TraceHarness, TraceInput, TraceOutput, TraceRecorder, TraceScenario, TraceSink, ALL_COMMAND_TAGS, ALL_EVENT_TAGS, + CommandTag, DataKey, EventTag, FeederKind, NormalizedCommand, NormalizedError, NormalizedEvent, ObjectKey, + RevisionKey, RunnerMode, TimeKey, Trace, TraceInput, TraceOutput, ALL_COMMAND_TAGS, ALL_EVENT_TAGS, ALL_FEEDER_KINDS, }; -use obc_host_core::{HostLoop, PlanHold, RideRepository, RouteRepository, TripCatalog}; -use obc_map_scene::BBox; -use obc_ports::{Fix, InputClock, LocationSource, RideClock, Sensors, SettingsSaveError}; -use obc_route::{gpx_to_obcr, NavError, RouteIndex, RouteReader}; - -/// Every behavior row locked by DC1. Keeping the inventory typed makes adding a scenario without -/// the corresponding acceptance row (or silently dropping a row during later refactors) fail. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -enum Requirement { - CatalogStoreChange, - CatalogRefresh, - CatalogRouteDelete, - CatalogRideDelete, - CatalogTripCascade, - CatalogUploadOrder, - CatalogIdentityRemap, - NavigationPlan, - NavigationCancel, - NavigationLateResult, - NavigationReplacement, - NavigationDetourPlan, - NavigationDetourCancel, - NavigationDetourCommit, - NavigationNoPath, - RecorderStart, - RecorderSave, - RecorderDiscard, - RecorderFinalizeFailure, - RecorderSessionReplacement, - SettingsDirtyRevision, - SettingsSuccess, - SettingsStaleResult, - SettingsFailure, - SettingsRetry, - RetentionRouteUseStamp, - RetentionRideSyncStamp, - RetentionExpiryDelete, - RetentionRetry, - RetentionTrustedClockGate, - DfuScanSuccess, - DfuScanFailure, - DfuInstallStart, - DfuInstallRefusal, - DfuConfirmedUpdate, - DfuFailedUpdate, - PlatformForgetBond, - PlatformCardSpaceScan, - DerivedRideTrackRepeatedUntilFill, - DerivedNavPreviewRepeatedUntilFill, - WeatherRefreshState, - WeatherInstalledDataChange, - WeatherStaleData, - WeatherAlertDelivery, -} - -const ALL_REQUIREMENTS: &[Requirement] = &[ - Requirement::CatalogStoreChange, - Requirement::CatalogRefresh, - Requirement::CatalogRouteDelete, - Requirement::CatalogRideDelete, - Requirement::CatalogTripCascade, - Requirement::CatalogUploadOrder, - Requirement::CatalogIdentityRemap, - Requirement::NavigationPlan, - Requirement::NavigationCancel, - Requirement::NavigationLateResult, - Requirement::NavigationReplacement, - Requirement::NavigationDetourPlan, - Requirement::NavigationDetourCancel, - Requirement::NavigationDetourCommit, - Requirement::NavigationNoPath, - Requirement::RecorderStart, - Requirement::RecorderSave, - Requirement::RecorderDiscard, - Requirement::RecorderFinalizeFailure, - Requirement::RecorderSessionReplacement, - Requirement::SettingsDirtyRevision, - Requirement::SettingsSuccess, - Requirement::SettingsStaleResult, - Requirement::SettingsFailure, - Requirement::SettingsRetry, - Requirement::RetentionRouteUseStamp, - Requirement::RetentionRideSyncStamp, - Requirement::RetentionExpiryDelete, - Requirement::RetentionRetry, - Requirement::RetentionTrustedClockGate, - Requirement::DfuScanSuccess, - Requirement::DfuScanFailure, - Requirement::DfuInstallStart, - Requirement::DfuInstallRefusal, - Requirement::DfuConfirmedUpdate, - Requirement::DfuFailedUpdate, - Requirement::PlatformForgetBond, - Requirement::PlatformCardSpaceScan, - Requirement::DerivedRideTrackRepeatedUntilFill, - Requirement::DerivedNavPreviewRepeatedUntilFill, - Requirement::WeatherRefreshState, - Requirement::WeatherInstalledDataChange, - Requirement::WeatherStaleData, - Requirement::WeatherAlertDelivery, -]; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Action { - Settle, - StoreChanged, - RefreshCatalogs, - UploadRoutesThenTrip, - RemapCatalogIdentity, - DeleteRoute, - DeleteRide, - CascadeDeleteTrip, - StartRoutePlan, - CancelRoutePlan, - DeliverLateRouteResult, - ReplaceRoutePlan, - PlanDetour, - CancelDetour, - CommitDetour, - RouteNoPath, - StartRecorder, - SaveRecorder, - DiscardRecorder, - FailRecorderFinalize, - ReplaceRecorderSession, - DirtySettings, - PersistSettings, - DeliverStaleSettingsResult, - DeliverMatchingSettingsResult, - FailSettingsPersist, - RetrySettingsPersist, - StampRouteUse, - StampRideSync, - DeleteExpiredObject, - RetryExpiredDelete, - GateExpiryUntilClockTrusted, - ScanDfuSuccess, - ScanDfuFailure, - StartDfuInstall, - AdmitDfuInstall, - RefuseDfuInstall, - ConfirmUpdate, - FailUpdate, - ForgetBond, - ScanCardSpace, - NeedRideTrack, - RemapRideIdentity, - ReplaceRideTrackNeed, - FillRideTrack, - NeedNavPreview, - ReplaceNavPreviewNeed, - FillNavPreview, - RefreshWeather, - InstallWeatherData, - MarkWeatherStale, - DeliverWeatherAlert, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ScreenState { - Home, - Menu, - Routes, - RouteOverview, - Rides, - RideDetail, - Map, - Detour, - Planning, - DetourPreview, - DfuCheck, - DfuConfirm, - DfuProgress, - DfuInstalling, - DfuError, - Warning, - WeatherAlert, - Other, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct VisibleState { - screen: ScreenState, - stack_depth: usize, - mode: Mode, - route_names: Vec, - route_ids: Vec, - ride_names: Vec, - ride_ids: Vec, - trip_names: Vec, - trip_ids: Vec, - active_route_name: Option, - active_route_id: Option, - requested_ride_id: Option, - recording: bool, - clock_trusted: bool, - rain_steps_ahead: u8, - settings_revision: Option, - settings_utc_offset_min: i16, - pending_host_command: bool, - nav_preview_missing: bool, - warning: Option, - retention_delete_attempts: u16, -} - -#[derive(Debug)] -enum Outcome { - Event(HostEvent), - FinishSave, - FinalizeFailed, - CardScanned, - RideTrack { id: u64 }, - NavPreview { generation: u16 }, - DetourCommitted, -} - -#[derive(Debug, Clone, Copy)] -enum PendingSettingsResult { - PersistLatest, - FailLatest, - PersistRevision(u16), -} - -/// The legacy adapter uses the real `App` protocol doors and `HostLoop`'s passive trace observer. -/// Inputs are real public app operations or UI gestures; each pass runs the real command dispatcher; -/// deliveries call the real `set_*` feeders and `apply_event`. Planner, detour-commit, recorder-finalize, -/// and derived-fill completions are scripted at that protocol boundary because the legacy interfaces -/// do not expose a deterministic completion seam. The fixture-backed `board_parity` suite separately -/// exercises the real planner/repository path; this fast corpus must not be read as a second planner. -struct LegacyHarness { - app: App, - routes: Vec, - route_ids: Vec, - rides: Vec, - ride_ids: Vec, - trip_stage_ids: Vec, - trip_present: bool, - nav_generation: u16, - host: HostLoop, - fail_next_finalize: bool, - commit_success_pending: bool, - pending_nav_plan: Option>, - pending_dfu_scan: Option>, - pending_dfu_install: Option>, - pending_settings_result: Option, - settings_revision: u16, - route_delete_fail_once: bool, - retention_delete_attempts: u16, - settings_retry_requested: bool, -} - -impl LegacyHarness { - fn new() -> Self { - let routes = vec![route("Alpha"), route("Beta"), route("Gamma")]; - let route_ids = vec![10, 20, 30]; - let rides = vec![ride("Morning"), ride("Evening")]; - let ride_ids = vec![70, 90]; - let trip_stage_ids = vec![10, 20]; - let mut app = App::new_idle(AppState::new(8_330_000, 46_570_000, 1.0)); - app.set_routes_with_ids(&routes, &route_ids); - app.set_rides(&rides, &ride_ids); - app.set_trips(&[TripInput { id: 50, name: "Alps", stage_ids: &trip_stage_ids }]); - Self { - app, - routes, - route_ids, - rides, - ride_ids, - trip_stage_ids, - trip_present: true, - nav_generation: 0, - host: HostLoop::new(), - fail_next_finalize: false, - commit_success_pending: false, - pending_nav_plan: None, - pending_dfu_scan: None, - pending_dfu_install: None, - pending_settings_result: None, - settings_revision: 0, - route_delete_fail_once: false, - retention_delete_attempts: 0, - settings_retry_requested: false, - } - } - - fn event(&mut self, event: HostEvent, trace: &mut TraceRecorder) { - trace.record_event(&event); - self.app.apply_event(event); - } - - fn feed_routes(&mut self, key: &'static str, trace: &mut TraceRecorder) { - self.app.set_routes_with_ids(&self.routes, &self.route_ids); - trace.record_feeder(FeederCall::new(FeederKind::RouteCatalog, key, self.routes.len())); - } - - fn feed_rides(&mut self, key: &'static str, trace: &mut TraceRecorder) { - self.app.set_rides(&self.rides, &self.ride_ids); - trace.record_feeder(FeederCall::new(FeederKind::RideCatalog, key, self.rides.len())); - } - - fn feed_trips(&mut self, key: &'static str, trace: &mut TraceRecorder) { - if self.trip_present { - self.app.set_trips(&[TripInput { id: 50, name: "Alps", stage_ids: &self.trip_stage_ids }]); - } else { - self.app.set_trips(&[]); - } - trace.record_feeder(FeederCall::new(FeederKind::TripCatalog, key, usize::from(self.trip_present))); - } - - fn reset_to_riding_map(&mut self) { - self.app = App::new_idle(AppState::new(7_500_000, 43_500_000, 1.0)); - self.app.set_routes_with_ids(&self.routes, &self.route_ids); - self.app.set_rides(&self.rides, &self.ride_ids); - self.app.set_map_nav_graph(true); - self.app.state.user_fix = Some(road_fix(0.0)); - self.app.apply_gesture(Gesture::BackHold); - self.app.apply_gesture(Gesture::Press); - // The first row is the trip folder; enter it, open the first stage, then start the ride. - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Press); - let bytes = road_obcr(); - let source = SliceSource(&bytes); - let index = RouteIndex::read(&source).unwrap(); - let reader = RouteReader::new(&index, &source); - struct OneFix(Option); - impl LocationSource for OneFix { - fn poll(&mut self) -> Option { - self.0.take() - } - } - let mut location = OneFix(Some(road_fix(0.31))); - self.app.tick(RideClock(0), Sensors::new(&mut location), Some(&reader)); - } - - fn tick_without_fix(&mut self) { - struct NoFix; - impl LocationSource for NoFix { - fn poll(&mut self) -> Option { - None - } - } - let mut location = NoFix; - self.app.tick(RideClock(0), Sensors::new(&mut location), None); - } - - fn open_detour_plan(&mut self) { - if !matches!(self.app.top_screen(), Screen::Detour(_)) { - self.reset_to_riding_map(); - self.app.apply_gesture(Gesture::BackHold); - self.app.apply_gesture(Gesture::Step(1)); - self.app.apply_gesture(Gesture::Press); - } - self.app.apply_gesture(Gesture::Press); - } - - fn snapshot_state(&self) -> VisibleState { - let screen = match self.app.top_screen() { - Screen::Home(_) => ScreenState::Home, - Screen::Menu(_) => ScreenState::Menu, - Screen::RouteMenu(_) => ScreenState::Routes, - Screen::RouteOverview(_) => ScreenState::RouteOverview, - Screen::Rides(_) => ScreenState::Rides, - Screen::RideDetail(_) => ScreenState::RideDetail, - Screen::Map(_) => ScreenState::Map, - Screen::Detour(_) => ScreenState::Detour, - Screen::NavPlanning(_) => ScreenState::Planning, - Screen::DetourPreview(_) => ScreenState::DetourPreview, - Screen::DfuCheck(_) => ScreenState::DfuCheck, - Screen::DfuConfirm(_) => ScreenState::DfuConfirm, - Screen::DfuProgress(_) => ScreenState::DfuProgress, - Screen::DfuInstalling(_) => ScreenState::DfuInstalling, - Screen::DfuError(_) => ScreenState::DfuError, - Screen::Warning(_) => ScreenState::Warning, - Screen::WeatherAlert(_) => ScreenState::WeatherAlert, - _ => ScreenState::Other, - }; - VisibleState { - screen, - stack_depth: self.app.debug_stack_len(), - mode: self.app.mode(), - route_names: self.app.routes().iter().map(|item| item.name.as_str().to_owned()).collect(), - route_ids: self.app.route_ids().iter().map(|&id| fixture_object_key(ObjectKind::Route, id)).collect(), - ride_names: self.app.rides().iter().map(|item| item.name.as_str().to_owned()).collect(), - ride_ids: self.app.ride_ids().iter().map(|&id| fixture_object_key(ObjectKind::Ride, id)).collect(), - trip_names: self.app.trips().iter().map(|item| item.name.as_str().to_owned()).collect(), - trip_ids: self.app.trips().iter().map(|trip| fixture_object_key(ObjectKind::Trip, trip.id)).collect(), - active_route_name: self - .app - .active_route_index() - .and_then(|index| self.app.routes().get(index)) - .map(|item| item.name.as_str().to_owned()), - active_route_id: self - .app - .active_route_index() - .and_then(|index| self.app.route_ids().get(index)) - .map(|&id| fixture_object_key(ObjectKind::Route, id)), - requested_ride_id: self.app.ride_track_request().map(|id| fixture_object_key(ObjectKind::Ride, id)), - recording: self.app.activity.is_tracking(), - clock_trusted: self.app.clock_trusted(), - rain_steps_ahead: self.app.state.rain_steps_ahead, - settings_revision: match self.settings_revision { - 0 => None, - 1 => Some(RevisionKey(0)), - 2 => Some(RevisionKey(1)), - other => panic!("unseeded fixture settings revision {other}"), - }, - settings_utc_offset_min: self.app.settings().utc_offset_min, - pending_host_command: self.app.has_pending_host_command(), - nav_preview_missing: self.app.nav_preview_missing(), - warning: match self.app.top_screen() { - Screen::Warning(card) => Some(card.flags()), - _ => None, - }, - retention_delete_attempts: self.retention_delete_attempts, - } - } -} - -impl TraceHarness for LegacyHarness { - type State = VisibleState; - type Outcome = Outcome; - - fn snapshot(&self) -> Self::State { - self.snapshot_state() - } - - fn apply_input(&mut self, action: &Action, trace: &mut TraceRecorder) { - match action { - Action::Settle => {} - Action::StoreChanged => self.event(HostEvent::StoreChanged, trace), - Action::RefreshCatalogs => {} - Action::UploadRoutesThenTrip => { - self.feed_routes("upload.routes", trace); - self.event(HostEvent::RouteUploaded { id: 10, replaced: false, elevation: None }, trace); - self.event(HostEvent::RouteUploaded { id: 20, replaced: false, elevation: None }, trace); - self.feed_trips("upload.trip", trace); - self.event(HostEvent::TripUploaded { id: 50, replaced: false }, trace); - } - Action::RemapCatalogIdentity => { - self.routes.swap(0, 2); - self.route_ids.swap(0, 2); - self.feed_routes("catalog.remap", trace); - self.feed_trips("catalog.remap-trips", trace); - } - Action::DeleteRoute => { - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Press); - // The trip folders are first. Open the first stage, then its first route overview. - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Step(1)); - self.app.apply_gesture(Gesture::Hold); - } - Action::DeleteRide => { - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Step(1)); - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Hold); - } - Action::CascadeDeleteTrip => { - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Hold); - self.app.apply_gesture(Gesture::Step(1)); - self.app.apply_gesture(Gesture::Hold); - } - Action::StartRoutePlan => { - assert!(self.app.debug_start_nav((0, 0), (1_000, 1_000), "First plan")); - } - Action::CancelRoutePlan => self.app.apply_gesture(Gesture::Back), - Action::DeliverLateRouteResult => { - self.feed_routes("nav.old-publication", trace); - self.event(HostEvent::NavPlanned(Ok(10)), trace); - } - Action::ReplaceRoutePlan => { - assert!(self.app.debug_start_nav((0, 0), (2_000, 2_000), "Replacement")); - } - Action::PlanDetour => self.open_detour_plan(), - Action::CancelDetour => self.app.apply_gesture(Gesture::Back), - Action::CommitDetour => { - self.app.set_detour_preview(&[(0, 0), (100, 100)]); - trace.record_feeder(FeederCall::new(FeederKind::DetourPreview, "detour.preview", 2)); - self.event( - HostEvent::DetourPlanned(Ok(DetourPreview { - cost_delta_m: 100, - total_distance_m: 900, - rejoin_m: 1_000, - ascent_m: Some(30), - })), - trace, - ); - self.app.apply_gesture(Gesture::Press); - self.commit_success_pending = true; - } - Action::RouteNoPath => { - assert!(self.app.debug_start_nav((0, 0), (1_000, 1_000), "No path")); - self.pending_nav_plan = Some(Err(NavError::NoPath)); - } - Action::StartRecorder => self.app.activity.start_session(), - Action::SaveRecorder => { - self.app.activity.end_session(); - self.app.activity.request_track(TrackAction::Save); - } - Action::DiscardRecorder => { - self.app.activity.end_session(); - self.app.activity.request_track(TrackAction::Discard); - } - Action::FailRecorderFinalize => { - self.app.activity.request_track(TrackAction::Save); - // Compatibility limitation: the legacy vocabulary has no recorder-finalize outcome; - // hosts report the failure through the generic warning event. - self.fail_next_finalize = true; - } - Action::ReplaceRecorderSession => { - self.app.activity.end_session(); - self.app.activity.start_session(); - } - Action::DirtySettings => { - let settings = *self.app.settings(); - self.app.set_settings(settings); - trace.feeder_revision(FeederKind::Settings, "settings.boot", 0, 1); - self.app.stamp_clock_ble(1_720_000_000, 60); - self.settings_revision = 1; - } - Action::PersistSettings => {} - Action::DeliverStaleSettingsResult => { - self.app.stamp_clock_ble(1_720_000_060, 120); - self.settings_revision = 2; - self.pending_settings_result = Some(PendingSettingsResult::PersistRevision(1)); - } - Action::DeliverMatchingSettingsResult => { - self.pending_settings_result = Some(PendingSettingsResult::PersistRevision(self.settings_revision)); - } - Action::FailSettingsPersist => self.pending_settings_result = Some(PendingSettingsResult::FailLatest), - Action::RetrySettingsPersist => { - // The failed revision is retained through the real bounded retry window. - self.settings_retry_requested = true; - self.app.advance_animations(InputClock(4_002)); - self.pending_settings_result = Some(PendingSettingsResult::PersistLatest); - } - Action::StampRouteUse => { - self.app.stamp_clock_ble(1_720_000_000, 60); - self.event(HostEvent::RouteUploaded { id: 10, replaced: false, elevation: None }, trace); - } - Action::StampRideSync => { - self.app.stamp_clock_ble(1_720_000_000, 60); - let mut stamped = self.rides[0].clone(); - stamped.synced = true; - stamped.synced_at_utc = 0; - self.rides[0] = stamped; - self.feed_rides("retention.synced", trace); - self.app.set_ride_retention_inventory(&[RideRetentionRecord { - id: self.ride_ids[0], - synced: true, - synced_at_utc: 0, - }]); - trace.record_feeder(FeederCall::new(FeederKind::RideRetention, "retention.rides", 1)); - self.app.force_retention_sweep(); - self.tick_without_fix(); - } - Action::DeleteExpiredObject => { - self.app.stamp_clock_ble(1_720_000_000, 60); - self.app.set_route_meta(&[ - obc_app::RouteRetentionMeta::new(obc_app::Retention::Day1, 1), - obc_app::RouteRetentionMeta::new(obc_app::Retention::Never, 0), - obc_app::RouteRetentionMeta::new(obc_app::Retention::Never, 0), - ]); - trace.record_feeder(FeederCall::new(FeederKind::RouteRetention, "retention.routes", 3)); - self.route_delete_fail_once = true; - self.app.force_retention_sweep(); - self.tick_without_fix(); - } - Action::RetryExpiredDelete => { - // The original failed candidate owns its retry; no new discovery sweep is needed. - self.app.advance_animations(InputClock(5_002)); - self.tick_without_fix(); - } - Action::GateExpiryUntilClockTrusted => { - assert!(!self.app.clock_trusted()); - self.app.force_retention_sweep(); - self.tick_without_fix(); - } - Action::ScanDfuSuccess => { - assert!(self.app.open_remote_dfu_check()); - self.pending_dfu_scan = Some(Ok(DfuScanReport::new("v1", "v2", false))); - } - Action::ScanDfuFailure => { - // Finish the preceding flow, then open a fresh real scan wait. - self.app.apply_gesture(Gesture::Back); - assert!(self.app.open_remote_dfu_check()); - self.pending_dfu_scan = Some(Err(DfuScanError::Damaged)); - } - Action::StartDfuInstall => { - assert!(self.app.open_remote_dfu_check()); - self.pending_dfu_scan = Some(Ok(DfuScanReport::new("v1", "v2", false))); - } - Action::AdmitDfuInstall => { - self.app.apply_gesture(Gesture::Press); - self.pending_dfu_install = Some(Ok(())); - } - Action::RefuseDfuInstall => { - self.app.apply_gesture(Gesture::Press); - self.pending_dfu_install = Some(Err(DfuInstallError::Recording)); - } - Action::ConfirmUpdate => self.event(HostEvent::UpdateConfirmed(clamp("v2")), trace), - Action::FailUpdate => { - self.event(HostEvent::UpdateFailed { why: DfuFailure::Reverted, staged: Some(clamp("v3")) }, trace) - } - Action::ForgetBond => self.app.state.ble_forget_pending = true, - Action::ScanCardSpace => { - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Step(-1)); - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Step(-1)); - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Step(3)); - self.app.apply_gesture(Gesture::Press); - } - Action::NeedRideTrack => { - if !matches!(self.app.top_screen(), Screen::RideDetail(_)) { - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Step(1)); - self.app.apply_gesture(Gesture::Press); - self.app.apply_gesture(Gesture::Press); - } - } - Action::RemapRideIdentity => { - self.rides.swap(0, 1); - self.ride_ids.swap(0, 1); - self.feed_rides("ride.remap", trace); - } - Action::ReplaceRideTrackNeed => { - self.app.apply_gesture(Gesture::Back); - self.app.apply_gesture(Gesture::Step(1)); - self.app.apply_gesture(Gesture::Press); - } - Action::FillRideTrack => {} - Action::NeedNavPreview => { - if !self.app.nav_preview_missing() { - assert!(self.app.debug_start_nav((0, 0), (1_000, 1_000), "Preview")); - self.feed_routes("nav.preview-route", trace); - self.event(HostEvent::NavPlanned(Ok(10)), trace); - self.nav_generation = self.nav_generation.wrapping_add(1); - } - } - Action::ReplaceNavPreviewNeed => { - self.app.apply_gesture(Gesture::Back); - assert!(self.app.debug_start_nav((0, 0), (2_000, 2_000), "New preview")); - self.feed_routes("nav.preview-replacement", trace); - self.event(HostEvent::NavPlanned(Ok(10)), trace); - self.nav_generation = self.nav_generation.wrapping_add(1); - } - Action::FillNavPreview => {} - Action::RefreshWeather => { - self.app.set_rain_view(3, 0.5); - trace.record_feeder(FeederCall::new(FeederKind::RainView, "weather.refresh", 3)); - } - Action::InstallWeatherData => { - self.app.weather_feed_changed(); - trace.record_feeder(FeederCall::new(FeederKind::WeatherSnapshot, "weather.installed", 1)); - trace.record_feeder(FeederCall::new(FeederKind::WeatherFeedChanged, "weather.installed", 1)); - } - Action::MarkWeatherStale => { - self.app.set_rain_view(0, 0.0); - trace.record_feeder(FeederCall::new(FeederKind::RainView, "weather.stale", 0)); - } - Action::DeliverWeatherAlert => { - assert!(self.app.show_weather_alert(obc_app::WeatherAlertKind::Storm, 12)); - } - } - } - - fn run_pass(&mut self, trace: &mut TraceRecorder) -> Vec { - let ride_track_need = self.app.ride_track_request(); - let mut route_repo = BorrowedRoutes { - catalog: &mut self.routes, - ids: &mut self.route_ids, - fail_delete_once: &mut self.route_delete_fail_once, - delete_attempts: &mut self.retention_delete_attempts, - }; - let mut ride_repo = BorrowedRides { catalog: &mut self.rides, ids: &mut self.ride_ids }; - let mut trip_repo = BorrowedTrips { present: &mut self.trip_present, stage_ids: &self.trip_stage_ids }; - let mut platform = Vec::new(); - let finish = self.host.reconcile_commands_traced( - &mut self.app, - &mut route_repo, - &mut ride_repo, - &mut trip_repo, - PlanHold::new(true, true), - true, - true, - |_app, command| platform.push(command), - trace, - ); - let mut outcomes = Vec::new(); - if let Some(result) = self.pending_nav_plan.take() { - outcomes.push(Outcome::Event(HostEvent::NavPlanned(result))); - } - if matches!(finish, Some(TrackAction::Save)) { - if std::mem::take(&mut self.fail_next_finalize) { - outcomes.push(Outcome::FinalizeFailed); - } else { - outcomes.push(Outcome::FinishSave); - } - } - if platform.iter().any(|command| matches!(command, HostCommand::ScanCardFree)) { - outcomes.push(Outcome::CardScanned); - } - let persisted_revision = platform.iter().find_map(|command| match command { - HostCommand::PersistSettings { revision } => Some(*revision), - _ => None, - }); - let ready_settings_result = !matches!(self.pending_settings_result, Some(PendingSettingsResult::PersistLatest)) - || persisted_revision.is_some(); - if ready_settings_result { - if let Some(result) = self.pending_settings_result.take() { - let revision = match result { - PendingSettingsResult::PersistRevision(revision) => revision, - PendingSettingsResult::PersistLatest | PendingSettingsResult::FailLatest => { - persisted_revision.unwrap_or(self.settings_revision) - } - }; - outcomes.push(Outcome::Event(match result { - PendingSettingsResult::FailLatest => { - HostEvent::SettingsPersistFailed { revision, error: SettingsSaveError::Backend } - } - PendingSettingsResult::PersistLatest | PendingSettingsResult::PersistRevision(_) => { - HostEvent::SettingsPersisted { revision } - } - })); - } - } - for command in platform { - match command { - HostCommand::Dfu(obc_app::DfuAction::Scan) => { - if let Some(result) = self.pending_dfu_scan.take() { - outcomes.push(Outcome::Event(HostEvent::DfuScanned(result))); - } - } - HostCommand::Dfu(obc_app::DfuAction::Install) => { - if let Some(result) = self.pending_dfu_install.take() { - outcomes.push(Outcome::Event(match result { - Ok(()) => HostEvent::DfuInstallBegan, - Err(error) => HostEvent::DfuInstallFailed(error), - })); - } - } - _ => {} - } - } - if let Some(id) = ride_track_need { - outcomes.push(Outcome::RideTrack { id }); - } - if self.app.nav_preview_missing() { - outcomes.push(Outcome::NavPreview { generation: self.nav_generation }); - } - if std::mem::take(&mut self.commit_success_pending) { - outcomes.push(Outcome::DetourCommitted); - } - outcomes - } - fn deliver(&mut self, outcome: Self::Outcome, trace: &mut TraceRecorder) { - match outcome { - Outcome::Event(event) => { - let settings_failed = matches!(event, HostEvent::SettingsPersistFailed { .. }); - self.event(event, trace); - if settings_failed && self.settings_retry_requested { - self.app.advance_animations(InputClock(6_003)); - } - } - Outcome::FinishSave => self.feed_rides("recorder.saved", trace), - Outcome::FinalizeFailed => self.event(HostEvent::Warning(WarningFlags::REC_ERROR), trace), - Outcome::CardScanned => self.event(HostEvent::CardScanned { free_bytes: Some(8 * 1024 * 1024) }, trace), - Outcome::RideTrack { id } => { - let current = self.app.ride_track_request(); - let data = ride_delivery_key(id, current); - // Legacy bulk feeders are not request-keyed. Record and apply even stale attempts; - // accepting this fill for a replacement view is a characterized compatibility defect. - self.app.set_ride_profile(None); - self.app.set_ride_preview(&[(0, 0), (1, 1)]); - trace.record_feeder(FeederCall::new(FeederKind::RideProfile, data, 0)); - trace.record_feeder(FeederCall::new(FeederKind::RidePreview, data, 2)); - } - Outcome::NavPreview { generation } => { - let data = nav_delivery_key(generation, self.nav_generation); - // The legacy preview feeder has no generation argument, so delayed old data is - // attempted against the current request and may satisfy it incorrectly. - self.app.set_nav_preview(&[(0, 0), (1, 1)]); - trace.record_feeder(FeederCall::new(FeederKind::NavPreview, data, 2)); - } - Outcome::DetourCommitted => { - self.feed_routes("detour.commit", trace); - self.event(HostEvent::DetourCommitted(Ok(10)), trace); - } - } - } -} - -struct BorrowedRoutes<'a> { - catalog: &'a mut Vec, - ids: &'a mut Vec, - fail_delete_once: &'a mut bool, - delete_attempts: &'a mut u16, -} - -impl RouteRepository for BorrowedRoutes<'_> { - fn catalog(&self) -> &[RouteSummary] { - self.catalog - } - - fn ids(&self) -> &[u64] { - self.ids - } - - fn delete_by_id(&mut self, id: u64) -> bool { - *self.delete_attempts = self.delete_attempts.saturating_add(1); - if std::mem::take(self.fail_delete_once) { - return false; - } - let Some(index) = self.ids.iter().position(|candidate| *candidate == id) else { return false }; - self.ids.remove(index); - self.catalog.remove(index); - true - } - - fn write_nav_route(&mut self, _bytes: &[u8]) -> Option { - None - } - - fn sync_active(&mut self, _want: Option) -> bool { - false - } - - fn active_source(&self) -> Option> { - None - } - - fn invalidate_active(&mut self) {} -} - -struct BorrowedRides<'a> { - catalog: &'a mut Vec, - ids: &'a mut Vec, -} - -impl RideRepository for BorrowedRides<'_> { - fn catalog(&self) -> &[RideSummary] { - self.catalog - } - - fn ids(&self) -> &[u64] { - self.ids - } - - fn delete_by_id(&mut self, id: u64) -> bool { - let Some(index) = self.ids.iter().position(|candidate| *candidate == id) else { return false }; - self.ids.remove(index); - self.catalog.remove(index); - true - } - - fn profile_by_id(&self, _id: u64) -> Option { - None - } - - fn preview_by_id(&self, _id: u64) -> Vec<(i32, i32)> { - vec![(0, 0), (1, 1)] - } -} - -struct BorrowedTrips<'a> { - present: &'a mut bool, - stage_ids: &'a [u64], -} - -impl TripCatalog for BorrowedTrips<'_> { - fn member_route_ids(&self, id: u64) -> Vec { - if *self.present && id == 50 { - self.stage_ids.to_vec() - } else { - Vec::new() - } - } - - fn delete_by_id(&mut self, id: u64) -> bool { - if *self.present && id == 50 { - *self.present = false; - true - } else { - false - } - } - - fn refeed(&self, app: &mut App) { - if *self.present { - app.set_trips(&[TripInput { id: 50, name: "Alps", stage_ids: self.stage_ids }]); - } else { - app.set_trips(&[]); - } - } -} - -fn ride_delivery_key(requested: u64, current: Option) -> &'static str { - match (requested, current) { - (70, Some(70)) => "ride.track.requested-morning.current-morning", - (70, Some(90)) => "ride.track.requested-morning.current-evening", - (90, Some(70)) => "ride.track.requested-evening.current-morning", - (90, Some(90)) => "ride.track.requested-evening.current-evening", - (70, None) => "ride.track.requested-morning.current-none", - (90, None) => "ride.track.requested-evening.current-none", - (_, Some(70)) => "ride.track.requested-other.current-morning", - (_, Some(90)) => "ride.track.requested-other.current-evening", - (_, Some(_)) => "ride.track.requested-other.current-other", - (_, None) => "ride.track.requested-other.current-none", - } -} - -fn nav_delivery_key(requested: u16, current: u16) -> &'static str { - match (requested, current) { - (0, 0) => "nav.preview.requested-g0.current-g0", - (0, 1) => "nav.preview.requested-g0.current-g1", - (0, 2) => "nav.preview.requested-g0.current-g2", - (1, 1) => "nav.preview.requested-g1.current-g1", - (1, 2) => "nav.preview.requested-g1.current-g2", - (2, 2) => "nav.preview.requested-g2.current-g2", - _ if requested == current => "nav.preview.requested-other.current-matching", - _ => "nav.preview.requested-other.current-replacement", - } -} - -fn fixture_object_key(kind: ObjectKind, id: u64) -> ObjectKey { - match (kind, id) { - (ObjectKind::Route, 10) => ObjectKey(0), - (ObjectKind::Route, 20) => ObjectKey(1), - (ObjectKind::Route, 30) => ObjectKey(2), - (ObjectKind::Ride, 70) => ObjectKey(3), - (ObjectKind::Ride, 90) => ObjectKey(4), - (ObjectKind::Trip, 50) => ObjectKey(5), - _ => panic!("unseeded fixture identity {kind:?}:{id}"), - } -} - -fn route(name: &str) -> RouteSummary { - let mut summary = RouteSummary { - name: Default::default(), - distance_km: 10, - climb_m: 100, - bbox: BBox { min_lon: 0, min_lat: 0, max_lon: 1_000, max_lat: 1_000 }, - start_lon: 0, - start_lat: 0, - }; - summary.name.push_str(name).unwrap(); - summary -} - -fn ride(name: &str) -> RideSummary { - let mut summary = RideSummary { - name: Default::default(), - start_time: 1_720_000_000, - distance_m: 1_000, - moving_time_s: 600, - climb_m: 10, - synced: false, - synced_at_utc: 0, - }; - summary.name.push_str(name).unwrap(); - summary -} - -#[derive(Default)] -struct TestSink(Vec); - -impl ByteSink for TestSink { - fn write(&mut self, bytes: &[u8]) -> Result<(), obc_formats::io::Error> { - self.0.extend_from_slice(bytes); - Ok(()) - } - - fn patch_at(&mut self, offset: u32, bytes: &[u8]) -> Result<(), obc_formats::io::Error> { - let start = offset as usize; - self.0[start..start + bytes.len()].copy_from_slice(bytes); - Ok(()) - } -} - -fn road_obcr() -> Vec { - let mut gpx = String::from(""); - for index in 0..=10 { - let lon = 7.50 + 0.04 * index as f64 / 10.0; - gpx.push_str(&format!("100")); - } - gpx.push_str(""); - let mut sink = TestSink::default(); - gpx_to_obcr(&SliceSource(gpx.as_bytes()), "Trace road", &mut sink).unwrap(); - sink.0 -} - -fn road_fix(fraction: f64) -> Fix { - Fix::at(43_500_000, ((7.50 + 0.04 * fraction) * 1e6) as i32) -} - -struct Scenario { - name: &'static str, - requirements: &'static [Requirement], - actions: &'static [Action], -} - -const SCENARIOS: &[Scenario] = &[ - Scenario { - name: "catalog.refresh-upload-remap", - requirements: &[ - Requirement::CatalogStoreChange, - Requirement::CatalogRefresh, - Requirement::CatalogUploadOrder, - Requirement::CatalogIdentityRemap, - ], - actions: &[ - Action::StoreChanged, - Action::RefreshCatalogs, - Action::UploadRoutesThenTrip, - Action::RemapCatalogIdentity, - ], - }, - Scenario { - name: "catalog.route-delete", - requirements: &[Requirement::CatalogRouteDelete], - actions: &[Action::DeleteRoute], - }, - Scenario { - name: "catalog.ride-delete", - requirements: &[Requirement::CatalogRideDelete], - actions: &[Action::DeleteRide], - }, - Scenario { - name: "catalog.trip-cascade", - requirements: &[Requirement::CatalogTripCascade], - actions: &[Action::CascadeDeleteTrip], - }, - Scenario { - name: "navigation.plan-cancel-late-replacement", - requirements: &[ - Requirement::NavigationPlan, - Requirement::NavigationCancel, - Requirement::NavigationLateResult, - Requirement::NavigationReplacement, - ], - actions: &[ - Action::StartRoutePlan, - Action::CancelRoutePlan, - Action::ReplaceRoutePlan, - Action::DeliverLateRouteResult, - ], - }, - Scenario { - name: "navigation.detour-lifecycle", - requirements: &[ - Requirement::NavigationDetourPlan, - Requirement::NavigationDetourCancel, - Requirement::NavigationDetourCommit, - ], - actions: &[Action::PlanDetour, Action::CancelDetour, Action::PlanDetour, Action::CommitDetour], - }, - Scenario { - name: "navigation.no-path", - requirements: &[Requirement::NavigationNoPath], - actions: &[Action::RouteNoPath], - }, - Scenario { - name: "recorder.start-save-discard", - requirements: &[Requirement::RecorderStart, Requirement::RecorderSave, Requirement::RecorderDiscard], - actions: &[Action::StartRecorder, Action::SaveRecorder, Action::StartRecorder, Action::DiscardRecorder], - }, - Scenario { - name: "recorder.failure-and-session-replacement", - requirements: &[Requirement::RecorderFinalizeFailure, Requirement::RecorderSessionReplacement], - actions: &[Action::StartRecorder, Action::FailRecorderFinalize, Action::ReplaceRecorderSession], - }, - Scenario { - name: "settings.revision-success-and-stale-result", - requirements: &[ - Requirement::SettingsDirtyRevision, - Requirement::SettingsSuccess, - Requirement::SettingsStaleResult, - ], - actions: &[ - Action::DirtySettings, - Action::PersistSettings, - Action::DeliverStaleSettingsResult, - Action::DeliverMatchingSettingsResult, - ], - }, - Scenario { - name: "settings.failure-and-retry", - requirements: &[Requirement::SettingsFailure, Requirement::SettingsRetry], - actions: &[Action::DirtySettings, Action::FailSettingsPersist, Action::RetrySettingsPersist], - }, - Scenario { - name: "retention.route-and-ride-stamps", - requirements: &[Requirement::RetentionRouteUseStamp, Requirement::RetentionRideSyncStamp], - actions: &[Action::StampRouteUse, Action::StampRideSync], - }, - Scenario { - name: "retention.expiry-retry-and-trusted-clock", - requirements: &[ - Requirement::RetentionExpiryDelete, - Requirement::RetentionRetry, - Requirement::RetentionTrustedClockGate, - ], - actions: &[Action::GateExpiryUntilClockTrusted, Action::DeleteExpiredObject, Action::RetryExpiredDelete], - }, - Scenario { - name: "dfu.scan-outcomes", - requirements: &[Requirement::DfuScanSuccess, Requirement::DfuScanFailure], - actions: &[Action::ScanDfuSuccess, Action::Settle, Action::Settle, Action::ScanDfuFailure], - }, - Scenario { - name: "dfu.install-start", - requirements: &[Requirement::DfuInstallStart], - actions: &[Action::StartDfuInstall, Action::Settle, Action::Settle, Action::AdmitDfuInstall], - }, - Scenario { - name: "dfu.install-refusal", - requirements: &[Requirement::DfuInstallRefusal], - actions: &[Action::StartDfuInstall, Action::Settle, Action::Settle, Action::RefuseDfuInstall], - }, - Scenario { - name: "dfu.boot-outcomes", - requirements: &[Requirement::DfuConfirmedUpdate, Requirement::DfuFailedUpdate], - actions: &[Action::ConfirmUpdate, Action::FailUpdate], - }, - Scenario { - name: "platform.bond-and-card-space", - requirements: &[Requirement::PlatformForgetBond, Requirement::PlatformCardSpaceScan], - actions: &[Action::ForgetBond, Action::ScanCardSpace], - }, - Scenario { - name: "derived-data.repeats-until-matching-fill", - requirements: &[ - Requirement::DerivedRideTrackRepeatedUntilFill, - Requirement::DerivedNavPreviewRepeatedUntilFill, - ], - actions: &[ - Action::NeedRideTrack, - Action::RemapRideIdentity, - Action::ReplaceRideTrackNeed, - Action::FillRideTrack, - Action::NeedNavPreview, - Action::ReplaceNavPreviewNeed, - Action::NeedNavPreview, - Action::FillNavPreview, - ], - }, - Scenario { - name: "weather.refresh-install-stale-alert", - requirements: &[ - Requirement::WeatherRefreshState, - Requirement::WeatherInstalledDataChange, - Requirement::WeatherStaleData, - Requirement::WeatherAlertDelivery, - ], - actions: &[ - Action::RefreshWeather, - Action::InstallWeatherData, - Action::MarkWeatherStale, - Action::DeliverWeatherAlert, - ], - }, -]; +use device_core_corpus::{ + command_count, command_precedes_event, definition, event_count, feeder_count, output_precedes, run_legacy, + run_matrix, step, LegacyHarness, Requirement, ScreenState, VisibleState, ALL_REQUIREMENTS, SCENARIOS, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct BoardDifference { @@ -1247,146 +76,6 @@ fn scenario_matrix_covers_every_dc1_behavior_row_once_or_more() { assert!(COMPATIBILITY_LIMITATIONS.iter().all(|difference| !difference.target_owner.is_empty())); } -fn action_name(action: Action) -> &'static str { - match action { - Action::Settle => "settle", - Action::StoreChanged => "store-changed", - Action::RefreshCatalogs => "refresh-catalogs", - Action::UploadRoutesThenTrip => "upload-routes-then-trip", - Action::RemapCatalogIdentity => "remap-catalog-identity", - Action::DeleteRoute => "delete-route", - Action::DeleteRide => "delete-ride", - Action::CascadeDeleteTrip => "cascade-delete-trip", - Action::StartRoutePlan => "start-route-plan", - Action::CancelRoutePlan => "cancel-route-plan", - Action::DeliverLateRouteResult => "deliver-old-route-result", - Action::ReplaceRoutePlan => "start-replacement-route-plan", - Action::PlanDetour => "plan-detour", - Action::CancelDetour => "cancel-detour", - Action::CommitDetour => "commit-detour", - Action::RouteNoPath => "route-no-path", - Action::StartRecorder => "start-recorder", - Action::SaveRecorder => "save-recorder", - Action::DiscardRecorder => "discard-recorder", - Action::FailRecorderFinalize => "fail-recorder-finalize", - Action::ReplaceRecorderSession => "replace-recorder-session", - Action::DirtySettings => "dirty-settings", - Action::PersistSettings => "persist-settings-pass", - Action::DeliverStaleSettingsResult => "deliver-stale-settings-result", - Action::DeliverMatchingSettingsResult => "deliver-matching-settings-result", - Action::FailSettingsPersist => "fail-settings-persist", - Action::RetrySettingsPersist => "retry-settings-persist", - Action::StampRouteUse => "stamp-route-use", - Action::StampRideSync => "stamp-ride-sync", - Action::DeleteExpiredObject => "delete-expired-object", - Action::RetryExpiredDelete => "retry-expired-delete", - Action::GateExpiryUntilClockTrusted => "gate-expiry-until-clock-trusted", - Action::ScanDfuSuccess => "scan-dfu-success", - Action::ScanDfuFailure => "scan-dfu-failure", - Action::StartDfuInstall => "start-dfu-install", - Action::AdmitDfuInstall => "admit-dfu-install", - Action::RefuseDfuInstall => "refuse-dfu-install", - Action::ConfirmUpdate => "confirm-update", - Action::FailUpdate => "fail-update", - Action::ForgetBond => "forget-bond", - Action::ScanCardSpace => "scan-card-space", - Action::NeedRideTrack => "need-ride-track", - Action::RemapRideIdentity => "remap-ride-identity", - Action::ReplaceRideTrackNeed => "replace-ride-track-need", - Action::FillRideTrack => "fill-ride-track", - Action::NeedNavPreview => "need-nav-preview", - Action::ReplaceNavPreviewNeed => "replace-nav-preview-need", - Action::FillNavPreview => "fill-nav-preview", - Action::RefreshWeather => "refresh-weather", - Action::InstallWeatherData => "install-weather-data", - Action::MarkWeatherStale => "mark-weather-stale", - Action::DeliverWeatherAlert => "deliver-weather-alert", - } -} - -fn definition(scenario: &Scenario) -> TraceScenario { - TraceScenario { - name: scenario.name, - steps: scenario - .actions - .iter() - .copied() - .map(|action| ScenarioStep::new(TraceInput::Named(action_name(action)), action)) - .collect(), - } -} - -fn normalization_seed() -> NormalizationSeed { - NormalizationSeed { - objects: vec![ - (ObjectKind::Route, 10, ObjectKey(0)), - (ObjectKind::Route, 20, ObjectKey(1)), - (ObjectKind::Route, 30, ObjectKey(2)), - (ObjectKind::Ride, 70, ObjectKey(3)), - (ObjectKind::Ride, 90, ObjectKey(4)), - (ObjectKind::Trip, 50, ObjectKey(5)), - ], - revisions: vec![(1, RevisionKey(0)), (2, RevisionKey(1))], - times: vec![(1_720_000_000, TimeKey(0)), (1_720_000_060, TimeKey(1)), (1_720_000_120, TimeKey(2))], - } -} - -fn run_legacy(scenario: &TraceScenario, mode: RunnerMode, harness: &mut LegacyHarness) -> Trace { - run_scenario_seeded(scenario, mode, &normalization_seed(), harness) - .unwrap_or_else(|error| panic!("{} failed in {mode:?}: {error:?}", scenario.name)) -} - -fn run_matrix(mode: RunnerMode) -> Vec> { - SCENARIOS - .iter() - .map(|scenario| { - let mut harness = LegacyHarness::new(); - run_legacy(&definition(scenario), mode, &mut harness) - }) - .collect() -} - -fn step<'a>(trace: &'a Trace, name: &'static str) -> &'a obc_host_core::trace::TraceStep { - trace - .steps - .iter() - .find(|step| step.input == TraceInput::Named(name)) - .unwrap_or_else(|| panic!("{} has no {name} step", trace.scenario)) -} - -fn command_count(trace: &Trace, tag: CommandTag) -> usize { - trace.steps.iter().flat_map(|step| &step.commands).filter(|command| command.tag() == tag).count() -} - -fn event_count(trace: &Trace, tag: EventTag) -> usize { - trace.steps.iter().flat_map(|step| &step.events).filter(|event| event.tag() == tag).count() -} - -fn feeder_count(trace: &Trace, kind: FeederKind) -> usize { - trace.steps.iter().flat_map(|step| &step.feeder_calls).filter(|call| call.feeder == kind).count() -} - -fn output_position(trace: &Trace, predicate: impl Fn(&TraceOutput) -> bool) -> Option<(usize, usize)> { - trace.steps.iter().enumerate().find_map(|(step_index, step)| { - step.timeline.iter().position(&predicate).map(|output_index| (step_index, output_index)) - }) -} - -fn command_precedes_event(trace: &Trace, command: CommandTag, event: EventTag) -> bool { - let command = - output_position(trace, |output| matches!(output, TraceOutput::Command(value) if value.tag() == command)); - let event = output_position(trace, |output| matches!(output, TraceOutput::Event(value) if value.tag() == event)); - matches!((command, event), (Some(command), Some(event)) if command < event) -} - -fn output_precedes( - trace: &Trace, - before: impl Fn(&TraceOutput) -> bool, - after: impl Fn(&TraceOutput) -> bool, -) -> bool { - matches!((output_position(trace, before), output_position(trace, after)), (Some(before), Some(after)) if before < after) -} - fn assert_requirement(requirement: Requirement, trace: &Trace) { let command = |tag| command_count(trace, tag) > 0; let event = |tag| event_count(trace, tag) > 0;