sim-lib-view-device 0.1.1

Device profiles, frame timing, and tier derivation for SIM Web surfaces.
Documentation
use sim_kernel::{Expr, Symbol};
use sim_lib_view::SurfaceCaps;
use sim_value::access;

use crate::{
    DegradationResolver, DeviceProfile, DeviceSurfaceCapsExt, DeviceTier, GlassesClass,
    ObservedRoute, RateClass, T_REX_3_PRO_48_CAPS_FIXTURE, WORN_CAPS_KIND, WORN_CAPS_NAMESPACE,
    derive_tier, glasses_class, tier_preset, trex3pro_48_worn_caps_fixture, worn_caps_fixture,
    worn_caps_fixture_names,
};

#[test]
fn presets_at_each_tier_round_trip_through_expr() {
    for tier in DeviceTier::ALL {
        let profile = tier_preset(tier);
        assert_eq!(profile.tier, tier);
        let back = DeviceProfile::from_expr(&profile.to_expr()).expect("profile round-trips");
        assert_eq!(back, profile);
    }
}

#[test]
fn derive_tier_is_authoritative_and_rate_defaults_safe() {
    for tier in DeviceTier::ALL {
        let profile = tier_preset(tier);
        assert_eq!(derive_tier(&profile), tier);
        for lower in DeviceTier::ALL {
            assert_eq!(tier.supports(lower), tier >= lower);
        }
    }
}

#[test]
fn surface_caps_extension_derives_rate_and_tier() {
    let caps = SurfaceCaps::from_preset("watch", "watch.local.1").expect("watch caps");
    let profile = caps.device_profile();
    assert_eq!(profile.kind.name.as_ref(), "watch");
    assert_eq!(profile.rate, RateClass::watch());
    assert_eq!(profile.tier, DeviceTier::Actuator);
}

#[test]
fn watch_presets_are_device_tiers_with_rate() {
    let presets = [
        "watch",
        "watch-glance",
        "watch-glance-large",
        "watch-sport",
        "watch-sleep",
    ];
    for preset in presets {
        let caps = SurfaceCaps::from_preset(preset, format!("{preset}.local"))
            .expect("watch preset exists");
        let back = SurfaceCaps::from_expr(&caps.to_expr()).expect("surface caps round-trip");
        assert_eq!(back, caps);

        let profile = caps.device_profile();
        assert_eq!(derive_tier(&profile), profile.tier, "{preset}");
        assert!(
            matches!(profile.tier, DeviceTier::Sensor | DeviceTier::Actuator),
            "{preset} resolved to {:?}",
            profile.tier
        );
        assert_ne!(profile.tier, DeviceTier::Rich, "{preset} must not be rich");
        assert_eq!(profile.rate, RateClass::watch(), "{preset}");
        assert!(has_symbol(&profile.display, "round"), "{preset}");
        assert!(has_symbol(&profile.links, "phone-relay"), "{preset}");
        assert!(has_symbol(&profile.output, "screen"), "{preset}");
        assert!(has_symbol(&profile.streams, "battery"), "{preset}");
        assert!(!has_symbol(&profile.input, "voice"), "{preset}");
    }

    let large = SurfaceCaps::from_preset("watch-glance-large", "watch.local.48")
        .expect("large watch preset");
    let large_profile = large.device_profile();
    assert!(has_symbol(&large_profile.input, "mic"));
    assert!(has_symbol(&large_profile.output, "haptic"));
    assert!(has_symbol(&large_profile.output, "face"));
    assert!(has_symbol(&large_profile.output, "speaker"));
    assert!(has_symbol(&large_profile.output, "mic"));
    assert!(access::field(&large_profile.to_expr(), "asr-site").is_none());
    assert!(!has_symbol(&large_profile.input, "crown"));

    let generic = SurfaceCaps::from_preset("watch", "watch.local.generic").expect("watch preset");
    assert!(has_symbol(&generic.device_profile().input, "crown"));
}

#[test]
fn weaker_watch_preset_is_a_subset() {
    let sport = SurfaceCaps::from_preset("watch-sport", "watch.local.sport")
        .expect("sport watch preset")
        .device_profile();
    let sleep = SurfaceCaps::from_preset("watch-sleep", "watch.local.sleep")
        .expect("sleep watch preset")
        .device_profile();

    assert_symbol_subset(&sleep.input, &sport.input);
    assert_symbol_subset(&sleep.output, &sport.output);
    assert_symbol_subset(&sleep.streams, &sport.streams);
    assert_symbol_subset(&sleep.links, &sport.links);
}

#[test]
fn glasses_presets_span_the_ladder_with_rateclass() {
    let presets = [
        "glasses",
        "glasses-hud",
        "glasses-hud-camera",
        "glasses-3dof",
        "glasses-stereo",
        "glasses-luma-ultra",
    ];
    for preset in presets {
        let caps = SurfaceCaps::from_preset(preset, format!("{preset}.local"))
            .expect("glasses preset exists");
        let back = SurfaceCaps::from_expr(&caps.to_expr()).expect("surface caps round-trip");
        assert_eq!(back, caps, "{preset}");

        let profile = caps.device_profile();
        assert_eq!(derive_tier(&profile), profile.tier, "{preset}");
        assert!(
            glasses_class(&profile).is_some(),
            "{preset} must resolve to a glasses profile"
        );
        assert!(has_symbol(&profile.display, "glance"), "{preset}");
    }

    let display_only = SurfaceCaps::from_preset("glasses", "glasses.local.display")
        .expect("display-only glasses")
        .device_profile();
    assert_eq!(display_only.tier, DeviceTier::Display);
    assert_eq!(display_only.rate, RateClass::safe_default());
    assert_eq!(
        glasses_class(&display_only),
        Some(GlassesClass::DisplayOnly)
    );
    assert!(has_symbol(&display_only.display, "mono"));
    assert!(has_symbol(&display_only.links, "usb"));

    let halo = SurfaceCaps::from_preset("glasses-hud", "halo.local.hud")
        .expect("halo hud")
        .device_profile();
    assert_eq!(halo.tier, DeviceTier::Actuator);
    assert_eq!(halo.rate.content_hz, 5);
    assert_eq!(halo.rate.adapt_hz, 30);
    assert_eq!(halo.rate.max_stale_ms, 200);
    assert_eq!(glasses_class(&halo), Some(GlassesClass::MonoHud));
    assert!(has_symbol(&halo.display, "mono"));
    assert!(has_symbol(&halo.display, "hud"));
    assert!(has_symbol(&halo.output, "audio"));
    assert!(has_symbol(&halo.output, "haptic"));
    assert!(has_symbol(&halo.links, "bluetooth"));
    assert!(has_symbol(&halo.links, "web-bluetooth"));
    assert!(has_symbol(&halo.links, "phone-relay"));

    let halo_camera = SurfaceCaps::from_preset("glasses-hud-camera", "halo.local.camera")
        .expect("halo camera")
        .device_profile();
    assert_eq!(halo_camera.tier, DeviceTier::Actuator);
    assert_eq!(glasses_class(&halo_camera), Some(GlassesClass::MonoHud));
    assert!(has_symbol(&halo_camera.input, "camera"));
    assert!(has_symbol(&halo_camera.streams, "camera"));

    let three_dof = SurfaceCaps::from_preset("glasses-3dof", "viture.local.3dof")
        .expect("3dof glasses")
        .device_profile();
    assert_eq!(three_dof.tier, DeviceTier::Sensor);
    assert_eq!(three_dof.rate.content_hz, 60);
    assert_eq!(three_dof.rate.adapt_hz, 120);
    assert_eq!(glasses_class(&three_dof), Some(GlassesClass::DisplayOnly));
    assert!(has_symbol(&three_dof.display, "3dof"));
    assert!(has_symbol(&three_dof.streams, "motion"));

    let stereo = SurfaceCaps::from_preset("glasses-stereo", "viture.local.stereo")
        .expect("stereo display")
        .device_profile();
    assert_eq!(stereo.tier, DeviceTier::Display);
    assert_eq!(stereo.rate, RateClass::safe_default());
    assert_eq!(glasses_class(&stereo), Some(GlassesClass::DisplayOnly));
    assert!(has_symbol(&stereo.display, "stereo"));

    let viture = SurfaceCaps::from_preset("glasses-luma-ultra", "viture.local.ultra")
        .expect("luma ultra")
        .device_profile();
    assert_eq!(viture.tier, DeviceTier::Rich);
    assert_eq!(viture.rate.content_hz, 60);
    assert_eq!(viture.rate.adapt_hz, 120);
    assert_eq!(viture.rate.max_stale_ms, 25);
    assert_eq!(glasses_class(&viture), Some(GlassesClass::Stereo6Dof));
    assert!(has_symbol(&viture.display, "stereo"));
    assert!(has_symbol(&viture.display, "6dof"));
    assert!(has_symbol(&viture.streams, "pose"));
    assert!(has_symbol(&viture.streams, "hand"));
    assert!(has_symbol(&viture.input, "gaze"));
    assert!(has_symbol(&viture.links, "usb"));

    let degradation =
        DegradationResolver::resolve(&viture, &ObservedRoute::from_profile(&display_only));
    assert_eq!(degradation.tier, DeviceTier::Display);
    assert!(
        degradation
            .reasons
            .iter()
            .any(|reason| reason == "missing stream: pose")
    );

    let phone = SurfaceCaps::from_preset("phone", "phone.local")
        .expect("phone")
        .device_profile();
    assert_eq!(glasses_class(&phone), None);
}

#[test]
fn missing_sensor_field_degrades_with_reason() {
    let requested = tier_preset(DeviceTier::Rich);
    let mut observed = ObservedRoute::from_profile(&requested);
    observed
        .streams
        .retain(|symbol| symbol.name.as_ref() != "pose");

    let degradation = DegradationResolver::resolve(&requested, &observed);

    assert_eq!(degradation.tier, DeviceTier::Actuator);
    assert!(
        degradation
            .reasons
            .iter()
            .any(|reason| reason == "missing stream: pose")
    );
}

#[test]
fn missing_rate_map_defaults_to_safe_envelope() {
    let mut entries = match SurfaceCaps::from_preset("glasses-hud", "halo.local.2")
        .expect("halo caps")
        .to_expr()
    {
        Expr::Map(entries) => entries,
        _ => unreachable!(),
    };
    entries
        .retain(|(key, _)| !matches!(key, Expr::Symbol(symbol) if symbol.name.as_ref() == "rate"));

    let caps = SurfaceCaps::from_expr(&Expr::Map(entries)).expect("older caps parse");

    assert_eq!(caps.device_rate(), RateClass::safe_default());
    assert_eq!(
        access::field(&caps.rate, "content-hz"),
        access::field(&RateClass::safe_default().to_expr(), "content-hz")
    );
}

#[test]
fn trex3pro_worn_caps_fixture_separates_claims_from_verified() {
    assert_eq!(worn_caps_fixture_names(), [T_REX_3_PRO_48_CAPS_FIXTURE]);
    assert_eq!(
        worn_caps_fixture(T_REX_3_PRO_48_CAPS_FIXTURE),
        Some(trex3pro_48_worn_caps_fixture())
    );
    assert!(worn_caps_fixture("unknown").is_none());

    let fixture = trex3pro_48_worn_caps_fixture();
    assert_eq!(
        access::field(&fixture, "kind"),
        Some(&Expr::Symbol(Symbol::qualified(
            WORN_CAPS_NAMESPACE,
            WORN_CAPS_KIND
        )))
    );
    assert_eq!(
        access::field(&fixture, "device"),
        Some(&Expr::Symbol(Symbol::new(T_REX_3_PRO_48_CAPS_FIXTURE)))
    );

    let claims = access::field(&fixture, "claims").expect("claims map");
    assert_eq!(
        access::field(claims, "size-mm"),
        Some(&sim_value::build::uint(48))
    );
    assert_eq!(
        access::field(claims, "keys"),
        Some(&sim_value::build::uint(4))
    );
    assert_eq!(access::field(claims, "ble-hr"), Some(&Expr::Bool(true)));
    assert_eq!(
        access::field(claims, "notification-out"),
        Some(&Expr::Bool(true))
    );

    let verified = access::field(&fixture, "verified").expect("verified map");
    let Expr::Map(entries) = verified else {
        panic!("verified must be a map");
    };
    assert!(!entries.is_empty());
    for (_, value) in entries {
        assert_eq!(value, &Expr::Bool(false));
    }

    assert_eq!(access::field(&fixture, "firmware"), Some(&Expr::Nil));
    assert_eq!(
        RateClass::from_expr(access::field(&fixture, "rate").expect("rate map")).unwrap(),
        RateClass::watch()
    );
}

fn has_symbol(symbols: &[Symbol], name: &str) -> bool {
    symbols
        .iter()
        .any(|symbol| symbol.namespace.is_none() && symbol.name.as_ref() == name)
}

fn assert_symbol_subset(subset: &[Symbol], superset: &[Symbol]) {
    for symbol in subset {
        assert!(
            superset.iter().any(|candidate| candidate == symbol),
            "{} missing from superset",
            symbol.as_qualified_str()
        );
    }
}