holger-ui 0.1.1

Operator/admin UI for holger over the HolgerObject core API — egui via facett, embedded (LocalHolger, direct core calls) or remote (RemoteHolger gRPC).
//! holger-ui **robot-UI** cell — the second concrete robot-UI matrix row in
//! holger (the first is `mannequin/tests/gui_robot.rs`). It drives a real
//! holger-ui view *headlessly* against a real [`LocalHolger`] fixture and
//! asserts the view's observable `state_json()` — exactly what an operator would
//! see in the Browse and Archive tabs, as data, not pixels.
//!
//! No display, no GPU, no `gui` feature: the view-model layer is transport- and
//! egui-agnostic by design and exposes `state_json()` on every view, so this is
//! the "see what the user sees, as data" robot-UI observation the LAW calls for.
//!
//! LAW: inject real input + assert real output. We seed a LocalHolger with real
//! artifacts + a deterministic archive, drive the Browse and Archive views, and
//! assert their `state_json()` carries back the exact artifacts / tree / stats —
//! never merely "didn't panic".

use std::collections::HashMap;
use std::sync::{Arc, Mutex};

use holger_ui::data::UiData;
use server_lib::exposed::fast_routes::FastRoutes;
use server_lib::LocalHolger;
use traits::{ArtifactFormat, ArtifactId, HolgerObject, RepositoryBackendTrait};

/// Emit one functional-status row for a real check into the `nornir test` matrix.
/// Gated behind `--features testmatrix` so the release build strips it entirely.
#[cfg(feature = "testmatrix")]
fn fstatus(component: &str, check: &str, ok: bool, detail: &str) {
    nornir_testmatrix::functional_status(component, check, ok, detail);
}

/// In-memory writable repository backend so the robot-UI cell doesn't depend on
/// any one ecosystem backend's on-disk format — the point under test is the
/// holger-ui view-model ↔ LocalHolger wiring + `state_json` observation.
struct MemRepo {
    name: String,
    store: Mutex<HashMap<(Option<String>, String, String), Vec<u8>>>,
}

impl MemRepo {
    fn new(name: &str) -> Self {
        Self {
            name: name.to_string(),
            store: Mutex::new(HashMap::new()),
        }
    }
    fn key(id: &ArtifactId) -> (Option<String>, String, String) {
        (id.namespace.clone(), id.name.clone(), id.version.clone())
    }
}

impl RepositoryBackendTrait for MemRepo {
    fn name(&self) -> &str {
        &self.name
    }
    fn format(&self) -> ArtifactFormat {
        ArtifactFormat::Rust
    }
    fn is_writable(&self) -> bool {
        true
    }
    fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<Vec<u8>>> {
        Ok(self.store.lock().unwrap().get(&Self::key(id)).cloned())
    }
    fn put(&self, id: &ArtifactId, data: &[u8]) -> anyhow::Result<()> {
        self.store.lock().unwrap().insert(Self::key(id), data.to_vec());
        Ok(())
    }
    fn list(
        &self,
        name_filter: Option<&str>,
        limit: usize,
    ) -> anyhow::Result<Vec<traits::ArtifactEntry>> {
        let store = self.store.lock().unwrap();
        let mut out = Vec::new();
        for ((namespace, name, version), bytes) in store.iter() {
            if let Some(f) = name_filter {
                if !name.contains(f) {
                    continue;
                }
            }
            out.push(traits::ArtifactEntry {
                id: ArtifactId {
                    namespace: namespace.clone(),
                    name: name.clone(),
                    version: version.clone(),
                },
                size_bytes: bytes.len() as i64,
                content_type: "application/octet-stream".into(),
            });
            if limit != 0 && out.len() >= limit {
                break;
            }
        }
        Ok(out)
    }
    fn archive_files(&self, prefix: Option<&str>) -> anyhow::Result<Vec<String>> {
        let files = vec![
            "crates/serde-1.0.0.crate".to_string(),
            "crates/tokio-1.2.0.crate".to_string(),
            "crates/anyhow-1.0.0.crate".to_string(),
        ];
        Ok(match prefix {
            Some(p) => files.into_iter().filter(|f| f.starts_with(p)).collect(),
            None => files,
        })
    }
    fn archive_info(&self) -> anyhow::Result<traits::ArchiveInfo> {
        Ok(traits::ArchiveInfo {
            file_count: 3,
            total_uncompressed_bytes: 4242,
            archive_path: self.name.clone(),
        })
    }
    fn handle_http2_request(
        &self,
        _method: &str,
        _suburl: &str,
        _body: &[u8],
    ) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
        Ok((404, Vec::new(), b"not found".to_vec()))
    }
}

/// A holger-ui data layer over a real LocalHolger with one writable repo, seeded
/// with two real artifacts.
fn seeded_ui() -> UiData {
    let repo: Arc<dyn RepositoryBackendTrait> = Arc::new(MemRepo::new("rust-dev"));
    let routes = FastRoutes::new(vec![("rust-dev".to_string(), repo)]);
    let holger: Arc<dyn HolgerObject> = Arc::new(LocalHolger::new(routes));
    let ui = UiData::new(holger).expect("runtime");
    for (name, bytes) in [("serde", &b"SERDE_BYTES"[..]), ("tokio", &b"TOKIO"[..])] {
        let id = ArtifactId {
            namespace: None,
            name: name.into(),
            version: "1.0.0".into(),
        };
        ui.put_artifact("rust-dev", &id, bytes).expect("seed put");
    }
    ui
}

/// Robot-UI: drive the Browse view headless and assert its `state_json()` lists
/// the seeded artifacts — the operator's Browse tab, observed as data.
#[test]
fn browse_view_state_json_lists_seeded_artifacts() {
    let mut ui = seeded_ui();

    // Drive: the operator opens the Browse tab on rust-dev (no display).
    ui.refresh_browse("rust-dev", None);

    // Observe: the view's own state_json — exactly what the Browse table renders.
    let s = ui.browse.state_json();
    assert_eq!(s["repository"], "rust-dev", "state_json carries the active repo: {s}");
    assert!(s["error"].is_null(), "no error in a healthy listing: {s}");
    let entries = s["entries"].as_array().expect("state_json has an entries array");
    assert_eq!(entries.len(), 2, "two seeded artifacts in the Browse listing: {s}");

    // Inject-assert: the exact artifacts (name + version + real byte size) we
    // seeded are the ones the operator sees.
    let serde = entries
        .iter()
        .find(|e| e["name"] == "serde")
        .expect("serde row present in Browse state_json");
    assert_eq!(serde["version"], "1.0.0");
    assert_eq!(serde["size_bytes"].as_i64(), Some(b"SERDE_BYTES".len() as i64));
    assert!(
        entries.iter().any(|e| e["name"] == "tokio"),
        "tokio row present in Browse state_json: {s}"
    );

    #[cfg(feature = "testmatrix")]
    fstatus(
        "holger-ui",
        "browse_view_state_json",
        s["repository"] == "rust-dev"
            && entries.len() == 2
            && entries.iter().any(|e| e["name"] == "serde")
            && entries.iter().any(|e| e["name"] == "tokio"),
        &format!("Browse state_json: repo={} {} entries (serde+tokio)", s["repository"], entries.len()),
    );
}

/// Robot-UI: drive the Archive view headless and assert its `state_json()`
/// exposes the archive tree + stats counts — the operator's Archive tab as data.
#[test]
fn archive_view_state_json_exposes_tree_and_stats() {
    let mut ui = seeded_ui();

    // Drive: the operator opens the Archive tab on rust-dev.
    ui.refresh_archive("rust-dev", None);

    // Observe: the Archive view's state_json — the file tree + stats counts.
    let s = ui.archive.state_json();
    assert!(s["error"].is_null(), "no error browsing the archive: {s}");
    assert_eq!(s["repository"], "rust-dev");
    assert_eq!(s["file_count"].as_u64(), Some(3), "stats file_count: {s}");
    assert_eq!(
        s["total_uncompressed_bytes"].as_u64(),
        Some(4242),
        "stats total bytes: {s}"
    );
    let files = s["files"].as_array().expect("state_json has a files tree");
    assert_eq!(files.len(), 3, "three archive entries in the tree: {s}");
    assert!(
        files.iter().any(|f| f == "crates/serde-1.0.0.crate"),
        "archive tree carries the real entries: {s}"
    );

    #[cfg(feature = "testmatrix")]
    fstatus(
        "holger-ui",
        "archive_view_state_json",
        s["file_count"].as_u64() == Some(3)
            && s["total_uncompressed_bytes"].as_u64() == Some(4242)
            && files.len() == 3
            && files.iter().any(|f| f == "crates/serde-1.0.0.crate"),
        &format!("Archive state_json: file_count={} bytes={} tree_len={}", s["file_count"], s["total_uncompressed_bytes"], files.len()),
    );
}