use super::entry_builder::{build_admin_entries, DynamicAdminEntry, DynamicAdminField};
use super::suggestions::{
derive_relation_suggestions, derive_suggestions, derive_suggestions_from_entries,
find_relation_suggestion, find_suggestion, find_suggestion_from_entries,
};
use super::{AdminEntry, AdminField, FieldType};
use crate::ai::ContextConfig;
use crate::schema::{Schema, SchemaField, SchemaModel, SCHEMA_VERSION};
const APPLICANT_FIELDS: &[AdminField] = &[
AdminField {
name: "id",
label: "id",
field_type: FieldType::I64,
editable: false,
relation: None,
choices: None,
},
AdminField {
name: "personnummer",
label: "personnummer",
field_type: FieldType::String,
editable: true,
relation: None,
choices: None,
},
AdminField {
name: "queue_start_date",
label: "queue_start_date",
field_type: FieldType::DateTime,
editable: true,
relation: None,
choices: None,
},
];
const FULLY_COVERED_FIELDS: &[AdminField] = &[
AdminField {
name: "id",
label: "id",
field_type: FieldType::I64,
editable: false,
relation: None,
choices: None,
},
AdminField {
name: "personnummer",
label: "personnummer",
field_type: FieldType::String,
editable: true,
relation: None,
choices: None,
},
AdminField {
name: "queue_start_date",
label: "queue_start_date",
field_type: FieldType::DateTime,
editable: true,
relation: None,
choices: None,
},
AdminField {
name: "annual_income",
label: "annual_income",
field_type: FieldType::I32,
editable: true,
relation: None,
choices: None,
},
];
const WIDGET_FIELDS: &[AdminField] = &[
AdminField {
name: "id",
label: "id",
field_type: FieldType::I64,
editable: false,
relation: None,
choices: None,
},
AdminField {
name: "name",
label: "name",
field_type: FieldType::String,
editable: true,
relation: None,
choices: None,
},
];
fn applicant_entry() -> AdminEntry {
AdminEntry::for_testing(
"applicants",
"Applicants",
"Applicant",
"applicants",
APPLICANT_FIELDS,
false,
)
}
fn fully_covered_entry() -> AdminEntry {
AdminEntry::for_testing(
"applicants",
"Applicants",
"Applicant",
"applicants",
FULLY_COVERED_FIELDS,
false,
)
}
fn widget_entry() -> AdminEntry {
AdminEntry::for_testing(
"widgets",
"Widgets",
"Widget",
"widgets",
WIDGET_FIELDS,
false,
)
}
fn core_user_entry() -> AdminEntry {
AdminEntry::for_testing("users", "Users", "User", "rustio_users", &[], true)
}
fn housing_context() -> ContextConfig {
ContextConfig {
country: Some("SE".into()),
industry: Some("housing".into()),
..Default::default()
}
}
#[test]
fn no_context_produces_no_suggestions() {
let entries = vec![applicant_entry()];
assert!(derive_suggestions(&entries, None).is_empty());
}
#[test]
fn no_industry_schema_produces_no_suggestions() {
let entries = vec![applicant_entry()];
let ctx = ContextConfig {
country: Some("SE".into()),
..Default::default()
};
assert!(derive_suggestions(&entries, Some(&ctx)).is_empty());
}
#[test]
fn unrelated_model_gets_no_suggestions() {
let entries = vec![widget_entry()];
let ctx = housing_context();
assert!(derive_suggestions(&entries, Some(&ctx)).is_empty());
}
#[test]
fn fully_covered_model_gets_no_suggestions() {
let entries = vec![fully_covered_entry()];
let ctx = housing_context();
assert!(derive_suggestions(&entries, Some(&ctx)).is_empty());
}
#[test]
fn missing_field_triggers_exactly_one_suggestion() {
let entries = vec![applicant_entry()];
let ctx = housing_context();
let suggestions = derive_suggestions(&entries, Some(&ctx));
assert_eq!(suggestions.len(), 1);
let s = &suggestions[0];
assert_eq!(s.field, "annual_income");
assert_eq!(s.admin_name, "applicants");
assert_eq!(s.model_singular, "Applicant");
assert_eq!(s.prompt, "add annual_income to applicants");
assert_eq!(s.action, "add_field");
assert!(s.reason.contains("housing"));
}
#[test]
fn core_models_are_skipped() {
let entries = vec![core_user_entry(), applicant_entry()];
let ctx = housing_context();
let all = derive_suggestions(&entries, Some(&ctx));
assert!(all.iter().all(|s| s.admin_name != "users"));
}
#[test]
fn ordering_is_deterministic() {
let entries = vec![applicant_entry()];
let ctx = housing_context();
let a = derive_suggestions(&entries, Some(&ctx));
let b = derive_suggestions(&entries, Some(&ctx));
assert_eq!(a, b);
}
#[test]
fn find_returns_the_existing_suggestion() {
let entries = vec![applicant_entry()];
let ctx = housing_context();
let hit = find_suggestion(&entries, Some(&ctx), "applicants", "annual_income");
assert!(hit.is_some());
assert_eq!(hit.unwrap().prompt, "add annual_income to applicants");
}
#[test]
fn find_rejects_crafted_urls() {
let entries = vec![applicant_entry()];
let ctx = housing_context();
assert!(find_suggestion(&entries, Some(&ctx), "applicants", "email").is_none());
assert!(find_suggestion(&entries, Some(&ctx), "applicants", "annual_income").is_some());
assert!(find_suggestion(&entries, Some(&ctx), "users", "email").is_none());
assert!(find_suggestion(&entries, None, "applicants", "annual_income").is_none());
}
#[test]
fn url_path_uses_admin_name_and_field() {
let entries = vec![applicant_entry()];
let ctx = housing_context();
let s = &derive_suggestions(&entries, Some(&ctx))[0];
assert_eq!(s.url_path(), "/admin/suggestions/applicants/annual_income");
}
fn schema_with_applicant_missing_income() -> Schema {
Schema {
version: SCHEMA_VERSION,
rustio_version: env!("CARGO_PKG_VERSION").to_string(),
models: vec![SchemaModel {
name: "Applicant".into(),
table: "applicants".into(),
admin_name: "applicants".into(),
display_name: "Applicants".into(),
singular_name: "Applicant".into(),
fields: vec![
SchemaField {
name: "id".into(),
ty: "i64".into(),
nullable: false,
editable: false,
relation: None,
},
SchemaField {
name: "personnummer".into(),
ty: "String".into(),
nullable: false,
editable: true,
relation: None,
},
SchemaField {
name: "queue_start_date".into(),
ty: "DateTime".into(),
nullable: false,
editable: true,
relation: None,
},
],
relations: vec![],
core: false,
}],
}
}
fn schema_with_applicant_fully_covered() -> Schema {
let mut s = schema_with_applicant_missing_income();
s.models[0].fields.push(SchemaField {
name: "annual_income".into(),
ty: "i64".into(),
nullable: false,
editable: true,
relation: None,
});
s
}
#[test]
#[ignore = "blocked: entry_builder module not yet ported; unblocks when build_admin_entries has a real body"]
fn schema_driven_suggestion_fires_for_missing_field() {
let ctx = housing_context();
let schema = schema_with_applicant_missing_income();
let dyn_entries = build_admin_entries(&schema);
let ss = derive_suggestions_from_entries(&dyn_entries, Some(&ctx));
assert_eq!(ss.len(), 1);
assert_eq!(ss[0].field, "annual_income");
assert_eq!(ss[0].admin_name, "applicants");
}
#[test]
#[ignore = "blocked: entry_builder module not yet ported; unblocks when build_admin_entries has a real body"]
fn schema_driven_suggestion_disappears_when_field_present() {
let ctx = housing_context();
let before = build_admin_entries(&schema_with_applicant_missing_income());
let after = build_admin_entries(&schema_with_applicant_fully_covered());
assert_eq!(
derive_suggestions_from_entries(&before, Some(&ctx)).len(),
1,
);
assert_eq!(derive_suggestions_from_entries(&after, Some(&ctx)).len(), 0,);
}
#[test]
#[ignore = "blocked: entry_builder module not yet ported; unblocks when build_admin_entries has a real body"]
fn schema_driven_and_compile_time_derivations_agree_when_shapes_match() {
let ctx = housing_context();
let dyn_entries = build_admin_entries(&schema_with_applicant_missing_income());
let compile_entries = vec![applicant_entry()];
let dyn_ss = derive_suggestions_from_entries(&dyn_entries, Some(&ctx));
let compile_ss = derive_suggestions(&compile_entries, Some(&ctx));
assert_eq!(dyn_ss.len(), compile_ss.len());
assert_eq!(dyn_ss[0].field, compile_ss[0].field);
assert_eq!(dyn_ss[0].admin_name, compile_ss[0].admin_name);
assert_eq!(dyn_ss[0].prompt, compile_ss[0].prompt);
assert_eq!(dyn_ss[0].confidence, compile_ss[0].confidence);
}
#[test]
#[ignore = "blocked: entry_builder module not yet ported; unblocks when build_admin_entries has a real body"]
fn schema_driven_find_rejects_crafted_urls() {
let ctx = housing_context();
let entries = build_admin_entries(&schema_with_applicant_missing_income());
assert!(
find_suggestion_from_entries(&entries, Some(&ctx), "applicants", "annual_income").is_some()
);
assert!(
find_suggestion_from_entries(&entries, Some(&ctx), "applicants", "email").is_none(),
"crafted URLs must not resolve to a suggestion"
);
assert!(find_suggestion_from_entries(&entries, Some(&ctx), "users", "annual_income").is_none(),);
}
#[test]
#[ignore = "blocked: entry_builder module not yet ported; DynamicAdminEntry is a placeholder struct"]
fn schema_driven_skips_core_models() {
let ctx = housing_context();
let core = DynamicAdminEntry {
admin_name: "users".into(),
display_name: "Users".into(),
singular_name: "User".into(),
table: "rustio_users".into(),
fields: vec![DynamicAdminField {
name: "personnummer".into(), ty: FieldType::String,
editable: true,
nullable: false,
}],
core: true,
};
let ss = derive_suggestions_from_entries(&[core], Some(&ctx));
assert!(
ss.is_empty(),
"core models must not receive suggestions, got: {:?}",
ss
);
}
fn schema_with_orphan_fk() -> Schema {
Schema {
version: SCHEMA_VERSION,
rustio_version: env!("CARGO_PKG_VERSION").to_string(),
models: vec![
SchemaModel {
name: "Applicant".into(),
table: "applicants".into(),
admin_name: "applicants".into(),
display_name: "Applicants".into(),
singular_name: "Applicant".into(),
fields: vec![SchemaField {
name: "id".into(),
ty: "i64".into(),
nullable: false,
editable: false,
relation: None,
}],
relations: vec![],
core: false,
},
SchemaModel {
name: "Application".into(),
table: "applications".into(),
admin_name: "applications".into(),
display_name: "Applications".into(),
singular_name: "Application".into(),
fields: vec![
SchemaField {
name: "id".into(),
ty: "i64".into(),
nullable: false,
editable: false,
relation: None,
},
SchemaField {
name: "applicant_id".into(),
ty: "i64".into(),
nullable: false,
editable: true,
relation: None,
},
],
relations: vec![],
core: false,
},
],
}
}
#[test]
fn relation_suggestion_fires_for_orphan_fk_with_matching_target() {
let schema = schema_with_orphan_fk();
let ss = derive_relation_suggestions(&schema);
assert_eq!(ss.len(), 1, "expected one suggestion, got {:?}", ss);
let s = &ss[0];
assert_eq!(s.admin_name, "applications");
assert_eq!(s.field, "applicant_id");
assert_eq!(s.action, "add_relation");
assert_eq!(s.prompt, "link Application to Applicant");
assert!(
s.reason.contains("applicant_id") && s.reason.contains("Applicant"),
"reason should cite both the column and the target: {}",
s.reason,
);
}
#[test]
fn relation_suggestion_disappears_once_relation_is_recorded() {
let mut schema = schema_with_orphan_fk();
let app = schema
.models
.iter_mut()
.find(|m| m.name == "Application")
.unwrap();
let field = app
.fields
.iter_mut()
.find(|f| f.name == "applicant_id")
.unwrap();
field.relation = Some(crate::schema::Relation {
model: "Applicant".into(),
field: "id".into(),
kind: crate::schema::RelationKind::BelongsTo,
display_field: None,
required: None,
on_delete: None,
});
let ss = derive_relation_suggestions(&schema);
assert!(
ss.is_empty(),
"suggestion must disappear once linked: {:?}",
ss,
);
}
#[test]
fn relation_suggestion_refuses_when_target_model_missing() {
let mut schema = schema_with_orphan_fk();
schema.models.retain(|m| m.name != "Applicant");
let ss = derive_relation_suggestions(&schema);
assert!(
ss.is_empty(),
"no target → no suggestion, not an invented link: {:?}",
ss,
);
}
#[test]
fn relation_suggestion_refuses_on_ambiguous_target() {
let mut schema = schema_with_orphan_fk();
schema.models.push(SchemaModel {
name: "OtherApplicant".into(),
table: "other_applicants".into(),
admin_name: "other_applicants".into(),
display_name: "Other Applicants".into(),
singular_name: "Applicant".into(), fields: vec![SchemaField {
name: "id".into(),
ty: "i64".into(),
nullable: false,
editable: false,
relation: None,
}],
relations: vec![],
core: false,
});
let ss = derive_relation_suggestions(&schema);
assert!(
ss.is_empty(),
"ambiguous target → refuse, don't pick one: {:?}",
ss,
);
}
#[test]
fn relation_suggestion_skips_core_models() {
let mut schema = schema_with_orphan_fk();
let app = schema
.models
.iter_mut()
.find(|m| m.name == "Application")
.unwrap();
app.core = true;
assert!(derive_relation_suggestions(&schema).is_empty());
}
#[test]
fn relation_suggestion_ignores_plain_id_column() {
let mut schema = schema_with_orphan_fk();
let app = schema
.models
.iter_mut()
.find(|m| m.name == "Application")
.unwrap();
app.fields.retain(|f| f.name != "applicant_id");
let ss = derive_relation_suggestions(&schema);
assert!(ss.is_empty(), "plain `id` must never trigger: {:?}", ss);
}
#[test]
fn find_relation_suggestion_rejects_unknown_pair() {
let schema = schema_with_orphan_fk();
assert!(find_relation_suggestion(&schema, "applications", "applicant_id").is_some());
assert!(find_relation_suggestion(&schema, "applications", "bogus").is_none());
assert!(find_relation_suggestion(&schema, "applicants", "applicant_id").is_none());
}
#[test]
fn relation_suggestion_is_deterministic() {
let schema = schema_with_orphan_fk();
let a = derive_relation_suggestions(&schema);
let b = derive_relation_suggestions(&schema);
assert_eq!(a, b, "derive_relation_suggestions must be deterministic");
}