Implement closed typed effect path

This commit is contained in:
tmk241
2026-09-01 00:58:29 +02:00
parent 353174604e
commit 31a0f02211
58 changed files with 4247 additions and 5933 deletions
+7
View File
@@ -2,6 +2,13 @@
name = "hemx-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
readme = "README.md"
description = "Core effect and protocol types for Hemx"
keywords = ["hypermedia", "protocol", "web"]
categories = ["web-programming"]
[lib]
path = "src/lib.rs"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Thomas Hain
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+8
View File
@@ -0,0 +1,8 @@
# hemx-core
Core effect types, protocol values, validation, and runtime contracts for Hemx.
Most applications should depend on the `hemx` facade instead.
## License
MIT
+5 -4
View File
@@ -1,8 +1,8 @@
//! Repeatable wire-encoding baseline: `cargo bench -p hemx-core --bench wire`.
use hemx_core::{
BuildFingerprint, Effect, EffectBatch, Payload, ResourceId, ResourceKind, ResourceRef,
EFFECT_BATCH_ABI_VERSION,
BuildFingerprint, Effect, EffectBatch, PatchMode, ResourceId, ResourceKind, ResourceRef,
SafeHtml, EFFECT_BATCH_ABI_VERSION,
};
use std::hint::black_box;
use std::time::Instant;
@@ -13,9 +13,10 @@ const ROUNDS: usize = 7;
fn representative_batch() -> EffectBatch {
let target = ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 42));
let ops = (0..50)
.map(|index| Effect::Put {
.map(|index| Effect::Patch {
target: target.clone(),
payload: Payload::Text(format!("item-{index}-{}", "x".repeat(64))),
mode: PatchMode::Morph,
html: SafeHtml::trusted(format!("item-{index}-{}", "x".repeat(64))),
})
.collect();
EffectBatch {
+275 -315
View File
@@ -69,7 +69,6 @@ impl ResourceId {
}
/// A generated UI target that can be inspected without exposing raw slots.
/// req: dx/006 req: test/001
pub trait GeneratedTarget {
#[doc(hidden)]
fn __hemx_resource_id(self) -> ResourceId;
@@ -104,33 +103,43 @@ impl ResourceRef {
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub enum NavigateMode {
Push,
pub enum PatchMode {
Morph,
Replace,
Redirect,
}
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub enum InsertPosition {
First,
Last,
Before(ResourceRef),
After(ResourceRef),
}
pub type MovePosition = InsertPosition;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub enum ScrollBlock {
Start,
Center,
End,
Nearest,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub enum ScrollBehavior {
Preserve,
Top,
Element(ResourceRef),
Auto,
Instant,
Smooth,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum Payload {
Text(String),
Html(String),
}
pub type Url = String;
pub type EventRef = String;
impl Payload {
pub fn text(value: impl ToString) -> Self {
Self::Text(value.to_string())
}
pub fn html(value: SafeHtml) -> Self {
Self::Html(value.into_string())
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub enum HistoryMode {
Push,
Replace,
}
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
@@ -179,7 +188,7 @@ impl core::fmt::Display for SafeHtml {
/// A generated, checked CSS class token.
///
/// Plain CSS/SCSS owns appearance; hemx only gives Rust a typed reference to
/// class names discovered from build inputs. req: style/001
/// class names discovered from build inputs.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct CssClass {
name: &'static str,
@@ -216,7 +225,6 @@ impl core::fmt::Display for CssClass {
}
/// A generated, checked parameter token.
/// req: codegen/003
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct ParamName {
name: &'static str,
@@ -245,7 +253,6 @@ impl core::fmt::Display for ParamName {
}
/// A generated, checked component token.
/// req: component/003
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct ComponentRef {
name: &'static str,
@@ -274,7 +281,6 @@ impl core::fmt::Display for ComponentRef {
}
/// A generated, checked event token.
/// req: codegen/006
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct EventName {
name: &'static str,
@@ -313,7 +319,6 @@ impl core::fmt::Display for EventName {
}
/// A small displayable list of generated CSS class tokens for hemplate `+class`.
/// req: style/003
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
pub struct CssClasses {
names: String,
@@ -413,41 +418,39 @@ pub enum FormControlKind {
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum Effect {
Put {
Patch {
target: ResourceRef,
payload: Payload,
mode: PatchMode,
html: SafeHtml,
},
Insert {
target: ResourceRef,
key: String,
payload: Payload,
},
Prepend {
target: ResourceRef,
key: String,
payload: Payload,
position: InsertPosition,
html: SafeHtml,
},
Remove {
target: ResourceRef,
key: Option<String>,
},
Move {
target: ResourceRef,
key: String,
before: Option<String>,
position: MovePosition,
},
Focus {
target: ResourceRef,
prevent_scroll: bool,
},
Navigate {
url: String,
mode: NavigateMode,
scroll: ScrollBehavior,
title: Option<String>,
Scroll {
target: ResourceRef,
block: ScrollBlock,
behavior: ScrollBehavior,
},
Emit {
name: String,
payload: String,
Visit {
url: Url,
history: HistoryMode,
},
Dispatch {
event: EventRef,
payload: Vec<u8>,
},
}
@@ -460,12 +463,10 @@ pub struct EffectBatch {
impl EffectBatch {
/// The versioned hemx codec is the sole public `EffectBatch` wire API.
///
/// ```compile_fail
/// let batch = hemx_core::EffectBatch::new(hemx_core::BuildFingerprint(1));
/// let _ = batch.to_postcard();
/// ```
/// req: wire/010 test
/// Return the exact number of bytes produced by [`Self::to_wire`].
pub fn encoded_len(&self) -> usize {
batch_wire_len(self)
@@ -493,6 +494,7 @@ impl EffectBatch {
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WireError {
BadMagic,
UnsupportedVersion,
Truncated,
InvalidUtf8,
UnknownTag,
@@ -508,35 +510,38 @@ fn batch_wire_len(batch: &EffectBatch) -> usize {
fn effect_wire_len(effect: &Effect) -> usize {
1 + match effect {
Effect::Put { target, payload } => ref_wire_len(target) + payload_wire_len(payload),
Effect::Patch { target, html, .. } => {
ref_wire_len(target) + 1 + str_wire_len(html.as_str())
}
Effect::Insert {
target,
key,
payload,
position,
html,
} => ref_wire_len(target) + position_wire_len(position) + str_wire_len(html.as_str()),
Effect::Remove { target } => ref_wire_len(target),
Effect::Move { target, position } => ref_wire_len(target) + position_wire_len(position),
Effect::Focus { target, .. } => ref_wire_len(target) + 1,
Effect::Scroll { target, .. } => ref_wire_len(target) + 2,
Effect::Visit { url, .. } => str_wire_len(url) + 1,
Effect::Dispatch { event, payload } => {
str_wire_len(event.as_str()) + str_wire_len_bytes(payload)
}
| Effect::Prepend {
target,
key,
payload,
} => ref_wire_len(target) + str_wire_len(key) + payload_wire_len(payload),
Effect::Remove { target, key } => {
ref_wire_len(target) + option_str_wire_len(key.as_deref())
}
Effect::Move {
target,
key,
before,
} => ref_wire_len(target) + str_wire_len(key) + option_str_wire_len(before.as_deref()),
Effect::Focus { target } => ref_wire_len(target),
Effect::Navigate {
url, scroll, title, ..
} => {
str_wire_len(url) + 1 + scroll_wire_len(scroll) + option_str_wire_len(title.as_deref())
}
Effect::Emit { name, payload } => str_wire_len(name) + str_wire_len(payload),
}
}
fn position_wire_len(position: &InsertPosition) -> usize {
1 + match position {
InsertPosition::First | InsertPosition::Last => 0,
InsertPosition::Before(reference) | InsertPosition::After(reference) => {
ref_wire_len(reference)
}
}
}
fn str_wire_len_bytes(value: &[u8]) -> usize {
4 + value.len()
}
fn ref_wire_len(reference: &ResourceRef) -> usize {
1 + 4
+ 1
@@ -546,23 +551,6 @@ fn ref_wire_len(reference: &ResourceRef) -> usize {
}
}
fn payload_wire_len(payload: &Payload) -> usize {
1 + match payload {
Payload::Text(value) | Payload::Html(value) => str_wire_len(value),
}
}
fn scroll_wire_len(scroll: &ScrollBehavior) -> usize {
1 + match scroll {
ScrollBehavior::Preserve | ScrollBehavior::Top => 0,
ScrollBehavior::Element(target) => ref_wire_len(target),
}
}
fn option_str_wire_len(value: Option<&str>) -> usize {
1 + value.map_or(0, str_wire_len)
}
const fn str_wire_len(value: &str) -> usize {
4 + value.len()
}
@@ -579,73 +567,100 @@ fn write_batch(batch: &EffectBatch, out: &mut Vec<u8>) {
fn write_effect(effect: &Effect, out: &mut Vec<u8>) {
match effect {
Effect::Put { target, payload } => {
Effect::Patch { target, mode, html } => {
write_u8(0, out);
write_ref(target, out);
write_payload(payload, out);
}
Effect::Insert {
target,
key,
payload,
} => {
write_u8(1, out);
write_ref(target, out);
write_str(key, out);
write_payload(payload, out);
}
Effect::Prepend {
target,
key,
payload,
} => {
write_u8(2, out);
write_ref(target, out);
write_str(key, out);
write_payload(payload, out);
}
Effect::Remove { target, key } => {
write_u8(3, out);
write_ref(target, out);
write_option_str(key.as_deref(), out);
}
Effect::Move {
target,
key,
before,
} => {
write_u8(4, out);
write_ref(target, out);
write_str(key, out);
write_option_str(before.as_deref(), out);
}
Effect::Focus { target } => {
write_u8(5, out);
write_ref(target, out);
}
Effect::Navigate {
url,
mode,
scroll,
title,
} => {
write_u8(6, out);
write_str(url, out);
write_u8(
match mode {
NavigateMode::Push => 0,
NavigateMode::Replace => 1,
NavigateMode::Redirect => 2,
PatchMode::Morph => 0,
PatchMode::Replace => 1,
},
out,
);
write_scroll(scroll, out);
write_option_str(title.as_deref(), out);
write_str(html.as_str(), out);
}
Effect::Emit { name, payload } => {
Effect::Insert {
target,
position,
html,
} => {
write_u8(1, out);
write_ref(target, out);
write_position(position, out);
write_str(html.as_str(), out);
}
Effect::Remove { target } => {
write_u8(2, out);
write_ref(target, out);
}
Effect::Move { target, position } => {
write_u8(3, out);
write_ref(target, out);
write_position(position, out);
}
Effect::Focus {
target,
prevent_scroll,
} => {
write_u8(4, out);
write_ref(target, out);
write_u8(u8::from(*prevent_scroll), out);
}
Effect::Scroll {
target,
block,
behavior,
} => {
write_u8(5, out);
write_ref(target, out);
write_u8(
match block {
ScrollBlock::Start => 0,
ScrollBlock::Center => 1,
ScrollBlock::End => 2,
ScrollBlock::Nearest => 3,
},
out,
);
write_u8(
match behavior {
ScrollBehavior::Auto => 0,
ScrollBehavior::Instant => 1,
ScrollBehavior::Smooth => 2,
},
out,
);
}
Effect::Visit { url, history } => {
write_u8(6, out);
write_str(url, out);
write_u8(
match history {
HistoryMode::Push => 0,
HistoryMode::Replace => 1,
},
out,
);
}
Effect::Dispatch { event, payload } => {
write_u8(7, out);
write_str(name, out);
write_str(payload, out);
write_str(event.as_str(), out);
write_bytes(payload, out);
}
}
}
fn write_position(position: &InsertPosition, out: &mut Vec<u8>) {
match position {
InsertPosition::First => write_u8(0, out),
InsertPosition::Last => write_u8(1, out),
InsertPosition::Before(reference) => {
write_u8(2, out);
write_ref(reference, out);
}
InsertPosition::After(reference) => {
write_u8(3, out);
write_ref(reference, out);
}
}
}
@@ -674,38 +689,9 @@ fn write_ref(reference: &ResourceRef, out: &mut Vec<u8>) {
}
}
fn write_payload(payload: &Payload, out: &mut Vec<u8>) {
match payload {
Payload::Text(value) => {
write_u8(0, out);
write_str(value, out);
}
Payload::Html(value) => {
write_u8(1, out);
write_str(value, out);
}
}
}
fn write_scroll(scroll: &ScrollBehavior, out: &mut Vec<u8>) {
match scroll {
ScrollBehavior::Preserve => write_u8(0, out),
ScrollBehavior::Top => write_u8(1, out),
ScrollBehavior::Element(target) => {
write_u8(2, out);
write_ref(target, out);
}
}
}
fn write_option_str(value: Option<&str>, out: &mut Vec<u8>) {
match value {
None => write_u8(0, out),
Some(value) => {
write_u8(1, out);
write_str(value, out);
}
}
fn write_bytes(value: &[u8], out: &mut Vec<u8>) {
write_u32(value.len() as u32, out);
out.extend_from_slice(value);
}
fn write_str(value: &str, out: &mut Vec<u8>) {
@@ -768,6 +754,11 @@ impl<'a> WireReader<'a> {
.map_err(|_| WireError::InvalidUtf8)
}
fn read_bytes(&mut self) -> Result<Vec<u8>, WireError> {
let len = self.read_u32()? as usize;
Ok(self.read_exact(len)?.to_vec())
}
fn read_exact(&mut self, len: usize) -> Result<&'a [u8], WireError> {
let remaining = &self.bytes[self.offset..];
let bytes = remaining.get(..len).ok_or(WireError::Truncated)?;
@@ -782,6 +773,9 @@ fn read_batch(bytes: &[u8]) -> Result<EffectBatch, WireError> {
return Err(WireError::BadMagic);
}
let abi_version = reader.read_u32()?;
if abi_version != EFFECT_BATCH_ABI_VERSION {
return Err(WireError::UnsupportedVersion);
}
let fingerprint = BuildFingerprint(reader.read_u64()?);
let ops_len = reader.read_u32()?;
let mut ops = Vec::new();
@@ -798,47 +792,73 @@ fn read_batch(bytes: &[u8]) -> Result<EffectBatch, WireError> {
fn read_effect(reader: &mut WireReader<'_>) -> Result<Effect, WireError> {
match reader.read_u8()? {
0 => Ok(Effect::Put {
0 => Ok(Effect::Patch {
target: read_ref(reader)?,
payload: read_payload(reader)?,
mode: match reader.read_u8()? {
0 => PatchMode::Morph,
1 => PatchMode::Replace,
_ => return Err(WireError::UnknownTag),
},
html: SafeHtml::trusted(reader.read_str()?),
}),
1 => Ok(Effect::Insert {
target: read_ref(reader)?,
key: reader.read_str()?,
payload: read_payload(reader)?,
position: read_position(reader)?,
html: SafeHtml::trusted(reader.read_str()?),
}),
2 => Ok(Effect::Prepend {
target: read_ref(reader)?,
key: reader.read_str()?,
payload: read_payload(reader)?,
}),
3 => Ok(Effect::Remove {
target: read_ref(reader)?,
key: read_option_str(reader)?,
}),
4 => Ok(Effect::Move {
target: read_ref(reader)?,
key: reader.read_str()?,
before: read_option_str(reader)?,
}),
5 => Ok(Effect::Focus {
2 => Ok(Effect::Remove {
target: read_ref(reader)?,
}),
6 => Ok(Effect::Navigate {
url: reader.read_str()?,
mode: match reader.read_u8()? {
0 => NavigateMode::Push,
1 => NavigateMode::Replace,
2 => NavigateMode::Redirect,
3 => Ok(Effect::Move {
target: read_ref(reader)?,
position: read_position(reader)?,
}),
4 => Ok(Effect::Focus {
target: read_ref(reader)?,
prevent_scroll: match reader.read_u8()? {
0 => false,
1 => true,
_ => return Err(WireError::UnknownTag),
},
scroll: read_scroll(reader)?,
title: read_option_str(reader)?,
}),
7 => Ok(Effect::Emit {
name: reader.read_str()?,
payload: reader.read_str()?,
5 => Ok(Effect::Scroll {
target: read_ref(reader)?,
block: match reader.read_u8()? {
0 => ScrollBlock::Start,
1 => ScrollBlock::Center,
2 => ScrollBlock::End,
3 => ScrollBlock::Nearest,
_ => return Err(WireError::UnknownTag),
},
behavior: match reader.read_u8()? {
0 => ScrollBehavior::Auto,
1 => ScrollBehavior::Instant,
2 => ScrollBehavior::Smooth,
_ => return Err(WireError::UnknownTag),
},
}),
6 => Ok(Effect::Visit {
url: reader.read_str()?,
history: match reader.read_u8()? {
0 => HistoryMode::Push,
1 => HistoryMode::Replace,
_ => return Err(WireError::UnknownTag),
},
}),
7 => Ok(Effect::Dispatch {
event: reader.read_str()?,
payload: reader.read_bytes()?,
}),
_ => Err(WireError::UnknownTag),
}
}
fn read_position(reader: &mut WireReader<'_>) -> Result<InsertPosition, WireError> {
match reader.read_u8()? {
0 => Ok(InsertPosition::First),
1 => Ok(InsertPosition::Last),
2 => Ok(InsertPosition::Before(read_ref(reader)?)),
3 => Ok(InsertPosition::After(read_ref(reader)?)),
_ => Err(WireError::UnknownTag),
}
}
@@ -861,31 +881,6 @@ fn read_ref(reader: &mut WireReader<'_>) -> Result<ResourceRef, WireError> {
Ok(ResourceRef { resource, scope })
}
fn read_payload(reader: &mut WireReader<'_>) -> Result<Payload, WireError> {
match reader.read_u8()? {
0 => Ok(Payload::Text(reader.read_str()?)),
1 => Ok(Payload::Html(reader.read_str()?)),
_ => Err(WireError::UnknownTag),
}
}
fn read_scroll(reader: &mut WireReader<'_>) -> Result<ScrollBehavior, WireError> {
match reader.read_u8()? {
0 => Ok(ScrollBehavior::Preserve),
1 => Ok(ScrollBehavior::Top),
2 => Ok(ScrollBehavior::Element(read_ref(reader)?)),
_ => Err(WireError::UnknownTag),
}
}
fn read_option_str(reader: &mut WireReader<'_>) -> Result<Option<String>, WireError> {
match reader.read_u8()? {
0 => Ok(None),
1 => Ok(Some(reader.read_str()?)),
_ => Err(WireError::UnknownTag),
}
}
pub trait IntoEffect {
fn append_to(self, ops: &mut Vec<Effect>);
@@ -995,16 +990,14 @@ impl<T> Slot<T> {
}
pub fn text(self, value: impl ToString) -> Effect {
Effect::Put {
target: ResourceRef::unscoped(self.id),
payload: Payload::text(value),
}
self.html(SafeHtml::trusted(value.to_string()))
}
pub fn html(self, value: impl Into<SafeHtml>) -> Effect {
Effect::Put {
Effect::Patch {
target: ResourceRef::unscoped(self.id),
payload: Payload::html(value.into()),
mode: PatchMode::Morph,
html: value.into(),
}
}
}
@@ -1049,9 +1042,9 @@ where
T: ToString,
{
Effect::Insert {
target: ResourceRef::unscoped(self.id),
key: key.to_string(),
payload: Payload::text(value),
target: ResourceRef::scoped(self.id, ScopeKey::KeyValue(key.to_string())),
position: InsertPosition::Last,
html: SafeHtml::trusted(value.to_string()),
}
}
@@ -1059,10 +1052,10 @@ where
where
T: ToString,
{
Effect::Prepend {
target: ResourceRef::unscoped(self.id),
key: key.to_string(),
payload: Payload::text(value),
Effect::Insert {
target: ResourceRef::scoped(self.id, ScopeKey::KeyValue(key.to_string())),
position: InsertPosition::First,
html: SafeHtml::trusted(value.to_string()),
}
}
@@ -1071,62 +1064,64 @@ where
T: ToString,
{
let key = key.to_string();
Effect::Put {
Effect::Patch {
target: ResourceRef {
resource: self.id,
scope: Some(ScopeKey::KeyValue(key)),
},
payload: Payload::text(value),
mode: PatchMode::Morph,
html: SafeHtml::trusted(value.to_string()),
}
}
pub fn append_html(self, key: K, value: impl Into<SafeHtml>) -> Effect {
Effect::Insert {
target: ResourceRef::unscoped(self.id),
key: key.to_string(),
payload: Payload::html(value.into()),
target: ResourceRef::scoped(self.id, ScopeKey::KeyValue(key.to_string())),
position: InsertPosition::Last,
html: value.into(),
}
}
pub fn prepend_html(self, key: K, value: impl Into<SafeHtml>) -> Effect {
Effect::Prepend {
target: ResourceRef::unscoped(self.id),
key: key.to_string(),
payload: Payload::html(value.into()),
Effect::Insert {
target: ResourceRef::scoped(self.id, ScopeKey::KeyValue(key.to_string())),
position: InsertPosition::First,
html: value.into(),
}
}
pub fn replace_html(self, key: K, value: impl Into<SafeHtml>) -> Effect {
let key = key.to_string();
Effect::Put {
Effect::Patch {
target: ResourceRef {
resource: self.id,
scope: Some(ScopeKey::KeyValue(key)),
},
payload: Payload::html(value.into()),
mode: PatchMode::Morph,
html: value.into(),
}
}
pub fn remove(self, key: K) -> Effect {
Effect::Remove {
target: ResourceRef::unscoped(self.id),
key: Some(key.to_string()),
target: ResourceRef::scoped(self.id, ScopeKey::KeyValue(key.to_string())),
}
}
pub fn move_before(self, key: K, before: K) -> Effect {
Effect::Move {
target: ResourceRef::unscoped(self.id),
key: key.to_string(),
before: Some(before.to_string()),
target: ResourceRef::scoped(self.id, ScopeKey::KeyValue(key.to_string())),
position: MovePosition::Before(ResourceRef::scoped(
self.id,
ScopeKey::KeyValue(before.to_string()),
)),
}
}
pub fn move_to_end(self, key: K) -> Effect {
Effect::Move {
target: ResourceRef::unscoped(self.id),
key: key.to_string(),
before: None,
target: ResourceRef::scoped(self.id, ScopeKey::KeyValue(key.to_string())),
position: MovePosition::Last,
}
}
}
@@ -1164,9 +1159,10 @@ impl<T> Atom<T> {
}
pub fn set(self, value: impl ToString) -> Effect {
Effect::Put {
Effect::Patch {
target: ResourceRef::unscoped(self.id),
payload: Payload::text(value),
mode: PatchMode::Morph,
html: SafeHtml::trusted(value.to_string()),
}
}
}
@@ -1297,48 +1293,18 @@ impl<T> Form<T> {
ResourceRef::scoped(self.id, ScopeKey::Field(name.into()))
}
pub fn reset(self) -> Effect {
Effect::Emit {
name: String::from("hemx:form-reset"),
payload: self.id.id.to_string(),
}
}
pub fn clear(self) -> Effect {
self.reset()
}
pub fn clear_field(self, field: impl Into<String>) -> Effect {
Effect::Put {
Effect::Patch {
target: self.field(field),
payload: Payload::text(""),
}
}
pub fn error(self, field: impl Into<String>, message: impl ToString) -> Effect {
let field = field.into();
let message = message.to_string();
let mut payload = self.id.id.to_string();
payload.push('\u{1f}');
payload.push_str(&field);
payload.push('\u{1f}');
payload.push_str(&message);
Effect::Emit {
name: String::from("hemx:form-error"),
payload,
mode: PatchMode::Morph,
html: SafeHtml::trusted(""),
}
}
pub fn focus(self, field: impl Into<String>) -> Effect {
Effect::Focus {
target: self.field(field),
}
}
pub fn disable_while_pending(self) -> Effect {
Effect::Emit {
name: String::from("hemx:form-disable-while-pending"),
payload: self.id.id.to_string(),
prevent_scroll: false,
}
}
}
@@ -1348,35 +1314,29 @@ pub fn navigate(url: impl Into<String>) -> Effect {
}
pub fn push(url: impl Into<String>) -> Effect {
Effect::Navigate {
Effect::Visit {
url: url.into(),
mode: NavigateMode::Push,
scroll: ScrollBehavior::Top,
title: None,
history: HistoryMode::Push,
}
}
pub fn replace(url: impl Into<String>) -> Effect {
Effect::Navigate {
Effect::Visit {
url: url.into(),
mode: NavigateMode::Replace,
scroll: ScrollBehavior::Top,
title: None,
history: HistoryMode::Replace,
}
}
pub fn redirect(url: impl Into<String>) -> Effect {
Effect::Navigate {
Effect::Visit {
url: url.into(),
mode: NavigateMode::Redirect,
scroll: ScrollBehavior::Top,
title: None,
history: HistoryMode::Replace,
}
}
pub fn event(name: impl Into<String>, payload: impl Into<String>) -> Effect {
Effect::Emit {
name: name.into(),
payload: payload.into(),
Effect::Dispatch {
event: name.into(),
payload: payload.into().into_bytes(),
}
}
+172 -490
View File
@@ -1,543 +1,225 @@
use hemx_core::{
event, navigate, redirect, replace, Atom, AtomSnapshot, AtomState, BuildFingerprint,
ComponentRef, CssClass, CssClasses, Effect, EffectBatch, EventName, Form, FormError, FormValue,
Handle, IntoEffect, KeyedSlot, NavigateMode, ParamName, Payload, ResourceId, ResourceKind,
ResourceRef, SafeHtml, ScopeKey, ScrollBehavior, Slot, WireError,
navigate, redirect, replace, BuildFingerprint, Effect, EffectBatch, EventName, Form,
HistoryMode, InsertPosition, KeyedSlot, MovePosition, ParamName, PatchMode, ResourceId,
ResourceKind, ResourceRef, SafeHtml, ScopeKey, ScrollBehavior, ScrollBlock, Slot, WireError,
EFFECT_BATCH_ABI_VERSION,
};
#[test]
fn effect_batch_wire_round_trips() {
let count = Slot::<u32>::new(1);
let todos = KeyedSlot::<u64, String>::new(2);
let user = Atom::<String>::new(3);
fn reference(id: u32, scope: Option<ScopeKey>) -> ResourceRef {
ResourceRef {
resource: ResourceId::new(ResourceKind::Slot, id),
scope,
}
}
let batch = (
count.text(2),
todos.append_text(7, String::from("Buy milk")),
todos.replace_text(7, String::from("Buy oat milk")),
user.set("Ada"),
navigate("/todos"),
event("toast", "Saved"),
)
.into_batch(BuildFingerprint(42));
fn batch(ops: Vec<Effect>) -> EffectBatch {
EffectBatch {
abi_version: EFFECT_BATCH_ABI_VERSION,
fingerprint: BuildFingerprint(7),
ops,
}
}
let bytes = batch.to_wire();
assert_eq!(&bytes[..4], b"HEMX");
assert_eq!(batch.encoded_len(), bytes.len()); // req: wire/007
let decoded = EffectBatch::from_wire(&bytes).unwrap();
assert_eq!(decoded, batch);
assert!(decoded.is_compatible());
fn every_effect() -> Vec<Effect> {
let target = reference(1, Some(ScopeKey::KeyValue(String::from("row"))));
let sibling = reference(2, None);
vec![
Effect::Patch {
target: target.clone(),
mode: PatchMode::Morph,
html: SafeHtml::trusted("<p>safe</p>"),
},
Effect::Patch {
target: sibling.clone(),
mode: PatchMode::Replace,
html: SafeHtml::trusted("<p>replace</p>"),
},
Effect::Insert {
target: target.clone(),
position: InsertPosition::First,
html: SafeHtml::trusted("<li>first</li>"),
},
Effect::Insert {
target: target.clone(),
position: InsertPosition::Last,
html: SafeHtml::trusted("<li>last</li>"),
},
Effect::Insert {
target: target.clone(),
position: InsertPosition::Before(sibling.clone()),
html: SafeHtml::trusted("<li>before</li>"),
},
Effect::Insert {
target: sibling.clone(),
position: InsertPosition::After(target.clone()),
html: SafeHtml::trusted("<li>after</li>"),
},
Effect::Remove {
target: target.clone(),
},
Effect::Move {
target: target.clone(),
position: MovePosition::Last,
},
Effect::Focus {
target: target.clone(),
prevent_scroll: true,
},
Effect::Scroll {
target: target.clone(),
block: ScrollBlock::Center,
behavior: ScrollBehavior::Smooth,
},
Effect::Visit {
url: String::from("/docs"),
history: HistoryMode::Replace,
},
Effect::Dispatch {
event: String::from("app:updated"),
payload: vec![0, 1, 255],
},
]
}
#[test]
fn compatibility_fixture_accepts_only_the_declared_v1_wire_version() {
// test req: abi/001 req: abi/003 req: v1_release/007
const V1_EMPTY_BATCH: &[u8] = &[
72, 69, 77, 88, // HEMX
1, 0, // ABI v1
0, 0, // reserved
7, 0, 0, 0, 0, 0, 0, 0, // fingerprint
0, 0, 0, 0, // zero operations
];
assert_eq!(
EffectBatch::from_wire(V1_EMPTY_BATCH).unwrap().to_wire(),
V1_EMPTY_BATCH
);
let mut future = V1_EMPTY_BATCH.to_vec();
future[4] = 2;
let future = EffectBatch::from_wire(&future).unwrap();
assert_eq!(future.abi_version, 2);
assert!(!future.is_compatible());
fn effect_batch_wire_round_trips() {
let original = batch(every_effect());
let bytes = original.to_wire();
assert_eq!(EffectBatch::from_wire(&bytes).unwrap(), original);
assert_eq!(bytes.len(), original.encoded_len());
}
#[test]
fn canonical_wire_covers_every_closed_variant_and_rejects_truncation() {
let slot = ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 0x0403_0201));
let atom = ResourceRef::scoped(
ResourceId::new(ResourceKind::Atom, 0x0807_0605),
ScopeKey::KeyValue(String::from("row")),
);
let handle = ResourceRef::scoped(
ResourceId::new(ResourceKind::Handle, 0x0c0b_0a09),
ScopeKey::Field(String::from("email")),
);
let form = ResourceRef::unscoped(ResourceId::new(ResourceKind::Form, 0x100f_0e0d));
let batch = EffectBatch {
abi_version: hemx_core::EFFECT_BATCH_ABI_VERSION,
fingerprint: BuildFingerprint(0x0807_0605_0403_0201),
ops: vec![
Effect::Put {
target: slot.clone(),
payload: Payload::Text(String::from("text")),
},
Effect::Put {
target: handle.clone(),
payload: Payload::Html(String::from("<p>safe</p>")),
},
Effect::Insert {
target: atom.clone(),
key: String::from("insert"),
payload: Payload::Text(String::from("one")),
},
Effect::Prepend {
target: form.clone(),
key: String::from("prepend"),
payload: Payload::Html(String::from("two")),
},
Effect::Remove {
target: slot.clone(),
key: None,
},
Effect::Remove {
target: atom,
key: Some(String::from("remove")),
},
Effect::Move {
target: handle.clone(),
key: String::from("move"),
before: Some(String::from("before")),
},
Effect::Focus { target: form },
Effect::Navigate {
url: String::from("/push"),
mode: NavigateMode::Push,
scroll: ScrollBehavior::Preserve,
title: None,
},
Effect::Navigate {
url: String::from("/replace"),
mode: NavigateMode::Replace,
scroll: ScrollBehavior::Top,
title: Some(String::from("Replace")),
},
Effect::Navigate {
url: String::from("/redirect"),
mode: NavigateMode::Redirect,
scroll: ScrollBehavior::Element(handle),
title: Some(String::from("Redirect")),
},
Effect::Emit {
name: String::from("notice"),
payload: String::from("saved"),
},
],
};
let original = batch(every_effect());
assert_eq!(original.ops.len(), 12);
let bytes = batch.to_wire();
assert_eq!(&bytes[..4], b"HEMX");
assert_eq!(batch.encoded_len(), bytes.len());
assert_eq!(EffectBatch::from_wire(&bytes), Ok(batch));
for end in 0..bytes.len() {
let bytes = original.to_wire();
for length in 0..bytes.len() {
assert_eq!(
EffectBatch::from_wire(&bytes[..end]),
Err(WireError::Truncated),
"prefix ending at byte {end} must fail closed"
EffectBatch::from_wire(&bytes[..length]),
Err(WireError::Truncated)
);
}
// req: wire/007 test req: wire/008 test req: wire/009 test
}
#[test]
fn canonical_wire_rejects_corrupt_tags_utf8_magic_and_trailing_bytes() {
const BATCH_HEADER_LEN: usize = 4 + 4 + 8 + 4;
const PUT_EFFECT_TAG: usize = BATCH_HEADER_LEN;
const PUT_RESOURCE_KIND_TAG: usize = PUT_EFFECT_TAG + 1;
const PUT_SCOPE_TAG: usize = PUT_RESOURCE_KIND_TAG + 1 + 4;
const PUT_PAYLOAD_TAG: usize = PUT_SCOPE_TAG + 1;
let original = batch(vec![Effect::Visit {
url: String::from("/same"),
history: HistoryMode::Push,
}]);
let bytes = original.to_wire();
assert_eq!(EffectBatch::from_wire(&bytes).unwrap().to_wire(), bytes);
let put = EffectBatch {
abi_version: 1,
fingerprint: BuildFingerprint(1),
ops: vec![Effect::Put {
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 1)),
payload: Payload::Text(String::from("value")),
}],
}
.to_wire();
for offset in [
PUT_EFFECT_TAG,
PUT_RESOURCE_KIND_TAG,
PUT_SCOPE_TAG,
PUT_PAYLOAD_TAG,
] {
let mut corrupt = put.clone();
corrupt[offset] = 0xff;
assert_eq!(EffectBatch::from_wire(&corrupt), Err(WireError::UnknownTag));
}
const NAVIGATE_MODE_TAG: usize = BATCH_HEADER_LEN + 1 + 4;
const NAVIGATE_SCROLL_TAG: usize = NAVIGATE_MODE_TAG + 1;
const NAVIGATE_TITLE_OPTION_TAG: usize = NAVIGATE_SCROLL_TAG + 1;
let navigate = EffectBatch {
abi_version: 1,
fingerprint: BuildFingerprint(1),
ops: vec![Effect::Navigate {
url: String::new(),
mode: NavigateMode::Push,
scroll: ScrollBehavior::Preserve,
title: None,
}],
}
.to_wire();
for offset in [
NAVIGATE_MODE_TAG,
NAVIGATE_SCROLL_TAG,
NAVIGATE_TITLE_OPTION_TAG,
] {
let mut corrupt = navigate.clone();
corrupt[offset] = 0xff;
assert_eq!(EffectBatch::from_wire(&corrupt), Err(WireError::UnknownTag));
}
let mut bad_magic = put.clone();
bad_magic[0] = b'X';
let mut bad_magic = bytes.clone();
bad_magic[0] ^= 0xff;
assert_eq!(EffectBatch::from_wire(&bad_magic), Err(WireError::BadMagic));
const PUT_TEXT_START: usize = PUT_PAYLOAD_TAG + 1 + 4;
let mut invalid_utf8 = put.clone();
invalid_utf8[PUT_TEXT_START] = 0xff;
let mut bad_tag = bytes.clone();
bad_tag[20] = 0xff;
assert_eq!(EffectBatch::from_wire(&bad_tag), Err(WireError::UnknownTag));
let mut bad_utf8 = bytes.clone();
let slash = bad_utf8.iter().position(|byte| *byte == b'/').unwrap();
bad_utf8[slash] = 0xff;
assert_eq!(
EffectBatch::from_wire(&invalid_utf8),
EffectBatch::from_wire(&bad_utf8),
Err(WireError::InvalidUtf8)
);
let mut trailing = put;
let mut trailing = bytes;
trailing.push(0);
assert_eq!(
EffectBatch::from_wire(&trailing),
Err(WireError::TrailingBytes)
);
// req: wire/008 test req: wire/009 test
}
#[test]
fn optional_effects_compose_into_batches() {
// req: component/005 req: public_api/003
let count = Slot::<u32>::new(1);
let batch = (Some(count.text(2)), Option::<Effect>::None).into_batch(BuildFingerprint(42));
fn compatibility_fixture_accepts_only_the_declared_v1_wire_version() {
let compatible = batch(Vec::new());
assert!(compatible.is_compatible());
assert_eq!(batch.ops.len(), 1);
assert_eq!(batch.ops[0], count.text(2));
}
#[test]
fn effect_collections_compose_into_batches() {
// req: canonical_authoring/003
let rows = KeyedSlot::<u64, String>::new(2);
let summary = Slot::<u32>::new(3);
let notice = Slot::<String>::new(4);
let dynamic_rows = [7, 8]
.into_iter()
.map(|id| rows.replace_text(id, format!("todo {id}")))
.collect::<Vec<_>>();
let fixed_notices = [notice.text("Saved"), notice.text("Synced")];
let batch =
(dynamic_rows, Some(summary.text(2)), fixed_notices).into_batch(BuildFingerprint(42));
assert_eq!(batch.ops.len(), 5);
assert_eq!(batch.ops[0], rows.replace_text(7, String::from("todo 7")));
assert_eq!(batch.ops[1], rows.replace_text(8, String::from("todo 8")));
assert_eq!(batch.ops[2], summary.text(2));
assert_eq!(batch.ops[3], notice.text("Saved"));
assert_eq!(batch.ops[4], notice.text("Synced"));
}
#[test]
fn keyed_slot_replace_uses_scoped_resource_ref() {
let todos = KeyedSlot::<u64, String>::new(9);
// req: codegen/002
let effect = todos.replace_text(12, String::from("done"));
let Effect::Put { target, payload } = effect else {
panic!("expected Put");
};
assert_eq!(target.resource.kind, ResourceKind::Slot);
assert_eq!(target.resource.id, 9);
assert_eq!(target.scope, Some(ScopeKey::KeyValue(String::from("12"))));
assert_eq!(payload, Payload::Text(String::from("done")));
}
#[test]
fn tuple_composition_supports_arity_twelve() {
let slot = Slot::<u8>::new(1);
let batch = (
slot.text(1),
slot.text(2),
slot.text(3),
slot.text(4),
slot.text(5),
slot.text(6),
slot.text(7),
slot.text(8),
slot.text(9),
slot.text(10),
slot.text(11),
slot.text(12),
)
.into_batch(BuildFingerprint(1));
assert_eq!(batch.ops.len(), 12);
}
#[test]
fn generated_form_helpers_target_form_fields() {
let signup = Form::<()>::new(4);
let Effect::Emit { name, payload } = signup.error("email", "Use your work email") else {
panic!("expected Emit");
};
assert_eq!(name, "hemx:form-error");
assert_eq!(payload, "4\u{1f}email\u{1f}Use your work email");
let Effect::Focus { target } = signup.focus("email") else {
panic!("expected Focus");
};
assert_eq!(target.scope, Some(ScopeKey::Field(String::from("email"))));
let mut incompatible = compatible.to_wire();
incompatible[4..8].copy_from_slice(&(EFFECT_BATCH_ABI_VERSION + 1).to_le_bytes());
assert_eq!(
EffectBatch::from_wire(&incompatible),
Err(WireError::UnsupportedVersion)
);
}
#[test]
fn generated_resource_helpers_preserve_target_keys_and_navigation_modes() {
let rows = KeyedSlot::<u64, String>::new(9);
let expected = ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 9));
assert_eq!(
rows.replace_html(12, SafeHtml::trusted("<li>done</li>")),
Effect::Put {
target: ResourceRef::scoped(expected.resource, ScopeKey::KeyValue("12".into())),
payload: Payload::Html("<li>done</li>".into()),
}
);
assert_eq!(
rows.remove(12),
Effect::Remove {
target: expected.clone(),
key: Some("12".into()),
}
);
assert_eq!(
rows.move_before(12, 13),
Effect::Move {
target: expected.clone(),
key: "12".into(),
before: Some("13".into()),
}
);
assert_eq!(
rows.move_to_end(12),
Effect::Move {
target: expected,
key: "12".into(),
before: None,
}
);
let form = Form::<()>::new(4);
assert_eq!(
form.clear_field("email"),
Effect::Put {
target: ResourceRef::scoped(
ResourceId::new(ResourceKind::Form, 4),
ScopeKey::Field("email".into()),
),
payload: Payload::Text(String::new()),
}
);
assert_eq!(
form.disable_while_pending(),
Effect::Emit {
name: "hemx:form-disable-while-pending".into(),
payload: "4".into(),
}
);
assert_eq!(form.clear(), form.reset());
for (effect, expected_mode) in [
(navigate("/push"), NavigateMode::Push),
(hemx_core::push("/push"), NavigateMode::Push),
(replace("/replace"), NavigateMode::Replace),
(redirect("/redirect"), NavigateMode::Redirect),
] {
let Effect::Navigate {
mode,
scroll,
title,
let rows: KeyedSlot<String, String> = KeyedSlot::new(3);
let append = rows.append_html("a".into(), SafeHtml::trusted("<li>a</li>"));
assert!(matches!(
append,
Effect::Insert {
target: ResourceRef {
scope: Some(ScopeKey::KeyValue(key)),
..
},
position: InsertPosition::Last,
..
} = effect
else {
panic!("navigation helper must return Navigate");
};
assert_eq!(mode, expected_mode);
assert_eq!(scroll, ScrollBehavior::Top);
assert_eq!(title, None);
}
// test req: list/003 req: form_effects/001 req: nav/001
}
} if key == "a"
));
#[test]
fn css_class_accumulation_preserves_existing_classes() {
const A: CssClass = CssClass::new("a");
const B: CssClass = CssClass::new("b");
const C: CssClass = CssClass::new("c");
assert_eq!(CssClasses::new([]).with(A).as_str(), "a");
assert_eq!(CssClasses::from(A).with(B).with(C).as_str(), "a b c");
// test req: style/003
assert!(matches!(
navigate("/push"),
Effect::Visit {
history: HistoryMode::Push,
..
}
));
assert!(matches!(
replace("/replace"),
Effect::Visit {
history: HistoryMode::Replace,
..
}
));
assert!(matches!(
redirect("/redirect"),
Effect::Visit {
history: HistoryMode::Replace,
..
}
));
}
#[test]
fn slot_html_requires_explicit_safe_html() {
let content = Slot::<String>::new(10);
let Effect::Put { payload, .. } = content.html(SafeHtml::trusted("<strong>ok</strong>")) else {
panic!("expected Put");
};
assert_eq!(payload, Payload::Html(String::from("<strong>ok</strong>")));
let slot: Slot<String> = Slot::new(4);
let effect = slot.html(SafeHtml::trusted("<strong>done</strong>"));
assert!(matches!(
effect,
Effect::Patch {
mode: PatchMode::Morph,
html,
..
} if html.as_str() == "<strong>done</strong>"
));
}
#[test]
fn safe_html_joins_only_explicit_safe_fragments() {
// req: html_safety/001 req: html_safety/002
let html = SafeHtml::join([
SafeHtml::trusted("<main>"),
SafeHtml::trusted("<strong>ok</strong>"),
SafeHtml::trusted("</main>"),
]);
assert_eq!(html.as_str(), "<main><strong>ok</strong></main>");
assert_eq!(html.as_ref(), "<main><strong>ok</strong></main>");
assert_eq!(html.to_string(), "<main><strong>ok</strong></main>");
fn generated_form_helpers_target_form_fields() {
let form: Form<()> = Form::new(5);
let effect = form.focus("email");
assert!(matches!(
effect,
Effect::Focus {
target: ResourceRef {
resource: ResourceId { kind: ResourceKind::Form, id: 5 },
scope: Some(ScopeKey::Field(field)),
},
prevent_scroll: false,
} if field == "email"
));
}
#[test]
fn param_names_format_generated_param_names() {
// req: codegen/003
let param = ParamName::new("todo_id");
assert_eq!(param.as_str(), "todo_id");
assert_eq!(param.as_ref(), "todo_id");
assert_eq!(param.to_string(), "todo_id");
}
#[test]
fn component_refs_format_generated_component_names() {
// req: component/003
let component = ComponentRef::new("todo_list");
assert_eq!(component.as_str(), "todo_list");
assert_eq!(component.as_ref(), "todo_list");
assert_eq!(component.to_string(), "todo_list");
}
#[test]
fn generated_resources_format_for_hemplate_dynamic_attrs() {
// req: public_api/001 req: public_api/002
assert_eq!(Slot::<String>::new(10).to_string(), "10");
assert_eq!(KeyedSlot::<u64, String>::new(11).to_string(), "11");
assert_eq!(Atom::<String>::new(12).to_string(), "12");
assert_eq!(Handle::<()>::new(42).to_string(), "42");
assert_eq!(Form::<()>::new(13).to_string(), "13");
}
#[test]
fn generated_css_classes_join_for_hemplate_dynamic_class_attrs() {
// req: style/003
const CARD: CssClass = CssClass::new("work-card");
const SELECTED: CssClass = CssClass::new("is-selected");
let classes = CssClasses::from([CARD, SELECTED]);
assert_eq!(classes.as_str(), "work-card is-selected");
assert_eq!(classes.to_string(), "work-card is-selected");
assert_eq!(CARD.with(SELECTED).as_str(), "work-card is-selected");
assert_eq!(
CARD.with_if(true, SELECTED).as_str(),
"work-card is-selected"
);
assert_eq!(CARD.with_if(false, SELECTED).as_str(), "work-card");
}
#[test]
fn atom_state_bootstrap_is_postcard_round_trippable() {
let state = AtomState {
atoms: vec![AtomSnapshot {
id: 7,
bytes: vec![1, 2, 3],
}],
};
let bytes = state.to_postcard().unwrap();
assert_eq!(AtomState::from_postcard(&bytes).unwrap(), state);
}
#[test]
fn navigation_helpers_choose_explicit_modes() {
let Effect::Navigate { mode, .. } = navigate("/docs") else {
panic!("expected Navigate");
};
assert_eq!(mode, NavigateMode::Push);
let Effect::Navigate { mode, .. } = replace("/docs") else {
panic!("expected Navigate");
};
assert_eq!(mode, NavigateMode::Replace);
let Effect::Navigate { mode, .. } = redirect("/login") else {
panic!("expected Navigate");
};
assert_eq!(mode, NavigateMode::Redirect);
}
#[test]
fn build_fingerprint_is_deterministic_from_abi_parts() {
let a = BuildFingerprint::from_parts(&[1, 2, 3, 4]);
let b = BuildFingerprint::from_parts(&[1, 2, 3, 4]);
let c = BuildFingerprint::from_parts(&[1, 2, 3, 5]);
assert_eq!(a, b);
assert_ne!(a, c);
assert_eq!(
BuildFingerprint::from_parts(&[]),
BuildFingerprint(0xcbf29ce484222325)
);
assert_eq!(a, BuildFingerprint(13725386680924731485));
assert_eq!(hemx_core::EFFECT_BATCH_ABI_VERSION, 1);
assert_eq!(hemx_core::SURFACE_SCHEMA_VERSION, 1);
assert_eq!(hemx_core::RUNTIME_ABI_VERSION, 1);
// test req: abi/001 req: abi/002 req: abi/003
}
#[test]
fn public_token_and_form_error_adapters_preserve_values() {
const NOTICE: EventName = EventName::new("notice");
assert_eq!(NOTICE.as_str(), "notice");
assert_eq!(NOTICE.as_ref(), "notice");
assert_eq!(NOTICE.to_string(), "notice");
assert_eq!(String::from(NOTICE), "notice");
assert_eq!(NOTICE.emit("saved"), event("notice", "saved"));
const ACTIVE: CssClass = CssClass::new("active");
assert_eq!(ACTIVE.as_str(), "active");
assert_eq!(ACTIVE.as_ref(), "active");
assert_eq!(ACTIVE.to_string(), "active");
let classes = CssClasses::from(ACTIVE).with(CssClass::new("selected"));
assert_eq!(classes.as_ref(), "active selected");
let error = FormError::new("invalid email");
assert_eq!(error.message(), "invalid email");
assert_eq!(error.to_string(), "invalid email");
assert_eq!(u32::parse_form_value("42"), Ok(42));
assert_eq!(
u32::parse_form_value("nope"),
Err("invalid form value".into())
);
assert_eq!(
Form::<()>::new(7).reset(),
Effect::Emit {
name: "hemx:form-reset".into(),
payload: "7".into(),
}
);
// test req: codegen/006 req: style/003 req: form/001 req: form_effects/001
fn public_tokens_preserve_values() {
assert_eq!(EventName::new("event").as_str(), "event");
assert_eq!(ParamName::new("param").as_str(), "param");
}