#![cfg(feature = "projections")]
use ferro_projections::render::{field_display_name, is_system_field};
use ferro_projections::{
FieldDef, FieldMeaning, Intent, IntentScore, NavigationHint, RelationshipDef, ServiceDef,
};
use ferro_theme::IntentSlotTemplate;
use crate::action::Action;
use crate::catalog::{global_catalog, Catalog};
use crate::component::{
CardProps, CardVariant, Column, DataTableProps, DescriptionItem, DescriptionListProps,
DropdownMenuAction, DropdownMenuProps, FormProps, KanbanBoardProps, KanbanColumnProps,
StatCardProps, Tab, TableProps, TabsProps,
};
use crate::spec::{Element, ElementBuilder, Spec};
use super::component_map::{
build_badge_props, build_column_for_field, build_description_item, build_input_props,
build_progress_props, build_relationship_button_props, build_relationship_text_props,
build_select_props, build_switch_props, build_text_props, lookup_meaning, lookup_relationship,
};
use super::error::ProjectionError;
use super::intent_layout::{default_template, pick_intent_template};
use super::{RenderMode, VisualContext};
impl Spec {
pub fn from_service_def(
service: &ServiceDef,
intents: &[IntentScore],
ctx: &VisualContext,
) -> Result<Spec, ProjectionError> {
if intents.is_empty() {
return Err(ProjectionError::EmptyIntents);
}
if ctx.intent_index >= intents.len() {
return Err(ProjectionError::IntentIndexOutOfBounds {
requested: ctx.intent_index,
available: intents.len(),
});
}
Self::from_service_def_with_catalog(service, intents, ctx, global_catalog())
}
pub(crate) fn from_service_def_with_catalog(
service: &ServiceDef,
intents: &[IntentScore],
ctx: &VisualContext,
catalog: &Catalog,
) -> Result<Spec, ProjectionError> {
if intents.is_empty() {
return Err(ProjectionError::EmptyIntents);
}
let intent_score =
intents
.get(ctx.intent_index)
.ok_or(ProjectionError::IntentIndexOutOfBounds {
requested: ctx.intent_index,
available: intents.len(),
})?;
let spec = if ctx.mode == RenderMode::Input {
build_input_spec(service)?
} else {
let template = ctx
.templates
.as_ref()
.and_then(|t| pick_intent_template(t, &intent_score.intent))
.cloned()
.unwrap_or_else(|| default_template(&intent_score.intent));
build_display_spec(service, &intent_score.intent, &template.display, ctx)?
};
match catalog.validate(&spec) {
Ok(()) => Ok(spec),
Err(errors) => {
#[cfg(debug_assertions)]
panic!("Projector emitted invalid spec: {errors:?}");
#[cfg(not(debug_assertions))]
{
Err(ProjectionError::CatalogValidation(errors))
}
}
}
}
}
fn resolve_title(service: &ServiceDef) -> String {
service
.display_name
.as_deref()
.unwrap_or(&service.name)
.to_string()
}
fn element_with_props(type_name: &str, props: serde_json::Value) -> ElementBuilder {
let obj = props
.as_object()
.expect("typed Props must serialize to a JSON object");
let mut el = Element::new(type_name);
for (k, v) in obj {
el = el.prop(k.clone(), v.clone());
}
el
}
fn element_with_props_and_children(
type_name: &str,
props: serde_json::Value,
children: Vec<String>,
) -> ElementBuilder {
let mut el = element_with_props(type_name, props);
for child in children {
el = el.child(child);
}
el
}
fn build_input_spec(service: &ServiceDef) -> Result<Spec, ProjectionError> {
let action = Action::new(format!("/{}", service.name));
let form_props = serde_json::to_value(FormProps {
action,
method: None,
guard: None,
max_width: None,
id: None,
enctype: None,
})
.expect("FormProps serialization cannot fail");
let mut children_ids: Vec<String> = Vec::new();
let mut field_elements: Vec<(String, ElementBuilder)> = Vec::new();
for field in service
.fields
.iter()
.filter(|f| f.writable && !is_system_field(&f.meaning))
{
let choice = lookup_meaning(&field.meaning);
let Some(type_name) = choice.input else {
continue;
};
let props = input_props_for(type_name, field)?;
let id = format!("field_{}", field.name);
field_elements.push((id.clone(), element_with_props(type_name, props)));
children_ids.push(id);
}
let root = element_with_props_and_children("Form", form_props, children_ids);
let mut builder = Spec::builder()
.title(resolve_title(service))
.element("root", root);
for (id, el) in field_elements {
builder = builder.element(id, el);
}
builder.build().map_err(ProjectionError::SpecBuild)
}
fn input_props_for(
type_name: &str,
field: &FieldDef,
) -> Result<serde_json::Value, ProjectionError> {
match type_name {
"Input" => Ok(build_input_props(field)),
"Select" => Ok(build_select_props(field)),
"Switch" => Ok(build_switch_props(field)),
other => Err(ProjectionError::UnknownComponent {
type_name: other.to_string(),
}),
}
}
fn build_display_spec(
service: &ServiceDef,
intent: &Intent,
template: &IntentSlotTemplate,
ctx: &VisualContext,
) -> Result<Spec, ProjectionError> {
let layout = template.layout.as_deref().unwrap_or("Card");
let mut aux_elements: Vec<(String, ElementBuilder)> = Vec::new();
let root = match layout {
"DataTable" => emit_datatable_root(service),
"Card" => emit_card_root(service, &template.slots, &mut aux_elements),
"Form" => {
return build_input_spec(service);
}
"KanbanBoard" => emit_kanban_root(service, ctx),
"StatCard" => emit_statcard_root(service, &template.slots, &mut aux_elements),
other => {
return Err(ProjectionError::UnknownComponent {
type_name: other.to_string(),
});
}
};
let _ = intent;
let mut builder = Spec::builder()
.title(resolve_title(service))
.element("root", root);
for (id, el) in aux_elements {
builder = builder.element(id, el);
}
builder.build().map_err(ProjectionError::SpecBuild)
}
fn emit_datatable_root(service: &ServiceDef) -> ElementBuilder {
let columns: Vec<Column> = service
.fields
.iter()
.filter(|f| f.readable && !is_system_field(&f.meaning))
.filter(|f| lookup_meaning(&f.meaning).column.is_some())
.map(build_column_for_field)
.collect();
let row_actions: Option<Vec<DropdownMenuAction>> = if service.actions.is_empty() {
None
} else {
Some(
service
.actions
.iter()
.map(|a| DropdownMenuAction {
label: a.display_name.as_deref().unwrap_or(&a.name).to_string(),
action: Action::new(format!("/{}/{{row_key}}/{}", service.name, a.name)),
destructive: false,
visible_if: None,
})
.collect(),
)
};
let row_key = if service.actions.is_empty() {
None
} else {
Some("id".to_string())
};
let props = serde_json::to_value(DataTableProps {
columns,
data_path: format!("/data/{}", service.name),
row_actions,
empty_message: None,
row_key,
row_href: None,
})
.expect("DataTableProps serialization cannot fail");
element_with_props("DataTable", props)
}
fn emit_card_root(
service: &ServiceDef,
slots: &[String],
aux: &mut Vec<(String, ElementBuilder)>,
) -> ElementBuilder {
let mut children: Vec<String> = Vec::new();
for slot in slots {
match slot.as_str() {
"title" => { }
"fields" => emit_fields_as_description_list(service, aux, &mut children),
"relationships" => emit_relationships(service, aux, &mut children),
"actions" => emit_actions_placeholder(service, aux, &mut children),
"metadata" => emit_metadata(service, aux, &mut children),
"body" => emit_body_placeholder(aux, &mut children),
_ => {}
}
}
let props = serde_json::to_value(CardProps {
title: resolve_title(service),
description: None,
subtitle: None,
badge: None,
max_width: None,
footer: Vec::new(),
variant: CardVariant::Bordered,
})
.expect("CardProps serialization cannot fail");
let mut el = element_with_props("Card", props);
for id in children {
el = el.child(id);
}
el
}
fn emit_kanban_root(service: &ServiceDef, ctx: &VisualContext) -> ElementBuilder {
let columns: Vec<KanbanColumnProps> = service
.state_machine
.as_ref()
.map(|sm| {
sm.states
.iter()
.map(|s| KanbanColumnProps {
id: s.name.clone(),
title: s.display_name.as_deref().unwrap_or(&s.name).to_string(),
count: 0,
children: Vec::new(),
})
.collect()
})
.unwrap_or_else(|| {
vec![KanbanColumnProps {
id: "default".to_string(),
title: resolve_title(service),
count: 0,
children: Vec::new(),
}]
});
let field_name_by = |pred: fn(&FieldMeaning) -> bool| -> Option<String> {
service
.fields
.iter()
.find(|f| f.readable && pred(&f.meaning))
.map(|f| f.name.clone())
};
let has_state_machine = service.state_machine.is_some();
let items_path = has_state_machine.then(|| format!("/data/{}", service.name));
let group_by = has_state_machine
.then(|| field_name_by(|m| matches!(m, FieldMeaning::Status)))
.flatten();
let card_title_key = has_state_machine
.then(|| {
field_name_by(|m| matches!(m, FieldMeaning::EntityName))
.or_else(|| field_name_by(|m| matches!(m, FieldMeaning::Identifier)))
})
.flatten();
let card_description_key = has_state_machine
.then(|| field_name_by(|m| matches!(m, FieldMeaning::Money)))
.flatten();
let row_actions: Option<Vec<DropdownMenuAction>> =
if !has_state_machine || service.actions.is_empty() {
None
} else {
Some(
service
.actions
.iter()
.map(|a| DropdownMenuAction {
label: a.display_name.as_deref().unwrap_or(&a.name).to_string(),
action: Action::new(format!("/{}/{{row_key}}/{}", service.name, a.name)),
destructive: false,
visible_if: None,
})
.collect(),
)
};
let row_key = row_actions.as_ref().map(|_| "id".to_string());
let mobile_default_column = ctx.current_state.clone();
let props = serde_json::to_value(KanbanBoardProps {
columns,
items_path,
group_by,
card_title_key,
card_description_key,
row_actions,
row_key,
mobile_default_column,
empty_label: None,
})
.expect("KanbanBoardProps serialization cannot fail");
element_with_props("KanbanBoard", props)
}
fn emit_statcard_root(
service: &ServiceDef,
slots: &[String],
aux: &mut Vec<(String, ElementBuilder)>,
) -> ElementBuilder {
let mut dropped: Vec<String> = Vec::new();
for slot in slots {
if slot == "metadata" {
emit_metadata(service, aux, &mut dropped);
}
}
let primary_field = service
.fields
.iter()
.find(|f| f.readable && matches!(f.meaning, FieldMeaning::Money | FieldMeaning::Quantity));
let (label, value_path) = primary_field
.map(|f| {
(
field_display_name(&f.name),
Some(format!("/data/{}/{}", service.name, f.name)),
)
})
.unwrap_or_else(|| (resolve_title(service), None));
let props = serde_json::to_value(StatCardProps {
label,
value: String::new(),
value_path,
icon: None,
subtitle: None,
sse_target: None,
})
.expect("StatCardProps serialization cannot fail");
element_with_props("StatCard", props)
}
fn emit_fields_as_description_list(
service: &ServiceDef,
aux: &mut Vec<(String, ElementBuilder)>,
children_out: &mut Vec<String>,
) {
let items: Vec<DescriptionItem> = service
.fields
.iter()
.filter(|f| f.readable && !is_system_field(&f.meaning))
.filter(|f| lookup_meaning(&f.meaning).display.is_some())
.map(build_description_item)
.collect();
if items.is_empty() {
return;
}
let props = serde_json::to_value(DescriptionListProps {
items,
columns: None,
data_path: None,
})
.expect("DescriptionListProps serialization cannot fail");
let id = "fields_list".to_string();
aux.push((id.clone(), element_with_props("DescriptionList", props)));
children_out.push(id);
}
fn emit_relationships(
service: &ServiceDef,
aux: &mut Vec<(String, ElementBuilder)>,
children_out: &mut Vec<String>,
) {
let tab_rels: Vec<&RelationshipDef> = service
.relationships
.iter()
.filter(|r| matches!(r.navigation, NavigationHint::Tab))
.collect();
if !tab_rels.is_empty() {
let tabs: Vec<Tab> = tab_rels
.iter()
.map(|r| Tab {
value: r.name.clone(),
label: field_display_name(&r.target),
children: Vec::new(),
})
.collect();
let default_tab = tabs.first().map(|t| t.value.clone()).unwrap_or_default();
let props = serde_json::to_value(TabsProps { default_tab, tabs })
.expect("TabsProps serialization cannot fail");
let id = "relationships_tabs".to_string();
aux.push((id.clone(), element_with_props("Tabs", props)));
children_out.push(id);
}
for rel in service.relationships.iter() {
if matches!(rel.navigation, NavigationHint::Tab) {
continue;
}
let Some(component) = lookup_relationship(rel.navigation) else {
continue; };
let props = match rel.navigation {
NavigationHint::Inline => build_relationship_text_props(rel),
NavigationHint::Link => build_relationship_button_props(rel),
NavigationHint::Nested => {
let col = Column {
key: "name".to_string(),
label: field_display_name(&rel.target),
format: None,
};
serde_json::to_value(TableProps {
columns: vec![col],
data_path: format!("/data/{}", rel.name),
row_actions: None,
empty_message: None,
sortable: None,
sort_column: None,
sort_direction: None,
})
.expect("TableProps serialization cannot fail")
}
_ => continue,
};
let id = format!("rel_{}", rel.name);
aux.push((id.clone(), element_with_props(component, props)));
children_out.push(id);
}
}
fn emit_actions_placeholder(
service: &ServiceDef,
aux: &mut Vec<(String, ElementBuilder)>,
children_out: &mut Vec<String>,
) {
if service.actions.is_empty() {
return;
}
let items: Vec<DropdownMenuAction> = service
.actions
.iter()
.map(|a| DropdownMenuAction {
label: a.display_name.as_deref().unwrap_or(&a.name).to_string(),
action: Action::new(format!("/{}/{}", service.name, a.name)),
destructive: false,
visible_if: None,
})
.collect();
let props = serde_json::to_value(DropdownMenuProps {
menu_id: format!("actions_{}", service.name),
trigger_label: "Actions".to_string(),
items,
trigger_variant: None,
})
.expect("DropdownMenuProps serialization cannot fail");
let id = "actions_menu".to_string();
aux.push((id.clone(), element_with_props("DropdownMenu", props)));
children_out.push(id);
}
fn emit_metadata(
service: &ServiceDef,
aux: &mut Vec<(String, ElementBuilder)>,
children_out: &mut Vec<String>,
) {
let items: Vec<DescriptionItem> = service
.fields
.iter()
.filter(|f| is_system_field(&f.meaning))
.map(build_description_item)
.collect();
if items.is_empty() {
return;
}
let props = serde_json::to_value(DescriptionListProps {
items,
columns: None,
data_path: None,
})
.expect("DescriptionListProps serialization cannot fail");
let id = "metadata_list".to_string();
aux.push((id.clone(), element_with_props("DescriptionList", props)));
children_out.push(id);
}
#[allow(clippy::ptr_arg)] fn emit_body_placeholder(
_aux: &mut Vec<(String, ElementBuilder)>,
_children_out: &mut Vec<String>,
) {
}
#[allow(dead_code)]
fn _reserved_props_noop() {
let _ = build_badge_props;
let _ = build_progress_props;
let _ = build_text_props;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::Catalog;
use ferro_projections::{
derive_intents, ActionDef, DataType, FieldMeaning, ServiceDef, StateDef, StateMachine,
Transition,
};
use ferro_theme::{IntentModeTemplates, IntentSlotTemplate, ThemeTemplates};
fn sample_service() -> ServiceDef {
ServiceDef::new("product")
.display_name("Product")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("name", DataType::String, FieldMeaning::EntityName)
.field("price", DataType::Float, FieldMeaning::Money)
.field("created_at", DataType::DateTime, FieldMeaning::CreatedAt)
}
fn service_with_actions() -> ServiceDef {
ServiceDef::new("staff")
.display_name("Staff")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("name", DataType::String, FieldMeaning::EntityName)
.action(ActionDef::new("view").display_name("View"))
.action(ActionDef::new("edit").display_name("Edit"))
.action(ActionDef::new("delete").display_name("Delete"))
}
fn service_with_state_machine() -> ServiceDef {
ServiceDef::new("order")
.display_name("Order")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("status", DataType::String, FieldMeaning::Status)
.state_machine(
StateMachine::new("lifecycle")
.initial("draft")
.state(StateDef::new("draft").display_name("Draft"))
.state(StateDef::new("submitted").display_name("Submitted"))
.state(StateDef::new("done").display_name("Done").final_state())
.transition(Transition::new("draft", "submit", "submitted"))
.transition(Transition::new("submitted", "complete", "done")),
)
}
#[allow(dead_code)]
fn service_with_money_field() -> ServiceDef {
ServiceDef::new("statistics")
.display_name("Statistics")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("total_revenue", DataType::Float, FieldMeaning::Money)
}
fn clean_catalog() -> Catalog {
Catalog::build_builtins_only().expect("builtins-only catalog builds clean")
}
#[test]
fn from_service_def_validates() {
let service = sample_service();
let intents = derive_intents(&service);
let ctx = VisualContext::default();
let cat = clean_catalog();
let spec = Spec::from_service_def_with_catalog(&service, &intents, &ctx, &cat)
.expect("valid projection should pass validation");
assert!(cat.validate(&spec).is_ok());
}
#[test]
fn from_service_def_browse_display() {
let service = sample_service();
let intents = derive_intents(&service);
let ctx = VisualContext {
intent_index: intents
.iter()
.position(|i| matches!(i.intent, Intent::Browse))
.unwrap_or(0),
mode: RenderMode::Display,
..Default::default()
};
let cat = clean_catalog();
let spec = Spec::from_service_def_with_catalog(&service, &intents, &ctx, &cat)
.expect("should project");
assert_eq!(spec.schema, "ferro-json-ui/v2");
let root = spec.elements.get(&spec.root).expect("root element exists");
assert_eq!(root.type_name, "DataTable");
}
#[test]
fn input_mode_always_form() {
let service = sample_service();
let intents = derive_intents(&service);
assert!(!intents.is_empty(), "sample service must derive intents");
let cat = clean_catalog();
for idx in 0..intents.len() {
let ctx = VisualContext {
intent_index: idx,
mode: RenderMode::Input,
..Default::default()
};
let spec = Spec::from_service_def_with_catalog(&service, &intents, &ctx, &cat)
.expect("every intent should project in Input mode");
let root = spec.elements.get(&spec.root).unwrap();
assert_eq!(
root.type_name, "Form",
"intent at index {idx} did not collapse to Form in Input mode"
);
}
}
#[test]
fn system_fields_excluded() {
let service = sample_service();
let intents = derive_intents(&service);
let ctx = VisualContext {
intent_index: intents
.iter()
.position(|i| matches!(i.intent, Intent::Browse))
.unwrap_or(0),
mode: RenderMode::Display,
..Default::default()
};
let cat = clean_catalog();
let spec = Spec::from_service_def_with_catalog(&service, &intents, &ctx, &cat).unwrap();
let root = spec.elements.get(&spec.root).unwrap();
let columns = root
.props
.get("columns")
.and_then(|c| c.as_array())
.expect("columns array present");
let keys: Vec<&str> = columns
.iter()
.filter_map(|c| c.get("key").and_then(|k| k.as_str()))
.collect();
assert!(keys.contains(&"name"), "name column expected: {keys:?}");
assert!(keys.contains(&"price"), "price column expected: {keys:?}");
assert!(
!keys.contains(&"id"),
"id column must be excluded: {keys:?}"
);
assert!(
!keys.contains(&"created_at"),
"created_at column must be excluded: {keys:?}"
);
}
#[test]
fn template_override() {
let service = sample_service();
let intents = derive_intents(&service);
let templates = ThemeTemplates {
browse: Some(IntentModeTemplates {
display: IntentSlotTemplate {
slots: vec!["title".into(), "stats".into(), "metadata".into()],
layout: Some("StatCard".into()),
},
input: IntentSlotTemplate::default(),
}),
focus: None,
collect: None,
process: None,
summarize: None,
analyze: None,
track: None,
};
let ctx = VisualContext {
intent_index: intents
.iter()
.position(|i| matches!(i.intent, Intent::Browse))
.unwrap_or(0),
mode: RenderMode::Display,
templates: Some(templates),
..Default::default()
};
let cat = clean_catalog();
let spec = Spec::from_service_def_with_catalog(&service, &intents, &ctx, &cat)
.expect("override projects");
let root = spec.elements.get(&spec.root).unwrap();
assert_eq!(
root.type_name, "StatCard",
"theme override must win over default_template"
);
}
#[test]
fn sensitive_field_never_appears_in_display_or_column() {
let service = ServiceDef::new("user")
.display_name("User")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("name", DataType::String, FieldMeaning::EntityName)
.field("password_hash", DataType::String, FieldMeaning::Sensitive);
let intents = derive_intents(&service);
let cat = clean_catalog();
let browse_idx = intents
.iter()
.position(|i| matches!(i.intent, Intent::Browse))
.unwrap_or(0);
let browse_ctx = VisualContext {
intent_index: browse_idx,
mode: RenderMode::Display,
..Default::default()
};
let browse_spec =
Spec::from_service_def_with_catalog(&service, &intents, &browse_ctx, &cat).unwrap();
let browse_root = browse_spec.elements.get(&browse_spec.root).unwrap();
let columns = browse_root
.props
.get("columns")
.and_then(|c| c.as_array())
.expect("columns array present");
let column_keys: Vec<&str> = columns
.iter()
.filter_map(|c| c.get("key").and_then(|k| k.as_str()))
.collect();
assert!(
!column_keys.contains(&"password_hash"),
"Sensitive field leaked into DataTable columns: {column_keys:?}"
);
let focus_idx = intents
.iter()
.position(|i| matches!(i.intent, Intent::Focus))
.unwrap_or(0);
let focus_ctx = VisualContext {
intent_index: focus_idx,
mode: RenderMode::Display,
..Default::default()
};
let focus_spec =
Spec::from_service_def_with_catalog(&service, &intents, &focus_ctx, &cat).unwrap();
let leaked = focus_spec.elements.values().any(|el| {
el.type_name == "DescriptionList"
&& el
.props
.get("items")
.and_then(|i| i.as_array())
.map(|arr| {
arr.iter().any(|item| {
item.get("label")
.and_then(|l| l.as_str())
.map(|s| s.to_lowercase().contains("password"))
.unwrap_or(false)
})
})
.unwrap_or(false)
});
assert!(
!leaked,
"Sensitive field leaked into a DescriptionList item"
);
}
#[test]
fn input_props_for_unknown_type_returns_unknown_component_error() {
let field = ferro_projections::FieldDef {
name: "x".into(),
data_type: DataType::String,
meaning: FieldMeaning::Email,
required: false,
is_list: false,
readable: true,
writable: true,
};
let result = super::input_props_for("DatePicker", &field);
match result {
Err(ProjectionError::UnknownComponent { type_name }) => {
assert_eq!(type_name, "DatePicker");
}
other => panic!("expected UnknownComponent, got {other:?}"),
}
}
#[test]
fn statcard_metadata_is_orphan_element() {
let service = sample_service();
let intents = derive_intents(&service);
let templates = ThemeTemplates {
browse: Some(IntentModeTemplates {
display: IntentSlotTemplate {
slots: vec!["title".into(), "stats".into(), "metadata".into()],
layout: Some("StatCard".into()),
},
input: IntentSlotTemplate::default(),
}),
focus: None,
collect: None,
process: None,
summarize: None,
analyze: None,
track: None,
};
let ctx = VisualContext {
intent_index: intents
.iter()
.position(|i| matches!(i.intent, Intent::Browse))
.unwrap_or(0),
mode: RenderMode::Display,
templates: Some(templates),
..Default::default()
};
let cat = clean_catalog();
let spec = Spec::from_service_def_with_catalog(&service, &intents, &ctx, &cat)
.expect("StatCard+metadata projects");
assert!(
spec.elements.contains_key("metadata_list"),
"metadata DescriptionList must be emitted as a sibling element"
);
let root = spec.elements.get(&spec.root).unwrap();
assert_eq!(root.type_name, "StatCard");
assert!(
!root.children.contains(&"metadata_list".to_string()),
"StatCard root must not claim metadata_list as a child: {:?}",
root.children
);
}
#[test]
fn empty_intents_returns_error() {
let service = sample_service();
let cat = clean_catalog();
let result =
Spec::from_service_def_with_catalog(&service, &[], &VisualContext::default(), &cat);
assert!(matches!(result, Err(ProjectionError::EmptyIntents)));
}
#[test]
fn out_of_bounds_intent_index_returns_error() {
let service = sample_service();
let intents = derive_intents(&service);
let ctx = VisualContext {
intent_index: intents.len() + 5,
..Default::default()
};
let cat = clean_catalog();
let result = Spec::from_service_def_with_catalog(&service, &intents, &ctx, &cat);
match result {
Err(ProjectionError::IntentIndexOutOfBounds {
requested,
available,
}) => {
assert_eq!(requested, intents.len() + 5);
assert_eq!(available, intents.len());
}
other => panic!("expected IntentIndexOutOfBounds, got {other:?}"),
}
}
#[test]
fn kanban_root_derives_columns_from_state_machine() {
use crate::component::KanbanBoardProps;
let service = service_with_state_machine();
let ctx = VisualContext::default();
let el = emit_kanban_root(&service, &ctx);
let built = el.build();
let props: KanbanBoardProps =
serde_json::from_value(built.props).expect("props decode as KanbanBoardProps");
assert_eq!(props.columns.len(), 3);
assert_eq!(props.columns[0].id, "draft");
assert_eq!(props.columns[0].title, "Draft");
assert_eq!(props.columns[1].id, "submitted");
assert_eq!(props.columns[1].title, "Submitted");
assert_eq!(props.columns[2].id, "done");
assert_eq!(props.columns[2].title, "Done");
assert_eq!(props.items_path.as_deref(), Some("/data/order"));
assert_eq!(props.group_by.as_deref(), Some("status"));
assert_eq!(props.card_title_key.as_deref(), Some("id"));
assert!(props.card_description_key.is_none());
}
#[test]
fn kanban_root_fallback_when_no_state_machine() {
use crate::component::KanbanBoardProps;
let service = sample_service(); let ctx = VisualContext::default();
let el = emit_kanban_root(&service, &ctx);
let built = el.build();
let props: KanbanBoardProps =
serde_json::from_value(built.props).expect("props decode as KanbanBoardProps");
assert_eq!(props.columns.len(), 1);
assert!(props.items_path.is_none());
assert!(props.group_by.is_none());
}
#[test]
fn actions_slot_emits_dropdown_from_service_actions() {
use crate::component::DropdownMenuProps;
let service = service_with_actions();
let mut aux: Vec<(String, ElementBuilder)> = Vec::new();
let mut children: Vec<String> = Vec::new();
emit_actions_placeholder(&service, &mut aux, &mut children);
assert_eq!(children, vec!["actions_menu".to_string()]);
let pos = aux
.iter()
.position(|(id, _)| id == "actions_menu")
.expect("DropdownMenu must be emitted");
let (_, el) = aux.remove(pos);
let built = el.build();
let props: DropdownMenuProps =
serde_json::from_value(built.props).expect("props decode as DropdownMenuProps");
assert_eq!(props.items.len(), service.actions.len());
assert_eq!(props.items[0].label, "View");
}
#[test]
fn datatable_root_has_row_actions_from_service_actions() {
use crate::component::DataTableProps;
let service = service_with_actions();
let el = emit_datatable_root(&service);
let built = el.build();
let props: DataTableProps =
serde_json::from_value(built.props).expect("props decode as DataTableProps");
let ra = props.row_actions.expect("row_actions must be populated");
assert_eq!(ra.len(), service.actions.len());
}
#[test]
fn statcard_root_binds_primary_stat_field() {
use crate::component::StatCardProps;
let service = service_with_money_field();
let mut aux: Vec<(String, ElementBuilder)> = Vec::new();
let el = emit_statcard_root(&service, &[], &mut aux);
let built = el.build();
let props: StatCardProps =
serde_json::from_value(built.props).expect("props decode as StatCardProps");
assert_eq!(
props.value_path.as_deref(),
Some("/data/statistics/total_revenue"),
"value_path must bind to the primary Money field path"
);
}
#[test]
fn statcard_root_empty_when_no_stat_field() {
use crate::component::StatCardProps;
let service = ServiceDef::new("note")
.display_name("Note")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("body", DataType::String, FieldMeaning::FreeText);
let mut aux: Vec<(String, ElementBuilder)> = Vec::new();
let el = emit_statcard_root(&service, &[], &mut aux);
let built = el.build();
let props: StatCardProps =
serde_json::from_value(built.props).expect("props decode as StatCardProps");
assert!(
props.value_path.is_none(),
"value_path must be None when no Money/Quantity field exists"
);
}
#[test]
fn datatable_root_includes_image_url_column() {
use crate::component::DataTableProps;
let service = ServiceDef::new("staff")
.display_name("Staff")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("name", DataType::String, FieldMeaning::EntityName)
.field("avatar_url", DataType::String, FieldMeaning::ImageUrl);
let el = emit_datatable_root(&service);
let built = el.build();
let props: DataTableProps =
serde_json::from_value(built.props).expect("props decode as DataTableProps");
assert!(
props.columns.iter().any(|c| c.key == "avatar_url"),
"avatar_url column must appear in DataTable columns; got: {:?}",
props.columns.iter().map(|c| &c.key).collect::<Vec<_>>()
);
}
#[test]
fn image_column_has_image_format() {
use crate::component::{ColumnFormat, DataTableProps};
let service = ServiceDef::new("staff")
.display_name("Staff")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("avatar_url", DataType::String, FieldMeaning::ImageUrl);
let el = emit_datatable_root(&service);
let built = el.build();
let props: DataTableProps =
serde_json::from_value(built.props).expect("props decode as DataTableProps");
let col = props
.columns
.iter()
.find(|c| c.key == "avatar_url")
.expect("avatar_url column must exist");
assert_eq!(
col.format,
Some(ColumnFormat::Image),
"ImageUrl column format must be Image"
);
}
}