readsight 1.0.1

Multilingual readability library — 86 languages, 17 formulas, TeX-based syllable counting via the Frank M. Liang algorithm.
Documentation
//! Integration tests for the `ReadSight` facade, error paths, and data sources.

use std::path::PathBuf;

use readsight::language::JsonLanguageRepository;
use readsight::{Config, Engine, Error, ReadSight, Script};

const SAMPLE: &str = "The quick brown fox jumps over the lazy dog. It was a sunny day.";

fn data_dir(sub: &str) -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("data")
        .join(sub)
}

#[test]
fn supported_languages_sorted_86() {
    let langs = ReadSight::supported_languages(None);
    assert_eq!(langs.len(), 86);
    let mut sorted = langs.clone();
    sorted.sort();
    assert_eq!(langs, sorted);
}

#[test]
fn language_accessor() {
    let rs = ReadSight::new("en-us").unwrap();
    assert_eq!(rs.language().code, "en-us");
    assert_eq!(rs.language().name, "English (US)");
    assert_eq!(rs.language().script, Script::Latin);
    assert_eq!(rs.language().script.as_str(), "Latin");
}

#[test]
fn code_is_normalized() {
    let rs = ReadSight::new("  EN-US  ").unwrap();
    assert_eq!(rs.language().code, "en-us");
}

#[test]
fn engine_alias_works() {
    let rs: Engine = Engine::new("fr").unwrap();
    assert_eq!(rs.language().code, "fr");
}

#[test]
fn unsupported_language_errors() {
    match ReadSight::new("zz-zz") {
        Err(Error::UnsupportedLanguage(code)) => assert_eq!(code, "zz-zz"),
        Err(other) => panic!("expected UnsupportedLanguage, got {other:?}"),
        Ok(_) => panic!("expected UnsupportedLanguage, got Ok"),
    }
}

#[test]
fn unsupported_formula_errors() {
    let rs = ReadSight::new("en-us").unwrap();
    // gulpease is Italian-only
    match rs.score("gulpease", SAMPLE) {
        Err(Error::UnsupportedFormula { formula, language }) => {
            assert_eq!(formula, "gulpease");
            assert_eq!(language, "en-us");
        }
        other => panic!("expected UnsupportedFormula, got {other:?}"),
    }
    assert!(rs.gulpease(SAMPLE).is_err());
    assert!(rs.osman(SAMPLE).is_err());
}

#[test]
fn empty_text_errors() {
    let rs = ReadSight::new("en-us").unwrap();
    assert!(matches!(rs.analyze("   "), Err(Error::EmptyText)));
    assert!(matches!(rs.gunning_fog(""), Err(Error::EmptyText)));
}

#[test]
fn convenience_methods_ok() {
    let rs = ReadSight::new("en-us").unwrap();
    assert!(rs.flesch_reading_ease(SAMPLE).is_ok());
    assert!(rs.flesch_kincaid_grade_level(SAMPLE).is_ok());
    assert!(rs.gunning_fog(SAMPLE).is_ok());
    assert!(rs.smog_index(SAMPLE).is_ok());
    assert!(rs.coleman_liau(SAMPLE).is_ok());
    assert!(rs.automated_readability_index(SAMPLE).is_ok());
    assert!(rs.lix(SAMPLE).is_ok());
    assert!(rs.dale_chall(SAMPLE).is_ok());
    assert!(rs.spache(SAMPLE).is_ok());

    let es = ReadSight::new("es").unwrap();
    assert!(es.fernandez_huerta(SAMPLE).is_ok());
    assert!(es.szigriszt_pazos(SAMPLE).is_ok());
    assert!(es.gutierrez_polini(SAMPLE).is_ok());
    assert!(es.crawford(SAMPLE).is_ok());

    assert!(ReadSight::new("it").unwrap().gulpease(SAMPLE).is_ok());
    assert!(ReadSight::new("pl").unwrap().fog_pl(SAMPLE).is_ok());
    assert!(ReadSight::new("ar").unwrap().osman(SAMPLE).is_ok());
}

#[test]
fn wiener_variants_and_invalid() {
    let rs = ReadSight::new("de-1996").unwrap();
    for v in 1..=4 {
        let r = rs.wiener_sachtextformel(SAMPLE, v).unwrap();
        assert_eq!(r.formula_name, format!("wiener_sachtextformel_{v}"));
    }
    match rs.wiener_sachtextformel(SAMPLE, 5) {
        Err(Error::InvalidVariant(5)) => {}
        other => panic!("expected InvalidVariant(5), got {other:?}"),
    }
}

#[test]
fn add_hyphenations_overrides() {
    let rs = ReadSight::new("en-us").unwrap();
    rs.add_hyphenations([("readsight".to_string(), "read-sight".to_string())]);
    assert_eq!(rs.split_word("readsight"), vec!["read", "sight"]);
}

#[test]
fn text_metric_helpers() {
    let rs = ReadSight::new("en-us").unwrap();
    assert_eq!(rs.word_count(SAMPLE), 14);
    assert_eq!(rs.sentence_count(SAMPLE), 2);
    assert!(rs.letter_count(SAMPLE) > 0);
    assert!(rs.average_syllables_per_word(SAMPLE) >= 1.0);
    assert!(rs.average_words_per_sentence(SAMPLE) > 0.0);
    assert!(!rs.histogram_syllables(SAMPLE).is_empty());
}

#[test]
fn total_syllables_equals_manual_sum() {
    let rs = ReadSight::new("en-us").unwrap();
    let text = "The quick brown fox jumps over the lazy dog";
    let manual: i64 = [
        "The", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog",
    ]
    .iter()
    .map(|w| rs.syllable_count(w))
    .sum();
    assert_eq!(rs.total_syllables(text), manual);
}

#[test]
fn histogram_sums_to_word_count() {
    let rs = ReadSight::new("en-us").unwrap();
    let stats = rs.analyze(SAMPLE).unwrap();
    let sum: i64 = stats.syllable_histogram.values().sum();
    assert_eq!(sum, stats.word_count);
}

#[test]
fn proper_noun_filtering() {
    let rs = ReadSight::new("en-us").unwrap();
    let text = "The beautiful Elephant.";
    assert_eq!(rs.words_with_more_than_n_syllables(text, 2, true), 2);
    assert_eq!(rs.words_with_more_than_n_syllables(text, 2, false), 1);
    assert_eq!(rs.polysyllable_count(text, true), 2);
}

#[test]
fn split_reconstructs_and_bounds_multilingual() {
    let cases: &[(&str, &[&str])] = &[
        ("en-us", &["banana", "computer", "beautiful", "a", "the"]),
        ("ru", &["молоко", "привет", "я"]),
        ("de-1996", &["Schreiben", "Deutsch"]),
        ("fr", &["bonjour"]),
        ("es", &["hola", "computadora"]),
        ("it", &["ciao"]),
    ];
    for (lang, words) in cases {
        let rs = ReadSight::new(lang).unwrap();
        for word in *words {
            let parts = rs.split_word(word);
            assert_eq!(parts.concat(), *word, "{lang}: reconstruct {word}");
            let count = rs.syllable_count(word);
            let len = word.chars().count() as i64;
            assert!(
                count >= 1 && count <= len,
                "{lang}: {word} count {count} len {len}"
            );
            // determinism
            assert_eq!(rs.split_word(word), parts, "{lang}: {word} deterministic");
        }
    }
}

#[test]
fn empty_and_single_char_words() {
    let rs = ReadSight::new("en-us").unwrap();
    assert_eq!(rs.syllable_count(""), 0);
    assert_eq!(rs.split_word(""), Vec::<String>::new());
    for c in ["a", "b", "z", "A", "Z"] {
        assert_eq!(rs.syllable_count(c), 1);
        assert_eq!(rs.split_word(c), vec![c]);
    }
}

#[test]
fn filesystem_data_source_matches_embedded() {
    let config = Config::from_dirs(data_dir("patterns"), data_dir("languages"));
    let fs = ReadSight::with_config("en-us", config).unwrap();
    let embedded = ReadSight::new("en-us").unwrap();
    assert_eq!(
        fs.syllable_count("hyphenation"),
        embedded.syllable_count("hyphenation")
    );
    assert_eq!(
        fs.flesch_reading_ease(SAMPLE).unwrap().score,
        embedded.flesch_reading_ease(SAMPLE).unwrap().score
    );
    assert_eq!(
        ReadSight::supported_languages(Some(&Config::from_dirs(
            data_dir("patterns"),
            data_dir("languages")
        )))
        .len(),
        86
    );
}

#[test]
fn language_layer_methods() {
    let rs = ReadSight::new("en-us").unwrap();
    let lang = rs.language();
    assert!(lang.supports_formula("flesch_reading_ease"));
    assert!(!lang.supports_formula("nonexistent"));
    assert!(lang.get_formula_config("lix").is_some());
    assert_eq!(
        lang.formula_number("flesch_reading_ease", "base"),
        Some(206.835)
    );
    assert_eq!(lang.formula_number("lix", "longWordThreshold"), Some(6.0));
    assert!(lang.get_supported_formulas().contains(&"lix".to_string()));
}

#[test]
fn repository_operations() {
    let mut repo = JsonLanguageRepository::new(Config::default_embedded());
    assert!(repo.exists("en-us"));
    assert!(repo.exists("  RU  "));
    assert!(!repo.exists("zz"));
    assert_eq!(repo.find("en-us").unwrap().code, "en-us");
    // memoized second lookup + exists cache-hit path
    assert_eq!(repo.find("en-us").unwrap().name, "English (US)");
    assert!(repo.exists("en-us"));
    assert!(matches!(
        repo.find("zz"),
        Err(Error::UnsupportedLanguage(_))
    ));
    assert_eq!(repo.list_codes().unwrap().len(), 86);
}

#[test]
fn filesystem_repository_lists_codes() {
    let mut repo = JsonLanguageRepository::new(Config::from_dirs(
        data_dir("patterns"),
        data_dir("languages"),
    ));
    assert_eq!(repo.list_codes().unwrap().len(), 86);
    assert!(repo.exists("en-us"));
    assert!(!repo.exists("zz-nonexistent"));
    assert_eq!(repo.find("de-1996").unwrap().code, "de-1996");
}

#[test]
fn registry_accessor() {
    let rs = ReadSight::new("en-us").unwrap();
    assert!(rs.registry().has("smog"));
    assert_eq!(rs.registry().list_names().len(), 17);
}

#[test]
fn filesystem_list_skips_non_json_and_handles_bad_dir() {
    // Build a small fixture dir (inside the repo) containing a .json language
    // file and a stray non-.json file, to exercise the extension filter.
    let base = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("temp/cov_fs");
    let langs = base.join("languages");
    let pats = base.join("patterns");
    std::fs::create_dir_all(&langs).unwrap();
    std::fs::create_dir_all(&pats).unwrap();
    std::fs::copy(
        data_dir("languages").join("en-us.json"),
        langs.join("en-us.json"),
    )
    .unwrap();
    std::fs::write(langs.join("notes.txt"), "ignore me").unwrap();

    let codes = ReadSight::supported_languages(Some(&Config::from_dirs(&pats, &langs)));
    assert_eq!(codes, vec!["en-us".to_string()]);

    // A missing directory yields an empty list (read_dir error path).
    let missing = ReadSight::supported_languages(Some(&Config::from_dirs(
        base.join("no-patterns"),
        base.join("no-languages"),
    )));
    assert!(missing.is_empty());

    std::fs::remove_dir_all(&base).ok();
}

#[test]
fn malformed_language_json_errors() {
    let base = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("temp/cov_badjson");
    let langs = base.join("languages");
    let pats = base.join("patterns");
    std::fs::create_dir_all(&langs).unwrap();
    std::fs::create_dir_all(&pats).unwrap();
    std::fs::write(langs.join("en-us.json"), "{ this is not valid json").unwrap();

    match ReadSight::with_config("en-us", Config::from_dirs(&pats, &langs)) {
        Err(Error::Json(_)) => {}
        Err(other) => panic!("expected Json error, got {other:?}"),
        Ok(_) => panic!("expected Json error, got Ok"),
    }

    std::fs::remove_dir_all(&base).ok();
}

#[test]
fn error_display_messages() {
    assert_eq!(
        Error::UnsupportedLanguage("xx".into()).to_string(),
        "Language \"xx\" is not supported."
    );
    assert_eq!(
        Error::UnsupportedFormula {
            formula: "smog".into(),
            language: "xx".into()
        }
        .to_string(),
        "Formula \"smog\" is not supported for language \"xx\"."
    );
    assert_eq!(Error::EmptyText.to_string(), "Text cannot be empty.");
    assert_eq!(
        Error::InvalidVariant(9).to_string(),
        "Wiener Sachtextformel variant must be 1-4, got 9."
    );
    assert!(Error::PatternFileNotFound("f.tex".into())
        .to_string()
        .contains("f.tex"));
    assert!(Error::InvalidPattern("bad".into())
        .to_string()
        .contains("bad"));
}

#[test]
fn pattern_file_not_found_on_bad_dirs() {
    let config = Config::from_dirs("no-such-patterns", data_dir("languages"));
    match ReadSight::with_config("en-us", config) {
        Err(Error::PatternFileNotFound(_)) => {}
        Err(other) => panic!("expected PatternFileNotFound, got {other:?}"),
        Ok(_) => panic!("expected PatternFileNotFound, got Ok"),
    }
}