GUI plugins #9
1 changed files with 548 additions and 74 deletions
pinakes-ui: rewrite renderer with interactive tabs; correct data context; per-item loop binding
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Iebe4945e1fcb7c28ff74a76e9d0717276a6a6964
commit
de913e54bc
|
|
@ -14,6 +14,7 @@ use pinakes_plugin_api::{
|
|||
FieldType,
|
||||
FlexDirection,
|
||||
JustifyContent,
|
||||
TabDefinition,
|
||||
TextContent,
|
||||
TextVariant,
|
||||
UiElement,
|
||||
|
|
@ -21,7 +22,7 @@ use pinakes_plugin_api::{
|
|||
};
|
||||
|
||||
use super::{
|
||||
actions::{ActionResult, execute_action},
|
||||
actions::execute_action,
|
||||
data::{PluginPageData, use_plugin_data},
|
||||
};
|
||||
use crate::client::ApiClient;
|
||||
|
|
@ -52,13 +53,78 @@ pub fn PluginViewRenderer(props: PluginViewProps) -> Element {
|
|||
class: "plugin-page",
|
||||
"data-plugin-id": props.plugin_id.clone(),
|
||||
h2 { class: "plugin-page-title", "{page.title}" }
|
||||
{ render_element(&page.root_element, &page_data.read()) }
|
||||
{ render_element(&page.root_element, &page_data.read(), props.client) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Props for the interactive [`PluginTabs`] component.
|
||||
#[derive(Props, PartialEq, Clone)]
|
||||
struct PluginTabsProps {
|
||||
tabs: Vec<TabDefinition>,
|
||||
default_tab: usize,
|
||||
data: PluginPageData,
|
||||
client: Signal<ApiClient>,
|
||||
}
|
||||
|
||||
/// Renders a tabbed interface with interactive tab switching.
|
||||
#[component]
|
||||
fn PluginTabs(props: PluginTabsProps) -> Element {
|
||||
let mut active = use_signal(|| props.default_tab);
|
||||
let active_idx = *active.read();
|
||||
|
||||
rsx! {
|
||||
div { class: "plugin-tabs",
|
||||
div { class: "plugin-tab-list", role: "tablist",
|
||||
for (idx, tab) in props.tabs.iter().enumerate() {
|
||||
{
|
||||
let tab_label = tab.label.clone();
|
||||
let tab_icon = tab.icon.clone();
|
||||
let is_active = idx == active_idx;
|
||||
rsx! {
|
||||
button {
|
||||
class: if is_active { "plugin-tab active" } else { "plugin-tab" },
|
||||
role: "tab",
|
||||
aria_selected: if is_active { "true" } else { "false" },
|
||||
onclick: move |_| active.set(idx),
|
||||
if let Some(icon) = tab_icon {
|
||||
span { class: "tab-icon", "{icon}" }
|
||||
}
|
||||
"{tab_label}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "plugin-tab-panels",
|
||||
for (idx, tab) in props.tabs.iter().enumerate() {
|
||||
{
|
||||
let is_active = idx == active_idx;
|
||||
let content = tab.content.clone();
|
||||
rsx! {
|
||||
div {
|
||||
class: if is_active {
|
||||
"plugin-tab-panel active"
|
||||
} else {
|
||||
"plugin-tab-panel"
|
||||
},
|
||||
hidden: !is_active,
|
||||
{ render_element(&content, &props.data, props.client) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a single [`UiElement`] with the provided data context.
|
||||
fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
||||
pub(crate) fn render_element(
|
||||
element: &UiElement,
|
||||
data: &PluginPageData,
|
||||
client: Signal<ApiClient>,
|
||||
) -> Element {
|
||||
match element {
|
||||
// Layout containers
|
||||
UiElement::Container {
|
||||
|
|
@ -78,7 +144,7 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
|||
class: "plugin-container",
|
||||
style: "{style}",
|
||||
for child in children {
|
||||
{ render_element(child, data) }
|
||||
{ render_element(child, data, client) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -97,7 +163,7 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
|||
class: "plugin-grid",
|
||||
style: "{style}",
|
||||
for child in children {
|
||||
{ render_element(child, data) }
|
||||
{ render_element(child, data, client) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -124,7 +190,7 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
|||
class: "plugin-flex",
|
||||
style: "{style}",
|
||||
for child in children {
|
||||
{ render_element(child, data) }
|
||||
{ render_element(child, data, client) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -142,12 +208,12 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
|||
aside {
|
||||
class: "plugin-split-sidebar",
|
||||
style: "width:{sidebar_width}px;flex-shrink:0;",
|
||||
{ render_element(sidebar, data) }
|
||||
{ render_element(sidebar, data, client) }
|
||||
}
|
||||
main {
|
||||
class: "plugin-split-main",
|
||||
style: "flex:1;min-width:0;",
|
||||
{ render_element(main, data) }
|
||||
{ render_element(main, data, client) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -155,40 +221,19 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
|||
|
||||
UiElement::Tabs { tabs, default_tab } => {
|
||||
rsx! {
|
||||
div {
|
||||
class: "plugin-tabs",
|
||||
div {
|
||||
class: "plugin-tab-list",
|
||||
role: "tablist",
|
||||
for (idx, tab) in tabs.iter().enumerate() {
|
||||
button {
|
||||
class: if idx == *default_tab { "plugin-tab active" } else { "plugin-tab" },
|
||||
role: "tab",
|
||||
aria_selected: if idx == *default_tab { "true" } else { "false" },
|
||||
if let Some(icon) = &tab.icon {
|
||||
span { class: "tab-icon", "{icon}" }
|
||||
}
|
||||
"{tab.label}"
|
||||
}
|
||||
}
|
||||
}
|
||||
div {
|
||||
class: "plugin-tab-panels",
|
||||
for (idx, tab) in tabs.iter().enumerate() {
|
||||
div {
|
||||
class: if idx == *default_tab { "plugin-tab-panel active" } else { "plugin-tab-panel" },
|
||||
hidden: idx != *default_tab,
|
||||
{ render_element(&tab.content, data) }
|
||||
}
|
||||
}
|
||||
}
|
||||
PluginTabs {
|
||||
tabs: tabs.clone(),
|
||||
default_tab: *default_tab,
|
||||
data: data.clone(),
|
||||
client,
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Typography
|
||||
UiElement::Heading { level, content, id } => {
|
||||
let text = resolve_text_content(content, &serde_json::json!({}));
|
||||
let ctx = data.as_json();
|
||||
let text = resolve_text_content(content, &ctx);
|
||||
let class = format!("plugin-heading level-{level}");
|
||||
let anchor = id.clone().unwrap_or_default();
|
||||
match level.min(&6) {
|
||||
|
|
@ -206,7 +251,8 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
|||
variant,
|
||||
allow_html,
|
||||
} => {
|
||||
let text = resolve_text_content(content, &serde_json::json!({}));
|
||||
let ctx = data.as_json();
|
||||
let text = resolve_text_content(content, &ctx);
|
||||
let variant_class = text_variant_class(variant);
|
||||
if *allow_html {
|
||||
let sanitized = ammonia::clean(&text);
|
||||
|
|
@ -300,9 +346,9 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
|||
onclick: move |_| {
|
||||
let a = action.clone();
|
||||
let fd = row_data.clone();
|
||||
let client = ApiClient::default();
|
||||
let c = client.read().clone();
|
||||
spawn(async move {
|
||||
let _ = execute_action(&client, &a, Some(&fd)).await;
|
||||
let _ = execute_action(&c, &a, Some(&fd)).await;
|
||||
});
|
||||
},
|
||||
"{act.label}"
|
||||
|
|
@ -336,14 +382,14 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
|||
div {
|
||||
class: "plugin-card-content",
|
||||
for child in content {
|
||||
{ render_element(child, data) }
|
||||
{ render_element(child, data, client) }
|
||||
}
|
||||
}
|
||||
if !footer.is_empty() {
|
||||
div {
|
||||
class: "plugin-card-footer",
|
||||
for child in footer {
|
||||
{ render_element(child, data) }
|
||||
{ render_element(child, data, client) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -394,16 +440,25 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
|||
if arr.is_empty() {
|
||||
div { class: "plugin-list-empty", "No items" }
|
||||
} else {
|
||||
ul { class: "plugin-list",
|
||||
for _item in arr {
|
||||
li {
|
||||
class: "plugin-list-item",
|
||||
{ render_element(item_template, data) }
|
||||
if *dividers {
|
||||
hr { class: "plugin-list-divider" }
|
||||
{
|
||||
let dividers = *dividers;
|
||||
let elements: Vec<Element> = arr
|
||||
.iter()
|
||||
.map(|item| {
|
||||
let mut item_data = data.clone();
|
||||
item_data.set_data("item".to_string(), item.clone());
|
||||
rsx! {
|
||||
li {
|
||||
class: "plugin-list-item",
|
||||
{ render_element(item_template, &item_data, client) }
|
||||
if dividers {
|
||||
hr { class: "plugin-list-divider" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
rsx! { ul { class: "plugin-list", for el in elements { {el} } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -454,9 +509,9 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
|||
disabled: *disabled,
|
||||
onclick: move |_| {
|
||||
let a = action_ref.clone();
|
||||
let client = ApiClient::default();
|
||||
let c = client.read().clone();
|
||||
spawn(async move {
|
||||
let _ = execute_action(&client, &a, None).await;
|
||||
let _ = execute_action(&c, &a, None).await;
|
||||
});
|
||||
},
|
||||
"{label}"
|
||||
|
|
@ -482,19 +537,25 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
|||
let vals = event.data().values();
|
||||
let map: serde_json::Map<String, serde_json::Value> = vals
|
||||
.into_iter()
|
||||
.map(|(k, v)| {
|
||||
.filter_map(|(k, v)| {
|
||||
let s = match v {
|
||||
FormValue::Text(s) => s,
|
||||
FormValue::File(_) => String::new(),
|
||||
FormValue::File(_) => {
|
||||
tracing::warn!(
|
||||
field = %k,
|
||||
"file upload not supported in plugin forms"
|
||||
);
|
||||
return None;
|
||||
},
|
||||
};
|
||||
(k, serde_json::Value::String(s))
|
||||
Some((k, serde_json::Value::String(s)))
|
||||
})
|
||||
.collect();
|
||||
serde_json::Value::Object(map)
|
||||
};
|
||||
let client = ApiClient::default();
|
||||
let c = client.read().clone();
|
||||
spawn(async move {
|
||||
let _ = execute_action(&client, &a, Some(&form_values)).await;
|
||||
let _ = execute_action(&c, &a, Some(&form_values)).await;
|
||||
});
|
||||
},
|
||||
for field in fields {
|
||||
|
|
@ -521,7 +582,7 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
|||
}
|
||||
if let Some(cancel) = cancel_label {
|
||||
button {
|
||||
r#type: "button",
|
||||
r#type: "reset",
|
||||
class: "plugin-button btn-secondary",
|
||||
"{cancel}"
|
||||
}
|
||||
|
|
@ -554,7 +615,8 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
|||
max,
|
||||
show_percentage,
|
||||
} => {
|
||||
let pct = evaluate_expression_as_f64(value);
|
||||
let ctx = data.as_json();
|
||||
let pct = evaluate_expression_as_f64(value, &ctx);
|
||||
let fraction = (pct / max).clamp(0.0, 1.0);
|
||||
let pct_int = (fraction * 100.0).round() as u32;
|
||||
rsx! {
|
||||
|
|
@ -618,11 +680,11 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
|||
then,
|
||||
else_element,
|
||||
} => {
|
||||
let ctx = serde_json::json!({});
|
||||
let ctx = data.as_json();
|
||||
if evaluate_expression_as_bool(condition, &ctx) {
|
||||
render_element(then, data)
|
||||
render_element(then, data, client)
|
||||
} else if let Some(else_el) = else_element {
|
||||
render_element(else_el, data)
|
||||
render_element(else_el, data, client)
|
||||
} else {
|
||||
rsx! {}
|
||||
}
|
||||
|
|
@ -643,15 +705,19 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element {
|
|||
if let Some(arr) = items.and_then(|v| v.as_array()) {
|
||||
if arr.is_empty() {
|
||||
if let Some(empty_el) = empty {
|
||||
return render_element(empty_el, data);
|
||||
return render_element(empty_el, data, client);
|
||||
}
|
||||
return rsx! {};
|
||||
}
|
||||
rsx! {
|
||||
for _item in arr {
|
||||
{ render_element(template, data) }
|
||||
}
|
||||
}
|
||||
let elements: Vec<Element> = arr
|
||||
.iter()
|
||||
.map(|item| {
|
||||
let mut item_data = data.clone();
|
||||
item_data.set_data("item".to_string(), item.clone());
|
||||
render_element(template, &item_data, client)
|
||||
})
|
||||
.collect();
|
||||
rsx! { for el in elements { {el} } }
|
||||
} else {
|
||||
rsx! {}
|
||||
}
|
||||
|
|
@ -737,7 +803,7 @@ fn render_form_field(field: &pinakes_plugin_api::FormField) -> Element {
|
|||
name: "{field.id}",
|
||||
multiple: *multiple,
|
||||
required: field.required,
|
||||
option { value: "", disabled: true, "Select…" }
|
||||
option { value: "", disabled: true, selected: true, "Select…" }
|
||||
for opt in options {
|
||||
option { value: "{opt.value}", "{opt.label}" }
|
||||
}
|
||||
|
|
@ -969,10 +1035,11 @@ fn evaluate_expression_as_bool(
|
|||
evaluate_expression(expr, ctx).as_bool().unwrap_or(false)
|
||||
}
|
||||
|
||||
fn evaluate_expression_as_f64(expr: &Expression) -> f64 {
|
||||
evaluate_expression(expr, &serde_json::json!({}))
|
||||
.as_f64()
|
||||
.unwrap_or(0.0)
|
||||
fn evaluate_expression_as_f64(
|
||||
expr: &Expression,
|
||||
ctx: &serde_json::Value,
|
||||
) -> f64 {
|
||||
evaluate_expression(expr, ctx).as_f64().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn extract_cell(row: &serde_json::Value, key: &str) -> String {
|
||||
|
|
@ -993,10 +1060,26 @@ fn extract_cell(row: &serde_json::Value, key: &str) -> String {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pinakes_plugin_api::Expression;
|
||||
use pinakes_plugin_api::{Expression, Operator};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn lit(v: serde_json::Value) -> Box<Expression> {
|
||||
Box::new(Expression::Literal(v))
|
||||
}
|
||||
|
||||
fn op_expr(
|
||||
left: serde_json::Value,
|
||||
op: Operator,
|
||||
right: serde_json::Value,
|
||||
) -> Expression {
|
||||
Expression::Operation {
|
||||
left: lit(left),
|
||||
op,
|
||||
right: lit(right),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_expression_literal() {
|
||||
let expr = Expression::Literal(serde_json::json!("hello"));
|
||||
|
|
@ -1004,6 +1087,13 @@ mod tests {
|
|||
assert_eq!(result, serde_json::json!("hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_expression_literal_number() {
|
||||
let expr = Expression::Literal(serde_json::json!(3.14));
|
||||
let result = evaluate_expression(&expr, &serde_json::json!({}));
|
||||
assert_eq!(result, serde_json::json!(3.14));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_expression_path() {
|
||||
let expr = Expression::Path("foo.bar".to_string());
|
||||
|
|
@ -1012,6 +1102,250 @@ mod tests {
|
|||
assert_eq!(result, serde_json::json!(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_expression_path_missing_key() {
|
||||
let expr = Expression::Path("foo.missing".to_string());
|
||||
let ctx = serde_json::json!({ "foo": { "bar": 1 } });
|
||||
let result = evaluate_expression(&expr, &ctx);
|
||||
assert_eq!(result, serde_json::Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_expression_path_array_index() {
|
||||
let expr = Expression::Path("items.1".to_string());
|
||||
let ctx = serde_json::json!({ "items": ["a", "b", "c"] });
|
||||
let result = evaluate_expression(&expr, &ctx);
|
||||
assert_eq!(result, serde_json::json!("b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_expression_path_array_out_of_bounds() {
|
||||
let expr = Expression::Path("items.9".to_string());
|
||||
let ctx = serde_json::json!({ "items": ["a"] });
|
||||
let result = evaluate_expression(&expr, &ctx);
|
||||
assert_eq!(result, serde_json::Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_expression_path_on_scalar() {
|
||||
let expr = Expression::Path("x.y".to_string());
|
||||
let ctx = serde_json::json!({ "x": 42 });
|
||||
let result = evaluate_expression(&expr, &ctx);
|
||||
assert_eq!(result, serde_json::Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_expression_eq_true() {
|
||||
let expr =
|
||||
op_expr(serde_json::json!(1), Operator::Eq, serde_json::json!(1));
|
||||
let result = evaluate_expression(&expr, &serde_json::json!({}));
|
||||
assert_eq!(result, serde_json::json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_expression_eq_false() {
|
||||
let expr =
|
||||
op_expr(serde_json::json!(1), Operator::Eq, serde_json::json!(2));
|
||||
let result = evaluate_expression(&expr, &serde_json::json!({}));
|
||||
assert_eq!(result, serde_json::json!(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_expression_ne() {
|
||||
let expr =
|
||||
op_expr(serde_json::json!("a"), Operator::Ne, serde_json::json!("b"));
|
||||
assert_eq!(
|
||||
evaluate_expression(&expr, &serde_json::json!({})),
|
||||
serde_json::json!(true)
|
||||
);
|
||||
let expr2 =
|
||||
op_expr(serde_json::json!("a"), Operator::Ne, serde_json::json!("a"));
|
||||
assert_eq!(
|
||||
evaluate_expression(&expr2, &serde_json::json!({})),
|
||||
serde_json::json!(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_expression_gt() {
|
||||
let expr =
|
||||
op_expr(serde_json::json!(5), Operator::Gt, serde_json::json!(3));
|
||||
assert_eq!(
|
||||
evaluate_expression(&expr, &serde_json::json!({})),
|
||||
serde_json::json!(true)
|
||||
);
|
||||
let expr2 =
|
||||
op_expr(serde_json::json!(3), Operator::Gt, serde_json::json!(5));
|
||||
assert_eq!(
|
||||
evaluate_expression(&expr2, &serde_json::json!({})),
|
||||
serde_json::json!(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_expression_gte() {
|
||||
let expr =
|
||||
op_expr(serde_json::json!(5), Operator::Gte, serde_json::json!(5));
|
||||
assert_eq!(
|
||||
evaluate_expression(&expr, &serde_json::json!({})),
|
||||
serde_json::json!(true)
|
||||
);
|
||||
let expr2 =
|
||||
op_expr(serde_json::json!(4), Operator::Gte, serde_json::json!(5));
|
||||
assert_eq!(
|
||||
evaluate_expression(&expr2, &serde_json::json!({})),
|
||||
serde_json::json!(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_expression_lt() {
|
||||
let expr =
|
||||
op_expr(serde_json::json!(3), Operator::Lt, serde_json::json!(5));
|
||||
assert_eq!(
|
||||
evaluate_expression(&expr, &serde_json::json!({})),
|
||||
serde_json::json!(true)
|
||||
);
|
||||
let expr2 =
|
||||
op_expr(serde_json::json!(5), Operator::Lt, serde_json::json!(3));
|
||||
assert_eq!(
|
||||
evaluate_expression(&expr2, &serde_json::json!({})),
|
||||
serde_json::json!(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_expression_lte() {
|
||||
let expr =
|
||||
op_expr(serde_json::json!(5), Operator::Lte, serde_json::json!(5));
|
||||
assert_eq!(
|
||||
evaluate_expression(&expr, &serde_json::json!({})),
|
||||
serde_json::json!(true)
|
||||
);
|
||||
let expr2 =
|
||||
op_expr(serde_json::json!(6), Operator::Lte, serde_json::json!(5));
|
||||
assert_eq!(
|
||||
evaluate_expression(&expr2, &serde_json::json!({})),
|
||||
serde_json::json!(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_expression_and() {
|
||||
let expr = op_expr(
|
||||
serde_json::json!(true),
|
||||
Operator::And,
|
||||
serde_json::json!(true),
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate_expression(&expr, &serde_json::json!({})),
|
||||
serde_json::json!(true)
|
||||
);
|
||||
let expr2 = op_expr(
|
||||
serde_json::json!(true),
|
||||
Operator::And,
|
||||
serde_json::json!(false),
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate_expression(&expr2, &serde_json::json!({})),
|
||||
serde_json::json!(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_expression_or() {
|
||||
let expr = op_expr(
|
||||
serde_json::json!(false),
|
||||
Operator::Or,
|
||||
serde_json::json!(true),
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate_expression(&expr, &serde_json::json!({})),
|
||||
serde_json::json!(true)
|
||||
);
|
||||
let expr2 = op_expr(
|
||||
serde_json::json!(false),
|
||||
Operator::Or,
|
||||
serde_json::json!(false),
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate_expression(&expr2, &serde_json::json!({})),
|
||||
serde_json::json!(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_expression_unimplemented_ops_return_null() {
|
||||
for op in [
|
||||
Operator::Concat,
|
||||
Operator::Add,
|
||||
Operator::Sub,
|
||||
Operator::Mul,
|
||||
Operator::Div,
|
||||
] {
|
||||
let expr = op_expr(serde_json::json!(1), op, serde_json::json!(2));
|
||||
assert_eq!(
|
||||
evaluate_expression(&expr, &serde_json::json!({})),
|
||||
serde_json::Value::Null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_expression_call_returns_null() {
|
||||
let expr = Expression::Call {
|
||||
function: "unknown".to_string(),
|
||||
args: vec![],
|
||||
};
|
||||
assert_eq!(
|
||||
evaluate_expression(&expr, &serde_json::json!({})),
|
||||
serde_json::Value::Null
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_expression_as_bool_true() {
|
||||
let expr = Expression::Literal(serde_json::json!(true));
|
||||
assert!(evaluate_expression_as_bool(&expr, &serde_json::json!({})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_expression_as_bool_false() {
|
||||
let expr = Expression::Literal(serde_json::json!(false));
|
||||
assert!(!evaluate_expression_as_bool(&expr, &serde_json::json!({})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_expression_as_bool_non_bool_returns_false() {
|
||||
let expr = Expression::Literal(serde_json::json!("yes"));
|
||||
assert!(!evaluate_expression_as_bool(&expr, &serde_json::json!({})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_expression_as_f64() {
|
||||
let expr = Expression::Literal(serde_json::json!(7.5));
|
||||
assert_eq!(
|
||||
evaluate_expression_as_f64(&expr, &serde_json::json!({})),
|
||||
7.5
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_expression_as_f64_non_numeric_returns_zero() {
|
||||
let expr = Expression::Literal(serde_json::json!("text"));
|
||||
assert_eq!(
|
||||
evaluate_expression_as_f64(&expr, &serde_json::json!({})),
|
||||
0.0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_expression_as_f64_from_path() {
|
||||
let expr = Expression::Path("score".to_string());
|
||||
let ctx = serde_json::json!({ "score": 42 });
|
||||
assert_eq!(evaluate_expression_as_f64(&expr, &ctx), 42.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_cell_string() {
|
||||
let row = serde_json::json!({ "name": "Alice", "count": 5 });
|
||||
|
|
@ -1020,6 +1354,32 @@ mod tests {
|
|||
assert_eq!(extract_cell(&row, "missing"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_cell_bool() {
|
||||
let row = serde_json::json!({ "active": true, "deleted": false });
|
||||
assert_eq!(extract_cell(&row, "active"), "true");
|
||||
assert_eq!(extract_cell(&row, "deleted"), "false");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_cell_null() {
|
||||
let row = serde_json::json!({ "value": null });
|
||||
assert_eq!(extract_cell(&row, "value"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_cell_nested_object() {
|
||||
let row = serde_json::json!({ "meta": { "x": 1 } });
|
||||
let result = extract_cell(&row, "meta");
|
||||
assert!(result.contains("x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_cell_non_object_row() {
|
||||
let row = serde_json::json!("not an object");
|
||||
assert_eq!(extract_cell(&row, "key"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_text_content_static() {
|
||||
let tc = TextContent::Static("Hello".to_string());
|
||||
|
|
@ -1031,4 +1391,118 @@ mod tests {
|
|||
let tc = TextContent::Empty;
|
||||
assert_eq!(resolve_text_content(&tc, &serde_json::json!({})), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_text_content_expression() {
|
||||
let tc = TextContent::Expression(Expression::Path("user.name".to_string()));
|
||||
let ctx = serde_json::json!({ "user": { "name": "Bob" } });
|
||||
// evaluate_expression returns a serde_json::Value; .to_string()
|
||||
// JSON-encodes it
|
||||
assert_eq!(resolve_text_content(&tc, &ctx), "\"Bob\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_text_content_expression_number() {
|
||||
let tc =
|
||||
TextContent::Expression(Expression::Literal(serde_json::json!(42)));
|
||||
assert_eq!(resolve_text_content(&tc, &serde_json::json!({})), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_text_content_expression_missing() {
|
||||
let tc = TextContent::Expression(Expression::Path("missing".to_string()));
|
||||
assert_eq!(resolve_text_content(&tc, &serde_json::json!({})), "null");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flex_direction_css() {
|
||||
assert_eq!(flex_direction_css(&FlexDirection::Row), "row");
|
||||
assert_eq!(flex_direction_css(&FlexDirection::Column), "column");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_justify_content_css() {
|
||||
assert_eq!(
|
||||
justify_content_css(&JustifyContent::FlexStart),
|
||||
"flex-start"
|
||||
);
|
||||
assert_eq!(justify_content_css(&JustifyContent::FlexEnd), "flex-end");
|
||||
assert_eq!(justify_content_css(&JustifyContent::Center), "center");
|
||||
assert_eq!(
|
||||
justify_content_css(&JustifyContent::SpaceBetween),
|
||||
"space-between"
|
||||
);
|
||||
assert_eq!(
|
||||
justify_content_css(&JustifyContent::SpaceAround),
|
||||
"space-around"
|
||||
);
|
||||
assert_eq!(
|
||||
justify_content_css(&JustifyContent::SpaceEvenly),
|
||||
"space-evenly"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_align_items_css() {
|
||||
assert_eq!(align_items_css(&AlignItems::FlexStart), "flex-start");
|
||||
assert_eq!(align_items_css(&AlignItems::FlexEnd), "flex-end");
|
||||
assert_eq!(align_items_css(&AlignItems::Center), "center");
|
||||
assert_eq!(align_items_css(&AlignItems::Stretch), "stretch");
|
||||
assert_eq!(align_items_css(&AlignItems::Baseline), "baseline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_button_variant_class() {
|
||||
assert_eq!(button_variant_class(&ButtonVariant::Primary), "btn-primary");
|
||||
assert_eq!(
|
||||
button_variant_class(&ButtonVariant::Secondary),
|
||||
"btn-secondary"
|
||||
);
|
||||
assert_eq!(
|
||||
button_variant_class(&ButtonVariant::Tertiary),
|
||||
"btn-tertiary"
|
||||
);
|
||||
assert_eq!(button_variant_class(&ButtonVariant::Danger), "btn-danger");
|
||||
assert_eq!(button_variant_class(&ButtonVariant::Success), "btn-success");
|
||||
assert_eq!(button_variant_class(&ButtonVariant::Ghost), "btn-ghost");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_badge_variant_class() {
|
||||
assert_eq!(badge_variant_class(&BadgeVariant::Default), "badge-default");
|
||||
assert_eq!(badge_variant_class(&BadgeVariant::Primary), "badge-primary");
|
||||
assert_eq!(
|
||||
badge_variant_class(&BadgeVariant::Secondary),
|
||||
"badge-secondary"
|
||||
);
|
||||
assert_eq!(badge_variant_class(&BadgeVariant::Success), "badge-success");
|
||||
assert_eq!(badge_variant_class(&BadgeVariant::Warning), "badge-warning");
|
||||
assert_eq!(badge_variant_class(&BadgeVariant::Error), "badge-error");
|
||||
assert_eq!(badge_variant_class(&BadgeVariant::Info), "badge-info");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chart_type_class() {
|
||||
assert_eq!(chart_type_class(&ChartType::Bar), "chart-bar");
|
||||
assert_eq!(chart_type_class(&ChartType::Line), "chart-line");
|
||||
assert_eq!(chart_type_class(&ChartType::Pie), "chart-pie");
|
||||
assert_eq!(chart_type_class(&ChartType::Area), "chart-area");
|
||||
assert_eq!(chart_type_class(&ChartType::Scatter), "chart-scatter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_text_variant_class() {
|
||||
assert_eq!(text_variant_class(&TextVariant::Body), "");
|
||||
assert_eq!(
|
||||
text_variant_class(&TextVariant::Secondary),
|
||||
"text-secondary"
|
||||
);
|
||||
assert_eq!(text_variant_class(&TextVariant::Success), "text-success");
|
||||
assert_eq!(text_variant_class(&TextVariant::Warning), "text-warning");
|
||||
assert_eq!(text_variant_class(&TextVariant::Error), "text-error");
|
||||
assert_eq!(text_variant_class(&TextVariant::Bold), "text-bold");
|
||||
assert_eq!(text_variant_class(&TextVariant::Italic), "text-italic");
|
||||
assert_eq!(text_variant_class(&TextVariant::Small), "text-small");
|
||||
assert_eq!(text_variant_class(&TextVariant::Large), "text-large");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue