face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! Integration tests for `Strategy::Top` via `build_tree` (slice 6,
//! §5.3 of `docs/design.md`).
//!
//! `Top { n }` keeps the top `n` clusters by frequency and rolls the
//! rest into a synthetic `(other)` cluster.
//!
//! - Top clusters sort by count desc, label asc (Exact's rule).
//! - `(other)` is always last.
//! - When distinct values ≤ `n`, no `(other)` cluster is emitted.
//! - When `n == 0`, every record goes to `(other)` (one cluster total).
//! - The `(other)` cluster: `id` extends parent with segment
//!   `(axis_field, "(other)")`, `label = "(other)"`, `axis = plan.axis.field`,
//!   `value = json!("(other)")`, `total = remainder count`,
//!   `score_min`/`score_max` over remainder, `clusters` recurses on
//!   `within`.

use face_core::{Axis, AxisPlan, Cluster, 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 top(field: &str, n: u32) -> Axis {
    axis(field, json!({"strategy": "top", "n": n}))
}

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

fn run(plan: AxisPlan, records: Vec<Record>) -> Vec<Cluster> {
    let mut diag = NullDiagnostics;
    build_tree(&plan, records, &mut diag).expect("Top build_tree should succeed")
}

fn records_from(items: Vec<Value>, score_path: Option<&str>) -> Vec<Record> {
    Record::from_items(items, score_path)
}

/// The standard 9-record dataset used across the `top_n` tests:
/// kinds bug=4, feat=2, docs=1, chore=1, other=1 → 5 distinct values,
/// 9 records total.
fn dataset_kinds() -> Vec<Record> {
    let items = vec![
        json!({"kind": "bug"}),
        json!({"kind": "bug"}),
        json!({"kind": "bug"}),
        json!({"kind": "bug"}),
        json!({"kind": "feat"}),
        json!({"kind": "feat"}),
        json!({"kind": "docs"}),
        json!({"kind": "chore"}),
        json!({"kind": "other"}),
    ];
    records_from(items, None)
}

mod top_n {
    //! Acceptance: §5.3 — `top { n }` keeps the top-N by count plus an
    //! `(other)` cluster, which is omitted when distinct values ≤ N.

    use super::*;

    #[test]
    fn top_3_keeps_three_clusters_plus_other() {
        // 5 distinct, n=3 → 3 top clusters + (other). Top by count:
        //   bug (4), feat (2), then a 3-way tie at 1
        //   among (chore, docs, other) → alphabetical: chore.
        // Remainder is docs (1) + other (1) = 2 records in (other).
        let plan = AxisPlan::leaf(top("kind", 3));
        let clusters = run(plan, dataset_kinds());

        assert_eq!(
            clusters.len(),
            4,
            "expected 3 top clusters + (other); got {} clusters: {:?}",
            clusters.len(),
            clusters.iter().map(|c| c.label.clone()).collect::<Vec<_>>(),
        );

        // First two: bug (4), feat (2). Pinned exactly.
        assert_eq!(clusters[0].label, "bug");
        assert_eq!(clusters[0].total, 4);
        assert_eq!(clusters[1].label, "feat");
        assert_eq!(clusters[1].total, 2);

        // Third top: 1-record cluster, alphabetically first among
        // (chore, docs, other) → chore. Soft-match by checking it is
        // a count-1 cluster from the tied group.
        assert!(
            ["chore", "docs", "other"].contains(&clusters[2].label.as_str()),
            "third top cluster should be one of the count-1 tied values, got {:?}",
            clusters[2].label,
        );
        assert_eq!(clusters[2].total, 1);

        // Last: the (other) cluster carries the remaining 2 records.
        assert_eq!(clusters[3].label, "(other)");
        assert_eq!(
            clusters[3].total, 2,
            "(other) should carry the two count-1 records that didn't make the top",
        );
    }

    #[test]
    fn other_cluster_is_last() {
        // The (other) cluster is always at the end of the result list.
        let plan = AxisPlan::leaf(top("kind", 2));
        let clusters = run(plan, dataset_kinds());

        let last = clusters.last().expect("non-empty clusters");
        assert_eq!(
            last.label,
            "(other)",
            "(other) must be last, got order {:?}",
            clusters.iter().map(|c| c.label.clone()).collect::<Vec<_>>(),
        );
    }

    #[test]
    fn top_3_no_other_when_le_n_distinct() {
        // 3 distinct values, n=3 → no (other).
        let items = vec![
            json!({"kind": "bug"}),
            json!({"kind": "bug"}),
            json!({"kind": "feat"}),
            json!({"kind": "docs"}),
        ];
        let plan = AxisPlan::leaf(top("kind", 3));
        let clusters = run(plan, records_from(items, None));

        assert_eq!(clusters.len(), 3);
        for c in &clusters {
            assert_ne!(
                c.label, "(other)",
                "no (other) cluster when distinct values ≤ n",
            );
        }
    }

    #[test]
    fn top_n_larger_than_distinct() {
        // 3 distinct values, n=10 → 3 clusters, no (other).
        let items = vec![
            json!({"kind": "bug"}),
            json!({"kind": "feat"}),
            json!({"kind": "docs"}),
        ];
        let plan = AxisPlan::leaf(top("kind", 10));
        let clusters = run(plan, records_from(items, None));

        assert_eq!(clusters.len(), 3);
        for c in &clusters {
            assert_ne!(
                c.label, "(other)",
                "no (other) cluster when distinct values ≤ n",
            );
        }
    }
}

mod n_zero {
    //! Acceptance: §5.3 — `top { n: 0 }` collapses every record into a
    //! single `(other)` cluster.

    use super::*;

    #[test]
    fn n_zero_collapses_all_to_other() {
        let plan = AxisPlan::leaf(top("kind", 0));
        let clusters = run(plan, dataset_kinds());

        assert_eq!(
            clusters.len(),
            1,
            "n=0 must collapse every record into a single (other) cluster",
        );
        assert_eq!(clusters[0].label, "(other)");
        assert_eq!(clusters[0].total, 9);
    }
}

mod other_cluster_shape {
    //! Acceptance: §5.3 — the synthetic `(other)` cluster's id, axis,
    //! value, and total fields follow the locked contract.

    use super::*;

    /// Build the dataset and pick the `(other)` cluster from a
    /// `Top { n: 2 }` plan over `dataset_kinds()`.
    fn other_cluster() -> Cluster {
        let plan = AxisPlan::leaf(top("kind", 2));
        let clusters = run(plan, dataset_kinds());
        clusters
            .into_iter()
            .find(|c| c.label == "(other)")
            .expect("(other) cluster present at n=2 with 5 distinct values")
    }

    #[test]
    fn other_id_extends_parent_with_other_value() {
        // From a depth-1 plan: id parses to one segment (kind, "(other)").
        let other = other_cluster();
        let segments = other.id.segments();
        assert_eq!(segments.len(), 1, "depth-1 (other) id has one segment");
        assert_eq!(segments[0].axis, "kind");
        assert_eq!(segments[0].value, "(other)");
    }

    #[test]
    fn other_axis_propagates() {
        let other = other_cluster();
        assert_eq!(other.axis, "kind");
    }

    #[test]
    fn other_value_is_json_string_other() {
        let other = other_cluster();
        assert_eq!(other.value, json!("(other)"));
    }

    #[test]
    fn other_total_equals_remainder() {
        // n=2 keeps bug (4) and feat (2). Remainder = docs+chore+other = 3.
        let other = other_cluster();
        assert_eq!(other.total, 3);
    }

    #[test]
    fn other_score_min_max_across_remainder() {
        // Build records with scores so that bug and feat (which are NOT
        // in (other)) have extreme scores, and the remainder records
        // have moderate ones. (other)'s score_min / score_max must come
        // ONLY from the remainder.
        let items = vec![
            json!({"kind": "bug",   "score": 0.01}),
            json!({"kind": "bug",   "score": 0.02}),
            json!({"kind": "bug",   "score": 0.03}),
            json!({"kind": "bug",   "score": 0.04}),
            json!({"kind": "feat",  "score": 0.99}),
            json!({"kind": "feat",  "score": 0.98}),
            // Remainder records — their range is [0.40, 0.70]:
            json!({"kind": "docs",  "score": 0.40}),
            json!({"kind": "chore", "score": 0.55}),
            json!({"kind": "other", "score": 0.70}),
        ];
        let plan = AxisPlan::leaf(top("kind", 2));
        let clusters = run(plan, records_from(items, Some(".score")));

        let other = clusters
            .iter()
            .find(|c| c.label == "(other)")
            .expect("(other) cluster present");
        assert_eq!(other.score_min, Some(0.40));
        assert_eq!(other.score_max, Some(0.70));
    }
}

mod recursion {
    //! Acceptance: §5.5 — top-cluster `(other)` recursion. Both the
    //! top-N clusters and the synthetic `(other)` cluster carry their
    //! own inner cluster list when `within` is non-empty.

    use super::*;

    #[test]
    fn top_within_exact() {
        // Outer Top { n: 2 } on kind, inner Exact on severity.
        // Records: 2 bug + 2 feat (the top-2) + 2 leftover (docs, chore).
        let items = vec![
            // top: bug (2)
            json!({"kind": "bug",   "severity": "high"}),
            json!({"kind": "bug",   "severity": "low"}),
            // top: feat (2)
            json!({"kind": "feat",  "severity": "high"}),
            json!({"kind": "feat",  "severity": "high"}),
            // remainder: rolls into (other), 2 distinct severities.
            json!({"kind": "docs",  "severity": "low"}),
            json!({"kind": "chore", "severity": "high"}),
        ];
        let plan = AxisPlan::with(top("kind", 2), AxisPlan::leaf(exact("severity")));
        let clusters = run(plan, records_from(items, None));

        // Three clusters: bug, feat, (other).
        assert_eq!(clusters.len(), 3);

        // The top clusters have inner Exact clusters.
        let bug = clusters
            .iter()
            .find(|c| c.label == "bug")
            .expect("bug present");
        assert!(
            !bug.clusters.is_empty(),
            "bug should have severity sub-clusters",
        );
        // bug has high (1) and low (1) — count desc with stable label asc → high, low.
        assert_eq!(bug.clusters.len(), 2);

        let feat = clusters
            .iter()
            .find(|c| c.label == "feat")
            .expect("feat present");
        assert!(
            !feat.clusters.is_empty(),
            "feat should have severity sub-clusters",
        );

        // (other) ALSO has its own inner cluster array — recursion
        // applies to (other) as well.
        let other = clusters
            .iter()
            .find(|c| c.label == "(other)")
            .expect("(other) present");
        assert!(
            !other.clusters.is_empty(),
            "(other) cluster must also recurse on `within`, got {:?}",
            other.clusters,
        );
        // 2 records in (other), one severity each → 2 sub-clusters.
        assert_eq!(other.clusters.len(), 2);
        let inner_total: u64 = other.clusters.iter().map(|c| c.total).sum();
        assert_eq!(inner_total, 2, "(other) inner totals sum to remainder");

        // Inner sub-cluster ids should extend (other)'s id.
        for inner in &other.clusters {
            assert_eq!(
                inner.id.depth(),
                2,
                "inner cluster id extends (other) id, got depth {}",
                inner.id.depth(),
            );
            let segs = inner.id.segments();
            assert_eq!(segs[0].axis, "kind");
            assert_eq!(segs[0].value, "(other)");
            assert_eq!(segs[1].axis, "severity");
        }
    }
}

mod sorting {
    //! Acceptance: §5.3 — top clusters use Exact's ordering rule
    //! (count desc, label asc). The `(other)` cluster is excluded from
    //! the ordering and pinned to the end.

    use super::*;

    #[test]
    fn top_clusters_sorted_count_desc_label_asc() {
        // Counts: a=3, b=2, c=2, d=1, e=1. With n=4, the top clusters
        // are a (3), then a tie at 2 between b and c (alphabetical →
        // b then c), then a tie at 1 between d and e (→ d). Remainder
        // is e (1) → (other) at the end.
        let items = vec![
            json!({"k": "a"}),
            json!({"k": "a"}),
            json!({"k": "a"}),
            json!({"k": "b"}),
            json!({"k": "b"}),
            json!({"k": "c"}),
            json!({"k": "c"}),
            json!({"k": "d"}),
            json!({"k": "e"}),
        ];
        let plan = AxisPlan::leaf(top("k", 4));
        let clusters = run(plan, records_from(items, None));

        assert_eq!(clusters.len(), 5, "4 top clusters + (other)");
        let labels: Vec<&str> = clusters.iter().map(|c| c.label.as_str()).collect();
        assert_eq!(
            labels,
            vec!["a", "b", "c", "d", "(other)"],
            "expected count-desc with alphabetic tie-break, (other) last",
        );

        // Counts pin too.
        let totals: Vec<u64> = clusters.iter().map(|c| c.total).collect();
        assert_eq!(totals, vec![3, 2, 2, 1, 1]);
    }
}