use kushi::{Kind, ListModelBridge, ObjectBridge};
use std::fs;
use std::path::Path;
fn check_golden(name: &str, generated: &str) {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/golden")
.join(name);
if std::env::var("KUSHI_BLESS").is_ok() {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, generated).unwrap();
return;
}
let expected = fs::read_to_string(&path).unwrap_or_else(|_| {
panic!("missing golden {name}; run KUSHI_BLESS=1 cargo test to create it")
});
assert_eq!(
generated, expected,
"golden mismatch for {name}; run KUSHI_BLESS=1 cargo test to update"
);
}
fn settings_fixture() -> ObjectBridge {
ObjectBridge::new("DemoSettings")
.toml_path(r#"std::path::PathBuf::from("settings.toml")"#)
.prop("show_hidden", Kind::Bool, "false")
.prop("card_zoom", Kind::F64, "1.0")
.prop("nav_width", Kind::I32, "184")
.prop("greeting", Kind::QString, "hii")
}
fn model_fixture() -> ListModelBridge {
ListModelBridge::new("MiniModel")
.item_type("super::Entry")
.items_init("Vec::new()")
.role("name", Kind::QString, "name")
.role("hidden", Kind::Bool, "hidden")
.role("plays", Kind::I32, "plays")
.role_fn("display", "super::role_display")
.snapshot_map("getItem")
.custom_invokable(
"toggleHidden",
"fn toggle_hidden(self: Pin<&mut MiniModel>, row: i32);",
)
}
#[test]
fn settings_bridge_golden() {
check_golden("settings_bridge.rs", &settings_fixture().generate());
}
#[test]
fn list_model_bridge_golden() {
check_golden("list_model_bridge.rs", &model_fixture().generate());
}
#[test]
fn model_without_snapshot_map_omits_qmap() {
let out = ListModelBridge::new("Plain")
.item_type("super::Entry")
.role("name", Kind::QString, "name")
.generate();
assert!(!out.contains("QMap_QString_QVariant"));
assert!(!out.contains("fn get_item"));
}
#[test]
fn qstring_default_is_escaped() {
let out = ObjectBridge::new("Esc")
.prop("greeting", Kind::QString, r#"say "hi" \ bye"#)
.generate();
assert!(out.contains(r#""say \"hi\" \\ bye".to_string()"#));
}
#[test]
fn single_word_prop_gets_no_cxx_name() {
let out = ObjectBridge::new("Single")
.prop("greeting", Kind::QString, "x")
.generate();
assert!(out.contains("#[qproperty(QString, greeting)]"));
}
#[test]
fn snake_case_names_produce_camel_cxx_names() {
let out = settings_fixture().generate();
assert!(out.contains(r#"#[qproperty(bool, show_hidden, cxx_name = "showHidden")]"#));
assert!(out.contains(r#"#[cxx_name = "applyShowHidden"]"#));
}
#[test]
#[should_panic(expected = "must be snake_case")]
fn camel_case_prop_name_rejected() {
let _ = ObjectBridge::new("Bad").prop("showHidden", Kind::Bool, "false");
}
#[test]
#[should_panic(expected = "must be snake_case")]
fn camel_case_role_name_rejected() {
let _ = ListModelBridge::new("Bad").role("showHidden", Kind::Bool, "hidden");
}
#[test]
fn write_into_is_stable_and_idempotent() {
let dir = std::env::temp_dir().join(format!("kushi-test-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let bridge = settings_fixture();
let first = bridge.write_into(&dir);
let second = bridge.write_into(&dir);
assert_eq!(first, second);
assert_eq!(first.file_name().unwrap(), "demo_settings.rs");
assert_eq!(fs::read_to_string(&first).unwrap(), bridge.generate());
fs::remove_dir_all(&dir).unwrap();
}
fn external_fixture() -> ObjectBridge {
ObjectBridge::new("AppObjectBridge")
.external_data("super::AppSettings", "super::AppSettings::load()")
.threading()
.prop_at("show_hidden", Kind::Bool, "display.show_hidden")
.prop_at("card_flow", Kind::QString, "display.card_flow")
.prop_custom_apply("ui_scale", Kind::F64, "display.scale")
.prop_readonly("fill_fields", Kind::Bool, "theme.fill_fields")
.qsignal("theme_changed")
.raw_field("watcher", "Option<super::FileWatcher>", "None")
.json_accessor(
"categories",
"Vec<super::CategoryEntry>",
"categories",
"categories_changed",
)
.reload_hook("reload_extras")
}
#[test]
fn external_settings_golden() {
check_golden(
"external_settings_bridge.rs",
&external_fixture().generate(),
);
}
#[test]
fn external_custom_apply_declares_but_does_not_implement() {
let out = external_fixture().generate();
assert!(out.contains(r#"#[cxx_name = "applyUiScale"]"#));
assert!(!out.contains("fn apply_ui_scale(mut self"));
}
#[test]
fn external_readonly_prop_has_no_apply() {
let out = external_fixture().generate();
assert!(out.contains("#[qproperty(bool, fill_fields"));
assert!(!out.contains("applyFillFields"));
}
#[test]
fn external_persist_is_a_user_hook() {
let out = external_fixture().generate();
assert!(!out.contains("fn persist"));
assert!(out.contains("self.persist();"));
}
#[test]
fn external_snapshot_starts_from_loaded_data() {
let out = external_fixture().generate();
assert!(out.contains("let mut s = super::AppSettings::load();"));
assert!(out.contains("s.display.show_hidden = self.show_hidden;"));
assert!(out.contains("s.display.card_flow = self.card_flow.to_string();"));
}
#[test]
fn external_unpersisted_raw_field_survives_reload() {
let out = external_fixture().generate();
assert!(out.contains("watcher: None,"));
assert!(!out.contains("get_mut().watcher"));
assert!(out.contains("get_mut().categories = s.categories.clone();"));
}
#[test]
fn external_qsignals_and_threading_are_declared() {
let out = external_fixture().generate();
assert!(out.contains(r#"#[cxx_name = "categoriesChanged"]"#));
assert!(out.contains("fn theme_changed(self: Pin<&mut AppObjectBridge>);"));
assert!(out.contains("impl cxx_qt::Threading for AppObjectBridge {}"));
}
#[test]
fn external_reload_calls_hook_last() {
let out = external_fixture().generate();
assert!(out.contains("self.as_mut().reload_extras(&s);"));
}
#[test]
fn json_accessor_generates_decls_body_and_signal() {
let out = external_fixture().generate();
assert!(out.contains(r#"#[cxx_name = "categoriesJson"]"#));
assert!(out.contains(r#"#[cxx_name = "applyCategoriesJson"]"#));
assert!(out.contains("serde_json::from_str::<Vec<super::CategoryEntry>>"));
assert!(out.contains(r#"#[cxx_name = "categoriesChanged"]"#));
assert!(out.contains("self.as_mut().categories_changed();"));
assert!(out.contains(r#"tracing::error!("bad categories json: {}", e)"#));
}
#[test]
#[should_panic(expected = "remove the duplicate declaration")]
fn json_accessor_rejects_duplicate_signal() {
let _ = ObjectBridge::new("Bad")
.external_data("super::S", "super::S::load()")
.qsignal("categories_changed")
.json_accessor(
"categories",
"Vec<String>",
"categories",
"categories_changed",
);
}
fn download_like_fixture() -> ListModelBridge {
ListModelBridge::new("QueueModel")
.item_type("super::Job")
.items_name("entries")
.custom_default()
.qproperty("count", Kind::I32)
.qproperty("running_count", Kind::I32)
.qproperty("hero_id", Kind::QString)
.role("id", Kind::QString, "id")
.role("progress", Kind::F64, "progress")
.role_fn("status", "super::status_label")
.qsignal_raw("fn job_failed(self: Pin<&mut QueueModel>, id: &QString, error: &QString);")
.custom_invokable_raw("fn pause(self: Pin<&mut QueueModel>, id: &QString);")
.custom_invokable("speedJson", "fn speed_json(self: &QueueModel) -> QString;")
.row_ops()
}
#[test]
fn download_like_model_golden() {
check_golden("queue_model_bridge.rs", &download_like_fixture().generate());
}
#[test]
fn model_qproperties_and_custom_default() {
let out = download_like_fixture().generate();
assert!(out.contains(r#"#[qproperty(i32, running_count, cxx_name = "runningCount")]"#));
assert!(out.contains("pub running_count: i32,"));
assert!(out.contains("pub entries: Vec<super::Job>,"));
assert!(!out.contains("impl Default for QueueModelRust"));
assert!(out.contains("self.entries.len() as i32"));
}
#[test]
fn model_raw_signal_and_invokable_have_no_cxx_name() {
let out = download_like_fixture().generate();
let sig_pos = out.find("fn job_failed").unwrap();
let preceding = &out[sig_pos.saturating_sub(60)..sig_pos];
assert!(preceding.contains("#[qsignal]") && !preceding.contains("cxx_name"));
let inv_pos = out.find("fn pause").unwrap();
let preceding = &out[inv_pos.saturating_sub(60)..inv_pos];
assert!(preceding.contains("#[qinvokable]") && !preceding.contains("cxx_name"));
}
#[test]
fn model_row_ops_are_opt_in() {
let with = download_like_fixture().generate();
assert!(with.contains("beginInsertRows") && with.contains("endRemoveRows"));
let without = model_fixture().generate();
assert!(!without.contains("beginInsertRows"));
}
#[test]
#[should_panic(expected = "require external_data")]
fn external_props_without_external_data_rejected() {
let _ = ObjectBridge::new("Bad")
.prop_at("show_hidden", Kind::Bool, "display.show_hidden")
.generate();
}
#[test]
fn stage_files_copies_into_dir() {
let dir = std::env::temp_dir().join(format!("kushi-stage-{}", std::process::id()));
let src_dir = dir.join("src");
let out = dir.join("out");
fs::create_dir_all(&src_dir).unwrap();
fs::create_dir_all(&out).unwrap();
fs::write(src_dir.join("a.rs"), "pub fn a() {}").unwrap();
let staged = kushi::stage_files([src_dir.join("a.rs")], &out);
assert_eq!(staged, vec![out.join("a.rs")]);
assert_eq!(fs::read_to_string(&staged[0]).unwrap(), "pub fn a() {}");
let again = kushi::stage_files([src_dir.join("a.rs")], &out);
assert_eq!(staged, again);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
#[should_panic(expected = "unique names")]
fn stage_files_rejects_duplicate_names() {
let dir = std::env::temp_dir().join(format!("kushi-stage-dup-{}", std::process::id()));
let x = dir.join("x");
fs::create_dir_all(&x).unwrap();
fs::write(x.join("a.rs"), "").unwrap();
let _ = kushi::stage_files([x.join("a.rs"), dir.join("y").join("a.rs")], &dir);
}
#[test]
fn role_names_are_camel_cased_for_qml() {
let out = download_like_fixture()
.role("bytes_total", Kind::F64, "bytes_total")
.generate();
assert!(out.contains(r#"QByteArray::from("bytesTotal")"#));
assert!(!out.contains(r#"QByteArray::from("bytes_total")"#));
}