|
|
|
@@ -4,8 +4,11 @@ use axum::routing::get;
|
|
|
|
|
use axum::Router;
|
|
|
|
|
use futures_util::{stream, StreamExt};
|
|
|
|
|
use hemplate::Hemplate;
|
|
|
|
|
use slhx::{IntoEffect, SafeHtml};
|
|
|
|
|
use slhx_axum::{runtime_js, sse, DispatchRejection, EffectResponse, HandlerRegistry, InteractionForm, PageRequest};
|
|
|
|
|
use slhx::{CssClass, CssClasses, IntoEffect, SafeHtml};
|
|
|
|
|
use slhx_axum::{
|
|
|
|
|
runtime_js, sse, DispatchRejection, EffectResponse, HandlerRegistry, InteractionForm,
|
|
|
|
|
PageRequest,
|
|
|
|
|
};
|
|
|
|
|
use slhx_techdemo::ui;
|
|
|
|
|
use std::collections::{BTreeMap, VecDeque};
|
|
|
|
|
use std::convert::Infallible;
|
|
|
|
@@ -97,7 +100,7 @@ struct Shared {
|
|
|
|
|
#[derive(Hemplate)]
|
|
|
|
|
#[hemplate = "partials"]
|
|
|
|
|
struct IssueLane {
|
|
|
|
|
class: String,
|
|
|
|
|
class: CssClass,
|
|
|
|
|
lane_id: &'static str,
|
|
|
|
|
move_handle: u32,
|
|
|
|
|
title: &'static str,
|
|
|
|
@@ -108,7 +111,7 @@ struct IssueLane {
|
|
|
|
|
#[derive(Hemplate)]
|
|
|
|
|
#[hemplate = "partials"]
|
|
|
|
|
struct IssueCard {
|
|
|
|
|
class: String,
|
|
|
|
|
class: CssClasses,
|
|
|
|
|
id: u64,
|
|
|
|
|
title: String,
|
|
|
|
|
stage: &'static str,
|
|
|
|
@@ -138,6 +141,34 @@ struct InspectorSelected {
|
|
|
|
|
#[hemplate = "partials"]
|
|
|
|
|
struct InspectorEmpty;
|
|
|
|
|
|
|
|
|
|
#[derive(Hemplate)]
|
|
|
|
|
#[hemplate = "partials"]
|
|
|
|
|
struct LiveFeed {
|
|
|
|
|
tick: u64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Hemplate)]
|
|
|
|
|
#[hemplate = "partials"]
|
|
|
|
|
struct ActivityFeed {
|
|
|
|
|
items: Vec<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Hemplate)]
|
|
|
|
|
#[hemplate = "partials"]
|
|
|
|
|
struct ArchitectureActivity;
|
|
|
|
|
|
|
|
|
|
#[derive(Hemplate)]
|
|
|
|
|
#[hemplate = "partials"]
|
|
|
|
|
struct ArchitectureInspector;
|
|
|
|
|
|
|
|
|
|
#[derive(Hemplate)]
|
|
|
|
|
#[hemplate = "partials"]
|
|
|
|
|
struct ArchitectureHero;
|
|
|
|
|
|
|
|
|
|
#[derive(Hemplate)]
|
|
|
|
|
#[hemplate = "partials"]
|
|
|
|
|
struct ArchitectureBoard;
|
|
|
|
|
|
|
|
|
|
#[tokio::main]
|
|
|
|
|
async fn main() {
|
|
|
|
|
let state = Arc::new(Shared { demo: Mutex::new(DemoState::default()) });
|
|
|
|
@@ -169,10 +200,10 @@ async fn home(State(state): State<Arc<Shared>>, request: PageRequest) -> impl In
|
|
|
|
|
// req: page_swap/001 req: page_swap/002 req: examples/001
|
|
|
|
|
async fn architecture(request: PageRequest) -> impl IntoResponse {
|
|
|
|
|
let body = ui::control_center::lower_html(include_str!("../templates/control_center.heml"))
|
|
|
|
|
.replace("__HERO__", &architecture_hero())
|
|
|
|
|
.replace("__BOARD__", &architecture_board())
|
|
|
|
|
.replace("__INSPECTOR__", &architecture_inspector())
|
|
|
|
|
.replace("__ACTIVITY__", &architecture_activity());
|
|
|
|
|
.replace("__HERO__", architecture_hero().as_str())
|
|
|
|
|
.replace("__BOARD__", architecture_board().as_str())
|
|
|
|
|
.replace("__INSPECTOR__", architecture_inspector().as_str())
|
|
|
|
|
.replace("__ACTIVITY__", architecture_activity().as_str());
|
|
|
|
|
request
|
|
|
|
|
.page(body, shell)
|
|
|
|
|
.title("slhx Architecture")
|
|
|
|
@@ -193,13 +224,13 @@ async fn interact(
|
|
|
|
|
// req: push/001 req: push/003 req: examples/001
|
|
|
|
|
async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResponse {
|
|
|
|
|
if params.contains_key("once") {
|
|
|
|
|
let effect = ui::control_center::slots::live_feed.html(SafeHtml::trusted(live_feed(1)));
|
|
|
|
|
let effect = ui::control_center::slots::live_feed.html(live_feed(1));
|
|
|
|
|
return sse(stream::iter([Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT))]).boxed());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let batches = stream::unfold(1_u64, |tick| async move {
|
|
|
|
|
tokio::time::sleep(Duration::from_secs(4)).await;
|
|
|
|
|
let effect = ui::control_center::slots::live_feed.html(SafeHtml::trusted(live_feed(tick)));
|
|
|
|
|
let effect = ui::control_center::slots::live_feed.html(live_feed(tick));
|
|
|
|
|
Some((Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), tick + 1))
|
|
|
|
|
})
|
|
|
|
|
.boxed();
|
|
|
|
@@ -308,8 +339,8 @@ fn registry(shared: Arc<Shared>) -> HandlerRegistry {
|
|
|
|
|
let mut demo = shared.demo.lock().unwrap();
|
|
|
|
|
demo.log("Simulated push event produced the same EffectBatch shape");
|
|
|
|
|
(
|
|
|
|
|
ui::control_center::slots::live_feed.html(SafeHtml::trusted(live_feed(demo.activity.len() as u64))),
|
|
|
|
|
ui::control_center::slots::activity.html(SafeHtml::trusted(render_activity(&demo))),
|
|
|
|
|
ui::control_center::slots::live_feed.html(live_feed(demo.activity.len() as u64)),
|
|
|
|
|
ui::control_center::slots::activity.html(render_activity(&demo)),
|
|
|
|
|
ui::control_center::slots::notice.text("Push simulated · no client app code"),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
@@ -337,7 +368,7 @@ fn demo_effects(demo: &DemoState, notice: &'static str) -> impl IntoEffect {
|
|
|
|
|
(
|
|
|
|
|
ui::control_center::slots::hero_metrics.html(SafeHtml::trusted(render_hero(demo))),
|
|
|
|
|
ui::control_center::slots::board.html(SafeHtml::trusted(render_board(demo))),
|
|
|
|
|
ui::control_center::slots::activity.html(SafeHtml::trusted(render_activity(demo))),
|
|
|
|
|
ui::control_center::slots::activity.html(render_activity(demo)),
|
|
|
|
|
ui::control_center::slots::inspector.html(SafeHtml::trusted(render_inspector(demo))),
|
|
|
|
|
ui::control_center::slots::notice.text(notice),
|
|
|
|
|
ui::control_center::forms::launch_work.clear("title"),
|
|
|
|
@@ -354,10 +385,11 @@ fn page_html(demo: &DemoState) -> String {
|
|
|
|
|
.replace("__HERO__", &render_hero(demo))
|
|
|
|
|
.replace("__BOARD__", &render_board(demo))
|
|
|
|
|
.replace("__INSPECTOR__", &render_inspector(demo))
|
|
|
|
|
.replace("__ACTIVITY__", &render_activity(demo))
|
|
|
|
|
.replace("__ACTIVITY__", render_activity(demo).as_str())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn shell(body: String) -> String {
|
|
|
|
|
let control_css = include_str!("../templates/control_center.css");
|
|
|
|
|
format!(
|
|
|
|
|
r#"<!doctype html>
|
|
|
|
|
<html lang="en">
|
|
|
|
@@ -401,13 +433,6 @@ fn shell(body: String) -> String {
|
|
|
|
|
.section-heading {{ display:flex; justify-content:space-between; gap:12px; color:var(--muted); margin-bottom:14px; }}
|
|
|
|
|
.section-heading strong {{ color:var(--cyan); }}
|
|
|
|
|
.lanes {{ display:grid; grid-template-columns:repeat(3, minmax(220px, 1fr)); gap:14px; align-items:start; }}
|
|
|
|
|
.lane {{ min-height:330px; border:1px solid var(--line); border-radius:24px; padding:14px; background:linear-gradient(180deg, rgba(0,0,0,.26), rgba(255,255,255,.035)); overflow:hidden; }}
|
|
|
|
|
.lane h3 {{ margin:0 0 4px; }}
|
|
|
|
|
.lane p {{ color:var(--muted); margin:0 0 12px; font-size:13px; }}
|
|
|
|
|
.work-card {{ border:1px solid rgba(255,255,255,.18); border-radius:20px; margin:12px 0; padding:14px; background:linear-gradient(145deg, rgba(255,255,255,.15), rgba(255,255,255,.055)); box-shadow:0 14px 38px rgba(0,0,0,.22), inset 0 1px 0 rgba(255,255,255,.1); overflow:hidden; overflow-wrap:anywhere; cursor:grab; }}
|
|
|
|
|
.work-card:active {{ cursor:grabbing; }}
|
|
|
|
|
.work-card.is-selected {{ border-color:rgba(68,231,255,.9); box-shadow:0 0 0 1px rgba(68,231,255,.4), 0 22px 55px rgba(68,231,255,.14); }}
|
|
|
|
|
.lane.drop-ready {{ border-color:rgba(184,255,90,.85); background:linear-gradient(180deg, rgba(184,255,90,.11), rgba(255,255,255,.04)); }}
|
|
|
|
|
.work-card header {{ display:flex; justify-content:space-between; gap:10px; align-items:start; }}
|
|
|
|
|
.pill {{ display:inline-flex; border:1px solid var(--line); border-radius:999px; padding:4px 9px; font-size:12px; color:var(--lime); }}
|
|
|
|
|
.impact {{ height:7px; border-radius:999px; background:rgba(255,255,255,.12); overflow:hidden; margin:12px 0; }}
|
|
|
|
@@ -428,6 +453,7 @@ fn shell(body: String) -> String {
|
|
|
|
|
code {{ color:var(--cyan); }}
|
|
|
|
|
@media (max-width: 920px) {{ .hero-shell,.workspace,.insight-grid {{ grid-template-columns:1fr; }} .lanes {{ grid-template-columns:1fr; }} }}
|
|
|
|
|
</style>
|
|
|
|
|
<style>{control_css}</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>{body}</body>
|
|
|
|
|
</html>"#
|
|
|
|
@@ -457,7 +483,7 @@ fn render_board(demo: &DemoState) -> String {
|
|
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.map(|(idx, (lane_id, title, description))| IssueLane {
|
|
|
|
|
class: String::from("lane"),
|
|
|
|
|
class: ui::control_center::classes::lane,
|
|
|
|
|
lane_id,
|
|
|
|
|
move_handle: ui::control_center::handles::move_to_lane.id().id,
|
|
|
|
|
title,
|
|
|
|
@@ -482,9 +508,12 @@ fn render_board(demo: &DemoState) -> String {
|
|
|
|
|
fn issue_card(item: &WorkItem, selected: bool) -> IssueCard {
|
|
|
|
|
IssueCard {
|
|
|
|
|
class: if selected {
|
|
|
|
|
String::from("work-card is-selected")
|
|
|
|
|
CssClasses::from([
|
|
|
|
|
ui::control_center::classes::work_card,
|
|
|
|
|
ui::control_center::classes::is_selected,
|
|
|
|
|
])
|
|
|
|
|
} else {
|
|
|
|
|
String::from("work-card")
|
|
|
|
|
CssClasses::from(ui::control_center::classes::work_card)
|
|
|
|
|
},
|
|
|
|
|
id: item.id,
|
|
|
|
|
title: item.title.clone(),
|
|
|
|
@@ -496,13 +525,11 @@ fn issue_card(item: &WorkItem, selected: bool) -> IssueCard {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn render_activity(demo: &DemoState) -> String {
|
|
|
|
|
let mut out = String::from("<ol class=\"activity\">");
|
|
|
|
|
for item in &demo.activity {
|
|
|
|
|
out.push_str(&format!("<li>{}</li>", escape_html(item)));
|
|
|
|
|
}
|
|
|
|
|
out.push_str("</ol>");
|
|
|
|
|
out
|
|
|
|
|
fn render_activity(demo: &DemoState) -> SafeHtml {
|
|
|
|
|
// req: html_safety/002 req: view/001
|
|
|
|
|
render_html(&ActivityFeed {
|
|
|
|
|
items: demo.activity.iter().cloned().collect(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn render_inspector(demo: &DemoState) -> String {
|
|
|
|
@@ -526,37 +553,183 @@ fn render_inspector(demo: &DemoState) -> String {
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn live_feed(tick: u64) -> String {
|
|
|
|
|
format!(
|
|
|
|
|
r#"<div class="live-row"><strong>SSE tick #{tick}</strong><br>Server streamed a typed EffectBatch into <code>slots::live_feed</code>.</div>"#
|
|
|
|
|
)
|
|
|
|
|
fn live_feed(tick: u64) -> SafeHtml {
|
|
|
|
|
// req: html_safety/002 req: view/001
|
|
|
|
|
render_html(&LiveFeed { tick })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn architecture_hero() -> String {
|
|
|
|
|
"<div class=\"metrics\"><div class=\"metric\"><strong>1</strong><span>template source of truth</span></div><div class=\"metric\"><strong>0</strong><span>CSS selectors in handlers</span></div><div class=\"metric\"><strong>∞</strong><span>typed composition through tuples</span></div></div>".into()
|
|
|
|
|
fn architecture_hero() -> SafeHtml {
|
|
|
|
|
// req: html_safety/002 req: view/001
|
|
|
|
|
render_html(&ArchitectureHero)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn architecture_board() -> String {
|
|
|
|
|
"<div class=\"lanes\"><section class=\"lane\"><h3>hemplate</h3><p>Owns syntax and Surface facts.</p></section><section class=\"lane\"><h3>slhx-build</h3><p>Generates resources and lowering tables.</p></section><section class=\"lane\"><h3>runtime</h3><p>Executes compact EffectBatch ops.</p></section></div>".into()
|
|
|
|
|
fn architecture_board() -> SafeHtml {
|
|
|
|
|
// req: html_safety/002 req: view/001
|
|
|
|
|
render_html(&ArchitectureBoard)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn architecture_inspector() -> String {
|
|
|
|
|
"<div class=\"inspector-row\"><b>Page swap</b><br>This route was fetched as a partial and rendered through the same root.</div>".into()
|
|
|
|
|
fn architecture_inspector() -> SafeHtml {
|
|
|
|
|
// req: html_safety/002 req: view/001
|
|
|
|
|
render_html(&ArchitectureInspector)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn architecture_activity() -> String {
|
|
|
|
|
"<ol class=\"activity\"><li>Clicked a real anchor</li><li>Fetched HTML with X-SLHX-Partial</li><li>Preserved native fallback semantics</li></ol>".into()
|
|
|
|
|
fn architecture_activity() -> SafeHtml {
|
|
|
|
|
// req: html_safety/002 req: view/001
|
|
|
|
|
render_html(&ArchitectureActivity)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn render_template(template: &impl Hemplate) -> String {
|
|
|
|
|
template.render().expect("techdemo hemplate partial renders")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn escape_html(value: &str) -> String {
|
|
|
|
|
value
|
|
|
|
|
.replace('&', "&")
|
|
|
|
|
.replace('<', "<")
|
|
|
|
|
.replace('>', ">")
|
|
|
|
|
.replace('"', """)
|
|
|
|
|
.replace('\'', "'")
|
|
|
|
|
fn render_html(template: &impl Hemplate) -> SafeHtml {
|
|
|
|
|
SafeHtml::trusted(render_template(template))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use scraper::{Html, Selector};
|
|
|
|
|
|
|
|
|
|
fn selector(value: &str) -> Selector {
|
|
|
|
|
Selector::parse(value).expect("test selector parses")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// req: style/001 req: style/002 req: style/003 req: test/005
|
|
|
|
|
#[test]
|
|
|
|
|
fn generated_css_class_flows_through_hemplate_dynamic_attribute() {
|
|
|
|
|
let board = render_board(&DemoState::default());
|
|
|
|
|
assert_eq!(ui::control_center::classes::lane.as_str(), "lane");
|
|
|
|
|
assert_eq!(ui::control_center::classes::work_card.as_str(), "work-card");
|
|
|
|
|
assert_eq!(ui::control_center::classes::is_selected.as_str(), "is-selected");
|
|
|
|
|
|
|
|
|
|
let document = Html::parse_fragment(&board);
|
|
|
|
|
let lane = document
|
|
|
|
|
.select(&selector(r#"section.lane[data-lane="compiler"]"#))
|
|
|
|
|
.next()
|
|
|
|
|
.expect("compiler lane renders");
|
|
|
|
|
assert_eq!(lane.value().attr("class"), Some("lane"));
|
|
|
|
|
|
|
|
|
|
let selected_card = document
|
|
|
|
|
.select(&selector(r#"article.work-card.is-selected[data-key="2"]"#))
|
|
|
|
|
.next()
|
|
|
|
|
.expect("selected work card renders");
|
|
|
|
|
assert_eq!(selected_card.value().attr("class"), Some("work-card is-selected"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// req: html_safety/002 req: view/001 req: test/005
|
|
|
|
|
#[test]
|
|
|
|
|
fn live_feed_payload_is_rendered_by_a_hemplate_view() {
|
|
|
|
|
let html = live_feed(7);
|
|
|
|
|
let document = Html::parse_fragment(html.as_str());
|
|
|
|
|
let row = document
|
|
|
|
|
.select(&selector(".live-row"))
|
|
|
|
|
.next()
|
|
|
|
|
.expect("live feed row renders");
|
|
|
|
|
let text = row.text().collect::<String>();
|
|
|
|
|
assert!(text.contains("SSE tick #7"));
|
|
|
|
|
assert!(row
|
|
|
|
|
.select(&selector("code"))
|
|
|
|
|
.any(|code| code.text().collect::<String>() == "slots::live_feed"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// req: html_safety/002 req: view/001 req: test/005
|
|
|
|
|
#[test]
|
|
|
|
|
fn activity_payload_is_rendered_by_a_hemplate_view() {
|
|
|
|
|
let mut demo = DemoState::default();
|
|
|
|
|
demo.activity.push_back("<b>escaped activity</b>".to_owned());
|
|
|
|
|
|
|
|
|
|
let html = render_activity(&demo);
|
|
|
|
|
let document = Html::parse_fragment(html.as_str());
|
|
|
|
|
let list = document
|
|
|
|
|
.select(&selector("ol.activity"))
|
|
|
|
|
.next()
|
|
|
|
|
.expect("activity list renders");
|
|
|
|
|
let items = list.select(&selector("li")).collect::<Vec<_>>();
|
|
|
|
|
assert_eq!(items.len(), demo.activity.len());
|
|
|
|
|
assert!(items
|
|
|
|
|
.last()
|
|
|
|
|
.expect("activity item renders")
|
|
|
|
|
.text()
|
|
|
|
|
.collect::<String>()
|
|
|
|
|
.contains("<b>escaped activity</b>"));
|
|
|
|
|
assert!(list.select(&selector("b")).next().is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// req: html_safety/002 req: view/001 req: test/005
|
|
|
|
|
#[test]
|
|
|
|
|
fn architecture_activity_is_rendered_by_a_hemplate_view() {
|
|
|
|
|
let html = architecture_activity();
|
|
|
|
|
let document = Html::parse_fragment(html.as_str());
|
|
|
|
|
let list = document
|
|
|
|
|
.select(&selector("ol.activity"))
|
|
|
|
|
.next()
|
|
|
|
|
.expect("architecture activity list renders");
|
|
|
|
|
let items = list.select(&selector("li")).collect::<Vec<_>>();
|
|
|
|
|
assert_eq!(items.len(), 3);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
items[1].text().collect::<String>(),
|
|
|
|
|
"Fetched HTML with X-SLHX-Partial"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// req: html_safety/002 req: view/001 req: test/005
|
|
|
|
|
#[test]
|
|
|
|
|
fn architecture_inspector_is_rendered_by_a_hemplate_view() {
|
|
|
|
|
let html = architecture_inspector();
|
|
|
|
|
let document = Html::parse_fragment(html.as_str());
|
|
|
|
|
let row = document
|
|
|
|
|
.select(&selector(".inspector-row"))
|
|
|
|
|
.next()
|
|
|
|
|
.expect("architecture inspector row renders");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
row.select(&selector("b")).next().map(|b| b.text().collect::<String>()),
|
|
|
|
|
Some("Page swap".to_owned())
|
|
|
|
|
);
|
|
|
|
|
assert!(row
|
|
|
|
|
.text()
|
|
|
|
|
.collect::<String>()
|
|
|
|
|
.contains("rendered through the same root"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// req: html_safety/002 req: view/001 req: test/005
|
|
|
|
|
#[test]
|
|
|
|
|
fn architecture_hero_is_rendered_by_a_hemplate_view() {
|
|
|
|
|
let html = architecture_hero();
|
|
|
|
|
let document = Html::parse_fragment(html.as_str());
|
|
|
|
|
let metrics = document.select(&selector(".metric")).collect::<Vec<_>>();
|
|
|
|
|
assert_eq!(metrics.len(), 3);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
metrics[0]
|
|
|
|
|
.select(&selector("span"))
|
|
|
|
|
.next()
|
|
|
|
|
.map(|span| span.text().collect::<String>()),
|
|
|
|
|
Some("template source of truth".to_owned())
|
|
|
|
|
);
|
|
|
|
|
assert!(document
|
|
|
|
|
.select(&selector(".metrics .metric strong"))
|
|
|
|
|
.any(|strong| strong.text().collect::<String>() == "∞"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// req: html_safety/002 req: view/001 req: test/005
|
|
|
|
|
#[test]
|
|
|
|
|
fn architecture_board_is_rendered_by_a_hemplate_view() {
|
|
|
|
|
let html = architecture_board();
|
|
|
|
|
let document = Html::parse_fragment(html.as_str());
|
|
|
|
|
let lanes = document.select(&selector(".lanes > section.lane")).collect::<Vec<_>>();
|
|
|
|
|
assert_eq!(lanes.len(), 3);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
lanes[0]
|
|
|
|
|
.select(&selector("h3"))
|
|
|
|
|
.next()
|
|
|
|
|
.map(|heading| heading.text().collect::<String>()),
|
|
|
|
|
Some("hemplate".to_owned())
|
|
|
|
|
);
|
|
|
|
|
assert!(lanes.iter().any(|lane| {
|
|
|
|
|
lane.select(&selector("p"))
|
|
|
|
|
.next()
|
|
|
|
|
.map(|paragraph| paragraph.text().collect::<String>())
|
|
|
|
|
.is_some_and(|text| text.contains("EffectBatch ops"))
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|