use super::*;
#[test]
fn brand_trims_name_and_ignores_blank_logo() {
assert_eq!(Brand::new(" Acme ").name, "Acme");
assert_eq!(Brand::new("Acme").logo, None);
assert_eq!(
Brand::new("Acme").logo(" /logo.svg ").logo.as_deref(),
Some("/logo.svg")
);
assert_eq!(Brand::new("Acme").logo(" ").logo, None);
}
#[tokio::test]
async fn layout_shell_renders_a_complete_document() {
use topcoat::{context::CxTestBuilder, view::view};
use crate::navigation::{NavTarget, NavigationItem};
let (parts, ()) = http::Request::builder()
.uri("/admin/users")
.body(())
.unwrap()
.into_parts();
let cx = CxTestBuilder::new()
.request_context(parts)
.app_context(vec![NavigationItem {
label: "Users".to_string(),
target: NavTarget::Url("/admin/users".to_string()),
order: 0,
icon: None,
}])
.build();
let cx_ref = &cx;
let slot = view! { cx_ref => "hello" }.boxed().into();
let html = Panel::layout_shell(&cx, slot)
.single()
.await
.unwrap()
.render(&cx);
assert!(
html.starts_with("<!DOCTYPE html>"),
"missing doctype in {html}"
);
assert!(
html.contains("<html>") && html.contains("<head>"),
"missing document head in {html}"
);
assert!(
html.contains("<title>Tablo</title>"),
"missing document title in {html}"
);
assert!(html.contains("hello"), "missing layout slot in {html}");
}
#[tokio::test]
async fn document_wraps_a_public_page() {
use topcoat::{context::CxTestBuilder, view::view};
let (parts, ()) = http::Request::builder()
.uri("/blog")
.body(())
.unwrap()
.into_parts();
let cx = CxTestBuilder::new().request_context(parts).build();
let cx_ref = &cx;
let html = Panel::document(
&cx,
"Tablo Blog",
view! { cx_ref => <div class="blog">"hello"</div> },
)
.await
.unwrap()
.single()
.await
.unwrap()
.render(&cx);
assert!(
html.starts_with("<!DOCTYPE html>"),
"missing doctype in {html}"
);
assert!(
html.contains("<title>Tablo Blog</title>"),
"missing document title in {html}"
);
// The panel renders the one `<body>`; the page's content sits inside it.
assert_eq!(
html.matches("<body").count(),
1,
"exactly one body element, got {html}"
);
assert_eq!(
html.matches("<head>").count(),
1,
"exactly one head element, got {html}"
);
assert!(
html.contains("<div class=\"blog\">"),
"missing the page's own content in {html}"
);
assert!(html.contains("hello"), "missing page content in {html}");
assert!(
!html.contains("rel=\"stylesheet\""),
"no stylesheet without shell assets, got {html}"
);
let slot = view! { cx_ref => "hello" }.boxed().into();
let shell = Panel::layout_shell(&cx, slot)
.single()
.await
.unwrap()
.render(&cx);
let titled = Panel::document(&cx, "Tablo", view! { cx_ref => "hello" })
.await
.unwrap()
.single()
.await
.unwrap()
.render(&cx);
assert_eq!(
head_of(&titled),
head_of(&shell),
"the public document must take the shell's head"
);
}
fn head_of(html: &str) -> &str {
let start = html.find("<head>").expect("a rendered head");
let len = html[start..].find("</head>").expect("a closed head") + "</head>".len();
&html[start..start + len]
}
#[tokio::test]
async fn shell_escapes_brand_name_and_logo() {
use topcoat::{context::CxTestBuilder, view::view};
use crate::navigation::{NavTarget, NavigationItem};
let (parts, ()) = http::Request::builder()
.uri("/admin/users")
.body(())
.unwrap()
.into_parts();
let mut panel = crate::panel::test_support::panel_state("/admin", crate::Auth::disabled());
panel.brand =
Some(Brand::new("<script>alert(1)</script>").logo("\"><script>alert(2)</script>"));
let cx = CxTestBuilder::new()
.request_context(parts)
.request_context(crate::panel::test_support::current_panel(panel))
.build();
let nav_items = vec![NavigationItem {
label: "Users".to_string(),
target: NavTarget::Url("/admin/users".to_string()),
order: 0,
icon: None,
}];
let cx_ref = &cx;
let slot = view! { cx_ref => "hello" }.boxed().into();
let html = Panel::render_shell(&cx, &nav_items, "/admin/users", slot, None)
.await
.unwrap()
.single()
.await
.unwrap()
.render(&cx);
assert!(
!html.contains("\"><script>"),
"brand must not break out of attributes, got {html}"
);
assert!(
html.contains("<script>alert(1)</script>"),
"missing escaped brand text, got {html}"
);
assert!(
html.contains("""),
"attribute quotes must be escaped, got {html}"
);
}
#[tokio::test]
async fn shell_dark_mode_sets_html_class_and_toggle() {
fn cx_with(dark: Option<bool>) -> Cx {
use topcoat::context::CxTestBuilder;
let (parts, ()) = http::Request::builder()
.uri("/admin/users")
.body(())
.unwrap()
.into_parts();
match dark {
Some(v) => CxTestBuilder::new()
.request_context(parts)
.request_context(dark_panel(v)),
None => CxTestBuilder::new().request_context(parts),
}
.build()
}
async fn document_html(cx: &Cx) -> String {
let slot = view! { cx => "hello" }.boxed().into();
Panel::layout_shell(cx, slot)
.single()
.await
.unwrap()
.render(cx)
}
let html = document_html(&cx_with(Some(true))).await;
assert!(
html.contains("<html class=\"dark\">"),
"a dark panel must set the html class, got {html}"
);
assert!(
html.contains("data-theme-toggle"),
"theme toggle must render, got {html}"
);
assert!(
html.contains("classList.add") && html.contains("classList.remove"),
"the document must carry the reconciling theme script, got {html}"
);
let html = document_html(&cx_with(Some(false))).await;
assert!(
html.contains("<html>"),
"a light panel must not set the dark class, got {html}"
);
let html = document_html(&cx_with(None)).await;
assert!(
html.contains("<html>"),
"no panel must not set the dark class, got {html}"
);
}
#[tokio::test]
async fn theme_cookie_overrides_the_dark_mode_default() {
use topcoat::context::CxTestBuilder;
async fn html_tag(default_dark: bool, cookie: &str) -> String {
let (parts, ()) = http::Request::builder()
.uri("/admin/users")
.header(http::header::COOKIE, cookie)
.body(())
.unwrap()
.into_parts();
let cx = CxTestBuilder::new()
.request_context(parts)
.request_context(dark_panel(default_dark))
.build();
let cx_ref = &cx;
let slot = view! { cx_ref => "hello" }.boxed().into();
let html = Panel::layout_shell(&cx, slot)
.single()
.await
.unwrap()
.render(&cx);
let start = html.find("<html").unwrap();
html[start..=start + html[start..].find('>').unwrap()].to_string()
}
assert_eq!(html_tag(false, "theme=dark").await, "<html class=\"dark\">");
assert_eq!(
html_tag(true, "sidebar_state=collapsed; theme=light").await,
"<html>"
);
assert_eq!(html_tag(true, "theme=sepia").await, "<html class=\"dark\">");
}
#[tokio::test]
async fn shell_notification_carries_dismiss_hooks() {
use crate::notification::Notification;
let enc = serde_json::to_string(&Notification::success("Created")).unwrap();
let html = shell_html_with_flash(&enc).await;
assert!(
html.contains("data-sonner-toast") && html.contains("data-type=\"success\""),
"shell toast must be the Sonner surface, got {html}"
);
assert!(
html.contains("data-close-button"),
"shell toast must carry the Sonner close button, got {html}"
);
assert!(
html.contains("data-title") && html.contains("Created"),
"shell toast must carry the title, got {html}"
);
assert!(
!html.contains("data-description"),
"a title-only toast renders no description, got {html}"
);
let enc =
serde_json::to_string(&Notification::error("Boom").description("What happened")).unwrap();
let html = shell_html_with_flash(&enc).await;
assert!(
html.contains("data-type=\"error\"") && html.contains("<svg"),
"an error toast must carry its type and destructive icon, got {html}"
);
assert!(
html.contains("data-description") && html.contains("What happened"),
"the description must render, got {html}"
);
}
fn opening_tag_at(html: &str, start: usize) -> &str {
let mut quoted = false;
for (offset, byte) in html.as_bytes()[start..].iter().enumerate() {
match byte {
b'"' => quoted = !quoted,
b'>' if !quoted => return &html[start..start + offset],
_ => {}
}
}
panic!("unterminated tag at byte {start} in {html}");
}
async fn shell_html_with_flash(enc: &str) -> String {
use topcoat::{context::CxTestBuilder, cookie::CookieJarCell, view::view};
use crate::navigation::{NavTarget, NavigationItem};
let mut parts = http::Request::builder()
.uri("/admin/users")
.body(())
.unwrap()
.into_parts()
.0;
parts.headers.insert(
http::header::COOKIE,
format!("{}={enc}", crate::notification::COOKIE_NAME)
.parse()
.unwrap(),
);
let cx = CxTestBuilder::new()
.request_context(parts)
.request_context(CookieJarCell::new())
.build();
let nav_items = vec![NavigationItem {
label: "Users".to_string(),
target: NavTarget::Url("/admin/users".to_string()),
order: 0,
icon: None,
}];
let cx_ref = &cx;
let slot = view! { cx_ref => "hello" }.boxed().into();
Panel::render_shell(&cx, &nav_items, "/admin/users", slot, None)
.await
.unwrap()
.single()
.await
.unwrap()
.render(&cx)
}
#[tokio::test]
async fn sidebar_orders_custom_items_by_sort_key() {
use topcoat::{context::CxTestBuilder, view::view};
use crate::navigation::{NavTarget, NavigationItem};
let (parts, ()) = http::Request::builder()
.uri("/admin/users")
.body(())
.unwrap()
.into_parts();
let cx = CxTestBuilder::new().request_context(parts).build();
let nav_items = vec![
NavigationItem {
label: "Users".to_string(),
target: NavTarget::Url("/admin/users".to_string()),
order: 0,
icon: None,
},
NavigationItem {
label: "Showcase".to_string(),
target: NavTarget::Url("/admin/showcase".to_string()),
order: -1,
icon: None,
},
];
let cx_ref = &cx;
let slot = view! { cx_ref => "hello" }.boxed().into();
let html = Panel::render_shell(&cx, &nav_items, "/admin/users", slot, None)
.await
.unwrap()
.single()
.await
.unwrap()
.render(&cx);
let showcase_at = html.find("Showcase").expect("custom item renders");
let users_at = html.find("Users").expect("resource item renders");
assert!(
showcase_at < users_at,
"an order: -1 custom item must precede resources, got {html}"
);
}
#[tokio::test]
async fn panel_shell_renders_sidebar_with_active_and_tokens() {
use topcoat::{context::CxTestBuilder, view::view};
use crate::navigation::{NavTarget, NavigationItem};
let cx = CxTestBuilder::new().build();
let cx_ref = &cx;
let nav_items = vec![
NavigationItem {
label: "Users".to_string(),
target: NavTarget::Url("/admin/users".to_string()),
order: 0,
icon: None,
},
NavigationItem {
label: "Showcase".to_string(),
target: NavTarget::Url("/admin/showcase".to_string()),
order: 0,
icon: None,
},
];
let slot = view! { cx_ref => "hello" }.boxed().into();
let html = Panel::render_shell(&cx, &nav_items, "/admin/users", slot, None)
.await
.unwrap()
.single()
.await
.unwrap()
.render(&cx);
assert!(
html.contains("data-sidebar=\"sidebar\""),
"missing sidebar data attr in {html}"
);
assert!(
html.contains("data-sidebar=\"provider\""),
"missing provider data attr in {html}"
);
assert!(
html.contains("data-sidebar=\"inset\""),
"missing inset data attr in {html}"
);
assert!(
html.contains("data-sidebar=\"group\"") || html.contains("Navigation"),
"missing sidebar group in {html}"
);
assert!(
html.contains("href=\"/admin/showcase\"") && html.contains("data-topcoat-link="),
"sidebar entries must carry runtime navigation in {html}"
);
assert!(!html.contains("href=\"\""), "no empty href in {html}");
assert!(
html.contains("data-state=\"expanded\"") && html.contains("data-topcoat-bind:data-state"),
"missing bound data-state in {html}"
);
assert!(
!html.contains("Managed via Resource::query seam"),
"dead placeholder must be gone, got {html}"
);
assert_eq!(
html.matches("data-sidebar=\"trigger\"").count(),
2,
"expected the desktop + mobile trigger pair in {html}"
);
assert!(
html.contains("data-topcoat-on:click"),
"missing runtime click bindings in {html}"
);
assert!(
html.contains("data-active=\"true\"") && html.contains("aria-current=\"page\""),
"missing active highlight in {html}"
);
assert!(
html.contains("<a") && html.contains("href=\"/admin/users\""),
"navigation must render as links in {html}"
);
assert!(
html.contains("Toggle dark mode") || html.contains("data-theme-toggle"),
"missing dark toggle in {html}"
);
assert!(
!html.contains("ac-sidebar") && !html.contains("ac-main") && !html.contains("ac-nav-item"),
"ac-* should not remain in shell, got {html}"
);
}
#[tokio::test]
async fn shell_has_a_single_main_landmark() {
use topcoat::{context::CxTestBuilder, view::view};
let cx = CxTestBuilder::new().build();
let cx_ref = &cx;
let slot = view! { cx_ref => "hello" }.boxed().into();
let html = Panel::render_shell(&cx, &[], "/admin", slot, None)
.await
.unwrap()
.single()
.await
.unwrap()
.render(&cx);
assert_eq!(
html.matches("<main").count(),
1,
"the shell must expose exactly one main landmark, got {html}"
);
let main_tag = opening_tag_at(&html, html.find("<main").expect("main landmark"));
assert!(
main_tag.contains("data-sidebar=\"inset\""),
"the inset stays the main landmark, got {main_tag}"
);
}
#[tokio::test]
async fn sidebar_sheet_header_carries_a_mobile_close_button() {
use topcoat::{context::CxTestBuilder, view::view};
let cx = CxTestBuilder::new().build();
let cx_ref = &cx;
let slot = view! { cx_ref => "hello" }.boxed().into();
let html = Panel::render_shell(&cx, &[], "/admin", slot, None)
.await
.unwrap()
.single()
.await
.unwrap()
.render(&cx);
let label = html
.find("aria-label=\"Close sidebar\"")
.unwrap_or_else(|| panic!("missing Close sidebar control in {html}"));
let tag_start = html[..label].rfind('<').expect("close control tag start");
let tag = opening_tag_at(&html, tag_start);
assert!(
tag.contains("<button") && tag.contains("data-topcoat-on:click"),
"close control must be a button wired to close the sheet, got {tag}"
);
}
#[tokio::test]
async fn toaster_renders_no_stray_span_when_empty() {
use topcoat::{context::CxTestBuilder, view::view};
use crate::navigation::NavigationItem;
let cx = CxTestBuilder::new().build();
let cx_ref = &cx;
let nav_items: Vec<NavigationItem> = vec![];
let slot = view! { cx_ref => "hello" }.boxed().into();
let html = Panel::render_shell(&cx, &nav_items, "/admin/users", slot, None)
.await
.unwrap()
.single()
.await
.unwrap()
.render(&cx);
let ol = &html[html.find("<ol").expect("toaster list")..];
let ol = &ol[..ol.find("</ol>").expect("toaster close") + "</ol>".len()];
assert!(
ol.contains("data-sonner-toaster"),
"sliced the toaster list, got {ol}"
);
assert!(
!ol.contains("<span"),
"empty toaster must not strand a span in the list, got {ol}"
);
}
#[tokio::test]
async fn render_shell_extra_class_reaches_the_provider() {
use topcoat::context::CxTestBuilder;
let cx = CxTestBuilder::new().build();
let cx_ref = &cx;
let nav_items: Vec<NavigationItem> = vec![];
let slot = view! { cx_ref => "hello" }.boxed().into();
let html = Panel::render_shell(
&cx,
&nav_items,
"/admin/users",
slot,
Some("my-shell".to_string()),
)
.await
.unwrap()
.single()
.await
.unwrap()
.render(&cx);
assert!(
html.contains("my-shell"),
"extra_class must reach the shell markup, got {html}"
);
}
#[tokio::test]
async fn collapsed_sidebar_cookie_seeds_the_signal() {
use topcoat::{context::CxTestBuilder, view::view};
use crate::navigation::{NavTarget, NavigationItem};
let (parts, ()) = http::Request::builder()
.uri("/admin/users")
.header(http::header::COOKIE, "theme=dark; sidebar_state=collapsed")
.body(())
.unwrap()
.into_parts();
let cx = CxTestBuilder::new().request_context(parts).build();
let cx_ref = &cx;
let nav_items = vec![NavigationItem {
label: "Users".to_string(),
target: NavTarget::Url("/admin/users".to_string()),
order: 0,
icon: None,
}];
let slot = view! { cx_ref => "hello" }.boxed().into();
let html = Panel::render_shell(&cx, &nav_items, "/admin/users", slot, None)
.await
.unwrap()
.single()
.await
.unwrap()
.render(&cx);
assert!(
html.contains("data-state=\"collapsed\"")
&& html.contains("data-collapsible=\"offcanvas\""),
"collapsed cookie must seed the collapsed state in {html}"
);
}
#[tokio::test]
async fn sidebar_marks_only_the_longest_matching_entry_active() {
use topcoat::{context::CxTestBuilder, view::view};
use crate::navigation::NavigationItem;
let nav_items = vec![
NavigationItem::at("Dashboard", "/admin"),
NavigationItem::at("Users", "/admin/users"),
NavigationItem::at("Overview", "/admin"),
];
for (path, active_label) in [
("/admin", "Dashboard"),
("/admin/users/create", "Users"),
("/admin/media", "Dashboard"),
] {
let (parts, ()) = http::Request::builder()
.uri(path)
.body(())
.unwrap()
.into_parts();
let cx = CxTestBuilder::new().request_context(parts).build();
let cx_ref = &cx;
let slot = view! { cx_ref => "hello" }.boxed().into();
let html = Panel::render_shell(&cx, &nav_items, path, slot, None)
.await
.unwrap()
.single()
.await
.unwrap()
.render(&cx);
let active: Vec<&str> = html
.match_indices("data-active=\"true\"")
.map(|(at, _)| {
let start = html[..at].rfind('<').unwrap();
let end = at + html[at..].find("</a>").unwrap();
&html[start..end]
})
.collect();
assert_eq!(active.len(), 1, "one active entry on {path}: {active:?}");
assert!(
active[0].contains(&format!("title=\"{active_label}\"")),
"{active_label} is active on {path}: {active:?}"
);
}
}
#[tokio::test]
async fn nav_item_icon_renders_before_its_label() {
use topcoat::{context::CxTestBuilder, view::view};
use crate::navigation::NavigationItem;
let (parts, ()) = http::Request::builder()
.uri("/admin/users")
.body(())
.unwrap()
.into_parts();
let cx = CxTestBuilder::new().request_context(parts).build();
let cx_ref = &cx;
let nav_items = vec![
NavigationItem::at("Users", "/admin/users").icon(tablo_ui::icons::USERS),
NavigationItem::at("Posts", "/admin/posts"),
];
let slot = view! { cx_ref => "hello" }.boxed().into();
let html = Panel::render_shell(&cx, &nav_items, "/admin/users", slot, None)
.await
.unwrap()
.single()
.await
.unwrap()
.render(&cx);
let button = |href: &str| {
let start = html
.find(&format!("href=\"{href}\""))
.unwrap_or_else(|| panic!("no link to {href} in {html}"));
let open = html[..start].rfind("<a").expect("the link's tag");
let close = open + html[open..].find("</a>").expect("the link's end");
html[open..close].to_string()
};
let users = button("/admin/users");
let svg = users
.find("<svg")
.expect("the Users entry renders its icon");
let label = users.find("<span>Users</span>").expect("the Users label");
assert!(svg < label, "the icon precedes the label in {users}");
assert!(
!button("/admin/posts").contains("<svg"),
"an entry without an icon renders none"
);
}
fn dark_panel(dark: bool) -> crate::panel::state::CurrentPanel {
let mut panel = crate::panel::test_support::panel_state("/admin", crate::Auth::disabled());
panel.dark_mode = dark;
crate::panel::test_support::current_panel(panel)
}