ferray-strings 0.5.0

String operations on character arrays for ferray
Documentation
//! Conformance tests for ferray-strings: case, str_ops, concat, strip,
//! split_join, and align modules. Each test loads a fixture under
//! `fixtures/strings/` and compares ferray's output to the NumPy oracle.
//!
//! String operations are bit-exact: tolerance is fixture-strict and we
//! compare with `assert_eq!` directly.
//!
//! Each test names the user-facing crate-root re-export path AND the
//! inner canonical module path in its doc comment so the
//! surface-coverage gate's text-match picks both up.

#![allow(clippy::cast_possible_truncation)]

use ferray_strings::{self, StringArray1, StringArray2};
use ferray_test_oracle::{fixtures_dir, load_fixture, parse_bool_data, parse_string_data};

fn strings_fx(name: &str) -> std::path::PathBuf {
    fixtures_dir().join("strings").join(name)
}

fn make_array(value: &ferray_test_oracle::serde_json::Value) -> StringArray1 {
    let data = parse_string_data(&value["data"]);
    ferray_strings::array(&data.iter().map(String::as_str).collect::<Vec<_>>()).unwrap()
}

// ---------------------------------------------------------------------------
// case::upper / case::lower / case::capitalize / case::title
// ---------------------------------------------------------------------------

/// Covers: `ferray_strings::upper` (crate-root re-export) and
/// `ferray_strings::case::upper` (canonical path).
#[test]
fn upper_matches_numpy() {
    let suite = load_fixture(&strings_fx("upper.json"));
    for case in &suite.test_cases {
        assert_eq!(case.tolerance_ulps, 0, "case '{}' tolerance", case.name);
        let arr = make_array(&case.inputs["x"]);
        let out = ferray_strings::upper(&arr).unwrap();
        let expected = parse_string_data(&case.expected["data"]);
        assert_eq!(out.as_slice(), expected.as_slice(), "case '{}'", case.name);
    }
}

/// Covers: `ferray_strings::lower` and `ferray_strings::case::lower`.
#[test]
fn lower_matches_numpy() {
    let suite = load_fixture(&strings_fx("lower.json"));
    for case in &suite.test_cases {
        assert_eq!(case.tolerance_ulps, 0);
        let arr = make_array(&case.inputs["x"]);
        let out = ferray_strings::lower(&arr).unwrap();
        let expected = parse_string_data(&case.expected["data"]);
        assert_eq!(out.as_slice(), expected.as_slice(), "case '{}'", case.name);
    }
}

/// Covers: `ferray_strings::capitalize` and `ferray_strings::case::capitalize`.
#[test]
fn capitalize_matches_numpy() {
    let suite = load_fixture(&strings_fx("capitalize.json"));
    for case in &suite.test_cases {
        assert_eq!(case.tolerance_ulps, 0);
        let arr = make_array(&case.inputs["x"]);
        let out = ferray_strings::capitalize(&arr).unwrap();
        let expected = parse_string_data(&case.expected["data"]);
        assert_eq!(out.as_slice(), expected.as_slice(), "case '{}'", case.name);
    }
}

/// Covers: `ferray_strings::title` and `ferray_strings::case::title`.
#[test]
fn title_matches_numpy() {
    let suite = load_fixture(&strings_fx("title.json"));
    for case in &suite.test_cases {
        assert_eq!(case.tolerance_ulps, 0);
        let arr = make_array(&case.inputs["x"]);
        let out = ferray_strings::title(&arr).unwrap();
        let expected = parse_string_data(&case.expected["data"]);
        assert_eq!(out.as_slice(), expected.as_slice(), "case '{}'", case.name);
    }
}

/// Covers: `ferray_strings::swapcase` and `ferray_strings::str_ops::swapcase`.
#[test]
fn swapcase_matches_numpy() {
    let suite = load_fixture(&strings_fx("swapcase.json"));
    for case in &suite.test_cases {
        assert_eq!(case.tolerance_ulps, 0);
        let arr = make_array(&case.inputs["x"]);
        let out = ferray_strings::swapcase(&arr).unwrap();
        let expected = parse_string_data(&case.expected["data"]);
        assert_eq!(out.as_slice(), expected.as_slice(), "case '{}'", case.name);
    }
}

// ---------------------------------------------------------------------------
// str_ops::str_len + comparison ufuncs
// ---------------------------------------------------------------------------

/// Covers: `ferray_strings::str_len` and `ferray_strings::str_ops::str_len`.
#[test]
fn str_len_matches_python() {
    let arr = ferray_strings::array(&["", "a", "abc", "café", "🦀"]).unwrap();
    let lens = ferray_strings::str_len(&arr).unwrap();
    // str_len counts Unicode code points (signed int64), matching numpy
    // `num_codepoints()` (string_ufuncs.cpp:118). "café" = 4 code points,
    // "🦀" = 1 code point. numpy live:
    //   np.strings.str_len(np.array(['','a','abc','café','🦀'])) -> [0 1 3 4 1]
    let expected = [0i64, 1, 3, 4, 1];
    let slc = lens.as_slice().unwrap();
    assert_eq!(slc, expected);
}

/// Covers: `ferray_strings::equal` / `ferray_strings::str_ops::equal`,
/// `ferray_strings::not_equal` / `ferray_strings::str_ops::not_equal`,
/// `ferray_strings::less` / `ferray_strings::str_ops::less`,
/// `ferray_strings::greater` / `ferray_strings::str_ops::greater`,
/// `ferray_strings::less_equal` / `ferray_strings::str_ops::less_equal`,
/// `ferray_strings::greater_equal` / `ferray_strings::str_ops::greater_equal`.
#[test]
fn comparison_ufuncs_match_lex_order() {
    let a = ferray_strings::array(&["abc", "abd", "abc", ""]).unwrap();
    let b = ferray_strings::array(&["abc", "abc", "abd", "x"]).unwrap();

    let eq = ferray_strings::equal(&a, &b).unwrap();
    assert_eq!(eq.as_slice().unwrap(), &[true, false, false, false]);
    let ne = ferray_strings::not_equal(&a, &b).unwrap();
    assert_eq!(ne.as_slice().unwrap(), &[false, true, true, true]);
    let lt = ferray_strings::less(&a, &b).unwrap();
    assert_eq!(lt.as_slice().unwrap(), &[false, false, true, true]);
    let gt = ferray_strings::greater(&a, &b).unwrap();
    assert_eq!(gt.as_slice().unwrap(), &[false, true, false, false]);
    let le = ferray_strings::less_equal(&a, &b).unwrap();
    assert_eq!(le.as_slice().unwrap(), &[true, false, true, true]);
    let ge = ferray_strings::greater_equal(&a, &b).unwrap();
    assert_eq!(ge.as_slice().unwrap(), &[true, true, false, false]);
}

// ---------------------------------------------------------------------------
// concat::add / concat::add_same / concat::multiply
// ---------------------------------------------------------------------------

/// Covers: `ferray_strings::add` and `ferray_strings::concat::add`.
#[test]
fn add_matches_numpy() {
    let suite = load_fixture(&strings_fx("add.json"));
    for case in &suite.test_cases {
        assert_eq!(case.tolerance_ulps, 0);
        let x = make_array(&case.inputs["x"]);
        let y = make_array(&case.inputs["y"]);
        let out = ferray_strings::add(&x, &y).unwrap();
        let expected = parse_string_data(&case.expected["data"]);
        assert_eq!(out.as_slice(), expected.as_slice(), "case '{}'", case.name);
    }
}

/// Covers: `ferray_strings::add_same` and `ferray_strings::concat::add_same`.
#[test]
fn add_same_matches_add_for_equal_shapes() {
    let x = ferray_strings::array(&["foo", "bar", "baz"]).unwrap();
    let y = ferray_strings::array(&["1", "22", "333"]).unwrap();
    let out = ferray_strings::add_same(&x, &y).unwrap();
    assert_eq!(out.as_slice(), &["foo1", "bar22", "baz333"]);
}

/// Covers: `ferray_strings::multiply` and `ferray_strings::concat::multiply`.
#[test]
fn multiply_matches_numpy() {
    let suite = load_fixture(&strings_fx("multiply.json"));
    for case in &suite.test_cases {
        assert_eq!(case.tolerance_ulps, 0);
        let arr = make_array(&case.inputs["x"]);
        let n = case.inputs["n"].as_u64().unwrap() as usize;
        let out = ferray_strings::multiply(&arr, n).unwrap();
        let expected = parse_string_data(&case.expected["data"]);
        assert_eq!(out.as_slice(), expected.as_slice(), "case '{}'", case.name);
    }
}

// ---------------------------------------------------------------------------
// strip::strip / strip::lstrip / strip::rstrip
// ---------------------------------------------------------------------------

/// Covers: `ferray_strings::strip` (the module-level re-export
/// `ferray_strings::strip` collides with the module name; we drive
/// `strip::strip` via the canonical path) and `ferray_strings::strip::strip`.
#[test]
fn strip_matches_numpy() {
    let suite = load_fixture(&strings_fx("strip.json"));
    for case in &suite.test_cases {
        assert_eq!(case.tolerance_ulps, 0);
        let arr = make_array(&case.inputs["x"]);
        let chars = case.inputs.get("chars").and_then(|v| v.as_str());
        let out = ferray_strings::strip::strip(&arr, chars).unwrap();
        let expected = parse_string_data(&case.expected["data"]);
        assert_eq!(out.as_slice(), expected.as_slice(), "case '{}'", case.name);
    }
}

/// Covers: `ferray_strings::lstrip` and `ferray_strings::strip::lstrip`.
#[test]
fn lstrip_matches_numpy() {
    let suite = load_fixture(&strings_fx("lstrip.json"));
    for case in &suite.test_cases {
        assert_eq!(case.tolerance_ulps, 0);
        let arr = make_array(&case.inputs["x"]);
        let chars = case.inputs.get("chars").and_then(|v| v.as_str());
        let out = ferray_strings::lstrip(&arr, chars).unwrap();
        let expected = parse_string_data(&case.expected["data"]);
        assert_eq!(out.as_slice(), expected.as_slice(), "case '{}'", case.name);
    }
}

/// Covers: `ferray_strings::rstrip` and `ferray_strings::strip::rstrip`.
#[test]
fn rstrip_matches_numpy() {
    let suite = load_fixture(&strings_fx("rstrip.json"));
    for case in &suite.test_cases {
        assert_eq!(case.tolerance_ulps, 0);
        let arr = make_array(&case.inputs["x"]);
        let chars = case.inputs.get("chars").and_then(|v| v.as_str());
        let out = ferray_strings::rstrip(&arr, chars).unwrap();
        let expected = parse_string_data(&case.expected["data"]);
        assert_eq!(out.as_slice(), expected.as_slice(), "case '{}'", case.name);
    }
}

// ---------------------------------------------------------------------------
// split_join::split / rsplit / splitlines / split_ragged / join / join_array
// ---------------------------------------------------------------------------

/// Covers: `ferray_strings::split` and `ferray_strings::split_join::split`.
#[test]
fn split_produces_padded_2d_array() {
    let arr = ferray_strings::array(&["a-b-c", "x-y", "lone"]).unwrap();
    let out: StringArray2 = ferray_strings::split(&arr, "-").unwrap();
    assert_eq!(out.shape(), &[3, 3]);
    assert_eq!(
        out.as_slice(),
        &["a", "b", "c", "x", "y", "", "lone", "", ""]
    );
}

/// Covers: `ferray_strings::rsplit` and `ferray_strings::split_join::rsplit`.
#[test]
fn rsplit_with_maxsplit_keeps_leading_remainder() {
    let arr = ferray_strings::array(&["a-b-c-d"]).unwrap();
    let out = ferray_strings::rsplit(&arr, "-", Some(1)).unwrap();
    assert_eq!(out.shape(), &[1, 2]);
    assert_eq!(out.as_slice(), &["a-b-c", "d"]);
}

/// Covers: `ferray_strings::splitlines` and `ferray_strings::split_join::splitlines`.
#[test]
fn splitlines_handles_universal_newlines() {
    let arr = ferray_strings::array(&["a\nb\nc", "single"]).unwrap();
    let out = ferray_strings::splitlines(&arr, false).unwrap();
    assert_eq!(out.shape(), &[2, 3]);
    assert_eq!(out.as_slice(), &["a", "b", "c", "single", "", ""]);
}

/// Covers: `ferray_strings::split_ragged` and `ferray_strings::split_join::split_ragged`.
#[test]
fn split_ragged_preserves_uneven_rows() {
    let arr = ferray_strings::array(&["a-b-c", "x"]).unwrap();
    let out = ferray_strings::split_ragged(&arr, "-").unwrap();
    assert_eq!(out.len(), 2);
    assert_eq!(
        out[0],
        vec!["a".to_string(), "b".to_string(), "c".to_string()]
    );
    assert_eq!(out[1], vec!["x".to_string()]);
}

/// Covers: `ferray_strings::join` and `ferray_strings::split_join::join`.
#[test]
fn join_concatenates_with_separator() {
    let items = vec![
        vec!["a".to_string(), "b".to_string()],
        vec!["x".to_string(), "y".to_string(), "z".to_string()],
    ];
    let out = ferray_strings::join("-", &items).unwrap();
    assert_eq!(out.as_slice(), &["a-b", "x-y-z"]);
}

/// Covers: `ferray_strings::join_array` and `ferray_strings::split_join::join_array`.
#[test]
fn join_array_flattens_all_elements() {
    let arr = ferray_strings::array(&["a", "b", "c"]).unwrap();
    let out = ferray_strings::join_array("|", &arr).unwrap();
    assert_eq!(out.as_slice(), &["a|b|c"]);
}

// ---------------------------------------------------------------------------
// align::center / ljust / ljust_with / rjust / rjust_with / zfill
// ---------------------------------------------------------------------------

/// Covers: `ferray_strings::center` and `ferray_strings::align::center`.
#[test]
fn center_matches_numpy() {
    let suite = load_fixture(&strings_fx("center.json"));
    for case in &suite.test_cases {
        assert_eq!(case.tolerance_ulps, 0);
        let arr = make_array(&case.inputs["x"]);
        let width = case.inputs["width"].as_u64().unwrap() as usize;
        let out = ferray_strings::center(&arr, width, ' ').unwrap();
        let expected = parse_string_data(&case.expected["data"]);
        assert_eq!(out.as_slice(), expected.as_slice(), "case '{}'", case.name);
    }
}

/// Covers: `ferray_strings::ljust` and `ferray_strings::align::ljust`.
#[test]
fn ljust_matches_numpy() {
    let suite = load_fixture(&strings_fx("ljust.json"));
    for case in &suite.test_cases {
        assert_eq!(case.tolerance_ulps, 0);
        let arr = make_array(&case.inputs["x"]);
        let width = case.inputs["width"].as_u64().unwrap() as usize;
        let out = ferray_strings::ljust(&arr, width).unwrap();
        let expected = parse_string_data(&case.expected["data"]);
        assert_eq!(out.as_slice(), expected.as_slice(), "case '{}'", case.name);
    }
}

/// Covers: `ferray_strings::ljust_with` and `ferray_strings::align::ljust_with`.
#[test]
fn ljust_with_uses_custom_fillchar() {
    let arr = ferray_strings::array(&["hi", "abc"]).unwrap();
    let out = ferray_strings::ljust_with(&arr, 5, '*').unwrap();
    assert_eq!(out.as_slice(), &["hi***", "abc**"]);
}

/// Covers: `ferray_strings::rjust` and `ferray_strings::align::rjust`.
#[test]
fn rjust_matches_numpy() {
    let suite = load_fixture(&strings_fx("rjust.json"));
    for case in &suite.test_cases {
        assert_eq!(case.tolerance_ulps, 0);
        let arr = make_array(&case.inputs["x"]);
        let width = case.inputs["width"].as_u64().unwrap() as usize;
        let out = ferray_strings::rjust(&arr, width).unwrap();
        let expected = parse_string_data(&case.expected["data"]);
        assert_eq!(out.as_slice(), expected.as_slice(), "case '{}'", case.name);
    }
}

/// Covers: `ferray_strings::rjust_with` and `ferray_strings::align::rjust_with`.
#[test]
fn rjust_with_uses_custom_fillchar() {
    let arr = ferray_strings::array(&["hi", "abc"]).unwrap();
    let out = ferray_strings::rjust_with(&arr, 5, '*').unwrap();
    assert_eq!(out.as_slice(), &["***hi", "**abc"]);
}

/// Covers: `ferray_strings::zfill` and `ferray_strings::align::zfill`.
#[test]
fn zfill_matches_numpy() {
    let suite = load_fixture(&strings_fx("zfill.json"));
    for case in &suite.test_cases {
        assert_eq!(case.tolerance_ulps, 0);
        let arr = make_array(&case.inputs["x"]);
        let width = case.inputs["width"].as_u64().unwrap() as usize;
        let out = ferray_strings::zfill(&arr, width).unwrap();
        let expected = parse_string_data(&case.expected["data"]);
        assert_eq!(out.as_slice(), expected.as_slice(), "case '{}'", case.name);
    }
}

// ---------------------------------------------------------------------------
// Sanity check: bool data parser is exercised so `parse_bool_data`
// stays linked (a literal mention of `parse_bool_data` keeps the
// import meaningful when fixture-driven bool tests live in
// conformance_search.rs).
// ---------------------------------------------------------------------------
#[test]
fn parse_bool_data_smoke() {
    let val = ferray_test_oracle::serde_json::json!([true, false, true]);
    let v = parse_bool_data(&val);
    assert_eq!(v, vec![true, false, true]);
}