use super::*;
fn trait_bridge_config_for_tests() -> ResolvedCrateConfig {
resolved_one(
r#"
[workspace]
languages = ["r"]
[[crates]]
name = "test-lib"
sources = ["src/lib.rs"]
[crates.r]
package_name = "testlib"
[[crates.trait_bridges]]
trait_name = "OcrBackend"
super_trait = "test_lib::Plugin"
registry_getter = "test_lib::get_ocr_backend_registry"
register_fn = "register_ocr_backend"
unregister_fn = "unregister_ocr_backend"
clear_fn = "clear_ocr_backends"
"#,
)
}
#[test]
fn extendr_module_registers_trait_bridge_register_unregister_clear() {
let backend = ExtendrBackend;
let config = trait_bridge_config_for_tests();
let api = make_api_surface();
let files = backend.generate_bindings(&api, &config).unwrap();
let lib_rs = files
.iter()
.find(|f| f.path.to_string_lossy().ends_with("lib.rs"))
.expect("lib.rs must be generated");
for sym in ["register_ocr_backend", "unregister_ocr_backend", "clear_ocr_backends"] {
assert!(
lib_rs.content.contains(&format!("fn {sym};")),
"extendr_module! must register `{sym}`:\n{}",
lib_rs.content
);
}
}
#[test]
fn extendr_wrappers_emits_trait_bridge_register_unregister_clear() {
let backend = ExtendrBackend;
let config = trait_bridge_config_for_tests();
let api = make_api_surface();
let files = backend.generate_public_api(&api, &config).unwrap();
let wrappers = files
.iter()
.find(|f| f.path.to_string_lossy().ends_with("extendr-wrappers.R"))
.expect("extendr-wrappers.R must be generated");
let content = &wrappers.content;
assert!(
content.contains("register_ocr_backend <- function(r_backend) .Call(\"wrap__register_ocr_backend\""),
"register wrapper must accept an R object and call wrap__register_ocr_backend:\n{content}"
);
assert!(
content.contains("unregister_ocr_backend <- function(name) .Call(\"wrap__unregister_ocr_backend\""),
"unregister wrapper must accept a name and call wrap__unregister_ocr_backend:\n{content}"
);
assert!(
content.contains("clear_ocr_backends <- function() .Call(\"wrap__clear_ocr_backends\""),
"clear wrapper must take no arguments:\n{content}"
);
}
#[test]
fn namespace_exports_trait_bridge_register_unregister_clear() {
let backend = ExtendrBackend;
let config = trait_bridge_config_for_tests();
let api = make_api_surface();
let files = backend.generate_public_api(&api, &config).unwrap();
let namespace = files
.iter()
.find(|f| f.path.to_string_lossy().ends_with("NAMESPACE"))
.expect("NAMESPACE must be generated");
for sym in ["register_ocr_backend", "unregister_ocr_backend", "clear_ocr_backends"] {
assert!(
namespace.content.contains(&format!("export({sym})")),
"NAMESPACE must export `{sym}`:\n{}",
namespace.content
);
}
}
#[test]
fn extendr_excludes_trait_bridge_functions_when_language_excluded() {
let config = resolved_one(
r#"
[workspace]
languages = ["r"]
[[crates]]
name = "test-lib"
sources = ["src/lib.rs"]
[crates.r]
package_name = "testlib"
[[crates.trait_bridges]]
trait_name = "OcrBackend"
super_trait = "test_lib::Plugin"
registry_getter = "test_lib::get_ocr_backend_registry"
register_fn = "register_ocr_backend"
unregister_fn = "unregister_ocr_backend"
clear_fn = "clear_ocr_backends"
exclude_languages = ["r"]
"#,
);
let collected = trait_bridge_wrappers::collect_trait_bridge_functions(&config);
assert!(
collected.is_empty(),
"no trait-bridge entries should be collected when r is excluded: {:?}",
collected.iter().map(|t| &t.name).collect::<Vec<_>>()
);
}
#[test]
fn regression_namespace_exports_functions_types_enums() {
let backend = ExtendrBackend;
let config = make_config();
let mut api = make_api_surface();
api.types.push(TypeDef {
name: "DocumentMetadata".to_string(),
rust_path: "test_lib::DocumentMetadata".to_string(),
original_rust_path: String::new(),
fields: vec![make_field("title", TypeRef::String, true)],
methods: vec![],
is_opaque: false,
is_clone: true,
is_copy: false,
is_trait: false,
has_default: false,
has_stripped_cfg_fields: false,
is_return_type: false,
serde_rename_all: None,
has_serde: false,
super_traits: vec![],
doc: String::new(),
cfg: None,
binding_excluded: false,
binding_exclusion_reason: None,
is_variant_wrapper: false,
has_lifetime_params: false,
version: Default::default(),
});
api.enums.push(EnumDef {
name: "ConversionResult".to_string(),
rust_path: "test_lib::ConversionResult".to_string(),
original_rust_path: String::new(),
variants: vec![
EnumVariant {
name: "Ok".to_string(),
fields: vec![make_field("content", TypeRef::String, false)],
is_default: false,
serde_rename: None,
is_tuple: true,
doc: String::new(),
binding_excluded: false,
binding_exclusion_reason: None,
originally_had_data_fields: false,
cfg: None,
version: Default::default(),
},
EnumVariant {
name: "Err".to_string(),
fields: vec![make_field("msg", TypeRef::String, false)],
is_default: false,
serde_rename: None,
is_tuple: true,
doc: String::new(),
binding_excluded: false,
binding_exclusion_reason: None,
originally_had_data_fields: false,
cfg: None,
version: Default::default(),
},
],
methods: vec![],
doc: String::new(),
cfg: None,
is_copy: false,
has_serde: false,
serde_tag: None,
serde_untagged: false,
serde_rename_all: None,
binding_excluded: false,
binding_exclusion_reason: None,
excluded_variants: vec![],
version: Default::default(),
});
let files = backend.generate_public_api(&api, &config).unwrap();
let namespace = files
.iter()
.find(|f| f.path.to_string_lossy().ends_with("NAMESPACE"))
.expect("NAMESPACE must be generated");
let content = &namespace.content;
assert!(
content.contains("useDynLib(testlib, .registration = TRUE)"),
"NAMESPACE must have useDynLib: {content}"
);
assert!(
content.contains("export(process)"),
"NAMESPACE must export free functions, got: {content}"
);
assert!(
content.contains("export(Config)"),
"NAMESPACE must export types like Config: {content}"
);
assert!(
content.contains("export(DocumentMetadata)"),
"NAMESPACE must export DocumentMetadata: {content}"
);
assert!(
content.contains("export(ConversionResult)"),
"NAMESPACE must export flat data enums: {content}"
);
let line_count = content.lines().count();
assert!(
line_count > 10,
"NAMESPACE should have many more than 10 lines, got {line_count}: {content}"
);
}
#[test]
fn r_field_long_descriptions_are_truncated_to_fit_120_char_lines() {
let backend = ExtendrBackend;
let config = make_config();
let long_doc = "Open Graph metadata (og:* properties) for social media Keys like \"title\", \"description\", \"image\", \"url\", etc.";
let api = ApiSurface {
crate_name: "test_lib".to_string(),
version: "0.1.0".to_string(),
types: vec![TypeDef {
name: "DocumentMetadata".to_string(),
rust_path: "test_lib::DocumentMetadata".to_string(),
original_rust_path: String::new(),
fields: vec![FieldDef {
doc: long_doc.to_string(),
..make_field("open_graph", TypeRef::String, true)
}],
methods: vec![],
is_opaque: false,
is_clone: true,
is_copy: false,
is_trait: false,
has_default: false,
has_stripped_cfg_fields: false,
is_return_type: false,
serde_rename_all: None,
has_serde: false,
super_traits: vec![],
doc: "Document metadata".to_string(),
cfg: None,
binding_excluded: false,
binding_exclusion_reason: None,
is_variant_wrapper: false,
has_lifetime_params: false,
version: Default::default(),
}],
functions: vec![],
enums: vec![],
errors: vec![],
excluded_type_paths: ::std::collections::HashMap::new(),
excluded_trait_names: ::std::collections::HashSet::new(),
services: vec![],
handler_contracts: vec![],
unsupported_public_items: Vec::new(),
};
let files = backend.generate_public_api(&api, &config).unwrap();
let wrappers = files
.iter()
.find(|f| f.path.to_string_lossy().ends_with("extendr-wrappers.R"))
.expect("extendr-wrappers.R must be generated");
let content = &wrappers.content;
for line in content.lines() {
if line.contains("@field open_graph") {
assert!(
line.len() <= 120,
"@field line must be <= 120 chars, got {} chars: {}",
line.len(),
line
);
assert!(
line.contains("Open Graph metadata"),
"@field description was over-truncated: {}",
line
);
return;
}
}
panic!("Could not find @field open_graph line in:\n{}", content);
}