face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! Integration tests for `Record::from_items`, `Record::new`, and the
//! `Diagnostics` smoke contract surfaced through `build_tree` (slice 5,
//! §5 of `docs/design.md`).
//!
//! These tests pin the per-record permissive score-resolution contract
//! on `Record::from_items`: missing paths, non-numeric values, and
//! `null` all yield `score: None` without erroring; numeric values
//! become `Some(_)`. The `diagnostics_smoke` mod exercises the
//! `Diagnostics` trait through `build_tree` so the only public surface
//! exercised is the one external callers actually see.

use face_core::{Axis, AxisPlan, NullDiagnostics, Record, VecDiagnostics, build_tree};
use serde_json::{Value, json};

/// Build an `Axis` via deserialization (the type is `#[non_exhaustive]`).
fn axis(field: &str, strategy: Value) -> Axis {
    let mut object = serde_json::Map::new();
    object.insert("field".into(), Value::String(field.into()));
    object.insert("auto".into(), Value::Bool(false));
    if let Value::Object(map) = strategy {
        for (k, v) in map {
            object.insert(k, v);
        }
    } else {
        panic!("strategy must be a JSON object");
    }
    serde_json::from_value(Value::Object(object)).expect("axis must deserialize")
}

mod from_items_no_score {
    //! `Record::from_items(items, None)` — every record has `score: None`,
    //! and the original `raw` value survives unchanged.

    use super::*;

    #[test]
    fn none_score_path_yields_none_scores() {
        let items = vec![
            json!({"score": 0.9}),
            json!({"score": 0.5}),
            json!({"name": "no-score-field"}),
        ];
        let records = Record::from_items(items, None);
        assert_eq!(records.len(), 3);
        for (i, r) in records.iter().enumerate() {
            assert!(
                r.score.is_none(),
                "record {i} should have score: None when score_path is None, got {:?}",
                r.score,
            );
        }
    }

    #[test]
    fn preserves_raw_value() {
        let original = json!({"a": 1, "b": [2, 3], "c": {"d": "x"}});
        let items = vec![original.clone()];
        let records = Record::from_items(items, None);
        assert_eq!(records.len(), 1);
        assert_eq!(
            records[0].raw, original,
            "raw must equal the original Value (deep equality)",
        );
    }
}

mod from_items_with_score {
    //! `Record::from_items(items, Some(path))` — per-record permissive
    //! score resolution.

    use super::*;

    #[test]
    fn numeric_score_resolved() {
        let items = vec![json!({"score": 0.9}), json!({"score": 0.42})];
        let records = Record::from_items(items, Some(".score"));
        assert_eq!(records[0].score, Some(0.9));
        assert_eq!(records[1].score, Some(0.42));
    }

    #[test]
    fn missing_score_path_per_record_silent() {
        let items = vec![
            json!({"score": 0.9}),
            json!({"name": "middle, no score"}),
            json!({"score": 0.5}),
        ];
        let records = Record::from_items(items, Some(".score"));
        assert_eq!(records[0].score, Some(0.9));
        assert!(
            records[1].score.is_none(),
            "missing path on a single record must yield score: None, not error",
        );
        assert_eq!(records[2].score, Some(0.5));
    }

    #[test]
    fn non_numeric_score_yields_none() {
        let items = vec![json!({"score": "high"})];
        let records = Record::from_items(items, Some(".score"));
        assert!(
            records[0].score.is_none(),
            "string at score path must yield score: None",
        );
    }

    #[test]
    fn nested_score_path() {
        let items = vec![
            json!({"data": {"score": 0.7}}),
            json!({"data": {"score": 0.1}}),
        ];
        let records = Record::from_items(items, Some(".data.score"));
        assert_eq!(records[0].score, Some(0.7));
        assert_eq!(records[1].score, Some(0.1));
    }

    #[test]
    fn null_score_yields_none() {
        let items = vec![json!({"score": null})];
        let records = Record::from_items(items, Some(".score"));
        assert!(
            records[0].score.is_none(),
            "null at score path must yield score: None",
        );
    }
}

mod constructors {
    //! `Record::new` constructs with `score: None`.

    use super::*;

    #[test]
    fn record_new_has_no_score() {
        let r = Record::new(json!({"x": 1}));
        assert!(r.score.is_none());
        assert_eq!(r.raw, json!({"x": 1}));
    }
}

mod diagnostics_smoke {
    //! Smoke-test the `Diagnostics` sinks through the only public
    //! function that produces `SkipReport`s: `build_tree`. Records with
    //! a non-string-coercible value at the axis field (here, an array)
    //! are dropped per §5.3, and the drop is reported via `record_skip`.
    //!
    //! `SkipReport` is `#[non_exhaustive]` and not `Deserialize`, so we
    //! cannot construct one externally — running through `build_tree` is
    //! the supported path.

    use super::*;

    /// A dataset where one record's `kind` field is an array (uncoercible
    /// to an `exact` string key per §5.3) and is therefore dropped during
    /// clustering.
    fn dataset_with_one_dropped() -> Vec<Record> {
        let items = vec![json!({"kind": "bug"}), json!({"kind": [1, 2]})];
        Record::from_items(items, None)
    }

    fn exact_axis() -> Axis {
        axis("kind", json!({"strategy": "exact"}))
    }

    #[test]
    fn null_diagnostics_swallows_skips() {
        let records = dataset_with_one_dropped();
        let plan = AxisPlan::leaf(exact_axis());
        let mut diag = NullDiagnostics;
        // No assertion on the sink itself — `NullDiagnostics` has no
        // observable state. The contract is that it does not panic.
        let clusters = build_tree(&plan, records, &mut diag).expect("build_tree must succeed");
        // The dropped record does not appear in any cluster, so the only
        // surviving cluster is `kind: "bug"` with total 1.
        assert_eq!(clusters.len(), 1);
        assert_eq!(clusters[0].total, 1);
    }

    #[test]
    fn vec_diagnostics_collects_skips() {
        let records = dataset_with_one_dropped();
        let plan = AxisPlan::leaf(exact_axis());
        let mut diag = VecDiagnostics::default();
        let _ = build_tree(&plan, records, &mut diag).expect("build_tree must succeed");
        assert_eq!(
            diag.skips.len(),
            1,
            "exactly one record was uncoercible and must produce one SkipReport, got {}",
            diag.skips.len(),
        );
    }
}