use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use http_body_util::BodyExt;
use tower::ServiceExt;
use zeyn::prelude::*;
use zeyn::ui::{mask_example, mask_fits, mask_raw};
use zeyn::{Aggregate, AggregateRequest, Component, Filter, I18n, Render, Rule, Sort, Value};
fn dec(s: &str) -> Decimal {
Decimal::parse(s).unwrap()
}
#[test]
fn decimals_are_exact_and_compare_by_value() {
let mut sum = Decimal::ZERO;
for _ in 0..10 {
sum = sum.checked_add(dec("0.1")).unwrap();
}
assert_eq!(sum, dec("1"), "ten times 0.1 is exactly 1");
assert_eq!(dec("1.50"), dec("1.5"));
assert_eq!(dec("1.50").to_string(), "1.50", "the scale is kept for display");
assert!(dec("-1.5") < dec("-1.2") && dec("-0.5") < dec("0.3") && dec("2") > dec("1.99"));
assert_eq!(dec(".5"), dec("0.5"));
assert_eq!(dec("12345E-2"), dec("123.45"));
assert_eq!(dec("1.5e3"), dec("1500"));
assert_eq!(dec("99999999999999999999999999999999999999").integer_digits(), 38);
assert!(Decimal::parse("999999999999999999999999999999999999999").is_none(), "39 digits do not fit");
assert!(Decimal::parse("1.2.3").is_none() && Decimal::parse("").is_none() && Decimal::parse("abc").is_none());
assert_eq!(dec("0.1250").fraction_digits(), 3);
assert_eq!(dec("2.5").round(0), dec("3"));
assert_eq!(dec("-2.5").round(0), dec("-3"));
assert_eq!(dec("10").div_int(3, 4), Some(dec("3.3333")));
assert_eq!(dec("2").div_int(3, 2), Some(dec("0.67")));
}
#[test]
fn decimals_read_and_write_in_the_users_language() {
assert_eq!(Decimal::parse_input("1.234,5678", "tr"), Some(dec("1234.5678")));
assert_eq!(Decimal::parse_input("1,234.5678", "en"), Some(dec("1234.5678")));
assert_eq!(Decimal::parse_input("-0,5", "tr"), Some(dec("-0.5")));
assert_eq!(Decimal::parse_input("1e5", "en"), None, "typed exponents are refused");
assert_eq!(dec("-1234567.25").format("tr"), "-1.234.567,25");
assert_eq!(dec("-1234567.25").format("en"), "-1,234,567.25");
assert_eq!(serde_json::to_value(dec("0.10")).unwrap(), serde_json::json!("0.10"));
assert_eq!(serde_json::from_value::<Decimal>(serde_json::json!(0.1)).unwrap(), dec("0.1"));
assert_eq!(serde_json::from_value::<Decimal>(serde_json::json!("3.25")).unwrap(), dec("3.25"));
assert_eq!(serde_json::from_value::<Decimal>(serde_json::json!(7)).unwrap(), dec("7"));
}
#[entity(table = "rates")]
pub struct Rate {
#[key]
pub id: RateId,
#[rule(precision = 8, scale = 4, min = 0, max = 1000)]
pub factor: Decimal,
#[rule(scale = 1, min = 0, max = 100)]
pub discount: Option<Decimal>,
pub plain: Decimal,
#[rule(mask = "(999) 999 99 99")]
pub phone: String,
#[rule(mask = "AA99 9999 9999 9999 9999 9999 99")]
pub iban: Option<String>,
#[rule(min_len = 8)]
pub password: String,
}
#[test]
fn decimal_rules_check_scale_precision_and_limits() {
let ok = Rate { factor: dec("12.3456"), phone: "5321234567".into(), ..Rate::default() };
assert!(Valid::new(ok.clone()).is_ok());
let rules = |r: Rate| Valid::new(r).unwrap_err().errors.into_iter().map(|e| (e.field, e.rule)).collect::<Vec<_>>();
assert_eq!(rules(Rate { factor: dec("1.23456"), ..ok.clone() }), [("factor", Rule::Scale(4))]);
assert_eq!(
rules(Rate { factor: dec("100000"), ..ok.clone() }),
[("factor", Rule::Max(1000)), ("factor", Rule::Precision(8))]
);
assert_eq!(rules(Rate { factor: dec("-0.5"), ..ok.clone() }), [("factor", Rule::Min(0))]);
assert_eq!(rules(Rate { discount: Some(dec("100.5")), ..ok.clone() }), [("discount", Rule::Max(100))]);
assert_eq!(rules(Rate { plain: dec("0.001"), ..ok.clone() }), [("plain", Rule::Scale(2))], "2 decimals by default");
assert_eq!(
rules(Rate { plain: dec("10000000000000000"), ..ok.clone() }),
[("plain", Rule::Precision(18))],
"18 digits by default, 16 before the separator"
);
let meta = &<Rate as zeyn::Model>::fields()[1];
assert_eq!(meta.decimal_limits(), (8, 4));
assert_eq!(<Rate as zeyn::Model>::fields()[3].decimal_limits(), (18, 2));
}
#[action(public)]
pub async fn save_rate(rate: Valid<Rate>) -> Result<Rate> {
Ok(rate.into_inner())
}
fn form(rate: Rate) -> zeyn::SubmitForm<Rate, save_rate> {
Form::of(rate)
.field(Rate::factor)
.field(Rate::discount)
.control(Rate::discount, Control::Percent)
.field(Rate::plain)
.field(Rate::phone)
.field(Rate::iban)
.field(Rate::password)
.control(Rate::password, Control::PasswordMeter)
.submit("Save", save_rate)
}
fn html(locale: &str, rate: Rate) -> String {
let mut out = String::new();
form(rate).render(&mut out, &Render::new("r", locale, I18n::builtin()));
out
}
#[test]
fn decimal_and_percent_inputs_keep_the_field_scale() {
let rate = Rate {
factor: dec("1234.5"),
discount: Some(dec("12.5")),
phone: "5321234567".into(),
password: "secret-value".into(),
..Rate::default()
};
let tr = html("tr", rate.clone());
assert!(tr.contains(r#"data-scale="4""#) && tr.contains(r#"value="1.234,5""#), "{tr}");
assert!(tr.contains(r#"class="z-number z-percent z-affix-first""#), "Turkish writes %12,5");
assert!(tr.contains(r#"aria-describedby="r-discount-unit r-discount-error""#));
assert!(tr.contains(r#"<span class="z-sr-only" id="r-discount-unit">yüzde</span>"#));
let en = html("en", rate);
assert!(en.contains(r#"class="z-number z-percent" data-zeyn-number"#) && en.contains(r#"value="12.5""#));
assert!(en.contains(r#"inputmode="decimal""#));
}
#[test]
fn masked_inputs_hold_the_raw_value_and_password_is_never_rendered_back() {
let rate = Rate { phone: "5321234567".into(), password: "secret-value".into(), ..Rate::default() };
let en = html("en", rate);
assert!(
en.contains(r#"<div class="z-mask" data-zeyn-mask="(999) 999 99 99" data-example="(___) ___ __ __">"#),
"{en}"
);
assert!(
en.contains(r#"class="z-mask-value" dir="ltr" inputmode="numeric""#) && en.contains(r#"value="5321234567""#)
);
assert!(en.contains(r#"data-zeyn-mask="AA99 9999 9999 9999 9999 9999 99""#) && en.contains(r#"inputmode="text""#));
assert!(!en.contains("secret-value"), "passwords are never written into the page");
assert!(en.contains(r#"autocomplete="new-password""#));
assert!(en.contains(
r#"class="z-password-toggle" aria-pressed="false" aria-controls="r-password" aria-label="Show password""#
));
assert!(en.contains(r#"aria-describedby="r-password-strength r-password-error""#));
assert!(en.contains(r#"data-zeyn-strength="r-password""#) && en.contains("Strength: weak"));
}
#[test]
fn masks_accept_typed_literals_and_check_on_the_server() {
assert_eq!(mask_raw("(999) 999 99 99", "(532) 123-45 67"), "5321234567");
assert_eq!(mask_raw("AA99 9999", "tr33 0006"), "TR330006");
assert_eq!(mask_raw("999", "12a"), "12a", "a letter in a digit slot is kept so the rule refuses it");
assert!(mask_fits("\\TR99", "33"), "escaped letters are literals");
assert_eq!(mask_example("\\TR99"), "TR__");
let f = form(Rate::default());
let values = serde_json::json!({
"factor": "234,5", "discount": "12,5", "plain": "3", "phone": "(532) 123 45 67",
"iban": "tr33 0006 1005 1978 6457 8413 26", "password": "long enough"
});
let rate = f.form().read(values.as_object().unwrap(), "tr", TimeZone::UTC).unwrap();
assert_eq!(rate.factor, dec("234.5"));
assert_eq!(rate.factor.scale(), 4, "read values get the field scale");
assert_eq!(rate.discount, Some(dec("12.5")));
assert_eq!(rate.phone, "5321234567");
assert_eq!(rate.iban.as_deref(), Some("TR330006100519786457841326"));
let bad = serde_json::json!({ "factor": "1,23456", "phone": "532 12", "iban": "33TR", "plain": "x", "password": "short" });
let errors = f.form().read(bad.as_object().unwrap(), "tr", TimeZone::UTC).unwrap_err();
let got: Vec<(&str, Rule)> = errors.errors.iter().map(|e| (e.field, e.rule)).collect();
assert!(got.contains(&("factor", Rule::Scale(4))), "{got:?}");
assert!(got.contains(&("phone", Rule::Mask("(999) 999 99 99"))));
assert!(got.contains(&("iban", Rule::Mask("AA99 9999 9999 9999 9999 9999 99"))));
assert!(got.contains(&("plain", Rule::Format)));
assert!(got.contains(&("password", Rule::MinLen(8))));
let phone = errors.errors.iter().find(|e| e.field == "phone").unwrap();
assert_eq!(I18n::builtin().text("tr", &phone.message), "Phone (___) ___ __ __ biçiminde olmalı.");
let api: Result<Valid<Rate>, _> = serde_json::from_value(
serde_json::json!({ "id": 0, "factor": "1", "plain": "1", "phone": "(532) 123 45 67", "password": "" }),
);
assert!(api.is_err(), "API input is checked against the raw pattern too");
}
#[test]
fn decimals_filter_sort_and_total_in_memory_without_floats() {
let rows: Vec<Rate> = ["0.1", "0.2", "0.3", "10.05"]
.iter()
.enumerate()
.map(|(i, f)| Rate { id: RateId(i as i64 + 1), factor: dec(f), ..Rate::default() })
.collect();
let q = DataQuery {
filters: vec![Filter::range("factor", Some("0,15"), Some("10.05"))],
sort: vec![Sort { field: "factor".into(), desc: true }],
aggregates: vec![
AggregateRequest { field: "factor".into(), op: Aggregate::Sum },
AggregateRequest { field: "factor".into(), op: Aggregate::Avg },
],
..DataQuery::default()
};
let page = q.apply(rows);
let got: Vec<String> = page.items.iter().map(|r| r.factor.to_string()).collect();
assert_eq!(got, ["10.05", "0.3", "0.2"]);
assert_eq!(page.totals[0].value, Value::Number(dec("10.55")));
assert_eq!(page.totals[1].value, Value::Number(dec("3.52")), "average rounds to the values' scale");
}
#[test]
fn decimal_fields_describe_themselves_for_apis_and_tools() {
let schema = zeyn::model_schema::<Rate>();
assert_eq!(schema["properties"]["factor"]["type"], "string");
assert!(schema["properties"]["factor"]["description"].as_str().unwrap().contains("8 digits and 4 decimals"));
let tools = form(Rate::default());
let tool = zeyn::FormSource::tool_schema(&tools);
assert!(tool["properties"]["factor"]["description"].as_str().unwrap().contains("at most 4 decimals"));
assert!(tool["properties"]["phone"]["description"].as_str().unwrap().contains("(999) 999 99 99"));
}
#[screen(route = "/rates", public)]
pub fn rates() -> Screen {
Screen::new("Rates").body(form(Rate { phone: "5321234567".into(), ..Rate::default() }))
}
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
async fn submit(values: serde_json::Value) -> (StatusCode, serde_json::Value) {
let app = App::new("Test").i18n(I18n::new("en")).feature(Feature::new("rates").screen(rates)).router().unwrap();
let req = Request::post("/_zeyn/form")
.header(header::CONTENT_TYPE, "application/json")
.header(header::COOKIE, format!("zeyn_csrf={TOKEN}"))
.header("x-zeyn-csrf", TOKEN)
.body(Body::from(serde_json::json!({ "screen": "rates", "node": "0", "values": values }).to_string()))
.unwrap();
let res = app.oneshot(req).await.unwrap();
let status = res.status();
let bytes = res.into_body().collect().await.unwrap().to_bytes();
(status, serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null))
}
#[tokio::test]
async fn the_form_endpoint_reads_decimals_and_refuses_bad_masks() {
let (status, body) = submit(
serde_json::json!({ "factor": "2.5", "plain": "1", "phone": "532 123 45 67", "password": "long enough" }),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
let (status, body) =
submit(serde_json::json!({ "factor": "2.55555", "plain": "1", "phone": "532", "password": "long enough" }))
.await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
let fields: Vec<&str> = body["errors"].as_array().unwrap().iter().filter_map(|e| e["field"].as_str()).collect();
assert_eq!(fields, ["factor", "phone"]);
}