face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! Integration tests for score-field detection (§4.3 of `docs/design.md`).
//!
//! Pins the contract for [`face_core::detect::score::detect_score`] and
//! [`face_core::detect::score::validate_score_path`]: named-candidate
//! priority, top-level-beats-nested, single-numeric fallback, the
//! "no score" outcomes, and the validation error matrix.
//!
//! Public surface under test (locked by the developer brief):
//!
//! ```ignore
//! pub struct ScoreDetection {
//!     pub path: Option<String>,
//!     pub fallback_reason: Option<String>,
//! }
//! pub fn detect_score(items: &[serde_json::Value])
//!     -> Result<ScoreDetection, FaceError>;
//! pub fn validate_score_path(items: &[serde_json::Value], path: &str)
//!     -> Result<(), FaceError>;
//! ```

use face_core::FaceError;
use face_core::detect::score::{ScoreDetection, detect_score, validate_score_path};
use serde_json::json;

/// Helper: assert that the detected path matches one of the accepted
/// renderings (with or without a leading `.`). The brief locks
/// jq-style paths but does not pin whether the leading dot is included
/// in the stored string. Soft-match both forms.
#[track_caller]
fn assert_path(det: &ScoreDetection, expected_unprefixed: &str) {
    let path = det
        .path
        .as_deref()
        .unwrap_or_else(|| panic!("expected Some path, got None; det = {det:?}"));
    let with_dot = format!(".{expected_unprefixed}");
    assert!(
        path == expected_unprefixed || path == with_dot,
        "expected `{expected_unprefixed}` or `{with_dot}`, got `{path}`",
    );
}

mod named_candidates {
    //! §4.3 priority order for named score fields:
    //! `score` > `rank` > `relevance` > `confidence` > `bm25` > `distance`.

    use super::*;

    #[test]
    fn picks_score_at_top_level() {
        let items = vec![json!({"score": 0.8, "name": "x"})];
        let det = detect_score(&items).expect("score field detects");
        assert_path(&det, "score");
        assert!(
            det.fallback_reason.is_none(),
            "named hit should not carry a fallback_reason; got {:?}",
            det.fallback_reason,
        );
    }

    #[test]
    fn picks_rank_when_score_absent() {
        let items = vec![json!({"rank": 5, "x": 1})];
        let det = detect_score(&items).expect("rank field detects");
        assert_path(&det, "rank");
    }

    #[test]
    fn priority_score_over_relevance() {
        let items = vec![json!({"relevance": 0.5, "score": 0.8})];
        let det = detect_score(&items).expect("score beats relevance by priority");
        assert_path(&det, "score");
    }

    #[test]
    fn priority_relevance_over_confidence() {
        let items = vec![json!({"confidence": 0.5, "relevance": 0.7})];
        let det = detect_score(&items).expect("relevance beats confidence by priority");
        assert_path(&det, "relevance");
    }

    #[test]
    fn bm25_picked_when_above_appear() {
        let items = vec![json!({"bm25": 12.5})];
        let det = detect_score(&items).expect("bm25 field detects when alone");
        assert_path(&det, "bm25");
    }

    #[test]
    fn distance_picked() {
        let items = vec![json!({"distance": 4.2})];
        let det = detect_score(&items).expect("distance field detects when alone");
        assert_path(&det, "distance");
    }
}

mod top_level_beats_nested {
    //! §4.3: when more than one named candidate appears, the most
    //! prominent (top-level over nested) wins.

    use super::*;

    #[test]
    fn top_level_score_wins_over_nested_score() {
        let items = vec![json!({"score": 0.5, "data": {"score": 0.9}})];
        let det = detect_score(&items).expect("top-level score beats nested score");
        assert_path(&det, "score");
    }

    #[test]
    fn nested_score_picked_when_no_top_level() {
        // When no top-level named candidate exists, walking into the
        // single object child should surface the nested `.data.score`.
        let items = vec![json!({"data": {"score": 0.7}})];
        let det = detect_score(&items).expect("nested score detects when no top-level");
        assert_path(&det, "data.score");
    }
}

mod single_numeric_fallback {
    //! §4.3: if no named candidate matches but exactly one numeric field
    //! exists at any depth, use it. Multiple unnamed numerics → no score
    //! with a fallback reason explaining the ambiguity.

    use super::*;

    #[test]
    fn single_unnamed_numeric_field_picked() {
        let items = vec![json!({"foo": 42})];
        let det = detect_score(&items).expect("single unnamed numeric is the score");
        assert_path(&det, "foo");
    }

    #[test]
    fn single_nested_numeric_field_picked() {
        let items = vec![json!({"a": {"b": 7.5}})];
        let det = detect_score(&items).expect("single nested numeric is the score");
        assert_path(&det, "a.b");
    }

    #[test]
    fn multiple_unnamed_no_match() {
        let items = vec![json!({"foo": 1, "bar": 2})];
        let det = detect_score(&items).expect("multiple unnamed numerics is `Ok` but `None`");
        assert!(
            det.path.is_none(),
            "multiple unnamed numerics → no score path; got {:?}",
            det.path,
        );
        let reason = det
            .fallback_reason
            .as_deref()
            .expect("a fallback_reason should explain why no score was picked");
        let lower = reason.to_lowercase();
        assert!(
            lower.contains("multiple") || lower.contains("numeric"),
            "fallback_reason should mention multiple/numeric; got `{reason}`",
        );
    }
}

mod no_score {
    //! §4.3: outcomes where no score path is detected.

    use super::*;

    #[test]
    fn all_string_fields() {
        let items = vec![json!({"name": "x", "kind": "y"})];
        let det = detect_score(&items).expect("no numeric is `Ok` but `None`");
        assert!(
            det.path.is_none(),
            "all-string items → no score path; got {:?}",
            det.path,
        );
    }

    #[test]
    fn empty_items_list() {
        let items: Vec<serde_json::Value> = Vec::new();
        let det = detect_score(&items).expect("empty items list returns `Ok`, not `Err`");
        assert!(
            det.path.is_none(),
            "empty items → no score path; got {:?}",
            det.path,
        );
        assert!(
            det.fallback_reason.is_some(),
            "empty items should carry some fallback_reason; got None",
        );
    }
}

mod ignored_shapes {
    //! §4.3: arrays inside items are not walked as score candidates,
    //! and booleans are not treated as numeric.

    use super::*;

    #[test]
    fn arrays_inside_items_are_not_walked() {
        // The nested `[1, 2, 3]` array contains numerics, but the score
        // detector walks object fields only. The top-level `.score`
        // wins regardless.
        let items = vec![json!({"data": {"counts": [1, 2, 3]}, "score": 0.5})];
        let det = detect_score(&items).expect("named top-level score wins over array contents");
        assert_path(&det, "score");
    }

    #[test]
    fn bool_is_not_numeric() {
        // Booleans look like 0/1 to JSON consumers but are NOT numeric
        // for score-detection purposes (Strategy taxonomy treats them
        // as 2-value enums in §5.1, but this is score detection).
        let items = vec![json!({"flag": true})];
        let det = detect_score(&items).expect("bool-only items resolve to `Ok` with no path");
        assert!(
            det.path.is_none(),
            "bool field is not numeric → no score path; got {:?}",
            det.path,
        );
    }
}

mod validate_score_path {
    //! `validate_score_path` confirms that an explicit path resolves to
    //! a numeric value in at least one of the first ~16 sampled items.

    use super::*;

    #[track_caller]
    fn assert_unknown_score_path(err: &FaceError, expected_path_unprefixed: &str) {
        match err {
            FaceError::UnknownScorePath { path } => {
                let with_dot = format!(".{expected_path_unprefixed}");
                assert!(
                    path == expected_path_unprefixed || path == &with_dot,
                    "expected path `{expected_path_unprefixed}` or `{with_dot}` in error, got `{path}`",
                );
            }
            other => panic!("expected FaceError::UnknownScorePath, got {other:?} ({other})",),
        }
    }

    #[test]
    fn valid_path_resolves_numeric() {
        let items = vec![json!({"score": 0.5})];
        validate_score_path(&items, ".score").expect("valid numeric path resolves");
    }

    #[test]
    fn unknown_path_errors() {
        let items = vec![json!({"score": 0.5})];
        let err = validate_score_path(&items, ".nope").expect_err("unknown path must error");
        assert_unknown_score_path(&err, "nope");
    }

    #[test]
    fn non_numeric_value_at_path_errors() {
        let items = vec![json!({"name": "hello"})];
        let err =
            validate_score_path(&items, ".name").expect_err("non-numeric value at path must error");
        assert_unknown_score_path(&err, "name");
    }

    #[test]
    fn numeric_in_first_16_is_enough() {
        // First 15 items have a non-numeric at the path; the 16th has a
        // numeric. The validation samples the first ~16 items, so this
        // should still succeed.
        //
        // Soft-match: if the developer chose a different sample-window
        // size (e.g. 8 or 32), this test may misbehave. The brief calls
        // out "first ~16 items" so 15 non-numerics + 1 numeric at index
        // 15 (zero-based) is the conservative shape.
        let mut items: Vec<serde_json::Value> =
            (0..15).map(|_| json!({"score": "not-a-number"})).collect();
        items.push(json!({"score": 0.5}));
        validate_score_path(&items, ".score").expect("a numeric within the sample window suffices");
    }
}