oxide_core 0.4.0

Rust engine primitives for Oxide (store, snapshot streams, error model, optional persistence).
Documentation
#![cfg(feature = "navigation-binding")]

use oxide_core::navigation::{
    DefaultExtra, DefaultReturn, NavCommand, NavRoute, NoExtra, NoReturn, OxideRoute,
    OxideRouteKind, OxideRoutePayload, Route, RouteContext,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::Duration;

#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
enum TestRouteKind {
    Home,
    Charts,
}

impl OxideRouteKind for TestRouteKind {
    fn as_str(&self) -> &'static str {
        match self {
            TestRouteKind::Home => "Home",
            TestRouteKind::Charts => "Charts",
        }
    }
}

#[derive(Clone, Serialize, Deserialize)]
struct TestRoute {
    kind: TestRouteKind,
}

impl Route for TestRoute {
    type Return = NoReturn;
    type Extra = NoExtra;
}

#[derive(Clone, Serialize, Deserialize)]
struct TestRoutePayload {
    kind: TestRouteKind,
}

impl OxideRoutePayload for TestRoutePayload {
    type Kind = TestRouteKind;

    fn kind(&self) -> Self::Kind {
        self.kind
    }
}

impl OxideRoute for TestRoute {
    type Payload = TestRoutePayload;

    fn into_payload(self) -> Self::Payload {
        TestRoutePayload { kind: self.kind }
    }
}

#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, Debug)]
struct TestExtra {
    label: String,
}

impl DefaultExtra for TestExtra {
    fn default_extra() -> Self {
        Self {
            label: "default".to_string(),
        }
    }
}

#[derive(Clone, Serialize, Deserialize)]
struct RouteWithExtras {
    kind: TestRouteKind,
    extras: TestExtra,
}

impl Route for RouteWithExtras {
    type Return = NoReturn;
    type Extra = TestExtra;

    fn extras(&self) -> Option<Self::Extra> {
        Some(self.extras.clone())
    }
}

impl OxideRoute for RouteWithExtras {
    type Payload = TestRoutePayload;

    fn into_payload(self) -> Self::Payload {
        TestRoutePayload { kind: self.kind }
    }
}

#[derive(Clone, Serialize, Deserialize)]
struct WrappedPayload {
    kind: TestRouteKind,
    payload: serde_json::Value,
}

impl OxideRoutePayload for WrappedPayload {
    type Kind = TestRouteKind;

    fn kind(&self) -> Self::Kind {
        self.kind
    }
}

#[derive(Clone, Serialize, Deserialize)]
struct RouteDefaults;

impl Route for RouteDefaults {
    type Return = NoReturn;
    type Extra = NoExtra;
}

#[test]
fn default_return_values_are_provided() {
    assert_eq!(<bool as DefaultReturn>::default_return(), false);
    assert_eq!(<i32 as DefaultReturn>::default_return(), 0);
    assert_eq!(<i64 as DefaultReturn>::default_return(), 0);
    assert_eq!(<u32 as DefaultReturn>::default_return(), 0);
    assert_eq!(<u64 as DefaultReturn>::default_return(), 0);
    assert_eq!(<String as DefaultReturn>::default_return(), String::new());
    let value: Option<u32> = DefaultReturn::default_return();
    assert_eq!(value, None);
}

#[test]
fn default_extra_values_are_provided() {
    let extra = <TestExtra as DefaultExtra>::default_extra();
    assert_eq!(
        extra,
        TestExtra {
            label: "default".to_string()
        }
    );
    let _ = <NoExtra as DefaultExtra>::default_extra();
}

#[test]
fn route_defaults_return_empty_values() {
    assert_eq!(<RouteDefaults as Route>::path(), None);
    let route = RouteDefaults;
    let params: HashMap<&'static str, String> = route.params();
    let query: HashMap<&'static str, String> = route.query();
    assert!(params.is_empty());
    assert!(query.is_empty());
    assert!(route.extras().is_none());
}

#[tokio::test]
async fn push_emits_a_command() {
    let runtime = oxide_core::NavigationRuntime::new();

    let mut rx = runtime.subscribe_commands().unwrap();

    runtime
        .push(TestRoute {
        kind: TestRouteKind::Home,
    })
        .unwrap();

    let cmd = tokio::time::timeout(Duration::from_secs(1), rx.recv())
        .await
        .unwrap()
        .unwrap();
    assert!(matches!(cmd, NavCommand::Push { route, ticket: None } if route.kind == "Home"));
}

#[tokio::test]
async fn push_with_ticket_can_be_resolved() {
    let runtime = oxide_core::NavigationRuntime::new();

    let route = TestRoute {
        kind: TestRouteKind::Charts,
    };

    let mut rx = runtime.subscribe_commands().unwrap();

    let (ticket, result_rx) = runtime.push_with_ticket(route.clone()).await.unwrap();

    let cmd = tokio::time::timeout(Duration::from_secs(1), rx.recv())
        .await
        .unwrap()
        .unwrap();
    match cmd {
        NavCommand::Push {
            route: pushed,
            ticket: Some(t),
        } if t == ticket => {
            assert_eq!(pushed.kind, "Charts");
        }
        other => panic!("unexpected command: {other:?}"),
    }

    let resolved = runtime.emit_result(&ticket, serde_json::json!({"ok": true})).await;
    assert!(resolved);

    let value = result_rx.await.unwrap();
    assert_eq!(value, serde_json::json!({"ok": true}));
}

#[tokio::test]
async fn pop_and_pop_until_emit_commands() {
    let runtime = oxide_core::NavigationRuntime::new();

    let mut rx = runtime.subscribe_commands().unwrap();

    runtime.pop().unwrap();
    runtime.pop_until(TestRouteKind::Home).unwrap();

    let first = tokio::time::timeout(Duration::from_secs(1), rx.recv())
        .await
        .unwrap()
        .unwrap();
    let second = tokio::time::timeout(Duration::from_secs(1), rx.recv())
        .await
        .unwrap()
        .unwrap();

    assert!(matches!(first, NavCommand::Pop { result: None }));
    assert!(matches!(second, NavCommand::PopUntil { kind } if kind == "Home"));
}

#[tokio::test]
async fn pop_with_json_emits_result() {
    let runtime = oxide_core::NavigationRuntime::new();

    let mut rx = runtime.subscribe_commands().unwrap();

    runtime
        .pop_with_json(serde_json::json!({"ok": true}))
        .unwrap();

    let cmd = tokio::time::timeout(Duration::from_secs(1), rx.recv())
        .await
        .unwrap()
        .unwrap();
    assert!(matches!(cmd, NavCommand::Pop { result: Some(_) }));
}

#[tokio::test]
async fn reset_emits_payload_without_envelope() {
    let runtime = oxide_core::NavigationRuntime::new();
    let mut rx = runtime.subscribe_commands().unwrap();

    runtime
        .reset(vec![WrappedPayload {
        kind: TestRouteKind::Charts,
        payload: serde_json::json!({"id": 7}),
    }])
        .unwrap();

    let cmd = tokio::time::timeout(Duration::from_secs(1), rx.recv())
        .await
        .unwrap()
        .unwrap();

    match cmd {
        NavCommand::Reset { routes } => {
            assert_eq!(routes.len(), 1);
            assert_eq!(routes[0].kind, "Charts");
            assert_eq!(routes[0].payload, serde_json::json!({"id": 7}));
        }
        _ => panic!("expected reset command"),
    }
}

#[tokio::test]
async fn route_extras_are_encoded_in_push() {
    let runtime = oxide_core::NavigationRuntime::new();
    let mut rx = runtime.subscribe_commands().unwrap();

    runtime
        .push(RouteWithExtras {
        kind: TestRouteKind::Home,
        extras: TestExtra {
            label: "hello".to_string(),
        },
    })
        .unwrap();

    let cmd = tokio::time::timeout(Duration::from_secs(1), rx.recv())
        .await
        .unwrap()
        .unwrap();

    match cmd {
        NavCommand::Push { route, ticket: None } => {
            assert_eq!(route.kind, "Home");
            assert_eq!(route.extras, Some(serde_json::json!({"label": "hello"})));
        }
        _ => panic!("expected push command"),
    }
}

#[test]
fn set_current_route_updates_context() {
    let runtime = oxide_core::NavigationRuntime::new();
    let _rx = runtime.subscribe_route_context();
    runtime.set_current_route(Some(NavRoute {
        kind: "Home".to_string(),
        payload: serde_json::json!({"id": 1}),
        extras: None,
    }));

    let context = runtime.current_route_context();
    assert_eq!(
        context.current,
        Some(NavRoute {
            kind: "Home".to_string(),
            payload: serde_json::json!({"id": 1}),
            extras: None,
        })
    );
}

#[tokio::test]
async fn navigation_ctx_emits_commands_and_exposes_route() {
    let runtime = oxide_core::NavigationRuntime::new();
    let context = RouteContext {
        current: Some(NavRoute {
            kind: "Charts".to_string(),
            payload: serde_json::json!({"id": 3}),
            extras: None,
        }),
    };
    let nav = oxide_core::NavigationCtx::new(&runtime, &context);

    assert_eq!(nav.route(), &context);

    let mut rx = runtime.subscribe_commands().unwrap();

    nav.push(TestRoute {
        kind: TestRouteKind::Home,
    })
    .unwrap();
    nav.pop().unwrap();
    nav.pop_until(TestRouteKind::Charts).unwrap();

    let mut seen = Vec::new();
    while seen.len() < 3 {
        let cmd = tokio::time::timeout(Duration::from_secs(1), rx.recv())
            .await
            .unwrap()
            .unwrap();
        seen.push(cmd);
    }

    assert!(seen.iter().any(|c| matches!(c, NavCommand::Push { route, ticket: None } if route.kind == "Home")));
    assert!(seen.iter().any(|c| matches!(c, NavCommand::Pop { result: None })));
    assert!(seen.iter().any(|c| matches!(c, NavCommand::PopUntil { kind } if kind == "Charts")));
}