#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
#![allow(clippy::multiple_crate_versions)]
use std::sync::Arc;
use async_trait::async_trait;
use clap::{Parser, Subcommand};
use hyperchad_renderer::{Color, RenderRunner, Renderer, ToRenderRunner};
use hyperchad_router::{Navigation, RoutePath, Router};
use switchy::unsync::{futures::channel::oneshot, runtime::Handle};
use switchy_env::var_parse_or;
#[cfg(all(feature = "actix", feature = "shared-state-transport"))]
use hyperchad_renderer_html::actix::SharedStateTransportDispatcher;
#[cfg(all(
feature = "logic",
feature = "shared-state-bridge",
feature = "actions"
))]
use hyperchad_shared_state_bridge::SharedStateRouteResolver;
#[cfg(all(feature = "logic", feature = "shared-state-bridge"))]
use hyperchad_shared_state_bridge::{BridgeError, RouteCommandInput, SharedStateRouteContext};
#[cfg(all(feature = "logic", feature = "shared-state-bridge"))]
use hyperchad_shared_state_models::CommandEnvelope;
#[cfg(all(feature = "actix", feature = "shared-state-transport"))]
use hyperchad_shared_state_models::{TransportInbound, TransportOutbound};
pub mod renderer;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
IO(#[from] std::io::Error),
#[error(transparent)]
Builder(#[from] BuilderError),
#[error(transparent)]
OtherSend(#[from] Box<dyn std::error::Error + Send>),
#[error(transparent)]
Async(#[from] switchy::unsync::Error),
#[error(transparent)]
Join(#[from] switchy::unsync::task::JoinError),
}
#[derive(Debug, thiserror::Error)]
pub enum BuilderError {
#[error("Missing Router")]
MissingRouter,
#[error("Missing Runtime")]
MissingRuntime,
}
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
#[command(subcommand)]
cmd: Commands,
}
#[derive(Subcommand, Debug, Clone, PartialEq, Eq)]
enum Commands {
DynamicRoutes,
Gen {
#[arg(short, long)]
output: Option<String>,
},
Clean {
#[arg(short, long)]
output: Option<String>,
},
Serve,
}
#[async_trait]
pub trait Generator {
async fn generate(&self, router: &Router, output: Option<String>) -> Result<(), Error> {
unimplemented!("generate: unimplemented router={router:?} output={output:?}")
}
}
#[async_trait]
pub trait Cleaner {
async fn clean(&self, output: Option<String>) -> Result<(), Error> {
unimplemented!("clean: unimplemented output={output:?}")
}
}
#[cfg(feature = "logic")]
type ActionHandler = Box<
dyn Fn(
(&str, Option<&hyperchad_actions::logic::Value>),
) -> Result<bool, Box<dyn std::error::Error>>
+ Send
+ Sync,
>;
#[cfg(all(feature = "logic", feature = "shared-state-bridge"))]
type SharedStateContextResolver = Box<
dyn Fn(
(&str, Option<&hyperchad_actions::logic::Value>),
) -> Result<SharedStateRouteContext, BridgeError>
+ Send
+ Sync,
>;
#[cfg(all(feature = "logic", feature = "shared-state-bridge"))]
type SharedStateCommandInputResolver = Box<
dyn Fn((&str, Option<&hyperchad_actions::logic::Value>)) -> Option<RouteCommandInput>
+ Send
+ Sync,
>;
#[cfg(all(
feature = "logic",
feature = "shared-state-bridge",
feature = "actions"
))]
#[allow(dead_code)]
#[derive(Clone)]
struct ActixSharedStateBridgeConfig {
command_tx: flume::Sender<CommandEnvelope>,
route_resolver: Arc<dyn SharedStateRouteResolver>,
command_input_resolver: Arc<SharedStateCommandInputResolver>,
}
#[cfg(all(feature = "actix", feature = "shared-state-transport"))]
type SharedStateInboundReceiverFactory =
Box<dyn Fn() -> flume::Receiver<TransportInbound> + Send + Sync>;
#[cfg(all(feature = "actix", feature = "shared-state-transport"))]
#[derive(Clone)]
enum ActixSharedStateTransportConfig {
Channel {
outbound_tx: flume::Sender<TransportOutbound>,
inbound_receiver_factory: Arc<SharedStateInboundReceiverFactory>,
},
Dispatcher {
dispatcher: Arc<dyn SharedStateTransportDispatcher>,
},
}
type ResizeListener = Box<dyn Fn(f32, f32) -> Result<(), Box<dyn std::error::Error>> + Send + Sync>;
#[derive(Clone)]
pub struct AppBuilder {
router: Option<Router>,
initial_route: Option<Navigation>,
x: Option<i32>,
y: Option<i32>,
background: Option<Color>,
title: Option<String>,
description: Option<String>,
viewport: Option<String>,
width: Option<f32>,
height: Option<f32>,
runtime_handle: Option<switchy::unsync::runtime::Handle>,
#[cfg(feature = "logic")]
action_handlers: Vec<Arc<ActionHandler>>,
#[cfg(all(
feature = "logic",
feature = "shared-state-bridge",
feature = "actions"
))]
actix_shared_state_bridge: Option<ActixSharedStateBridgeConfig>,
#[cfg(all(feature = "actix", feature = "shared-state-transport"))]
actix_shared_state_transport: Option<ActixSharedStateTransportConfig>,
resize_listeners: Vec<Arc<ResizeListener>>,
#[cfg(feature = "assets")]
static_asset_routes: Vec<hyperchad_renderer::assets::StaticAssetRoute>,
#[cfg(feature = "assets")]
asset_not_found_behavior: hyperchad_renderer::assets::AssetNotFoundBehavior,
#[cfg(feature = "html")]
css_urls: Vec<String>,
#[cfg(feature = "html")]
css_paths: Vec<String>,
#[cfg(feature = "html")]
inline_css: Vec<String>,
}
impl std::fmt::Debug for AppBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut builder = f.debug_struct("AppBuilder");
builder
.field("router", &self.router)
.field("initial_route", &self.initial_route)
.field("x", &self.x)
.field("y", &self.y)
.field("background", &self.background)
.field("title", &self.title)
.field("description", &self.description)
.field("viewport", &self.viewport)
.field("width", &self.width)
.field("height", &self.height)
.field("runtime", &self.runtime_handle);
#[cfg(feature = "assets")]
{
builder.field("static_asset_routes", &self.static_asset_routes);
builder.field("asset_not_found_behavior", &self.asset_not_found_behavior);
}
#[cfg(feature = "html")]
{
builder
.field("css_urls", &self.css_urls)
.field("css_paths", &self.css_paths)
.field("inline_css", &self.inline_css);
}
builder.finish_non_exhaustive()
}
}
impl Default for AppBuilder {
fn default() -> Self {
Self::new()
}
}
impl AppBuilder {
#[must_use]
pub const fn new() -> Self {
Self {
router: None,
initial_route: None,
x: None,
y: None,
background: None,
title: None,
description: None,
viewport: None,
width: None,
height: None,
runtime_handle: None,
#[cfg(feature = "logic")]
action_handlers: vec![],
#[cfg(all(
feature = "logic",
feature = "shared-state-bridge",
feature = "actions"
))]
actix_shared_state_bridge: None,
#[cfg(all(feature = "actix", feature = "shared-state-transport"))]
actix_shared_state_transport: None,
resize_listeners: vec![],
#[cfg(feature = "assets")]
static_asset_routes: vec![],
#[cfg(feature = "assets")]
asset_not_found_behavior: hyperchad_renderer::assets::AssetNotFoundBehavior::NotFound,
#[cfg(feature = "html")]
css_urls: vec![],
#[cfg(feature = "html")]
css_paths: vec![],
#[cfg(feature = "html")]
inline_css: vec![],
}
}
#[must_use]
pub fn with_router(mut self, router: Router) -> Self {
self.router = Some(router);
self
}
pub fn router(&mut self, router: Router) -> &mut Self {
self.router = Some(router);
self
}
#[must_use]
pub fn with_initial_route(mut self, initial_route: impl Into<Navigation>) -> Self {
self.initial_route = Some(initial_route.into());
self
}
pub fn initial_route(&mut self, initial_route: impl Into<Navigation>) -> &mut Self {
self.initial_route = Some(initial_route.into());
self
}
#[must_use]
pub const fn with_width(mut self, width: f32) -> Self {
self.width.replace(width);
self
}
pub const fn width(&mut self, width: f32) -> &mut Self {
self.width = Some(width);
self
}
#[must_use]
pub const fn with_height(mut self, height: f32) -> Self {
self.height.replace(height);
self
}
pub const fn height(&mut self, height: f32) -> &mut Self {
self.height = Some(height);
self
}
#[must_use]
pub const fn with_size(self, width: f32, height: f32) -> Self {
self.with_width(width).with_height(height)
}
pub const fn size(&mut self, width: f32, height: f32) -> &mut Self {
self.width(width).height(height);
self
}
#[must_use]
pub const fn with_x(mut self, x: i32) -> Self {
self.x.replace(x);
self
}
pub const fn x(&mut self, x: i32) -> &mut Self {
self.x = Some(x);
self
}
#[must_use]
pub const fn with_y(mut self, y: i32) -> Self {
self.y.replace(y);
self
}
pub const fn y(&mut self, y: i32) -> &mut Self {
self.y = Some(y);
self
}
#[must_use]
pub const fn with_position(self, x: i32, y: i32) -> Self {
self.with_x(x).with_y(y)
}
pub const fn position(&mut self, x: i32, y: i32) -> &mut Self {
self.x(x).y(y);
self
}
#[must_use]
pub fn with_viewport(mut self, content: String) -> Self {
self.viewport.replace(content);
self
}
#[must_use]
pub const fn with_background(mut self, color: Color) -> Self {
self.background.replace(color);
self
}
#[must_use]
pub fn with_title(mut self, title: String) -> Self {
self.title.replace(title);
self
}
#[must_use]
pub fn with_description(mut self, description: String) -> Self {
self.description.replace(description);
self
}
#[cfg(feature = "logic")]
#[must_use]
pub fn with_action_handler<E: std::error::Error + 'static>(
mut self,
func: impl Fn(&str, Option<&hyperchad_actions::logic::Value>) -> Result<bool, E>
+ Send
+ Sync
+ 'static,
) -> Self {
self.action_handlers.push(Arc::new(Box::new(move |(a, b)| {
func(a, b).map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
})));
self
}
#[cfg(all(feature = "logic", feature = "shared-state-bridge"))]
#[must_use]
pub fn with_shared_state_bridge(
mut self,
command_tx: flume::Sender<CommandEnvelope>,
context_resolver: impl Fn(
&str,
Option<&hyperchad_actions::logic::Value>,
) -> Result<SharedStateRouteContext, BridgeError>
+ Send
+ Sync
+ 'static,
command_input_resolver: impl Fn(
&str,
Option<&hyperchad_actions::logic::Value>,
) -> Option<RouteCommandInput>
+ Send
+ Sync
+ 'static,
) -> Self {
let context_resolver: Arc<SharedStateContextResolver> =
Arc::new(Box::new(move |(action, value)| {
context_resolver(action, value)
}));
let command_input_resolver: Arc<SharedStateCommandInputResolver> =
Arc::new(Box::new(move |(action, value)| {
command_input_resolver(action, value)
}));
self.action_handlers
.push(Arc::new(Box::new(move |(action, value)| {
let Some(command_input) = command_input_resolver((action, value)) else {
return Ok(false);
};
let context = context_resolver((action, value)).map_err(|e| {
Box::new(std::io::Error::other(format!(
"Shared state context resolution failed for action '{action}': {e}"
))) as Box<dyn std::error::Error>
})?;
let command = hyperchad_shared_state_bridge::command_from_route(
context,
command_input,
)
.map_err(|e| {
Box::new(std::io::Error::other(format!(
"Shared state command construction failed for action '{action}': {e}"
))) as Box<dyn std::error::Error>
})?;
command_tx.send(command).map_err(|e| {
Box::new(std::io::Error::other(format!(
"Shared state command send failed for action '{action}': {e}"
))) as Box<dyn std::error::Error>
})?;
Ok(true)
})));
self
}
#[cfg(all(
feature = "logic",
feature = "shared-state-bridge",
feature = "actions"
))]
#[must_use]
pub fn with_shared_state_route_bridge(
mut self,
command_tx: flume::Sender<CommandEnvelope>,
route_resolver: Arc<dyn SharedStateRouteResolver>,
command_input_resolver: impl Fn(
&str,
Option<&hyperchad_actions::logic::Value>,
) -> Option<RouteCommandInput>
+ Send
+ Sync
+ 'static,
) -> Self {
let command_input_resolver: Arc<SharedStateCommandInputResolver> =
Arc::new(Box::new(move |(action, value)| {
command_input_resolver(action, value)
}));
self.actix_shared_state_bridge = Some(ActixSharedStateBridgeConfig {
command_tx,
route_resolver,
command_input_resolver,
});
self
}
#[cfg(all(feature = "actix", feature = "shared-state-transport"))]
#[must_use]
pub fn with_shared_state_transport(
mut self,
outbound_tx: flume::Sender<TransportOutbound>,
inbound_receiver_factory: impl Fn() -> flume::Receiver<TransportInbound> + Send + Sync + 'static,
) -> Self {
self.actix_shared_state_transport = Some(ActixSharedStateTransportConfig::Channel {
outbound_tx,
inbound_receiver_factory: Arc::new(Box::new(inbound_receiver_factory)),
});
self
}
#[cfg(all(feature = "actix", feature = "shared-state-transport"))]
#[must_use]
pub fn with_shared_state_transport_dispatcher(
mut self,
dispatcher: Arc<dyn SharedStateTransportDispatcher>,
) -> Self {
self.actix_shared_state_transport =
Some(ActixSharedStateTransportConfig::Dispatcher { dispatcher });
self
}
#[must_use]
pub fn with_runtime_handle(mut self, handle: Handle) -> Self {
self.runtime_handle.replace(handle);
self
}
#[must_use]
pub fn with_on_resize<E: std::error::Error + 'static>(
mut self,
func: impl Fn(f32, f32) -> Result<(), E> + Send + Sync + 'static,
) -> Self {
self.resize_listeners
.push(Arc::new(Box::new(move |width, height| {
func(width, height).map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
})));
self
}
#[cfg(feature = "logic")]
#[allow(unused)]
fn listen_actions(
&self,
action_handlers: Vec<Arc<ActionHandler>>,
) -> flume::Sender<(String, Option<hyperchad_actions::logic::Value>)> {
let (action_tx, action_rx) =
flume::unbounded::<(String, Option<hyperchad_actions::logic::Value>)>();
self.runtime_handle().spawn(async move {
while let Ok((action, value)) = action_rx.recv_async().await {
log::debug!(
"Received action: action={action} value={value:?} for {} handler(s)",
action_handlers.len()
);
for handler in &action_handlers {
if let Err(e) = handler((action.as_str(), value.as_ref())) {
moosicbox_assert::die_or_error!(
"Action handler error action={action}: {e:?}"
);
}
}
}
});
action_tx
}
#[allow(unused)]
fn listen_resize(
&self,
resize_listeners: Vec<Arc<ResizeListener>>,
) -> flume::Sender<(f32, f32)> {
let (resize_tx, resize_rx) = flume::unbounded::<(f32, f32)>();
self.runtime_handle().spawn(async move {
while let Ok((width, height)) = resize_rx.recv_async().await {
log::debug!(
"Received resize: {width}, {height} for {} listener(s)",
resize_listeners.len()
);
for listener in &resize_listeners {
if let Err(e) = listener(width, height) {
moosicbox_assert::die_or_error!(
"Action listener error width={width} height={height}: {e:?}"
);
}
}
}
});
resize_tx
}
#[must_use]
fn runtime_handle(&self) -> switchy::unsync::runtime::Handle {
self.runtime_handle
.clone()
.unwrap_or_else(switchy::unsync::runtime::Handle::current)
}
#[cfg(feature = "assets")]
#[must_use]
pub fn with_static_asset_route(
mut self,
path: impl Into<hyperchad_renderer::assets::StaticAssetRoute>,
) -> Self {
self.static_asset_routes.push(path.into());
self
}
#[cfg(feature = "assets")]
pub fn static_asset_route(
&mut self,
path: impl Into<hyperchad_renderer::assets::StaticAssetRoute>,
) -> &mut Self {
self.static_asset_routes.push(path.into());
self
}
#[cfg(feature = "assets")]
pub fn with_static_asset_route_result<
Path: TryInto<hyperchad_renderer::assets::StaticAssetRoute>,
>(
mut self,
path: Path,
) -> Result<Self, Path::Error> {
self.static_asset_routes.push(path.try_into()?);
Ok(self)
}
#[cfg(feature = "assets")]
pub fn static_asset_route_result<
Path: TryInto<hyperchad_renderer::assets::StaticAssetRoute>,
>(
&mut self,
path: Path,
) -> Result<&mut Self, Path::Error> {
self.static_asset_routes.push(path.try_into()?);
Ok(self)
}
#[cfg(feature = "assets")]
#[must_use]
pub const fn with_asset_not_found_behavior(
mut self,
behavior: hyperchad_renderer::assets::AssetNotFoundBehavior,
) -> Self {
self.asset_not_found_behavior = behavior;
self
}
#[cfg(feature = "assets")]
pub const fn asset_not_found_behavior(
&mut self,
behavior: hyperchad_renderer::assets::AssetNotFoundBehavior,
) -> &mut Self {
self.asset_not_found_behavior = behavior;
self
}
#[cfg(feature = "html")]
#[must_use]
pub fn with_css_url(mut self, url: impl Into<String>) -> Self {
self.css_urls.push(url.into());
self
}
#[cfg(feature = "html")]
pub fn css_url(&mut self, url: impl Into<String>) -> &mut Self {
self.css_urls.push(url.into());
self
}
#[cfg(feature = "html")]
#[must_use]
pub fn with_css_urls(mut self, urls: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.css_urls.extend(urls.into_iter().map(Into::into));
self
}
#[cfg(feature = "html")]
#[must_use]
pub fn with_css_path(mut self, path: impl Into<String>) -> Self {
self.css_paths.push(path.into());
self
}
#[cfg(feature = "html")]
pub fn css_path(&mut self, path: impl Into<String>) -> &mut Self {
self.css_paths.push(path.into());
self
}
#[cfg(feature = "html")]
#[must_use]
pub fn with_css_paths(mut self, paths: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.css_paths.extend(paths.into_iter().map(Into::into));
self
}
#[cfg(feature = "html")]
#[must_use]
pub fn with_inline_css(mut self, css: impl Into<String>) -> Self {
self.inline_css.push(css.into());
self
}
#[cfg(feature = "html")]
pub fn inline_css(&mut self, css: impl Into<String>) -> &mut Self {
self.inline_css.push(css.into());
self
}
#[cfg(feature = "html")]
#[must_use]
pub fn with_inline_css_blocks(
mut self,
css_blocks: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.inline_css
.extend(css_blocks.into_iter().map(Into::into));
self
}
pub fn build<R: Renderer + ToRenderRunner + Generator + Cleaner + Clone + 'static>(
self,
renderer: R,
) -> Result<App<R>, BuilderError> {
let router = self.router.ok_or(BuilderError::MissingRouter)?;
Ok(App {
renderer,
router,
runtime: None,
runtime_handle: self.runtime_handle,
x: self.x,
y: self.y,
background: self.background,
title: self.title,
description: self.description,
viewport: self.viewport,
width: self.width.unwrap_or(800.0),
height: self.height.unwrap_or(600.0),
initial_route: self.initial_route,
})
}
}
#[derive(Debug)]
pub struct App<R: Renderer + ToRenderRunner + Generator + Cleaner + Clone + 'static> {
pub renderer: R,
pub router: Router,
runtime: Option<switchy::unsync::runtime::Runtime>,
pub runtime_handle: Option<switchy::unsync::runtime::Handle>,
x: Option<i32>,
y: Option<i32>,
background: Option<Color>,
title: Option<String>,
description: Option<String>,
viewport: Option<String>,
width: f32,
height: f32,
initial_route: Option<Navigation>,
}
impl<R: Renderer + ToRenderRunner + Generator + Cleaner + Clone + 'static> App<R> {
pub fn run(self) -> Result<(), Error> {
let args = Args::parse();
log::debug!("run: args={args:?}");
match args.cmd {
Commands::DynamicRoutes => self.dynamic_routes(),
Commands::Clean { output } => self.clean_sync(output),
Commands::Gen { output } => self.generate_sync(output),
Commands::Serve => self.handle_serve(),
}
}
pub fn handle_serve(mut self) -> Result<(), Error> {
let runtime = self.runtime_handle()?;
let mut runner = runtime.block_on(async move { self.serve().await })?;
let (tx, rx) = oneshot::channel();
runtime.spawn(rx);
runner.run()?;
tx.send(()).unwrap();
Ok(())
}
fn runtime_handle(&mut self) -> Result<Handle, Error> {
Ok(if let Some(handle) = self.runtime_handle.clone() {
handle
} else {
let threads = var_parse_or("MAX_THREADS", 64usize);
log::debug!("Running with {threads} max blocking threads");
let runtime = switchy::unsync::runtime::Builder::new()
.max_blocking_threads(u16::try_from(threads).unwrap())
.build()?;
let handle = runtime.handle();
self.runtime_handle = Some(handle.clone());
self.runtime = Some(runtime);
handle
})
}
pub fn dynamic_routes(&self) -> Result<(), Error> {
let dynamic_routes = self.router.routes.read().unwrap().clone();
for (path, _) in &dynamic_routes {
println!(
"{}",
match path {
RoutePath::Literal(path) => path,
RoutePath::Literals(paths) => {
if let Some(path) = paths.first() {
path
} else {
continue;
}
}
RoutePath::LiteralPrefix(..) => continue,
}
);
}
Ok(())
}
pub fn generate_sync(mut self, output: Option<String>) -> Result<(), Error> {
self.runtime_handle()?
.block_on(async move { self.generate(output).await })
}
pub async fn generate(&self, output: Option<String>) -> Result<(), Error> {
self.renderer.generate(&self.router, output).await?;
Ok(())
}
pub fn clean_sync(mut self, output: Option<String>) -> Result<(), Error> {
self.runtime_handle()?
.block_on(async move { self.clean(output).await })
}
pub async fn clean(&self, output: Option<String>) -> Result<(), Error> {
self.renderer.clean(output).await?;
Ok(())
}
pub fn serve_sync(mut self) -> Result<Box<dyn RenderRunner>, Error> {
self.runtime_handle()?
.block_on(async move { self.serve().await })
}
#[allow(clippy::unused_async)]
pub async fn serve(&mut self) -> Result<Box<dyn RenderRunner>, Error> {
let router = self.router.clone();
let initial_route = self.initial_route.clone();
log::debug!("app: starting app");
if let Some(initial_route) = initial_route {
log::debug!("app: navigating to home");
let _handle = router.navigate_spawn(initial_route);
}
let handle = self.runtime_handle()?;
let mut renderer = self.renderer.clone();
let width = self.width;
let height = self.height;
let x = self.x;
let y = self.y;
let background = self.background;
let title = self.title.clone();
let description = self.description.clone();
let viewport = self.viewport.clone();
handle.spawn({
let renderer = renderer.clone();
async move {
log::debug!("app_native_lib::start: router listening");
#[allow(unused_variables, clippy::never_loop)]
while let Some(content) = router.wait_for_navigation().await {
log::debug!("app_native_lib::start: router received content");
match content {
hyperchad_renderer::Content::View(boxed_view) => {
renderer.render(*boxed_view).await?;
}
hyperchad_renderer::Content::Raw { .. } => {
moosicbox_assert::die_or_warn!("Received invalid content type");
}
#[cfg(feature = "json")]
hyperchad_renderer::Content::Json(..) => {
moosicbox_assert::die_or_warn!("Received invalid content type");
}
}
}
Ok::<_, Error>(())
}
});
log::debug!("app: initialing renderer");
renderer
.init(
width,
height,
x,
y,
background,
title.as_deref(),
description.as_deref(),
viewport.as_deref(),
)
.await?;
log::debug!("app: to_runner");
Ok(renderer.to_runner(handle)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
use hyperchad_renderer::Color;
use hyperchad_router::Router;
use pretty_assertions::assert_eq;
#[test_log::test]
fn test_app_builder_new() {
let builder = AppBuilder::new();
assert!(builder.router.is_none());
assert!(builder.initial_route.is_none());
assert!(builder.x.is_none());
assert!(builder.y.is_none());
assert!(builder.background.is_none());
assert!(builder.title.is_none());
assert!(builder.description.is_none());
assert!(builder.viewport.is_none());
assert!(builder.width.is_none());
assert!(builder.height.is_none());
assert!(builder.runtime_handle.is_none());
}
#[test_log::test]
fn test_app_builder_default() {
let builder = AppBuilder::default();
assert!(builder.router.is_none());
}
#[test_log::test]
fn test_app_builder_with_router() {
let router = Router::new();
let builder = AppBuilder::new().with_router(router);
assert!(builder.router.is_some());
}
#[test_log::test]
fn test_app_builder_router_method() {
let router = Router::new();
let mut builder = AppBuilder::new();
builder.router(router);
assert!(builder.router.is_some());
}
#[test_log::test]
fn test_app_builder_with_initial_route() {
let builder = AppBuilder::new().with_initial_route("/home");
assert!(builder.initial_route.is_some());
}
#[test_log::test]
fn test_app_builder_initial_route_method() {
let mut builder = AppBuilder::new();
builder.initial_route("/home");
assert!(builder.initial_route.is_some());
}
#[test_log::test]
fn test_app_builder_with_width() {
let builder = AppBuilder::new().with_width(1024.0);
assert_eq!(builder.width, Some(1024.0));
}
#[test_log::test]
fn test_app_builder_width_method() {
let mut builder = AppBuilder::new();
builder.width(1024.0);
assert_eq!(builder.width, Some(1024.0));
}
#[test_log::test]
fn test_app_builder_with_height() {
let builder = AppBuilder::new().with_height(768.0);
assert_eq!(builder.height, Some(768.0));
}
#[test_log::test]
fn test_app_builder_height_method() {
let mut builder = AppBuilder::new();
builder.height(768.0);
assert_eq!(builder.height, Some(768.0));
}
#[test_log::test]
fn test_app_builder_with_size() {
let builder = AppBuilder::new().with_size(1920.0, 1080.0);
assert_eq!(builder.width, Some(1920.0));
assert_eq!(builder.height, Some(1080.0));
}
#[test_log::test]
fn test_app_builder_size_method() {
let mut builder = AppBuilder::new();
builder.size(1920.0, 1080.0);
assert_eq!(builder.width, Some(1920.0));
assert_eq!(builder.height, Some(1080.0));
}
#[test_log::test]
fn test_app_builder_with_x() {
let builder = AppBuilder::new().with_x(100);
assert_eq!(builder.x, Some(100));
}
#[test_log::test]
fn test_app_builder_x_method() {
let mut builder = AppBuilder::new();
builder.x(100);
assert_eq!(builder.x, Some(100));
}
#[test_log::test]
fn test_app_builder_with_y() {
let builder = AppBuilder::new().with_y(200);
assert_eq!(builder.y, Some(200));
}
#[test_log::test]
fn test_app_builder_y_method() {
let mut builder = AppBuilder::new();
builder.y(200);
assert_eq!(builder.y, Some(200));
}
#[test_log::test]
fn test_app_builder_with_position() {
let builder = AppBuilder::new().with_position(300, 400);
assert_eq!(builder.x, Some(300));
assert_eq!(builder.y, Some(400));
}
#[test_log::test]
fn test_app_builder_position_method() {
let mut builder = AppBuilder::new();
builder.position(300, 400);
assert_eq!(builder.x, Some(300));
assert_eq!(builder.y, Some(400));
}
#[test_log::test]
fn test_app_builder_with_viewport() {
let viewport = "width=device-width, initial-scale=1.0".to_string();
let builder = AppBuilder::new().with_viewport(viewport.clone());
assert_eq!(builder.viewport, Some(viewport));
}
#[test_log::test]
fn test_app_builder_with_background() {
let color = Color::from_hex("#ffffff");
let builder = AppBuilder::new().with_background(color);
assert_eq!(builder.background, Some(color));
}
#[test_log::test]
fn test_app_builder_with_title() {
let title = "Test App".to_string();
let builder = AppBuilder::new().with_title(title.clone());
assert_eq!(builder.title, Some(title));
}
#[test_log::test]
fn test_app_builder_with_description() {
let description = "A test application".to_string();
let builder = AppBuilder::new().with_description(description.clone());
assert_eq!(builder.description, Some(description));
}
#[test_log::test]
fn test_app_builder_chaining() {
let router = Router::new();
let builder = AppBuilder::new()
.with_router(router)
.with_size(1024.0, 768.0)
.with_position(100, 200)
.with_title("Test".to_string())
.with_description("Test app".to_string());
assert!(builder.router.is_some());
assert_eq!(builder.width, Some(1024.0));
assert_eq!(builder.height, Some(768.0));
assert_eq!(builder.x, Some(100));
assert_eq!(builder.y, Some(200));
assert_eq!(builder.title, Some("Test".to_string()));
assert_eq!(builder.description, Some("Test app".to_string()));
}
#[test_log::test]
fn test_app_builder_build_missing_router() {
use crate::renderer::stub::StubRenderer;
let builder = AppBuilder::new();
let result = builder.build(StubRenderer);
assert!(result.is_err());
match result {
Err(BuilderError::MissingRouter) => (),
_ => panic!("Expected BuilderError::MissingRouter"),
}
}
#[test_log::test]
#[allow(clippy::float_cmp)]
fn test_app_builder_build_success() {
use crate::renderer::stub::StubRenderer;
let router = Router::new();
let builder = AppBuilder::new()
.with_router(router)
.with_size(800.0, 600.0)
.with_position(50, 100);
let result = builder.build(StubRenderer);
assert!(result.is_ok());
let app = result.unwrap();
assert_eq!(app.width, 800.0);
assert_eq!(app.height, 600.0);
assert_eq!(app.x, Some(50));
assert_eq!(app.y, Some(100));
}
#[test_log::test]
#[allow(clippy::float_cmp)]
fn test_app_builder_build_default_dimensions() {
use crate::renderer::stub::StubRenderer;
let router = Router::new();
let builder = AppBuilder::new().with_router(router);
let result = builder.build(StubRenderer);
assert!(result.is_ok());
let app = result.unwrap();
assert_eq!(app.width, 800.0);
assert_eq!(app.height, 600.0);
}
#[test_log::test]
fn test_commands_equality() {
assert_eq!(Commands::DynamicRoutes, Commands::DynamicRoutes);
assert_eq!(
Commands::Gen {
output: Some("output".to_string())
},
Commands::Gen {
output: Some("output".to_string())
}
);
assert_eq!(
Commands::Clean {
output: Some("output".to_string())
},
Commands::Clean {
output: Some("output".to_string())
}
);
assert_eq!(Commands::Serve, Commands::Serve);
}
#[test_log::test]
fn test_commands_inequality() {
assert_ne!(Commands::DynamicRoutes, Commands::Serve);
assert_ne!(
Commands::Gen {
output: Some("output1".to_string())
},
Commands::Gen {
output: Some("output2".to_string())
}
);
assert_ne!(
Commands::Clean {
output: Some("output1".to_string())
},
Commands::Clean { output: None }
);
}
#[test_log::test]
fn test_commands_clone() {
let cmd = Commands::Gen {
output: Some("test".to_string()),
};
let cloned = cmd.clone();
assert_eq!(cmd, cloned);
}
#[test_log::test]
fn test_builder_error_display() {
let error = BuilderError::MissingRouter;
assert_eq!(error.to_string(), "Missing Router");
let error = BuilderError::MissingRuntime;
assert_eq!(error.to_string(), "Missing Runtime");
}
#[test_log::test]
fn test_error_from_builder_error() {
let builder_error = BuilderError::MissingRouter;
let error: Error = builder_error.into();
match error {
Error::Builder(BuilderError::MissingRouter) => (),
_ => panic!("Expected Error::Builder(MissingRouter)"),
}
}
#[test_log::test]
fn test_error_from_io_error() {
let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
let error: Error = io_error.into();
match error {
Error::IO(_) => (),
_ => panic!("Expected Error::IO"),
}
}
#[cfg(feature = "html")]
#[test_log::test]
fn test_app_builder_with_css_url() {
let url = "https://example.com/style.css".to_string();
let builder = AppBuilder::new().with_css_url(url.clone());
assert_eq!(builder.css_urls, vec![url]);
}
#[cfg(feature = "html")]
#[test_log::test]
fn test_app_builder_css_url_method() {
let url = "https://example.com/style.css".to_string();
let mut builder = AppBuilder::new();
builder.css_url(url.clone());
assert_eq!(builder.css_urls, vec![url]);
}
#[cfg(feature = "html")]
#[test_log::test]
fn test_app_builder_with_css_urls() {
let urls = vec![
"https://example.com/style1.css".to_string(),
"https://example.com/style2.css".to_string(),
];
let builder = AppBuilder::new().with_css_urls(urls.clone());
assert_eq!(builder.css_urls, urls);
}
#[cfg(feature = "html")]
#[test_log::test]
fn test_app_builder_with_css_path() {
let path = "/assets/style.css".to_string();
let builder = AppBuilder::new().with_css_path(path.clone());
assert_eq!(builder.css_paths, vec![path]);
}
#[cfg(feature = "html")]
#[test_log::test]
fn test_app_builder_css_path_method() {
let path = "/assets/style.css".to_string();
let mut builder = AppBuilder::new();
builder.css_path(path.clone());
assert_eq!(builder.css_paths, vec![path]);
}
#[cfg(feature = "html")]
#[test_log::test]
fn test_app_builder_with_css_paths() {
let paths = vec![
"/assets/style1.css".to_string(),
"/assets/style2.css".to_string(),
];
let builder = AppBuilder::new().with_css_paths(paths.clone());
assert_eq!(builder.css_paths, paths);
}
#[cfg(feature = "html")]
#[test_log::test]
fn test_app_builder_with_inline_css() {
let css = "body { margin: 0; }".to_string();
let builder = AppBuilder::new().with_inline_css(css.clone());
assert_eq!(builder.inline_css, vec![css]);
}
#[cfg(feature = "html")]
#[test_log::test]
fn test_app_builder_inline_css_method() {
let css = "body { margin: 0; }".to_string();
let mut builder = AppBuilder::new();
builder.inline_css(css.clone());
assert_eq!(builder.inline_css, vec![css]);
}
#[cfg(feature = "html")]
#[test_log::test]
fn test_app_builder_with_inline_css_blocks() {
let blocks = vec![
"body { margin: 0; }".to_string(),
"h1 { color: red; }".to_string(),
];
let builder = AppBuilder::new().with_inline_css_blocks(blocks.clone());
assert_eq!(builder.inline_css, blocks);
}
#[test_log::test]
fn test_app_builder_debug_format() {
let router = Router::new();
let builder = AppBuilder::new()
.with_router(router)
.with_size(800.0, 600.0)
.with_title("Test".to_string());
let debug_str = format!("{builder:?}");
assert!(debug_str.contains("AppBuilder"));
assert!(debug_str.contains("router"));
assert!(debug_str.contains("width"));
assert!(debug_str.contains("height"));
assert!(debug_str.contains("title"));
}
#[test_log::test]
fn test_app_builder_clone() {
let router = Router::new();
let builder = AppBuilder::new()
.with_router(router)
.with_size(1024.0, 768.0);
let cloned = builder.clone();
assert_eq!(builder.width, cloned.width);
assert_eq!(builder.height, cloned.height);
}
#[test_log::test]
fn test_app_builder_build_default_stub() {
use crate::renderer::stub::StubRenderer;
let router = Router::new();
let result = AppBuilder::new().with_router(router).build_default_stub();
assert!(result.is_ok());
let app = result.unwrap();
assert!(matches!(app.renderer, StubRenderer));
}
#[test_log::test]
fn test_app_builder_build_stub() {
use crate::renderer::stub::StubRenderer;
let router = Router::new();
let result = AppBuilder::new()
.with_router(router)
.build_stub(StubRenderer);
assert!(result.is_ok());
}
#[test_log::test]
fn test_stub_runner_run() {
use crate::renderer::stub::StubRunner;
use hyperchad_renderer::RenderRunner;
let mut runner = StubRunner;
let result = runner.run();
assert!(result.is_ok());
}
#[test_log::test]
fn test_app_builder_with_runtime_handle() {
let router = Router::new();
let runtime = switchy::unsync::runtime::Builder::new()
.max_blocking_threads(1)
.build()
.expect("Failed to build runtime");
let handle = runtime.handle();
let builder = AppBuilder::new()
.with_router(router)
.with_runtime_handle(handle);
assert!(builder.runtime_handle.is_some());
}
#[cfg(all(
feature = "html",
feature = "actix",
feature = "vanilla-js",
feature = "logic",
feature = "actions",
feature = "shared-state-bridge"
))]
#[test_log::test]
fn test_build_default_html_vanilla_js_actix_applies_shared_state_route_bridge() {
use std::sync::Arc;
use hyperchad_shared_state_bridge::{
BridgeError, RouteCommandInput, SharedStateRouteResolver, resolve_route_context,
};
use hyperchad_shared_state_models::{
ChannelId, CommandId, IdempotencyKey, ParticipantId, PayloadBlob, Revision,
};
#[derive(Debug)]
struct TestRouteResolver;
impl SharedStateRouteResolver for TestRouteResolver {
fn resolve_channel_id(
&self,
_request: &hyperchad_router::RouteRequest,
) -> Result<ChannelId, BridgeError> {
Ok(ChannelId::new("channel-1"))
}
fn resolve_participant_id(
&self,
_request: &hyperchad_router::RouteRequest,
) -> Result<ParticipantId, BridgeError> {
Ok(ParticipantId::new("participant-1"))
}
}
let payload =
PayloadBlob::from_serializable(&11_u32).expect("shared-state payload should serialize");
let (command_tx, _command_rx) = flume::unbounded();
let runtime = switchy::unsync::runtime::Builder::new()
.max_blocking_threads(1)
.build()
.expect("test runtime should initialize");
let app = AppBuilder::new()
.with_router(Router::new())
.with_runtime_handle(runtime.handle())
.with_shared_state_route_bridge(
command_tx.clone(),
Arc::new(TestRouteResolver),
move |action, _value| {
if action != "set-counter" {
return None;
}
Some(RouteCommandInput {
command_id: CommandId::new("command-1"),
idempotency_key: IdempotencyKey::new("idem-1"),
expected_revision: Revision::new(8),
command_name: "SET_COUNTER".to_string(),
payload: payload.clone(),
metadata: std::collections::BTreeMap::new(),
})
},
)
.build_default_html_vanilla_js_actix()
.expect("default actix renderer should build");
let shared_state_bridge = app
.renderer
.app
.shared_state_bridge
.expect("shared-state bridge should be attached to actix app");
assert!(shared_state_bridge.command_tx.same_channel(&command_tx));
let route_command_input = (shared_state_bridge.command_input_resolver)("set-counter", None)
.expect("command input resolver should map set-counter action");
assert_eq!(route_command_input.command_name, "SET_COUNTER");
assert_eq!(route_command_input.expected_revision, Revision::new(8));
let route_request = hyperchad_router::RouteRequest::from_path(
"/channel-1",
hyperchad_router::RequestInfo::default(),
);
let route_context =
resolve_route_context(shared_state_bridge.route_resolver.as_ref(), &route_request)
.expect("route resolver should produce shared-state context");
assert_eq!(route_context.channel_id, ChannelId::new("channel-1"));
assert_eq!(
route_context.participant_id,
ParticipantId::new("participant-1")
);
}
#[cfg(all(
feature = "html",
feature = "actix",
feature = "vanilla-js",
feature = "shared-state-transport"
))]
#[test_log::test]
fn test_build_default_html_vanilla_js_actix_applies_shared_state_transport() {
use hyperchad_shared_state_models::{TransportInbound, TransportOutbound, TransportPing};
let (outbound_tx, _outbound_rx) = flume::unbounded::<TransportOutbound>();
let runtime = switchy::unsync::runtime::Builder::new()
.max_blocking_threads(1)
.build()
.expect("test runtime should initialize");
let app = AppBuilder::new()
.with_router(Router::new())
.with_runtime_handle(runtime.handle())
.with_shared_state_transport(outbound_tx.clone(), || {
let (inbound_tx, inbound_rx) = flume::unbounded::<TransportInbound>();
inbound_tx
.send(TransportInbound::Pong(TransportPing { sent_at_ms: 9 }))
.expect("inbound event should enqueue");
inbound_rx
})
.build_default_html_vanilla_js_actix()
.expect("default actix renderer should build");
let shared_state_transport = app
.renderer
.app
.shared_state_transport
.expect("shared-state transport should be attached to actix app");
assert!(
shared_state_transport
.outbound_tx
.same_channel(&outbound_tx)
);
let inbound_rx = (shared_state_transport.inbound_receiver_factory)();
assert_eq!(
inbound_rx
.try_recv()
.expect("inbound receiver should have queued event"),
TransportInbound::Pong(TransportPing { sent_at_ms: 9 })
);
}
#[cfg(all(
feature = "html",
feature = "actix",
feature = "vanilla-js",
feature = "shared-state-transport"
))]
#[test_log::test]
fn test_build_default_html_vanilla_js_actix_applies_shared_state_transport_dispatcher() {
use std::sync::Arc;
use async_trait::async_trait;
use hyperchad_renderer_html::actix::SharedStateTransportDispatcher;
use hyperchad_shared_state_models::{
ChannelId, EventEnvelope, TransportInbound, TransportOutbound,
};
#[derive(Debug)]
struct TestDispatcher;
#[async_trait]
impl SharedStateTransportDispatcher for TestDispatcher {
async fn ingest_outbound(
&self,
_outbound: TransportOutbound,
) -> Result<Vec<TransportInbound>, Box<dyn std::error::Error + Send + Sync + 'static>>
{
Ok(Vec::new())
}
async fn subscribe_channel(
&self,
_channel_id: &ChannelId,
) -> Result<
flume::Receiver<EventEnvelope>,
Box<dyn std::error::Error + Send + Sync + 'static>,
> {
let (_tx, rx) = flume::unbounded();
Ok(rx)
}
}
let runtime = switchy::unsync::runtime::Builder::new()
.max_blocking_threads(1)
.build()
.expect("test runtime should initialize");
let app = AppBuilder::new()
.with_router(Router::new())
.with_runtime_handle(runtime.handle())
.with_shared_state_transport_dispatcher(Arc::new(TestDispatcher))
.build_default_html_vanilla_js_actix()
.expect("default actix renderer should build");
let shared_state_transport = app
.renderer
.app
.shared_state_transport
.expect("shared-state transport should be attached to actix app");
let _ = shared_state_transport;
}
#[test_log::test(switchy_async::test)]
async fn test_stub_renderer_init() {
use crate::renderer::stub::StubRenderer;
use hyperchad_renderer::Renderer;
let mut renderer = StubRenderer;
let result = renderer
.init(800.0, 600.0, None, None, None, None, None, None)
.await;
assert!(result.is_ok());
}
#[test_log::test(switchy_async::test)]
async fn test_stub_renderer_init_with_all_options() {
use crate::renderer::stub::StubRenderer;
use hyperchad_renderer::{Color, Renderer};
let mut renderer = StubRenderer;
let background = Color::from_hex("#ffffff");
let result = renderer
.init(
1920.0,
1080.0,
Some(100),
Some(200),
Some(background),
Some("Test Title"),
Some("Test Description"),
Some("width=device-width"),
)
.await;
assert!(result.is_ok());
}
#[test_log::test(switchy_async::test)]
async fn test_stub_renderer_render() {
use crate::renderer::stub::StubRenderer;
use hyperchad_renderer::{Renderer, View};
let renderer = StubRenderer;
let view = View::default();
let result = renderer.render(view).await;
assert!(result.is_ok());
}
#[test_log::test(switchy_async::test)]
async fn test_stub_renderer_emit_event() {
use crate::renderer::stub::StubRenderer;
use hyperchad_renderer::Renderer;
let renderer = StubRenderer;
let result = renderer
.emit_event("test_event".to_string(), Some("test_value".to_string()))
.await;
assert!(result.is_ok());
}
#[test_log::test(switchy_async::test)]
async fn test_stub_renderer_emit_event_without_value() {
use crate::renderer::stub::StubRenderer;
use hyperchad_renderer::Renderer;
let renderer = StubRenderer;
let result = renderer.emit_event("test_event".to_string(), None).await;
assert!(result.is_ok());
}
#[test_log::test(switchy_async::test)]
async fn test_stub_renderer_generator_generate() {
use crate::Generator;
use crate::renderer::stub::StubRenderer;
let renderer = StubRenderer;
let router = Router::new();
let result = renderer.generate(&router, None).await;
assert!(result.is_ok());
}
#[test_log::test(switchy_async::test)]
async fn test_stub_renderer_generator_generate_with_output() {
use crate::Generator;
use crate::renderer::stub::StubRenderer;
let renderer = StubRenderer;
let router = Router::new();
let result = renderer
.generate(&router, Some("/tmp/test_output".to_string()))
.await;
assert!(result.is_ok());
}
#[test_log::test(switchy_async::test)]
async fn test_stub_renderer_cleaner_clean() {
use crate::Cleaner;
use crate::renderer::stub::StubRenderer;
let renderer = StubRenderer;
let result = renderer.clean(None).await;
assert!(result.is_ok());
}
#[test_log::test(switchy_async::test)]
async fn test_stub_renderer_cleaner_clean_with_output() {
use crate::Cleaner;
use crate::renderer::stub::StubRenderer;
let renderer = StubRenderer;
let result = renderer.clean(Some("/tmp/test_output".to_string())).await;
assert!(result.is_ok());
}
#[test_log::test]
fn test_stub_renderer_to_runner() {
use crate::renderer::stub::StubRenderer;
use hyperchad_renderer::ToRenderRunner;
let renderer = StubRenderer;
let runtime = switchy::unsync::runtime::Builder::new()
.max_blocking_threads(1)
.build()
.expect("Failed to build runtime");
let handle = runtime.handle();
let result = renderer.to_runner(handle);
assert!(result.is_ok());
}
#[test_log::test(switchy_async::test)]
async fn test_app_generate_with_stub_renderer() {
use crate::renderer::stub::StubRenderer;
let router = Router::new();
let app = AppBuilder::new()
.with_router(router)
.build(StubRenderer)
.expect("Failed to build app");
let result = app.generate(None).await;
assert!(result.is_ok());
}
#[test_log::test(switchy_async::test)]
async fn test_app_clean_with_stub_renderer() {
use crate::renderer::stub::StubRenderer;
let router = Router::new();
let app = AppBuilder::new()
.with_router(router)
.build(StubRenderer)
.expect("Failed to build app");
let result = app.clean(None).await;
assert!(result.is_ok());
}
#[test_log::test]
fn test_app_dynamic_routes_empty_router() {
use crate::renderer::stub::StubRenderer;
let router = Router::new();
let app = AppBuilder::new()
.with_router(router)
.build(StubRenderer)
.expect("Failed to build app");
let result = app.dynamic_routes();
assert!(result.is_ok());
}
#[test_log::test]
fn test_stub_renderer_add_responsive_trigger() {
use crate::renderer::stub::StubRenderer;
use hyperchad_renderer::{
Renderer,
transformer::{Number, ResponsiveTrigger},
};
let mut renderer = StubRenderer;
let trigger = ResponsiveTrigger::MaxWidth(Number::Real(768.0));
renderer.add_responsive_trigger("test".to_string(), trigger);
}
#[test_log::test]
#[allow(clippy::float_cmp)]
fn test_app_build_transfers_all_properties() {
use crate::renderer::stub::StubRenderer;
use hyperchad_renderer::Color;
let router = Router::new();
let background = Color::from_hex("#123456");
let app = AppBuilder::new()
.with_router(router)
.with_size(1024.0, 768.0)
.with_position(50, 100)
.with_background(background)
.with_title("Test Title".to_string())
.with_description("Test Description".to_string())
.with_viewport("width=device-width".to_string())
.with_initial_route("/home")
.build(StubRenderer)
.expect("Failed to build app");
assert_eq!(app.width, 1024.0);
assert_eq!(app.height, 768.0);
assert_eq!(app.x, Some(50));
assert_eq!(app.y, Some(100));
assert_eq!(app.background, Some(background));
assert_eq!(app.title, Some("Test Title".to_string()));
assert_eq!(app.description, Some("Test Description".to_string()));
assert_eq!(app.viewport, Some("width=device-width".to_string()));
assert!(app.initial_route.is_some());
}
}