cranpose-ui 0.9.9

Composable widgets, text, layouts and input for Cranpose apps
Documentation
use std::{cell::RefCell, rc::Rc};

use cranpose_core::MutableState;
use cranpose_ui::{
    Catalog, LayoutDirection, Locale, Modifier, ProvideLayoutDirection, ProvideLocalization, Text,
    TextStyle, UiString, composable, layout_direction, run_test_composition, tr,
    translation_messages, translations,
};

translation_messages!(pub mod messages, "tests/fixtures/localization/en/cranpose-ui.ftl");

fn locale(tag: &str) -> Locale {
    Locale::parse(tag).expect("locale")
}

#[test]
fn event_messages_own_arguments_and_follow_later_language_changes() {
    let name = String::from("Ana 商品");
    let message = cranpose_ui::message!("Hello, {name}!", id = "hello", name = name.as_str());
    drop(name);
    let mut composition = run_test_composition(|| {});
    let language = MutableState::with_runtime(locale("en"), composition.runtime_handle());
    let output = Rc::new(RefCell::new(String::new()));
    composition
        .render(981, || {
            DeferredRoot(language, message.clone(), output.clone());
        })
        .expect("initial render");
    assert!(output.borrow().contains("Hello"));
    language.set_value(locale("fr"));
    composition
        .process_invalid_scopes()
        .expect("language change");
    assert!(output.borrow().contains("Bonjour"));
    assert!(output.borrow().contains("Ana 商品"));
}

#[composable]
fn DeferredRoot(
    language: MutableState<Locale>,
    message: cranpose_ui::localization::DeferredMessage,
    output: Rc<RefCell<String>>,
) {
    ProvideLocalization(&catalog(), language.value(), || {
        *output.borrow_mut() = cranpose_ui::localization::localized_message(&message).into();
    });
}

fn catalog() -> Catalog {
    translations!("tests/fixtures/localization", fallback = "en")
}

#[composable]
fn Label(output: Rc<RefCell<Vec<String>>>, tick: u32) {
    let label = tr!("Save", id = "save");
    output
        .borrow_mut()
        .push(format!("{tick}:{}", label.as_str()));
    Text(
        label.clone(),
        Modifier::empty().content_description(label),
        TextStyle::default(),
    );
}

#[composable]
fn Root(
    catalog: Catalog,
    language: MutableState<Locale>,
    tick: MutableState<u32>,
    output: Rc<RefCell<Vec<String>>>,
) {
    ProvideLocalization(&catalog, language.value(), || Label(output, tick.value()));
}

#[test]
fn changing_language_after_a_cached_read_updates_existing_composables() {
    let mut composition = run_test_composition(|| {});
    let language = MutableState::with_runtime(locale("en"), composition.runtime_handle());
    let tick = MutableState::with_runtime(0, composition.runtime_handle());
    let output = Rc::new(RefCell::new(Vec::new()));
    composition
        .render(901, || Root(catalog(), language, tick, output.clone()))
        .expect("render");
    tick.set_value(1);
    composition.process_invalid_scopes().expect("cached read");
    language.set_value(locale("fr"));
    composition
        .process_invalid_scopes()
        .expect("language switch");
    assert_eq!(
        output.borrow().last().map(String::as_str),
        Some("1:Enregistrer")
    );
}

#[test]
fn nested_providers_restore_language_and_direction_and_allow_overrides() {
    let mut seen = Vec::new();
    run_test_composition(|| {
        ProvideLocalization(&catalog(), locale("fr"), || {
            seen.push((tr!("Save", id = "save").to_string(), layout_direction()));
            ProvideLocalization(&catalog(), locale("ar"), || {
                seen.push((UiString::Search.resolve(), layout_direction()));
                ProvideLayoutDirection(LayoutDirection::Ltr, || {
                    assert_eq!(layout_direction(), LayoutDirection::Ltr);
                });
            });
            seen.push((tr!("Save", id = "save").to_string(), layout_direction()));
        });
    });
    assert_eq!(
        seen,
        [
            ("Enregistrer".into(), LayoutDirection::Ltr),
            ("بحث".into(), LayoutDirection::Rtl),
            ("Enregistrer".into(), LayoutDirection::Ltr)
        ]
    );
}

#[test]
fn separate_compositions_and_framework_overrides_do_not_share_language() {
    let mut first = String::new();
    let mut second = String::new();
    let _a = run_test_composition(|| {
        ProvideLocalization(&catalog(), locale("fr"), || {
            first = UiString::Copy.resolve();
        });
    });
    let _b = run_test_composition(|| {
        ProvideLocalization(&catalog(), locale("en"), || {
            second = UiString::Copy.resolve();
        });
    });
    assert_eq!(first, "Copier autrement");
    assert_eq!(second, "Copy");
}

#[test]
fn generated_accessors_and_borrowed_arguments_work_without_a_provider() {
    let mut seen = Vec::new();
    run_test_composition(|| {
        let name = "Ana".to_owned();
        seen.push(tr!("Hello, {name}!", name = name.as_str()).to_string());
        seen.push(messages::files(2).to_string());
        seen.push(messages::save().to_string());
    });
    assert!(seen[0].contains("Ana"));
    assert!(seen[1].contains("files"));
    assert_eq!(seen[2], "Save");
}

#[test]
fn replacing_catalogs_and_changing_arguments_refreshes_text() {
    let mut composition = run_test_composition(|| {});
    let mut result = String::new();
    for (source, count, expected) in [
        ("files = { $count } documents", 2, "documents"),
        ("files = { $count } articles", 3, "articles"),
    ] {
        let catalog = Catalog::from_resources(
            "en",
            &[cranpose_ui::localization::Resource {
                locale: "fr",
                namespace: "cranpose-ui",
                source,
            }],
        )
        .expect("catalog");
        composition
            .render(905, || {
                ProvideLocalization(&catalog, locale("fr"), || {
                    result = messages::files(count).to_string();
                });
            })
            .expect("render");
        assert!(result.contains(expected));
        assert!(result.contains(&count.to_string()));
    }
}

#[test]
fn application_preferences_update_retained_text_and_restore_system_language() {
    use cranpose_ui::{
        UiText,
        localization::{LanguagePreference, Localization},
    };
    let application =
        Localization::persistent(catalog(), vec![locale("fr")], PreferenceStore::default());
    let controller = Rc::new(RefCell::new(None));
    let output = Rc::new(RefCell::new(String::new()));
    let retained = UiText::from(cranpose_ui::message!("Save", id = "save"));
    let mut composition = run_test_composition(|| {});
    composition
        .render(991, || {
            PreferenceRoot(
                application.clone(),
                controller.clone(),
                retained.clone(),
                output.clone(),
            );
        })
        .expect("render");
    let controller = controller.borrow().clone().expect("controller");
    wait_for_preferences(&mut composition, &controller);
    assert_eq!(*output.borrow(), "Enregistrer");
    choose_language(
        &mut composition,
        &controller,
        LanguagePreference::from_setting(Some("en"), application.catalog()),
    );
    assert_eq!(*output.borrow(), "Save");
    choose_language(&mut composition, &controller, LanguagePreference::System);
    assert_eq!(*output.borrow(), "Enregistrer");
}

#[derive(Default)]
struct PreferenceStore(std::sync::Mutex<Option<String>>);

impl cranpose_ui::localization::LanguagePreferenceStore for PreferenceStore {
    fn load(&self) -> Result<Option<String>, cranpose_ui::localization::LocalizationError> {
        Ok(self.0.lock().expect("preference store").clone())
    }

    fn save(&self, value: &str) -> Result<(), cranpose_ui::localization::LocalizationError> {
        *self.0.lock().expect("preference store") = Some(value.into());
        Ok(())
    }
}

#[test]
fn in_memory_language_changes_do_not_require_a_worker_runtime() {
    use cranpose_ui::localization::{LanguagePreference, Localization};
    let application = Localization::new(catalog(), vec![locale("fr")]);
    let controller = Rc::new(RefCell::new(None));
    let output = Rc::new(RefCell::new(String::new()));
    let mut composition = run_test_composition(|| {});
    composition
        .render(992, || {
            PreferenceRoot(
                application.clone(),
                controller.clone(),
                cranpose_ui::message!("Save", id = "save").into(),
                output.clone(),
            );
        })
        .expect("render");
    let controller = controller.borrow().clone().expect("controller");
    assert!(!controller.is_busy());
    controller.select(LanguagePreference::from_setting(
        Some("en"),
        application.catalog(),
    ));
    composition
        .process_invalid_scopes()
        .expect("language change");
    assert_eq!(controller.error(), None);
    assert_eq!(*output.borrow(), "Save");
}

#[composable]
fn PreferenceRoot(
    application: cranpose_ui::localization::Localization,
    controller: Rc<RefCell<Option<cranpose_ui::LocalizationController>>>,
    retained: cranpose_ui::UiText,
    output: Rc<RefCell<String>>,
) {
    cranpose_ui::ProvideLanguagePreferences(application, &[locale("fr")], || {
        *controller.borrow_mut() = cranpose_ui::local_localization().current();
        *output.borrow_mut() = retained.resolve().to_string();
    });
}

fn choose_language(
    composition: &mut cranpose_ui::TestComposition,
    controller: &cranpose_ui::LocalizationController,
    preference: cranpose_ui::localization::LanguagePreference,
) {
    let action = controller.clone();
    composition
        .runtime_handle()
        .spawn_ui(async move {
            action.select(preference);
        })
        .expect("event");
    wait_for_preferences(composition, controller);
}

fn wait_for_preferences(
    composition: &mut cranpose_ui::TestComposition,
    controller: &cranpose_ui::LocalizationController,
) {
    let until = std::time::Instant::now() + std::time::Duration::from_secs(2);
    loop {
        composition.runtime_handle().drain_ui();
        composition
            .process_invalid_scopes()
            .expect("preference update");
        if !controller.is_busy() {
            break;
        }
        assert!(
            std::time::Instant::now() < until,
            "preference request exceeded deadline"
        );
        std::thread::yield_now();
    }
    assert_eq!(controller.error(), None);
}

#[test]
fn literal_ui_text_remains_literal_inside_a_language_provider() {
    let literal = cranpose_ui::UiText::from("Save");
    run_test_composition(|| {
        ProvideLocalization(&catalog(), locale("fr"), || {
            assert_eq!(literal.resolve().as_str(), "Save");
            Text(literal.clone(), Modifier::empty(), TextStyle::default());
        });
    });
}