sim-web-shell 0.1.12

sim-web-shell: the binary that serves the SIM WebUI shell.
Documentation
use super::*;
use sim_lib_intent::{Origin, intent};

fn key_path(key: &str) -> Expr {
    Expr::List(vec![Expr::Vector(vec![
        Expr::Symbol(Symbol::new("k")),
        Expr::Symbol(Symbol::new(key)),
    ])])
}

fn edit_intent(key: &str, value: &str) -> Expr {
    intent(
        "edit-field",
        Origin::human(1),
        vec![
            ("target", demo_value()),
            ("path", key_path(key)),
            ("value", Expr::String(value.to_owned())),
        ],
    )
}

#[test]
fn submit_edit_returns_a_patch_that_reconstructs_the_scene() {
    let mut live = LiveSession::new().unwrap();
    let before = live.open(DEFAULT_RESOURCE, DEFAULT_PANE).unwrap();
    sim_lib_scene::validate_scene(&before).expect("initial scene is valid");

    let updates = live
        .submit(DEFAULT_PANE, &edit_intent("title", "changed"))
        .unwrap();
    assert_eq!(updates.len(), 1, "the subscribed pane updates exactly once");
    let update = &updates[0];
    assert_ne!(update.scene, before, "the Scene changed");
    let rebuilt = sim_lib_scene::apply(&before, &update.diff).unwrap();
    assert_eq!(
        rebuilt, update.scene,
        "the diff reconstructs the new Scene from the old one"
    );
}

#[test]
fn open_returns_a_valid_scene() {
    let mut live = LiveSession::new().unwrap();
    let scene = live.open(DEFAULT_RESOURCE, DEFAULT_PANE).unwrap();
    sim_lib_scene::validate_scene(&scene).expect("open returns a valid Scene");
}

#[test]
fn a_browser_json_intent_decodes_and_drives_a_root_edit() {
    let body = r#"{"kind":"intent/edit-field","origin":{"operator":"human","at-tick":2},"target":{},"path":[],"value":"hello"}"#;
    let intent = decode_intent_body(body).unwrap();
    let kind = match &intent {
        Expr::Map(entries) => entries.iter().find_map(|(key, value)| {
            matches!(key, Expr::Symbol(symbol) if &*symbol.name == "kind").then_some(value)
        }),
        _ => None,
    };
    assert!(
        matches!(kind, Some(Expr::Symbol(_))),
        "the kind tag is lifted to a symbol"
    );

    let mut live = LiveSession::new().unwrap();
    live.open(DEFAULT_RESOURCE, DEFAULT_PANE).unwrap();
    let updates = live.submit(DEFAULT_PANE, &intent).unwrap();
    assert_eq!(updates.len(), 1);
}

#[test]
fn a_malformed_body_is_an_error_not_a_panic() {
    assert!(decode_intent_body("this is not json").is_err());
    assert!(
        decode_intent_body("[1, 2, 3]").is_err(),
        "a non-object intent body is rejected"
    );
}

#[test]
fn an_intent_without_a_kind_fails_closed_on_submit() {
    let intent = decode_intent_body(r#"{"origin":{"operator":"human","at-tick":1}}"#).unwrap();
    let mut live = LiveSession::new().unwrap();
    assert!(
        live.submit(DEFAULT_PANE, &intent).is_err(),
        "an intent without a kind is rejected, not executed"
    );
}

#[test]
fn patches_scenes_and_errors_encode_as_untagged_json() {
    let mut live = LiveSession::new().unwrap();
    live.open(DEFAULT_RESOURCE, DEFAULT_PANE).unwrap();
    let updates = live
        .submit(DEFAULT_PANE, &edit_intent("title", "x"))
        .unwrap();

    let patches = encode_patches(&updates);
    assert!(patches.contains("\"patches\""), "carries a patches array");
    assert!(patches.contains("scene/patch"), "patches are scene patches");

    let scene = encode_scene(&live.open(DEFAULT_RESOURCE, DEFAULT_PANE).unwrap());
    assert!(scene.contains("\"scene\""), "carries a scene field");

    assert!(
        error_json("boom").contains("boom"),
        "errors carry a message"
    );
}

#[test]
fn session_ids_are_opaque_and_validated() {
    let mut table = LiveSessionTable::with_config(
        Box::new(DefaultLiveSurfaceFactory),
        LiveSessionTableConfig {
            capacity: 2,
            idle_ttl: Duration::from_secs(60),
        },
    );
    let (session_id, _) = table.open(None, DEFAULT_RESOURCE, DEFAULT_PANE).unwrap();
    assert_eq!(session_id.len(), 32);
    assert!(session_id.bytes().all(|b| b.is_ascii_hexdigit()));
    assert!(
        table
            .submit("not-a-session", DEFAULT_PANE, &edit_intent("title", "x"))
            .is_err(),
        "malformed ids fail closed"
    );
}

#[test]
fn browser_sessions_are_isolated() {
    let mut table = LiveSessionTable::new(Box::new(DefaultLiveSurfaceFactory));
    let (left, _) = table.open(None, DEFAULT_RESOURCE, DEFAULT_PANE).unwrap();
    let (right, right_before) = table.open(None, DEFAULT_RESOURCE, DEFAULT_PANE).unwrap();
    table
        .submit(&left, DEFAULT_PANE, &edit_intent("title", "left-only"))
        .unwrap();
    let (_, right_after) = table
        .open(Some(&right), DEFAULT_RESOURCE, DEFAULT_PANE)
        .unwrap();
    assert_eq!(
        right_after, right_before,
        "a write in one browser session must not alter another session"
    );
}

#[test]
fn close_cancels_a_session() {
    let mut table = LiveSessionTable::new(Box::new(DefaultLiveSurfaceFactory));
    let (session_id, _) = table.open(None, DEFAULT_RESOURCE, DEFAULT_PANE).unwrap();
    table.close(&session_id).unwrap();
    assert!(
        table
            .submit(&session_id, DEFAULT_PANE, &edit_intent("title", "closed"))
            .is_err(),
        "closed sessions cannot be reused"
    );
}

#[test]
fn idle_sessions_expire() {
    let start = Instant::now();
    let mut table = LiveSessionTable::with_config(
        Box::new(DefaultLiveSurfaceFactory),
        LiveSessionTableConfig {
            capacity: 4,
            idle_ttl: Duration::from_secs(1),
        },
    );
    let (session_id, _) = table
        .open_at(None, DEFAULT_RESOURCE, DEFAULT_PANE, start)
        .unwrap();
    let later = start + Duration::from_secs(2);
    assert!(
        table
            .submit_at(
                &session_id,
                DEFAULT_PANE,
                &edit_intent("title", "expired"),
                later,
            )
            .is_err(),
        "idle sessions are evicted before use"
    );
    assert_eq!(table.len(), 0);
}

#[test]
fn capacity_evicts_the_oldest_session_deterministically() {
    let start = Instant::now();
    let mut table = LiveSessionTable::with_config(
        Box::new(DefaultLiveSurfaceFactory),
        LiveSessionTableConfig {
            capacity: 2,
            idle_ttl: Duration::from_secs(60),
        },
    );
    let (first, _) = table
        .open_at(None, DEFAULT_RESOURCE, DEFAULT_PANE, start)
        .unwrap();
    let (second, _) = table
        .open_at(
            None,
            DEFAULT_RESOURCE,
            DEFAULT_PANE,
            start + Duration::from_secs(1),
        )
        .unwrap();
    table
        .submit_at(
            &second,
            DEFAULT_PANE,
            &edit_intent("title", "touch"),
            start + Duration::from_secs(2),
        )
        .unwrap();
    let (third, _) = table
        .open_at(
            None,
            DEFAULT_RESOURCE,
            DEFAULT_PANE,
            start + Duration::from_secs(3),
        )
        .unwrap();
    assert_ne!(third, first);
    assert_eq!(table.len(), 2);
    assert!(
        table
            .submit_at(
                &first,
                DEFAULT_PANE,
                &edit_intent("title", "evicted"),
                start + Duration::from_secs(4),
            )
            .is_err(),
        "the oldest idle session is evicted first"
    );
    table
        .submit_at(
            &second,
            DEFAULT_PANE,
            &edit_intent("title", "survives"),
            start + Duration::from_secs(5),
        )
        .unwrap();
}