Implement closed typed effect path
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user