#![allow(dead_code)]
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use http_body_util::BodyExt;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use tokio::sync::{Mutex, OnceCell};
use tower::ServiceExt;
use umbral_admin::{
AdminPlugin, BarPayload, ChartPoint, HeatmapPayload, KpiPayload, ProgressPayload,
RadialPayload, Series, Span, Widget, WidgetDataFn, WidgetFilter, WidgetKind, WidgetPayload,
};
use umbral_auth::{AuthPlugin, AuthUser, create_user_with_flags};
use umbral_sessions::SessionsPlugin;
static BOOT: OnceCell<axum::Router> = OnceCell::const_new();
static LOCK: Mutex<()> = Mutex::const_new(());
async fn boot() -> &'static axum::Router {
BOOT.get_or_init(|| async {
let settings = umbral::Settings::from_env().expect("settings");
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("phase4_dashboard.sqlite");
std::mem::forget(tmp);
let pool_obj = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(
SqliteConnectOptions::new()
.busy_timeout(std::time::Duration::from_secs(5))
.filename(&path)
.create_if_missing(true),
)
.await
.expect("pool");
let custom_widget = Widget {
key: "test_kpi",
title: "Test KPI".to_string(),
kind: WidgetKind::Kpi,
default_span: Span { cols: 3, rows: 1 },
permission: None,
default_period: None,
filters: Vec::new(),
data: WidgetDataFn::new(|_user| async move {
WidgetPayload::Kpi(KpiPayload {
value: "99".to_string(),
unit: Some("items".to_string()),
delta: Some(5.2),
sparkline: None,
})
}),
};
let radial_widget = Widget {
key: "test_radial",
title: "Test Radial".to_string(),
kind: WidgetKind::Radial,
default_span: Span { cols: 3, rows: 2 },
permission: None,
default_period: None,
filters: Vec::new(),
data: WidgetDataFn::new(|_user| async move {
WidgetPayload::Radial(RadialPayload::single("Done", 73.0))
}),
};
let heatmap_widget = Widget {
key: "test_heatmap",
title: "Test Heatmap".to_string(),
kind: WidgetKind::Heatmap,
default_span: Span { cols: 6, rows: 3 },
permission: None,
default_period: None,
filters: Vec::new(),
data: WidgetDataFn::new(|_user| async move {
WidgetPayload::Heatmap(HeatmapPayload::from_grid(
["R1"],
["a", "b"],
vec![vec![1.0, 2.0]],
))
}),
};
let filtered_widget = Widget::new(
"test_filtered",
"Filtered",
WidgetKind::Bar,
WidgetDataFn::with_params(|_user, params| async move {
let status = params.choice("status").unwrap_or("none").to_string();
let period = params.period.clone().unwrap_or_else(|| "none".into());
let range = params
.date_range()
.map(|(s, e)| format!("{s}..{e}"))
.unwrap_or_else(|| "none".into());
WidgetPayload::Bar(BarPayload {
series: vec![Series {
name: format!("status={status};period={period};range={range}"),
points: vec![ChartPoint {
x: "x".to_string(),
y: 1.0,
}],
}],
x_type: "t".to_string(),
})
}),
)
.filter(WidgetFilter::period_default())
.filter(WidgetFilter::date_range())
.filter(WidgetFilter::choice(
"status",
"Status",
[("open", "Open"), ("paid", "Paid")],
));
let progress_widget = Widget {
key: "test_progress",
title: "Test Progress".to_string(),
kind: WidgetKind::Progress,
default_span: Span { cols: 3, rows: 3 },
permission: None,
default_period: None,
filters: Vec::new(),
data: WidgetDataFn::new(|_user| async move {
WidgetPayload::Progress(ProgressPayload::from_pairs([("A", 10.0), ("B", 5.0)]))
}),
};
let app = umbral::App::builder()
.settings(settings)
.database("default", pool_obj)
.plugin(AuthPlugin::<AuthUser>::default())
.plugin(SessionsPlugin::default().without_auto_layer())
.plugin(
AdminPlugin::default()
.register_widget(umbral_admin::builtin_total_models_widget())
.register_widget(umbral_admin::builtin_recent_users_widget())
.register_widget(custom_widget)
.register_widget(radial_widget)
.register_widget(heatmap_widget)
.register_widget(progress_widget)
.register_widget(filtered_widget),
)
.build()
.expect("App::build");
umbral::migrate::create_tables_for_tests()
.await
.expect("create the test schema");
app.into_router()
})
.await
}
async fn staff_cookie() -> String {
let user = match create_user_with_flags("dash_user", "dash@example.com", "pass123", true, false)
.await
{
Ok(u) => u,
Err(_) => {
let pool = umbral::db::pool();
sqlx::query_as::<_, umbral_auth::AuthUser>(
"SELECT id, username, email, password_hash, is_active, is_staff, is_superuser, date_joined, last_login, email_verified_at \
FROM auth_user WHERE username = 'dash_user'",
)
.fetch_one(&pool)
.await
.expect("lookup dash_user")
}
};
let tok = umbral_sessions::create_session(Some(user.id.to_string()), None)
.await
.expect("session");
format!("umbral_session={tok}")
}
#[tokio::test]
async fn catalog_lists_registered_widgets() {
let _guard = LOCK.lock().await;
let router = boot().await;
let cookie = staff_cookie().await;
let req = Request::builder()
.uri("/admin/api/dashboard/catalog")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
let arr = json.as_array().unwrap();
assert!(arr.len() >= 3, "expected ≥3 widgets, got {}", arr.len());
let keys: Vec<&str> = arr.iter().filter_map(|v| v["key"].as_str()).collect();
assert!(
keys.contains(&"umbral_total_models"),
"missing umbral_total_models"
);
assert!(
keys.contains(&"umbral_recent_users"),
"missing umbral_recent_users"
);
assert!(keys.contains(&"test_kpi"), "missing test_kpi");
}
#[tokio::test]
async fn widget_data_returns_typed_payload() {
let _guard = LOCK.lock().await;
let router = boot().await;
let cookie = staff_cookie().await;
let req = Request::builder()
.uri("/admin/api/dashboard/widgets/test_kpi/data")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["key"].as_str().unwrap_or(""), "test_kpi");
assert_eq!(json["kind"].as_str().unwrap_or(""), "kpi");
assert_eq!(json["payload"]["value"].as_str().unwrap_or(""), "99");
assert_eq!(json["payload"]["unit"].as_str().unwrap_or(""), "items");
}
#[tokio::test]
async fn new_widget_kinds_render_html_fragments() {
let _guard = LOCK.lock().await;
let router = boot().await;
let cookie = staff_cookie().await;
for (key, marker) in [
("test_radial", "data-umbral-chart=\"radial\""),
("test_heatmap", "data-umbral-chart=\"heatmap\""),
("test_progress", "progress-widget"),
] {
let req = Request::builder()
.uri(format!("/admin/api/dashboard/widgets/{key}/data"))
.header(header::COOKIE, cookie.clone())
.header("hx-request", "true")
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "{key} should render 200");
let body = resp.into_body().collect().await.unwrap().to_bytes();
let html = String::from_utf8_lossy(&body);
assert!(
html.contains(marker),
"{key} fragment must contain `{marker}` (macro not registered?); got: {html}"
);
}
}
#[tokio::test]
async fn unknown_widget_key_returns_404() {
let _guard = LOCK.lock().await;
let router = boot().await;
let cookie = staff_cookie().await;
let req = Request::builder()
.uri("/admin/api/dashboard/widgets/no_such_widget/data")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn dashboard_page_renders_widget_placeholders() {
let _guard = LOCK.lock().await;
let router = boot().await;
let cookie = staff_cookie().await;
let req = Request::builder()
.uri("/admin/")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let html = String::from_utf8_lossy(&body);
assert!(
html.contains("hx-get=\"/admin/api/dashboard/widgets/"),
"expected HTMX widget placeholders in dashboard HTML"
);
}
#[tokio::test]
async fn admin_js_served_as_external_asset_not_inline() {
let _guard = LOCK.lock().await;
let router = boot().await;
let cookie = staff_cookie().await;
let asset_req = Request::builder()
.uri("/static/admin/admin.js")
.body(Body::empty())
.unwrap();
let asset_resp = router.clone().oneshot(asset_req).await.unwrap();
assert_eq!(asset_resp.status(), StatusCode::OK);
let ct = asset_resp
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(
ct.starts_with("application/javascript"),
"admin.js Content-Type should be application/javascript, got `{ct}`"
);
let body = asset_resp.into_body().collect().await.unwrap().to_bytes();
assert!(
body.len() > 1000,
"admin.js body should be non-trivial, got {} bytes",
body.len()
);
assert_eq!(
body.as_ref(),
include_bytes!("../src/assets/admin.js").as_slice(),
"/static/admin/admin.js should serve the embedded admin.js bytes"
);
let page_req = Request::builder()
.uri("/admin/?dashboard=1")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap();
let page_resp = router.clone().oneshot(page_req).await.unwrap();
let html = String::from_utf8_lossy(&page_resp.into_body().collect().await.unwrap().to_bytes())
.into_owned();
assert!(
html.contains("var umbralAdminBase = '/admin'"),
"umbralAdminBase bootstrap should be inline (read by admin.js)"
);
assert!(
html.contains("/static/admin/admin.js"),
"wrapper.html should reference the external admin.js on the unified static URL"
);
assert!(
!html.contains("// Sheet stack state machine."),
"old inline block-5 IIFE comment should be gone from wrapper.html"
);
assert!(
!html.contains("// Extend the early-declared window.umbral stub"),
"old inline block-3 IIFE comment should be gone from wrapper.html"
);
}
#[tokio::test]
async fn change_password_dialog_uses_html_template_not_js_concat() {
let _guard = LOCK.lock().await;
let router = boot().await;
let cookie = staff_cookie().await;
let req = Request::builder()
.uri("/admin/?dashboard=1")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let html = String::from_utf8_lossy(&body);
assert!(
html.contains("<template id=\"umbral-change-password-dialog-template\">"),
"expected change-password <template> block in wrapper.html"
);
assert!(
html.contains("data-change-pw-form"),
"expected `data-change-pw-form` selector hook on the form"
);
assert!(
!html.contains("change-password\"' +"),
"old JS-string-concat change-password builder should be removed"
);
}
#[tokio::test]
async fn dashboard_layout_round_trips() {
let _guard = LOCK.lock().await;
let router = boot().await;
let cookie = staff_cookie().await;
let layout = r#"[{"key":"test_kpi","span":{"cols":6,"rows":2}}]"#;
let put_req = Request::builder()
.method("PUT")
.uri("/admin/api/dashboard/layout")
.header(header::COOKIE, cookie.clone())
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(layout))
.unwrap();
let put_resp = router.clone().oneshot(put_req).await.unwrap();
assert_eq!(put_resp.status(), StatusCode::OK);
let get_req = Request::builder()
.uri("/admin/api/dashboard/layout")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap();
let get_resp = router.clone().oneshot(get_req).await.unwrap();
assert_eq!(get_resp.status(), StatusCode::OK);
let body = get_resp.into_body().collect().await.unwrap().to_bytes();
let body_str = String::from_utf8_lossy(&body);
assert!(body_str.contains("test_kpi"), "layout not saved/returned");
}
#[test]
fn wrapper_body_carries_csrf_hx_headers() {
let wrapper = include_str!("../templates/wrapper.html");
let body_line = wrapper
.lines()
.find(|l| l.trim_start().starts_with("<body"))
.expect("wrapper.html must have a <body> tag");
assert!(
body_line.contains("hx-headers"),
"missing hx-headers: {body_line}"
);
assert!(
body_line.contains("X-CSRF-Token"),
"missing X-CSRF-Token: {body_line}"
);
assert!(
body_line.contains("{{ csrf_token }}"),
"must use the ambient token: {body_line}"
);
}
#[test]
fn admin_js_fetches_send_csrf_header() {
let js = include_str!("../src/assets/admin.js");
assert!(
js.contains("function csrfHeaders()"),
"admin.js needs the csrfHeaders helper"
);
let writes = js.matches("method: 'PUT'").count() + js.matches("method: 'POST'").count();
let wired = js.matches("csrfHeaders()").count();
assert!(
writes > 0 && wired >= writes,
"every write fetch must spread csrfHeaders(): {wired} uses for {writes} writes"
);
}
#[test]
fn admin_js_mounts_widget_editors() {
let js = include_str!("../src/assets/admin.js");
assert!(js.contains("initWidgetEditors"), "no widget-editor init");
assert!(
js.contains("new EasyMDE"),
"markdown editor (EasyMDE) not mounted"
);
assert!(js.contains("new Quill"), "rte editor (Quill) not mounted");
assert!(
js.contains("CodeMirror.fromTextArea"),
"code editor (CodeMirror) not mounted"
);
assert!(
js.contains("claim(root, 'markdown')"),
"markdown widget not claimed"
);
assert!(js.contains("claim(root, 'rte')"), "rte widget not claimed");
assert!(
js.contains("claim(root, 'code')"),
"code widget not claimed"
);
assert!(
js.contains("DOMPurify"),
"previews not sandboxed via DOMPurify"
);
assert!(
js.contains("sanitizerFunction"),
"EasyMDE preview not routed through the sanitizer"
);
assert!(
js.contains(r#"data-widget="' + selector + '"#),
"dynamic widget selector missing"
);
assert!(
js.contains("loadScript"),
"editors should be lazy-loaded from CDN"
);
assert!(js.contains("data-widget-mounted"), "no idempotency marker");
assert!(
js.matches("umbral.initWidgetEditors").count() >= 3,
"must mount on DOMContentLoaded, htmx:afterSwap, AND the sheet path"
);
}
#[tokio::test]
async fn test_dashboard_cards_use_shared_card_recipe() {
let _guard = LOCK.lock().await;
let router = boot().await;
let cookie = staff_cookie().await;
let req = Request::builder()
.uri("/admin/")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let html = String::from_utf8_lossy(&body);
assert!(
html.contains("shadow-card"),
"dashboard cards must use the shared shadow-card recipe"
);
}
#[test]
fn wrapper_themes_the_editors() {
let wrapper = include_str!("../templates/wrapper.html");
assert!(
wrapper.contains("umbral-editor-theme"),
"no editor theme block"
);
assert!(wrapper.contains(".EasyMDEContainer"), "EasyMDE not themed");
assert!(wrapper.contains(".umbral-rte .ql-"), "Quill not themed");
assert!(
wrapper.contains("var(--surface-container-low)"),
"editor theme must use admin design tokens, not hardcoded colors"
);
}
#[tokio::test]
async fn test_dashboard_widget_grid_renders_via_macro() {
let _guard = LOCK.lock().await;
let router = boot().await;
let cookie = staff_cookie().await;
let req = Request::builder()
.uri("/admin/")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let html = String::from_utf8_lossy(&body);
assert!(
html.contains("/api/dashboard/widgets/") && html.contains("hx-trigger=\"load\""),
"dashboard still renders widget cells via the shared grid macro"
);
}
async fn filtered_html(router: &axum::Router, cookie: &str, query: &str) -> String {
let uri = if query.is_empty() {
"/admin/api/dashboard/widgets/test_filtered/data".to_string()
} else {
format!("/admin/api/dashboard/widgets/test_filtered/data?{query}")
};
let req = Request::builder()
.uri(uri)
.header(header::COOKIE, cookie)
.header("HX-Request", "true")
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = resp.into_body().collect().await.unwrap().to_bytes();
String::from_utf8_lossy(&body).to_string()
}
#[tokio::test]
async fn declared_filters_reach_the_data_closure() {
let _guard = LOCK.lock().await;
let router = boot().await;
let cookie = staff_cookie().await;
let html = filtered_html(router, &cookie, "status=paid&period=7d").await;
assert!(
html.contains("status=paid"),
"the choice filter's value must reach the closure; got: {html}"
);
assert!(
html.contains("period=7d"),
"the period must reach the closure; got: {html}"
);
}
#[tokio::test]
async fn a_non_line_widget_renders_its_declared_controls() {
let _guard = LOCK.lock().await;
let router = boot().await;
let cookie = staff_cookie().await;
let html = filtered_html(router, &cookie, "").await;
assert!(
html.contains("<select") && html.contains("value=\"paid\""),
"the choice filter renders a select with its options on a BAR widget"
);
assert!(
html.contains("type=\"date\""),
"the date-range filter renders two date inputs"
);
assert!(
html.contains("aria-pressed"),
"the period filter renders its chip strip"
);
}
#[tokio::test]
async fn a_chosen_filter_is_sticky_across_requests() {
let _guard = LOCK.lock().await;
let router = boot().await;
let cookie = staff_cookie().await;
let _ = filtered_html(router, &cookie, "status=paid").await;
let html = filtered_html(router, &cookie, "").await;
assert!(
html.contains("status=paid"),
"the previously chosen status must survive a bare request; got: {html}"
);
assert!(
html.contains(r#"<option value="paid" selected"#),
"and the select must render it as the selected option"
);
}
#[tokio::test]
async fn a_control_carries_the_other_filters_values() {
let _guard = LOCK.lock().await;
let router = boot().await;
let cookie = staff_cookie().await;
let html = filtered_html(router, &cookie, "status=open&period=90d").await;
assert!(
html.contains("period=7d&status=open") || html.contains("period=7d&status=open"),
"a period chip must carry the active status along; got: {html}"
);
}
#[tokio::test]
async fn export_honours_the_active_filters() {
let _guard = LOCK.lock().await;
let router = boot().await;
let cookie = staff_cookie().await;
let req = Request::builder()
.uri("/admin/api/dashboard/widgets/test_filtered/export.csv?status=paid&period=7d")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let ctype = resp
.headers()
.get("Content-Type")
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
let disp = resp
.headers()
.get("Content-Disposition")
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
assert!(ctype.starts_with("text/csv"), "served as CSV, got {ctype}");
assert!(
disp.contains("attachment") && disp.contains("test_filtered.csv"),
"downloads as a named file, got {disp}"
);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let csv = String::from_utf8_lossy(&body);
assert!(csv.starts_with("series,x,y\n"), "has a header row: {csv}");
assert!(
csv.contains("status=paid") && csv.contains("period=7d"),
"the export ran the closure with the ACTIVE filters: {csv}"
);
}
#[tokio::test]
async fn export_refuses_a_shape_with_no_rows() {
let _guard = LOCK.lock().await;
let router = boot().await;
let cookie = staff_cookie().await;
let req = Request::builder()
.uri("/admin/api/dashboard/widgets/test_kpi/export.csv")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn export_requires_staff() {
let _guard = LOCK.lock().await;
let router = boot().await;
let req = Request::builder()
.uri("/admin/api/dashboard/widgets/test_filtered/export.csv")
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_ne!(
resp.status(),
StatusCode::OK,
"an anonymous export must not succeed"
);
}
#[tokio::test]
async fn a_saved_layout_reorders_the_dashboard() {
let _guard = LOCK.lock().await;
let router = boot().await;
let cookie = staff_cookie().await;
let layout = r#"[
{"key":"test_progress","span":{"cols":6,"rows":2}},
{"key":"test_kpi","span":{"cols":3,"rows":1}}
]"#;
let req = Request::builder()
.method("PUT")
.uri("/admin/api/dashboard/layout")
.header(header::COOKIE, cookie.clone())
.header("Content-Type", "application/json")
.body(Body::from(layout))
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = Request::builder()
.uri("/admin/")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let html = String::from_utf8_lossy(&body);
let progress_at = html
.find("id=\"widget-test_progress\"")
.expect("progress widget renders");
let kpi_at = html
.find("id=\"widget-test_kpi\"")
.expect("kpi widget renders");
assert!(
progress_at < kpi_at,
"the saved layout must put test_progress before test_kpi; the dashboard \
is still rendering in registration order"
);
let cell = &html[progress_at.saturating_sub(400)..progress_at];
assert!(
cell.contains("span 6"),
"the saved span must win over the registration default; got: {cell}"
);
}
#[tokio::test]
async fn a_layout_entry_for_an_unknown_widget_is_ignored() {
let _guard = LOCK.lock().await;
let router = boot().await;
let cookie = staff_cookie().await;
let layout = r#"[{"key":"widget_that_does_not_exist","span":{"cols":12,"rows":4}}]"#;
let req = Request::builder()
.method("PUT")
.uri("/admin/api/dashboard/layout")
.header(header::COOKIE, cookie.clone())
.header("Content-Type", "application/json")
.body(Body::from(layout))
.unwrap();
assert_eq!(
router.clone().oneshot(req).await.unwrap().status(),
StatusCode::OK
);
let req = Request::builder()
.uri("/admin/")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "the dashboard still renders");
let body = resp.into_body().collect().await.unwrap().to_bytes();
let html = String::from_utf8_lossy(&body);
assert!(
!html.contains("widget_that_does_not_exist"),
"a stale layout entry must not render a ghost widget"
);
assert!(
html.contains("id=\"widget-test_kpi\""),
"and the real widgets still render"
);
}