use axum::body::Body;
use axum::http::{HeaderMap, Request, StatusCode, header};
use http_body_util::BodyExt;
use tower::ServiceExt;
use zeyn::prelude::*;
use zeyn::{Component, I18n, Identity, Render};
#[action(permission = "org.read")]
pub async fn departments(q: TreeQuery) -> Result<Vec<TreeItem>> {
Ok(match q.parent.as_deref() {
None => vec![TreeItem::branch("sales", "Sales <dept>").hint("2"), TreeItem::leaf("hr", "People")],
Some("sales") => vec![TreeItem::leaf("north", "North"), TreeItem::leaf("south", "South")],
Some(_) => return Err(Error::NotFound),
})
}
#[screen(route = "/org", public)]
pub fn org() -> Screen {
Screen::new("Org").body(Card::new().add(Tree::of(departments).label("Departments")))
}
#[test]
fn trees_render_an_empty_aria_tree_that_loads_its_top_level() {
let html = org::run().html(&org::META);
assert!(html.contains(r#"<ul role="tree" aria-label="Departments" data-testid="org.tree" aria-busy="true">"#));
let schema = org::run().schema(&org::META);
assert_eq!(schema["body"][0]["children"][0]["type"], "tree");
assert_eq!(schema["body"][0]["children"][0]["action"]["name"], "departments");
}
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
async fn data(perms: &str, input: serde_json::Value, key: &str) -> (StatusCode, serde_json::Value) {
let router = App::new("Test")
.i18n(I18n::new("en"))
.auth(|h: &HeaderMap| {
let perms: Vec<String> = h.get("x-perms")?.to_str().ok()?.split(',').map(String::from).collect();
Some(Identity::user("u").with_permissions(perms))
})
.feature(Feature::new("org").screen(org))
.router()
.unwrap();
let body = serde_json::json!({ "screen": "org", "node": "0.0", "key": key, "input": input });
let req = Request::post("/_zeyn/data")
.header("x-perms", perms)
.header(header::CONTENT_TYPE, "application/json")
.header(header::COOKIE, format!("zeyn_csrf={TOKEN}"))
.header("x-zeyn-csrf", TOKEN)
.body(Body::from(body.to_string()))
.unwrap();
let res = router.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 tree_children_load_through_the_actions_permission() {
let (status, roots) = data("org.read", serde_json::json!({ "parent": null }), "").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
roots[0],
serde_json::json!({ "id": "sales", "label": "Sales <dept>", "has_children": true, "hint": "2" })
);
let (_, children) = data("org.read", serde_json::json!({ "parent": "sales" }), "").await;
assert_eq!(children.as_array().unwrap().len(), 2);
let (status, _) = data("org.read", serde_json::json!({ "parent": "nope" }), "").await;
assert_eq!(status, StatusCode::NOT_FOUND);
let (status, _) = data("", serde_json::json!({}), "").await;
assert_eq!(status, StatusCode::FORBIDDEN);
let (status, _) = data("org.read", serde_json::json!({}), "other").await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
fn render(chart: Chart, locale: &str) -> String {
let mut out = String::new();
chart.render(&mut out, &Render::new("s", locale, I18n::builtin()));
out
}
#[test]
fn bar_charts_are_accessible_svg_with_a_legend_and_a_data_table() {
let html = render(
Chart::bar("Sales <2026>")
.labels(["Jan", "Feb <b>"])
.series("Shop", [1200.5, 204.0])
.series("Web", [f64::NAN, 50.0])
.decimals(1),
"tr",
);
assert!(html.contains(r#"<svg class="z-chart-svg" viewBox="0 0 640 260" role="group" aria-roledescription="grafik" aria-labelledby="s-chart--title s-chart--desc" focusable="false">"#));
assert!(html.contains(r#"<figcaption class="z-chart-title" id="s-chart--title">Sales <2026></figcaption>"#));
assert!(html.contains("2 kategoride 2 seri gösteren sütun grafiği. En yüksek: Jan, Shop, 1.200,5."), "{html}");
assert!(html.contains(r#"aria-label="Feb <b>, Web: 50,0""#));
assert!(!html.contains("<b>"), "labels are escaped");
assert!(
html.contains(r#">1.500,0</text>"#) && html.contains(r#">500,0</text>"#) && html.contains(r#">0,0</text>"#),
"axis goes to a round number"
);
assert!(html.contains(r#"<li><span class="z-chart-swatch z-series-1" aria-hidden="true"></span>Web</li>"#));
assert!(
html.contains(r#"<tr><th scope="row">Jan</th><td class="z-num">1.200,5</td><td class="z-num">0,0</td></tr>"#),
"missing numbers count as zero"
);
}
#[test]
fn line_and_donut_charts_describe_themselves() {
let line = render(Chart::line("Orders").labels(["W1", "W2", "W3"]).series("Orders", [4.0, 9.0, 7.0]), "en");
assert!(line.contains(r#"<polyline class="z-chart-line z-series-0""#) && line.matches("<circle").count() == 3);
assert!(line.contains("Highest: W2, 9."));
let donut = render(Chart::donut("Units").labels(["A", "B"]).series("Count", [30.0, 10.0]), "en");
assert!(donut.contains(r#"aria-label="A: 30 (75%)""#) && donut.contains(r#"aria-label="B: 10 (25%)""#));
assert!(donut.contains(r#">40</text>"#), "the total sits in the middle");
assert!(donut.contains("Donut chart of 2 parts. Largest: A, 30."));
}
fn every_kind() -> Vec<(&'static str, Chart)> {
let months = ["Jan", "Feb", "Mar"];
vec![
("bar", Chart::bar("Sales").labels(months).series("Shop", [3.0, 5.0, 4.0]).series("Web", [1.0, 2.0, 6.0])),
(
"stacked",
Chart::stacked_bar("Sales").labels(months).series("Shop", [3.0, 5.0, 4.0]).series("Web", [1.0, 2.0, 6.0]),
),
("line", Chart::line("Orders").labels(months).series("Orders", [3.0, 5.0, 4.0])),
(
"area",
Chart::area("Orders").labels(months).series("Orders", [3.0, 5.0, 4.0]).series("Returns", [1.0, 1.0, 2.0]),
),
(
"combo",
Chart::combo("Sales")
.labels(months)
.series("Sales", [300.0, 500.0, 400.0])
.line_series("Margin", [12.0, 18.0, 9.0])
.right_axis(),
),
(
"scatter",
Chart::scatter("Price and units").points("Tea", [(1.5, 40.0), (2.5, 22.0)]).points("Coffee", [(4.0, 12.0)]),
),
("donut", Chart::donut("Units").labels(["A", "B"]).series("Count", [30.0, 10.0])),
("gauge", Chart::gauge("Load").value(72.0).range(0.0, 120.0).target(90.0)),
(
"funnel",
Chart::funnel("Sales funnel")
.labels(["Visits", "Carts", "Orders"])
.series("People", [1000.0, 300.0, 120.0]),
),
]
}
#[test]
fn every_chart_kind_has_a_summary_a_table_and_one_tab_stop_over_named_marks() {
for (name, chart) in every_kind() {
let html = render(chart, "en");
assert!(html.contains("<desc id="), "{name}: summary");
assert!(!html.contains("{"), "{name}: every placeholder is filled: {html}");
assert!(html.contains(r#"<table class="z-chart-table">"#), "{name}: data table");
assert!(html.contains(r#"<div class="z-chart-tip" aria-hidden="true" hidden></div>"#), "{name}: tooltip");
assert_eq!(html.matches(r#"tabindex="0""#).count(), 1, "{name}: one tab stop");
let marks = html.matches("z-chart-mark").count();
assert!(marks >= 1, "{name}: marks");
assert_eq!(html.matches(r#"role="img" aria-label="#).count(), marks, "{name}: every mark is named");
assert!(!html.contains("<title>"), "{name}: no native tooltips that would be read twice");
}
}
#[test]
fn new_chart_kinds_say_what_they_show() {
let all: std::collections::HashMap<_, _> = every_kind().into_iter().map(|(n, c)| (n, render(c, "en"))).collect();
assert!(all["stacked"].contains("Highest total: Mar, 10."), "{}", all["stacked"]);
assert!(all["stacked"].contains(r#"aria-label="Mar, Web: 6""#));
assert!(all["area"].contains(r#"<polygon class="z-chart-area z-series-0""#));
assert!(all["combo"].contains(r#"<polyline class="z-chart-line z-series-1""#));
assert!(all["combo"].contains(r#"text-anchor="start">20</text>"#), "the line has its own axis on the right");
assert!(all["combo"].contains("z-chart-swatch-line"));
assert!(
all["scatter"].contains(r#"aria-label="Tea: 2, 22""#) || all["scatter"].contains(r#"aria-label="Tea: 3, 22""#)
);
assert!(all["scatter"].contains("Scatter chart of 3 points in 2 groups."));
assert!(all["gauge"].contains("Gauge at 72, between 0 and 120. Target: 90."));
assert!(all["gauge"].contains(r#"aria-label="72 of 120""#) && all["gauge"].contains("z-chart-target"));
assert!(all["funnel"].contains(r#"aria-label="Carts: 300 (30%)""#));
let tr = render(Chart::gauge("Yük").value(1234.5).decimals(1).range(0.0, 2000.0), "tr");
assert!(tr.contains("1.234,5"));
}
#[test]
fn kpi_cards_show_value_direction_target_and_trend() {
let mut out = String::new();
Kpi::new("Wait <min>", 12.0)
.unit("min")
.delta(-8.0)
.lower_is_better()
.target(10.0)
.trend([15.0, 14.0, 12.0])
.render(&mut out, &Render::new("s", "en", I18n::builtin()));
assert!(out.contains(r#"role="group" aria-labelledby="s-kpi--title""#));
assert!(out.contains("Wait <min>"));
assert!(out.contains(r#"<p class="z-kpi-delta z-kpi-good"><span aria-hidden="true">â–¼</span> Down 8.0% from the previous period</p>"#), "{out}");
assert!(out.contains("Target 10, 120% reached"));
assert!(out.contains("Trend of 3 values from 15 to 12, lowest 12, highest 15"));
let mut tr = String::new();
Kpi::new("Ciro", 1500.0).delta(4.3).render(&mut tr, &Render::new("s", "tr", I18n::builtin()));
assert!(tr.contains("1.500") && tr.contains("Önceki döneme göre yüzde 4,3 artış") && tr.contains("z-kpi-good"));
}
#[test]
fn sparkline_and_bullet_cells_are_small_named_svgs() {
use zeyn::CellRenderer;
let r = Render::english("s");
let spark = zeyn::ui::SparklineCell::new().render(&vec![1.0, f64::NAN, 4.0], &r);
assert!(spark.starts_with(r#"<svg class="z-spark" viewBox="0 0 80 20" role="img" aria-label="Trend of 3 values from 1 to 4, lowest 0, highest 4">"#), "{spark}");
assert_eq!(zeyn::ui::SparklineCell::new().render(&Vec::new(), &r), "");
let bullet = zeyn::ui::BulletCell::new(80.0, 100.0).render(&Money::tl(9_000), &r);
assert!(bullet.contains(r#"aria-label="₺90.00, target 80""#), "{bullet}");
assert!(bullet.contains(r#"<rect class="z-bullet-bar" y="3" width="90.0""#));
assert!(bullet.contains(r#"x="79.2""#));
let over = zeyn::ui::BulletCell::new(80.0, 100.0).render(&500_i64, &r);
assert!(over.contains(r#"width="100.0""#), "values over the maximum are capped");
}
#[test]
fn sparkline_and_bullet_cells_render_ten_thousand_rows_quickly() {
use zeyn::CellRenderer;
let r = Render::english("s");
let spark = zeyn::ui::SparklineCell::new();
let bullet = zeyn::ui::BulletCell::new(700.0, 1000.0);
let rows: Vec<Vec<f64>> = (0..10_000).map(|i| (0..12).map(|k| ((i * 7 + k * 13) % 100) as f64).collect()).collect();
let started = std::time::Instant::now();
let mut bytes = 0;
for (i, values) in rows.iter().enumerate() {
bytes += spark.render(values, &r).len();
bytes += bullet.render(&(i as i64 % 1000), &r).len();
}
let took = started.elapsed();
println!("10,000 rows of sparkline and bullet cells: {took:?}, {} KB of HTML", bytes / 1024);
assert!(took.as_millis() < 2_000, "10,000 rows took {took:?}");
assert!(bytes / 10_000 < 1_200, "about a kilobyte per row");
}
fn token(css: &str, from: &str, token: &str) -> (f64, f64, f64) {
let start = css.find(from).unwrap();
let at = start + css[start..].find(&format!("{token}:")).unwrap() + token.len() + 1;
let hex = css[at..].trim_start().trim_start_matches('#');
let c = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).unwrap() as f64 / 255.0;
(c(0), c(2), c(4))
}
fn contrast(a: (f64, f64, f64), b: (f64, f64, f64)) -> f64 {
let lin = |c: f64| if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) };
let l = |(r, g, b): (f64, f64, f64)| 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
let (x, y) = (l(a), l(b));
(x.max(y) + 0.05) / (x.min(y) + 0.05)
}
#[test]
fn chart_colors_reach_three_to_one_in_both_themes() {
let components = include_str!("../assets/components.css");
let zeyn = include_str!("../assets/zeyn.css");
for (theme, chart_from, surface_from) in [
("light", ":root", ":root"),
("dark", "prefers-color-scheme: dark", "prefers-color-scheme: dark"),
("dark (chosen)", ":root[data-theme=\"dark\"] {", ":root[data-theme=\"dark\"] {"),
] {
for surface in ["--z-surface", "--z-surface-2"] {
let bg = token(zeyn, surface_from, surface);
for n in 1..=6 {
let fg = token(components, chart_from, &format!("--z-chart-{n}"));
let ratio = contrast(fg, bg);
assert!(ratio >= 3.0, "{theme}: --z-chart-{n} on {surface} is {ratio:.2}:1");
}
}
}
}