face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! Integration tests for `Strategy::Bands` via `build_tree` (slice 7,
//! §5.2 of `docs/design.md`).
//!
//! `Bands { count }` partitions records by the numeric value at the
//! axis field into equal-width bands over `[min, max]`. Each band
//! becomes a cluster; empty bands are dropped (no `total: 0` clusters).
//!
//! Behavior pinned by these tests:
//!
//! - Cluster count between 1 and `count` (empty bands dropped).
//! - Sum of cluster totals equals the number of input records that
//!   carried a numeric value at the axis field.
//! - Sort: descending by lower bound.
//! - Cluster `label` is `"{min}–{max}"` with the en-dash `–` (U+2013).
//!   Float-precision is not pinned (soft-match).
//! - Cluster `value` is a JSON object with numeric `min` and `max`
//!   keys. The exact `f64` representation is not pinned.
//! - `id` parses round-trip via [`face_core::ClusterId::parse_canonical`].
//! - Empty input → empty cluster vec.
//! - Single record → 1 cluster with `total: 1`.
//! - Records whose axis value isn't a number (string, missing, null)
//!   are dropped; whether a skip diagnostic fires is the developer's
//!   call (soft-match — not asserted here).
//! - Recursion: `Bands` outer with `Exact` inner produces a two-tier
//!   tree.

use face_core::{Axis, AxisPlan, Cluster, ClusterId, NullDiagnostics, Record, 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")
}

fn bands(field: &str, count: u8) -> Axis {
    axis(field, json!({"strategy": "bands", "count": count}))
}

fn exact(field: &str) -> Axis {
    axis(field, json!({"strategy": "exact"}))
}

/// Drive `build_tree` with a `NullDiagnostics`, panic if it errors.
fn run(plan: AxisPlan, records: Vec<Record>) -> Vec<Cluster> {
    let mut diag = NullDiagnostics;
    build_tree(&plan, records, &mut diag).expect("Bands build_tree should succeed")
}

/// Convenience: build records from a list of inline JSON values.
fn records_from(items: Vec<Value>, score_path: Option<&str>) -> Vec<Record> {
    Record::from_items(items, score_path)
}

/// Pull a numeric `min` or `max` field out of a cluster's `value`.
fn value_bound(cluster: &Cluster, key: &str) -> f64 {
    cluster.value[key].as_f64().unwrap_or_else(|| {
        panic!("cluster.value[{key:?}] should be a JSON number; got {cluster:?}")
    })
}

mod basic {
    //! Six records evenly spread across `[0.1, 1.0]`, `count = 5`.
    //!
    //! Acceptance: §5.2 — equal-width bands over `[min, max]`.

    use super::*;

    /// Standard six-record dataset: `[0.1, 0.2, 0.5, 0.7, 0.9, 1.0]`.
    fn dataset() -> Vec<Record> {
        let items = vec![
            json!({"score": 0.1}),
            json!({"score": 0.2}),
            json!({"score": 0.5}),
            json!({"score": 0.7}),
            json!({"score": 0.9}),
            json!({"score": 1.0}),
        ];
        records_from(items, None)
    }

    #[test]
    fn partitions_records_into_bands() {
        let plan = AxisPlan::leaf(bands("score", 5));
        let clusters = run(plan, dataset());

        // Empty bands are dropped, so `count` is an upper bound, not a
        // pin. There must be at least one cluster.
        assert!(
            !clusters.is_empty(),
            "expected at least one cluster, got none",
        );
        assert!(
            clusters.len() <= 5,
            "expected at most 5 clusters (count=5), got {}: {:?}",
            clusters.len(),
            clusters.iter().map(|c| c.label.clone()).collect::<Vec<_>>(),
        );

        // Sort: descending by lower bound.
        let lowers: Vec<f64> = clusters.iter().map(|c| value_bound(c, "min")).collect();
        let mut sorted = lowers.clone();
        sorted.sort_by(|a, b| b.total_cmp(a));
        assert_eq!(
            lowers, sorted,
            "clusters must be sorted by lower bound descending",
        );
    }

    #[test]
    fn total_equals_input_count() {
        let plan = AxisPlan::leaf(bands("score", 5));
        let clusters = run(plan, dataset());

        let sum: u64 = clusters.iter().map(|c| c.total).sum();
        assert_eq!(
            sum, 6,
            "sum of cluster totals must equal input record count (6); got {sum}",
        );
    }

    #[test]
    fn cluster_id_uses_label_segment() {
        // The cluster's id is the rendered canonical form; running it
        // through `parse_canonical` should yield the same id back.
        // Acceptance: §6.1 — id round-trips via canonical parse.
        let plan = AxisPlan::leaf(bands("score", 5));
        let clusters = run(plan, dataset());

        for cluster in &clusters {
            let rendered = cluster.id.to_string();
            let parsed = ClusterId::parse_canonical(&rendered).unwrap_or_else(|err| {
                panic!("cluster id {rendered:?} must parse via parse_canonical; err: {err:?}",)
            });
            assert_eq!(
                parsed, cluster.id,
                "cluster id must round-trip via canonical form",
            );
        }
    }
}

mod edges {
    //! Edge cases: single record, empty input, all-equal values, and
    //! non-numeric values.
    //!
    //! Acceptance: §5.2 — non-numeric / missing path → drop; empty
    //! input → empty cluster vec; single record → 1 cluster with min == max.

    use super::*;

    #[test]
    fn single_record_yields_one_cluster() {
        let items = vec![json!({"score": 0.5})];
        let plan = AxisPlan::leaf(bands("score", 5));
        let clusters = run(plan, records_from(items, None));

        assert_eq!(clusters.len(), 1, "single record → exactly one cluster");
        assert_eq!(clusters[0].total, 1);
    }

    #[test]
    fn empty_records_returns_empty() {
        let plan = AxisPlan::leaf(bands("score", 5));
        let clusters = run(plan, Vec::new());
        assert!(
            clusters.is_empty(),
            "empty input must produce empty cluster vec; got {} clusters",
            clusters.len(),
        );
    }

    #[test]
    fn all_records_same_value_yields_one_cluster() {
        // All values are identical → min == max → one band covers them.
        let items = vec![
            json!({"score": 0.5}),
            json!({"score": 0.5}),
            json!({"score": 0.5}),
        ];
        let plan = AxisPlan::leaf(bands("score", 5));
        let clusters = run(plan, records_from(items, None));

        assert_eq!(
            clusters.len(),
            1,
            "all-equal values must collapse to one cluster",
        );
        assert_eq!(clusters[0].total, 3);
    }

    #[test]
    fn non_numeric_records_dropped() {
        // Mix of numeric and non-numeric values: numerics survive,
        // non-numerics drop. The total of surviving clusters must equal
        // the numeric count.
        let items = vec![
            json!({"score": 0.5}),
            json!({"score": "high"}), // string → drop
            json!({"score": 0.7}),
            json!({"score": null}), // null → drop
            json!({}),              // missing field → drop
        ];
        let plan = AxisPlan::leaf(bands("score", 5));
        let clusters = run(plan, records_from(items, None));

        let sum: u64 = clusters.iter().map(|c| c.total).sum();
        assert_eq!(
            sum, 2,
            "only the two numeric records (0.5, 0.7) should appear in clusters; got total {sum}",
        );
    }
}

mod label {
    //! Cluster labels follow the format `"{min}–{max}"` with the
    //! en-dash `–` (U+2013) — not a hyphen `-` and not an em-dash `—`.
    //!
    //! Acceptance: §5.2 — value-range labels for numeric strategies.

    use super::*;

    fn dataset() -> Vec<Record> {
        let items = vec![
            json!({"score": 0.1}),
            json!({"score": 0.5}),
            json!({"score": 1.0}),
        ];
        records_from(items, None)
    }

    #[test]
    fn label_contains_en_dash() {
        let plan = AxisPlan::leaf(bands("score", 5));
        let clusters = run(plan, dataset());

        // At least one cluster should carry the en-dash label.
        let has_en_dash = clusters.iter().any(|c| c.label.contains('\u{2013}'));
        assert!(
            has_en_dash,
            "expected at least one cluster label to contain en-dash (U+2013); got {:?}",
            clusters.iter().map(|c| c.label.clone()).collect::<Vec<_>>(),
        );
    }

    #[test]
    fn label_format_is_min_dash_max() {
        // Each cluster's label, split on the en-dash, should yield two
        // parseable f64 values. Don't pin precision — just that both
        // sides are numbers.
        let plan = AxisPlan::leaf(bands("score", 5));
        let clusters = run(plan, dataset());

        for cluster in &clusters {
            let parts: Vec<&str> = cluster.label.split('\u{2013}').collect();
            assert_eq!(
                parts.len(),
                2,
                "label {:?} must split into exactly two parts on en-dash; got {} parts",
                cluster.label,
                parts.len(),
            );
            let lo: f64 = parts[0].trim().parse().unwrap_or_else(|err| {
                panic!("label lhs {:?} must parse as f64; err: {err}", parts[0])
            });
            let hi: f64 = parts[1].trim().parse().unwrap_or_else(|err| {
                panic!("label rhs {:?} must parse as f64; err: {err}", parts[1])
            });
            assert!(
                lo <= hi,
                "label {:?} must have lo <= hi; got lo={lo}, hi={hi}",
                cluster.label,
            );
        }
    }
}

mod value_shape {
    //! Cluster `value` is `{"min": <f64>, "max": <f64>}`.
    //!
    //! Acceptance: spec §5.2 + slice 7 contract — value carries the
    //! numeric range, not the string label. Both `min` and `max` must
    //! be JSON numbers; their exact `f64` representations are not
    //! pinned (the implementation may round during conversion via
    //! `serde_json::Number::from_f64`).

    use super::*;

    #[test]
    fn cluster_value_has_min_max_keys() {
        let items = vec![json!({"score": 0.1}), json!({"score": 1.0})];
        let plan = AxisPlan::leaf(bands("score", 5));
        let clusters = run(plan, records_from(items, None));

        for cluster in &clusters {
            assert!(
                cluster.value.is_object(),
                "cluster.value must be a JSON object for Bands; got {:?}",
                cluster.value,
            );
            let min = &cluster.value["min"];
            let max = &cluster.value["max"];
            assert!(
                min.is_number(),
                "cluster.value[\"min\"] must be a JSON number; got {min:?}",
            );
            assert!(
                max.is_number(),
                "cluster.value[\"max\"] must be a JSON number; got {max:?}",
            );
            // min <= max as a sanity check.
            let lo = min.as_f64().unwrap();
            let hi = max.as_f64().unwrap();
            assert!(
                lo <= hi,
                "cluster.value range must satisfy min <= max; got min={lo}, max={hi}",
            );
        }
    }
}

mod recursion {
    //! `Bands` (outer) with `Exact` (inner). Verifies the two-tier
    //! tree: each top-level band carries an `Exact`-clustered child
    //! list of distinct kinds within that band.
    //!
    //! Acceptance: §5.5 — `--within` chains a second axis; per-axis
    //! strategy dispatch applies recursively.

    use super::*;

    #[test]
    fn bands_within_exact() {
        let items = vec![
            json!({"score": 0.1, "kind": "bug"}),
            json!({"score": 0.2, "kind": "bug"}),
            json!({"score": 0.5, "kind": "feat"}),
            json!({"score": 0.7, "kind": "bug"}),
            json!({"score": 0.9, "kind": "feat"}),
            json!({"score": 1.0, "kind": "feat"}),
        ];
        let outer = bands("score", 3);
        let inner = exact("kind");
        let plan = AxisPlan::with(outer, AxisPlan::leaf(inner));

        let clusters = run(plan, records_from(items, None));

        assert!(
            !clusters.is_empty(),
            "outer Bands must produce at least one cluster",
        );

        // Sum of top-level totals equals input.
        let outer_total: u64 = clusters.iter().map(|c| c.total).sum();
        assert_eq!(outer_total, 6);

        // Each top-level cluster has at least one child cluster (every
        // record carries a `kind`, so `Exact` will fire for every band).
        for cluster in &clusters {
            assert!(
                !cluster.clusters.is_empty(),
                "each Bands cluster must recurse into Exact children; cluster {:?} has none",
                cluster.label,
            );
            // Children's totals add up to the parent's total.
            let child_total: u64 = cluster.clusters.iter().map(|c| c.total).sum();
            assert_eq!(
                child_total, cluster.total,
                "child totals must add up to parent total in cluster {:?}",
                cluster.label,
            );
            // Each child has axis="kind" (the inner axis field).
            for child in &cluster.clusters {
                assert_eq!(child.axis, "kind");
            }
        }
    }
}