smix-fixture 0.3.0

smix-fixture — fixture chip registry loader for the `- fixture: <id>` yaml verb. Loads JSON declaring `{ testID, signal, timeoutMs }` per fixture id; used by smix-adapter-maestro runtime to open the QA overlay, tap the chip, and await the declared log signal. insight-roadmap §A (v0.3.0).
Documentation
//! smix-fixture — fixture chip registry.
//!
//! # Purpose (insight-roadmap §A, v0.3.0 Phase B)
//!
//! The `- fixture: <id>` yaml verb (in smix-adapter-maestro) looks up
//! `<id>` in a JSON registry to find:
//!
//! - `testID` — the a11y id of the chip in the QA overlay to tap
//! - `signal` — the log line regex the chip emits when its seed
//!   operation completes
//! - `timeoutMs` — how long to wait for the signal
//!
//! smix reads the registry once per run from a path resolved via
//! `.smix/config.json` `fixturesRegistry` field.
//!
//! # Format
//!
//! JSON (TS module loader deferred to v0.3.5):
//!
//! ```json
//! {
//!   "version": 1,
//!   "fixtures": {
//!     "prime-search-history": {
//!       "testID": "qa-chip-prime-search-history",
//!       "signal": {
//!         "regex": "\\[fixture\\] prime-search-history: seeded (\\d+) rows",
//!         "level": "log"
//!       },
//!       "timeoutMs": 8000
//!     }
//!   }
//! }
//! ```

use std::collections::BTreeMap;
use std::path::Path;

use serde::{Deserialize, Serialize};
use thiserror::Error;

/// One fixture chip declaration.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FixtureDecl {
    /// a11y id of the chip to tap in the QA overlay. Serialized as
    /// `testID` (all-caps ID) to match the insight-side registry.ts
    /// convention.
    #[serde(rename = "testID")]
    pub test_id: String,
    /// Log signal the chip emits when seeding completes.
    pub signal: SignalMatcher,
    /// Timeout in milliseconds. Runtime awaits `signal` up to this.
    #[serde(rename = "timeoutMs")]
    pub timeout_ms: u64,
}

/// Signal matcher (mirrors `smix_metro_log::SignalMatcher` at the wire
/// level).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SignalMatcher {
    pub regex: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub level: Option<String>,
}

/// Registry file shape.
#[derive(Debug, Deserialize)]
struct RegistryFile {
    #[serde(default)]
    #[allow(dead_code)]
    version: u32,
    fixtures: BTreeMap<String, FixtureDecl>,
}

/// Loaded registry.
#[derive(Clone, Debug)]
pub struct FixtureRegistry {
    fixtures: BTreeMap<String, FixtureDecl>,
}

#[derive(Debug, Error)]
pub enum RegistryError {
    #[error("read {path}: {source}")]
    Io {
        path: String,
        #[source]
        source: std::io::Error,
    },
    #[error("parse {path}: {detail}")]
    Malformed { path: String, detail: String },
    #[error("fixture not found: {id} (registered: {known:?})")]
    NotFound { id: String, known: Vec<String> },
}

impl FixtureRegistry {
    /// Load a registry JSON file.
    pub fn load(path: &Path) -> Result<Self, RegistryError> {
        let text = std::fs::read_to_string(path).map_err(|source| RegistryError::Io {
            path: path.display().to_string(),
            source,
        })?;
        let file: RegistryFile =
            serde_json::from_str(&text).map_err(|e| RegistryError::Malformed {
                path: path.display().to_string(),
                detail: e.to_string(),
            })?;
        Ok(Self {
            fixtures: file.fixtures,
        })
    }

    pub fn get(&self, id: &str) -> Option<&FixtureDecl> {
        self.fixtures.get(id)
    }

    pub fn require(&self, id: &str) -> Result<&FixtureDecl, RegistryError> {
        self.get(id).ok_or_else(|| RegistryError::NotFound {
            id: id.to_string(),
            known: self.fixtures.keys().cloned().collect(),
        })
    }

    pub fn ids(&self) -> impl Iterator<Item = &String> {
        self.fixtures.keys()
    }

    /// Construct from an in-memory map. Test / library use — the CLI
    /// always goes through [`Self::load`].
    pub fn from_map(fixtures: BTreeMap<String, FixtureDecl>) -> Self {
        Self { fixtures }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    fn write(tmp: &TempDir, name: &str, content: &str) -> std::path::PathBuf {
        let p = tmp.path().join(name);
        std::fs::write(&p, content).unwrap();
        p
    }

    #[test]
    fn load_single_fixture() {
        let tmp = TempDir::new().unwrap();
        let path = write(
            &tmp,
            "reg.json",
            r#"{
                "version": 1,
                "fixtures": {
                    "prime-search-history": {
                        "testID": "qa-chip-prime-search-history",
                        "signal": {
                            "regex": "\\[fixture\\] prime-search-history: seeded (\\d+) rows",
                            "level": "log"
                        },
                        "timeoutMs": 8000
                    }
                }
            }"#,
        );
        let reg = FixtureRegistry::load(&path).unwrap();
        let f = reg.require("prime-search-history").unwrap();
        assert_eq!(f.test_id, "qa-chip-prime-search-history");
        assert_eq!(f.timeout_ms, 8000);
        assert_eq!(f.signal.level.as_deref(), Some("log"));
    }

    #[test]
    fn not_found_lists_available() {
        let tmp = TempDir::new().unwrap();
        let path = write(
            &tmp,
            "reg.json",
            r#"{"fixtures":{
                "a":{"testID":"t-a","signal":{"regex":"a"},"timeoutMs":100},
                "b":{"testID":"t-b","signal":{"regex":"b"},"timeoutMs":100}
            }}"#,
        );
        let reg = FixtureRegistry::load(&path).unwrap();
        let err = reg.require("nope").unwrap_err();
        match err {
            RegistryError::NotFound { known, .. } => {
                assert_eq!(known, vec!["a".to_string(), "b".to_string()]);
            }
            other => panic!("expected NotFound, got {other:?}"),
        }
    }

    #[test]
    fn malformed_json_surfaces_error() {
        let tmp = TempDir::new().unwrap();
        let path = write(&tmp, "reg.json", "not json");
        let err = FixtureRegistry::load(&path).unwrap_err();
        matches!(err, RegistryError::Malformed { .. });
    }

    #[test]
    fn multi_fixture_ids_iterate_deterministic() {
        let tmp = TempDir::new().unwrap();
        let path = write(
            &tmp,
            "reg.json",
            r#"{"fixtures":{
                "z":{"testID":"z","signal":{"regex":"."},"timeoutMs":100},
                "a":{"testID":"a","signal":{"regex":"."},"timeoutMs":100},
                "m":{"testID":"m","signal":{"regex":"."},"timeoutMs":100}
            }}"#,
        );
        let reg = FixtureRegistry::load(&path).unwrap();
        let ids: Vec<&String> = reg.ids().collect();
        assert_eq!(ids, vec!["a", "m", "z"]);
    }
}