refactor!: rename slhx to hemx
Rename the tracked product identity, crate/package names, Rust paths/macros, generated artifacts, runtime files, public attributes, examples, docs, requirements, and tests from slhx to hemx without compatibility shims. Verified with cargo run -p hemx-xtask -- test, cargo test -p hemx-derive --test compile_fail, cargo test -p hemx-js, cargo test -p hemx-axum, cargo test -p hemx-v0-examples, cargo check --workspace, redgate list, redgate refs, redgate health --strict, git diff --check, and git grep/ls-files legacy-name audits. req: misc/001 req: codegen/001 req: component/004 req: runtime/001
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "hemx-test"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
hemx-core = { path = "../hemx-core" }
|
||||
@@ -0,0 +1,706 @@
|
||||
use hemx_core::{
|
||||
Atom, BuildFingerprint, Effect, EffectBatch, Form, GeneratedTarget, IntoEffect, KeyedSlot,
|
||||
NavigateMode, Payload, ResourceId, ResourceKind, ResourceRef, ScopeKey, Slot,
|
||||
};
|
||||
|
||||
pub fn run<I, F, R>(handler: F, input: I) -> EffectInspector
|
||||
where
|
||||
F: FnOnce(I) -> R,
|
||||
R: IntoEffect,
|
||||
{
|
||||
inspect(handler(input))
|
||||
}
|
||||
|
||||
pub fn inspect(effect: impl IntoEffect) -> EffectInspector {
|
||||
inspect_batch(effect.into_batch(BuildFingerprint(0)))
|
||||
}
|
||||
|
||||
/// Inspect an already-dispatched batch without matching raw effect variants in tests.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn inspect_batch(batch: EffectBatch) -> EffectInspector {
|
||||
EffectInspector { batch }
|
||||
}
|
||||
|
||||
/// Decode and inspect an effect wire response without exposing `EffectBatch` in tests.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn inspect_wire(bytes: &[u8]) -> EffectInspector {
|
||||
inspect_batch(EffectBatch::from_wire(bytes).expect("hemx effect wire response"))
|
||||
}
|
||||
|
||||
/// Return the resource id behind a generated target for low-level test assertions.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn target_resource(target: impl GeneratedTarget) -> ResourceId {
|
||||
target.__hemx_resource_id()
|
||||
}
|
||||
|
||||
/// Return the unscoped resource reference behind a generated target for low-level test assertions.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn target_ref(target: impl GeneratedTarget) -> ResourceRef {
|
||||
ResourceRef::unscoped(target_resource(target))
|
||||
}
|
||||
|
||||
/// Build an interaction request body from a generated handle and form fields.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn handle_form_body<I>(handle: hemx_core::Handle<I>, fields: &[(&str, &str)]) -> String {
|
||||
let mut body = form_pair("__h", &handle.to_string());
|
||||
for (name, value) in fields {
|
||||
body.push('&');
|
||||
body.push_str(&form_pair(name, value));
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
/// Build a request body for invalid-handle tests without exposing the wire field name.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn unknown_handle_form_body(id: u32) -> String {
|
||||
form_pair("__h", &id.to_string())
|
||||
}
|
||||
|
||||
/// Build a browser-driver selector from a generated handle without exposing runtime ids in tests.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn handle_selector<I>(handle: hemx_core::Handle<I>) -> String {
|
||||
attr_selector("data-hid", &handle.to_string())
|
||||
}
|
||||
|
||||
/// Build a selector for a clickable button with a generated handle.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn handle_button_selector<I>(handle: hemx_core::Handle<I>) -> String {
|
||||
format!("button{}", handle_selector(handle))
|
||||
}
|
||||
|
||||
/// Build a selector for a heading in a semantic container without spelling document structure in examples.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn heading_selector(scope_selector: &str, level: u8) -> String {
|
||||
assert!((1..=6).contains(&level), "heading level must be 1..=6");
|
||||
if scope_selector.is_empty() {
|
||||
format!("h{level}")
|
||||
} else {
|
||||
format!("{scope_selector} h{level}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a selector for article content without spelling document structure in examples.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn article_selector() -> &'static str {
|
||||
"article"
|
||||
}
|
||||
|
||||
/// Build a selector for emphasized/card-title text without spelling document structure.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn strong_text_selector() -> &'static str {
|
||||
"strong"
|
||||
}
|
||||
|
||||
/// Build a selector for secondary/help text without spelling document structure.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn small_text_selector() -> &'static str {
|
||||
"small"
|
||||
}
|
||||
|
||||
/// Build a selector for an HTML tag that must be absent when user text is escaped.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn escaped_markup_selector(tag: &str) -> String {
|
||||
assert!(
|
||||
tag.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-'),
|
||||
"tag selector must be a simple tag name"
|
||||
);
|
||||
tag.to_owned()
|
||||
}
|
||||
|
||||
/// Build a selector for list items without spelling document structure in examples.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn list_item_selector(scope_selector: &str) -> String {
|
||||
if scope_selector.is_empty() {
|
||||
"li".to_owned()
|
||||
} else {
|
||||
format!("{scope_selector} li")
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a selector for prose text in a semantic container without spelling document structure.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn prose_selector(scope_selector: &str) -> String {
|
||||
if scope_selector.is_empty() {
|
||||
"p".to_owned()
|
||||
} else {
|
||||
format!("{scope_selector} p")
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a selector for a form in a semantic container without spelling form structure in examples.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn form_selector(scope_selector: &str) -> String {
|
||||
format!("{scope_selector} form")
|
||||
}
|
||||
|
||||
/// Build a selector for a form select's options from the authoring field name.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn select_options_selector(field: &str) -> String {
|
||||
format!("select{} > option", attr_selector("name", field))
|
||||
}
|
||||
|
||||
/// Build a selector for an app-owned semantic class.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn class_selector(class: &str) -> String {
|
||||
assert_simple_selector_part(class, "class");
|
||||
format!(".{class}")
|
||||
}
|
||||
|
||||
/// Build a selector for an element carrying an app-owned semantic class.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn element_class_selector(element: &str, class: &str) -> String {
|
||||
assert_simple_selector_part(element, "element");
|
||||
assert_simple_selector_part(class, "class");
|
||||
format!("{element}.{class}")
|
||||
}
|
||||
|
||||
/// Build a selector for classed children inside an app-owned semantic container.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn class_child_selector(parent_class: &str, element: &str, class: &str) -> String {
|
||||
assert_simple_selector_part(parent_class, "parent class");
|
||||
assert_simple_selector_part(element, "element");
|
||||
assert_simple_selector_part(class, "class");
|
||||
format!(".{parent_class} > {element}.{class}")
|
||||
}
|
||||
|
||||
/// Build a selector for an element inside an app-owned semantic class.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn class_descendant_selector(parent_class: &str, element: &str) -> String {
|
||||
assert_simple_selector_part(parent_class, "parent class");
|
||||
assert_simple_selector_part(element, "element");
|
||||
format!(".{parent_class} {element}")
|
||||
}
|
||||
|
||||
/// Build a selector for disabled action buttons without spelling CSS selector state in examples.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn disabled_button_selector() -> &'static str {
|
||||
"button[disabled]"
|
||||
}
|
||||
|
||||
/// Build a selector for progressive-enhancement navigation links.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn nav_link_selector(href: &str) -> String {
|
||||
format!("a{}", attr_selector("href", href))
|
||||
}
|
||||
|
||||
/// Build a selector for page-enhanced navigation links that do not use handler dispatch.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn page_nav_link_selector(href: &str) -> String {
|
||||
format!(
|
||||
"{}[data-hemx-nav]:not([data-hemx-handle])",
|
||||
nav_link_selector(href)
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a browser-driver selector from a generated target without exposing runtime ids in tests.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn target_selector(target: impl GeneratedTarget) -> String {
|
||||
let resource = target.__hemx_resource_id();
|
||||
let attr = match resource.kind {
|
||||
ResourceKind::Slot => "data-sid",
|
||||
ResourceKind::Atom => "data-aid",
|
||||
ResourceKind::Handle => "data-hid",
|
||||
ResourceKind::Form => "data-fid",
|
||||
};
|
||||
attr_selector(attr, &resource.id.to_string())
|
||||
}
|
||||
|
||||
/// Build a selector for an hemx root from its authoring name.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn root_selector(name: &str) -> String {
|
||||
attr_selector("data-hemx-root", name)
|
||||
}
|
||||
|
||||
/// Build a selector for a specific root element from its authoring name.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn root_element_selector(element: &str, name: &str) -> String {
|
||||
format!("{}{}", element, root_selector(name))
|
||||
}
|
||||
|
||||
/// Build a selector for the document body without spelling raw document structure in examples.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn document_body_selector() -> &'static str {
|
||||
"body"
|
||||
}
|
||||
|
||||
/// Build a selector for the document title without spelling raw document structure in examples.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn document_title_selector() -> &'static str {
|
||||
"title"
|
||||
}
|
||||
|
||||
/// Build a selector for the hemx runtime script without exposing its asset path in tests.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn runtime_script_selector() -> &'static str {
|
||||
"script[src=\"/hemx.js\"]"
|
||||
}
|
||||
|
||||
/// Build a selector for any hemx root without spelling the attribute in tests.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn any_root_selector() -> &'static str {
|
||||
"[data-hemx-root]"
|
||||
}
|
||||
|
||||
/// Build a selector for a keyed generated row without spelling runtime key metadata.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn keyed_selector(base_selector: &str, key: impl ToString) -> String {
|
||||
format!(
|
||||
"{}{}",
|
||||
base_selector,
|
||||
attr_selector("data-key", &key.to_string())
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a selector for all generated keyed rows under a semantic base selector.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn keyed_items_selector(base_selector: &str) -> String {
|
||||
format!("{base_selector}[data-key]")
|
||||
}
|
||||
|
||||
/// Build a selector for an island from its authoring name.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn island_selector(name: &str) -> String {
|
||||
attr_selector("data-hemx-island", name)
|
||||
}
|
||||
|
||||
/// Return the island metadata attribute name without spelling it in product tests.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn island_attribute_name() -> &'static str {
|
||||
"data-hemx-island"
|
||||
}
|
||||
|
||||
/// Return the runtime island event name for an authoring island name.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn island_event_name(name: &str) -> String {
|
||||
format!("hemx:island-{name}")
|
||||
}
|
||||
|
||||
/// Return an SSE enhancement marker without spelling framework metadata in tests.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn sse_endpoint_marker(path: &str) -> String {
|
||||
format!("data-hemx-sse=\"{path}\"")
|
||||
}
|
||||
|
||||
/// Return the island snapshot marker without spelling island metadata in tests.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn island_snapshot_marker() -> &'static str {
|
||||
"data-island-snapshot="
|
||||
}
|
||||
|
||||
/// Build a selector for island readouts without spelling island metadata in tests.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn island_readout_selector() -> &'static str {
|
||||
"[data-island-readout]"
|
||||
}
|
||||
|
||||
/// Build browser-driver JavaScript for injecting a synthetic island probe.
|
||||
///
|
||||
/// This lets product tests exercise the island bridge without spelling hemx island
|
||||
/// metadata attributes in the test body. req: test/001 req: dx/006
|
||||
pub fn island_probe_script(
|
||||
element_id: &str,
|
||||
island_name: &str,
|
||||
snapshot: &str,
|
||||
event_detail: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
r#"
|
||||
const root = arguments[0];
|
||||
const islandName = {island_name};
|
||||
const island = document.createElement('article');
|
||||
island.id = {element_id};
|
||||
island.setAttribute('data-hemx-island', islandName);
|
||||
island.setAttribute('data-island-snapshot', {snapshot});
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 32;
|
||||
canvas.height = 16;
|
||||
island.appendChild(canvas);
|
||||
|
||||
const readout = document.createElement('p');
|
||||
readout.setAttribute('data-island-readout', '');
|
||||
readout.textContent = 'waiting';
|
||||
island.appendChild(readout);
|
||||
|
||||
root.appendChild(island);
|
||||
setTimeout(() => {{
|
||||
root.dispatchEvent(new CustomEvent('hemx:island-' + islandName, {{ bubbles: true, detail: {event_detail} }}));
|
||||
}}, 25);
|
||||
return true;
|
||||
"#,
|
||||
element_id = js_string(element_id),
|
||||
island_name = js_string(island_name),
|
||||
snapshot = js_string(snapshot),
|
||||
event_detail = js_string(event_detail),
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a scoped selector for island readouts without spelling island metadata in tests.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn scoped_island_readout_selector(scope_selector: &str) -> String {
|
||||
format!("{scope_selector} {}", island_readout_selector())
|
||||
}
|
||||
|
||||
fn assert_simple_selector_part(value: &str, label: &str) {
|
||||
assert!(
|
||||
value
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-'),
|
||||
"{label} selector part must contain only ascii alphanumerics or '-'"
|
||||
);
|
||||
}
|
||||
|
||||
fn attr_selector(name: &str, value: &str) -> String {
|
||||
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
format!(r#"[{name}="{escaped}"]"#)
|
||||
}
|
||||
|
||||
fn js_string(value: &str) -> String {
|
||||
let mut escaped = String::from("\"");
|
||||
for ch in value.chars() {
|
||||
match ch {
|
||||
'\\' => escaped.push_str("\\\\"),
|
||||
'"' => escaped.push_str("\\\""),
|
||||
'\n' => escaped.push_str("\\n"),
|
||||
'\r' => escaped.push_str("\\r"),
|
||||
'\t' => escaped.push_str("\\t"),
|
||||
ch => escaped.push(ch),
|
||||
}
|
||||
}
|
||||
escaped.push('"');
|
||||
escaped
|
||||
}
|
||||
|
||||
fn form_pair(name: &str, value: &str) -> String {
|
||||
format!("{}={}", form_encode(name), form_encode(value))
|
||||
}
|
||||
|
||||
fn form_encode(value: &str) -> String {
|
||||
let mut encoded = String::new();
|
||||
for byte in value.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
encoded.push(byte as char)
|
||||
}
|
||||
b' ' => encoded.push('+'),
|
||||
_ => encoded.push_str(&format!("%{byte:02X}")),
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EffectInspector {
|
||||
batch: EffectBatch,
|
||||
}
|
||||
|
||||
impl EffectInspector {
|
||||
pub fn batch(&self) -> &EffectBatch {
|
||||
&self.batch
|
||||
}
|
||||
|
||||
pub fn ops(&self) -> &[Effect] {
|
||||
&self.batch.ops
|
||||
}
|
||||
|
||||
pub fn contains(&self, op: &Effect) -> bool {
|
||||
self.batch.ops.contains(op)
|
||||
}
|
||||
|
||||
pub fn op_count(&self) -> usize {
|
||||
self.batch.ops.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.batch.ops.is_empty()
|
||||
}
|
||||
|
||||
pub fn has_resource(&self, resource: ResourceId) -> bool {
|
||||
self.batch
|
||||
.ops
|
||||
.iter()
|
||||
.any(|op| op_targets_resource(op, resource))
|
||||
}
|
||||
|
||||
/// Assert against the same generated target object application handlers use.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn has_target(&self, target: impl GeneratedTarget) -> bool {
|
||||
self.has_resource(target.__hemx_resource_id())
|
||||
}
|
||||
|
||||
/// Assert that a generated target receives a text update, without matching raw effects.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn updates_text(&self, target: impl GeneratedTarget) -> bool {
|
||||
let resource = target.__hemx_resource_id();
|
||||
self.batch.ops.iter().any(|op| {
|
||||
matches!(
|
||||
op,
|
||||
Effect::Put {
|
||||
target,
|
||||
payload: Payload::Text(_),
|
||||
} if target.resource == resource
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Assert that a generated target receives an HTML update, without matching raw effects.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn updates_html(&self, target: impl GeneratedTarget) -> bool {
|
||||
let resource = target.__hemx_resource_id();
|
||||
self.batch.ops.iter().any(|op| {
|
||||
matches!(
|
||||
op,
|
||||
Effect::Put {
|
||||
target,
|
||||
payload: Payload::Html(_),
|
||||
} if target.resource == resource
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Assert that a generated target receives an HTML update containing text.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn updates_html_containing(&self, target: impl GeneratedTarget, needle: &str) -> bool {
|
||||
let resource = target.__hemx_resource_id();
|
||||
self.batch.ops.iter().any(|op| {
|
||||
matches!(
|
||||
op,
|
||||
Effect::Put {
|
||||
target,
|
||||
payload: Payload::Html(html),
|
||||
} if target.resource == resource && html.contains(needle)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Assert that a keyed generated target is replaced with HTML containing text.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn replaces_keyed_html_containing(
|
||||
&self,
|
||||
target: impl GeneratedTarget,
|
||||
key: impl ToString,
|
||||
needle: &str,
|
||||
) -> bool {
|
||||
let resource = target.__hemx_resource_id();
|
||||
let scope = Some(ScopeKey::KeyValue(key.to_string()));
|
||||
self.batch.ops.iter().any(|op| {
|
||||
matches!(
|
||||
op,
|
||||
Effect::Put {
|
||||
target,
|
||||
payload: Payload::Html(html),
|
||||
} if target.resource == resource && target.scope == scope && html.contains(needle)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Assert that a keyed generated target appends HTML containing text.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn inserts_html_containing(
|
||||
&self,
|
||||
target: impl GeneratedTarget,
|
||||
key: impl ToString,
|
||||
needle: &str,
|
||||
) -> bool {
|
||||
let resource = target.__hemx_resource_id();
|
||||
let key = key.to_string();
|
||||
self.batch.ops.iter().any(|op| {
|
||||
matches!(
|
||||
op,
|
||||
Effect::Insert {
|
||||
target,
|
||||
key: actual_key,
|
||||
payload: Payload::Html(html),
|
||||
} if target.resource == resource && actual_key == &key && html.contains(needle)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Assert that a keyed generated target removes a key.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn removes_key(&self, target: impl GeneratedTarget, key: impl ToString) -> bool {
|
||||
let resource = target.__hemx_resource_id();
|
||||
let key = key.to_string();
|
||||
self.batch.ops.iter().any(|op| {
|
||||
matches!(
|
||||
op,
|
||||
Effect::Remove {
|
||||
target,
|
||||
key: Some(actual_key),
|
||||
} if target.resource == resource && actual_key == &key
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Assert that the batch requests a push navigation to a URL.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn pushes_to(&self, url: &str) -> bool {
|
||||
self.batch.ops.iter().any(|op| {
|
||||
matches!(
|
||||
op,
|
||||
Effect::Navigate {
|
||||
url: actual_url,
|
||||
mode: NavigateMode::Push,
|
||||
..
|
||||
} if actual_url == url
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Assert that any payload or URL contains text, without matching raw effects.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn payload_contains(&self, needle: &str) -> bool {
|
||||
self.batch
|
||||
.ops
|
||||
.iter()
|
||||
.any(|op| effect_payload_contains(op, needle))
|
||||
}
|
||||
|
||||
/// Assert that no payload or URL contains text, without matching raw effects.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn payload_excludes(&self, needle: &str) -> bool {
|
||||
self.batch
|
||||
.ops
|
||||
.iter()
|
||||
.all(|op| !effect_payload_contains(op, needle))
|
||||
}
|
||||
|
||||
/// Assert that generated keyed-row metadata for a key is absent from payloads.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn payload_excludes_key(&self, key: impl ToString) -> bool {
|
||||
self.payload_excludes(&format!("data-key=\"{}\"", key.to_string()))
|
||||
}
|
||||
|
||||
/// Return HTML for a generated target containing text, without exposing raw payloads.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn target_html_containing(
|
||||
&self,
|
||||
target: impl GeneratedTarget,
|
||||
needle: &str,
|
||||
) -> Option<&str> {
|
||||
let resource = target.__hemx_resource_id();
|
||||
self.batch.ops.iter().find_map(|op| match op {
|
||||
Effect::Put {
|
||||
target,
|
||||
payload: Payload::Html(html),
|
||||
} if target.resource == resource && html.contains(needle) => Some(html.as_str()),
|
||||
Effect::Insert {
|
||||
target,
|
||||
payload: Payload::Html(html),
|
||||
..
|
||||
} if target.resource == resource && html.contains(needle) => Some(html.as_str()),
|
||||
Effect::Prepend {
|
||||
target,
|
||||
payload: Payload::Html(html),
|
||||
..
|
||||
} if target.resource == resource && html.contains(needle) => Some(html.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Assert that a named generated event is emitted with the exact payload.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn emits(&self, name: &str, payload: &str) -> bool {
|
||||
self.batch.ops.iter().any(|op| {
|
||||
matches!(
|
||||
op,
|
||||
Effect::Emit {
|
||||
name: actual_name,
|
||||
payload: actual_payload,
|
||||
} if actual_name == name && actual_payload == payload
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Assert that a named generated event payload contains text.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn emits_containing(&self, name: &str, needle: &str) -> bool {
|
||||
self.batch.ops.iter().any(|op| {
|
||||
matches!(
|
||||
op,
|
||||
Effect::Emit {
|
||||
name: actual_name,
|
||||
payload,
|
||||
} if actual_name == name && payload.contains(needle)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn has_ref(&self, target: &ResourceRef) -> bool {
|
||||
self.batch.ops.iter().any(|op| op_targets_ref(op, target))
|
||||
}
|
||||
|
||||
pub fn has_slot<T>(&self, slot: Slot<T>) -> bool {
|
||||
self.has_resource(slot.id())
|
||||
}
|
||||
|
||||
pub fn has_keyed_slot<K, T>(&self, slot: KeyedSlot<K, T>) -> bool
|
||||
where
|
||||
K: ToString,
|
||||
{
|
||||
self.has_resource(slot.id())
|
||||
}
|
||||
|
||||
pub fn has_atom<T>(&self, atom: Atom<T>) -> bool {
|
||||
self.has_resource(atom.id())
|
||||
}
|
||||
|
||||
pub fn has_form<T>(&self, form: Form<T>) -> bool {
|
||||
self.has_resource(form.id())
|
||||
}
|
||||
|
||||
/// Assert that a generated form is reset/cleared without matching raw events in tests.
|
||||
/// req: test/001 req: dx/006
|
||||
pub fn resets_form<T>(&self, form: Form<T>) -> bool {
|
||||
let form_id = form.id().id.to_string();
|
||||
self.batch.ops.iter().any(|op| {
|
||||
matches!(
|
||||
op,
|
||||
Effect::Emit { name, payload }
|
||||
if name == "hemx:form-reset" && payload == &form_id
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn effect_payload_contains(op: &Effect, needle: &str) -> bool {
|
||||
match op {
|
||||
Effect::Put { payload, .. }
|
||||
| Effect::Insert { payload, .. }
|
||||
| Effect::Prepend { payload, .. } => payload_value(payload).contains(needle),
|
||||
Effect::Emit { payload, .. } => payload.contains(needle),
|
||||
Effect::Navigate { url, .. } => url.contains(needle),
|
||||
Effect::Remove { .. } | Effect::Move { .. } | Effect::Focus { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn payload_value(payload: &Payload) -> &str {
|
||||
match payload {
|
||||
Payload::Text(value) | Payload::Html(value) => value,
|
||||
}
|
||||
}
|
||||
|
||||
fn op_targets_resource(op: &Effect, resource: ResourceId) -> bool {
|
||||
match op {
|
||||
Effect::Put { target, .. }
|
||||
| Effect::Insert { target, .. }
|
||||
| Effect::Prepend { target, .. }
|
||||
| Effect::Remove { target, .. }
|
||||
| Effect::Move { target, .. }
|
||||
| Effect::Focus { target } => target.resource == resource,
|
||||
Effect::Navigate { .. } | Effect::Emit { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn op_targets_ref(op: &Effect, wanted: &ResourceRef) -> bool {
|
||||
match op {
|
||||
Effect::Put { target, .. }
|
||||
| Effect::Insert { target, .. }
|
||||
| Effect::Prepend { target, .. }
|
||||
| Effect::Remove { target, .. }
|
||||
| Effect::Move { target, .. }
|
||||
| Effect::Focus { target } => target == wanted,
|
||||
Effect::Navigate { .. } | Effect::Emit { .. } => false,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[test]
|
||||
fn canonical_examples_do_not_author_browser_javascript() {
|
||||
// req: examples/005
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||
let examples = root.join("examples");
|
||||
let mut failures = Vec::new();
|
||||
scan_examples(&examples, &mut |path, text| {
|
||||
if path.components().any(|part| part.as_os_str() == "tests") {
|
||||
return;
|
||||
}
|
||||
for (line_no, line) in text.lines().enumerate() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.contains("<script") && !allowed_example_script(path, trimmed) {
|
||||
failures.push(format!(
|
||||
"{}:{}: only the hemx runtime or explicit opaque-island scripts are allowed",
|
||||
path.display(),
|
||||
line_no + 1
|
||||
));
|
||||
}
|
||||
if contains_inline_event_handler(trimmed) {
|
||||
failures.push(format!(
|
||||
"{}:{}: inline on*= handler is not allowed",
|
||||
path.display(),
|
||||
line_no + 1
|
||||
));
|
||||
}
|
||||
if trimmed.to_ascii_lowercase().contains("javascript:") {
|
||||
failures.push(format!(
|
||||
"{}:{}: javascript: URL is not allowed",
|
||||
path.display(),
|
||||
line_no + 1
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_examples_do_not_author_low_level_resource_plumbing() {
|
||||
// req: dx/002 req: ceremony/002 req: ceremony/004 req: examples/003
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||
let examples = root.join("examples");
|
||||
let forbidden = [
|
||||
"ResourceId::new",
|
||||
".id().id",
|
||||
"include!(concat!",
|
||||
"OUT_DIR",
|
||||
"global_exports(",
|
||||
"EffectWriter",
|
||||
"RuntimeOpcode",
|
||||
"data-hid",
|
||||
"data-sid",
|
||||
"data-fid",
|
||||
"data-aid",
|
||||
"data-handle-id",
|
||||
"data-slot-id",
|
||||
"data-form-id",
|
||||
"data-atom-id",
|
||||
"::lower(include_str!",
|
||||
"lower_html(",
|
||||
"render_html(",
|
||||
"HandlerRegistry",
|
||||
"InteractionForm",
|
||||
"register_handle(",
|
||||
"SafeHtml",
|
||||
"hemx::advanced",
|
||||
"advanced::slots",
|
||||
"KeyedSlot",
|
||||
"Slot<",
|
||||
"Effect::",
|
||||
"Payload::",
|
||||
"NavigateMode",
|
||||
"ScopeKey",
|
||||
"opcode",
|
||||
"wire format",
|
||||
"manual generated",
|
||||
"RenderSlotExt",
|
||||
"RenderKeyedSlotExt",
|
||||
".render(&",
|
||||
".html(ui::render",
|
||||
".html(hemx::render",
|
||||
".html(super::ui::render",
|
||||
"ui::put(",
|
||||
"ui::append(",
|
||||
"ui::replace(",
|
||||
"application/hemx",
|
||||
"EffectBatch",
|
||||
"__h=",
|
||||
"name=\"__h\"",
|
||||
"name='__h'",
|
||||
];
|
||||
let mut failures = Vec::new();
|
||||
scan_examples(&examples, &mut |path, text| {
|
||||
if path.components().any(|part| part.as_os_str() == "tests") {
|
||||
return;
|
||||
}
|
||||
let Some(ext) = path.extension().and_then(|ext| ext.to_str()) else {
|
||||
return;
|
||||
};
|
||||
if !matches!(ext, "rs" | "heml" | "html") {
|
||||
return;
|
||||
}
|
||||
let runtime_source = if ext == "rs" {
|
||||
text.split("#[cfg(test)]").next().unwrap_or(text)
|
||||
} else {
|
||||
text
|
||||
};
|
||||
for (line_no, line) in runtime_source.lines().enumerate() {
|
||||
if let Some(token) = forbidden.iter().find(|token| line.contains(*token)) {
|
||||
failures.push(format!(
|
||||
"{}:{}: example runtime code must use generated resources, not `{token}`",
|
||||
path.display(),
|
||||
line_no + 1
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_prelude_does_not_export_low_level_primitives() {
|
||||
// req: public_api/005 req: dx/006
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||
let facade = std::fs::read_to_string(root.join("hemx/src/lib.rs")).unwrap();
|
||||
let prelude = facade
|
||||
.split("pub mod prelude {")
|
||||
.nth(1)
|
||||
.and_then(|tail| tail.split("\n}").next())
|
||||
.expect("hemx facade exposes prelude module");
|
||||
let exported = prelude
|
||||
.split(|c: char| !(c == '_' || c.is_ascii_alphanumeric()))
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
for token in [
|
||||
"SafeHtml",
|
||||
"Effect",
|
||||
"Slot",
|
||||
"KeyedSlot",
|
||||
"ResourceId",
|
||||
"EffectBatch",
|
||||
"BuildFingerprint",
|
||||
] {
|
||||
assert!(
|
||||
!exported.contains(&token),
|
||||
"hemx::prelude must not export low-level `{token}`"
|
||||
);
|
||||
}
|
||||
assert!(prelude.contains("Html"), "hemx::prelude should export Html");
|
||||
assert!(
|
||||
prelude.contains("IntoEffect"),
|
||||
"hemx::prelude should export IntoEffect"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_example_docs_do_not_teach_low_level_plumbing() {
|
||||
// req: dx/002 req: dx/003 req: examples/003
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||
let examples = root.join("examples");
|
||||
let forbidden = [
|
||||
"generated handles and slots",
|
||||
"generated hemx handles",
|
||||
"generated handle ids",
|
||||
"numeric handle ids",
|
||||
"runtime lowering",
|
||||
"application/hemx responses",
|
||||
"EffectBatch",
|
||||
"manual registry",
|
||||
"HandlerRegistry",
|
||||
"InteractionForm",
|
||||
"register_handle(",
|
||||
"lower_html(",
|
||||
"render_html(",
|
||||
"SafeHtml",
|
||||
"KeyedSlot",
|
||||
"Slot<",
|
||||
"Effect::",
|
||||
"opcode",
|
||||
"wire format",
|
||||
"manual generated",
|
||||
"Effect::batch",
|
||||
"Effect::class",
|
||||
"Effect::move",
|
||||
"Effect::set",
|
||||
"Effect::broadcast",
|
||||
"Effect::ack",
|
||||
"SyncEffect::",
|
||||
"postcard DOM ops",
|
||||
"data-hid",
|
||||
"data-sid",
|
||||
".render(&",
|
||||
"addEventListener(",
|
||||
"querySelector",
|
||||
"querySelectorAll",
|
||||
];
|
||||
let mut failures = Vec::new();
|
||||
scan_examples(&examples, &mut |path, text| {
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("md")
|
||||
|| is_advanced_boundary_doc(text)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (line_no, line) in text.lines().enumerate() {
|
||||
if let Some(token) = forbidden.iter().find(|token| line.contains(*token)) {
|
||||
failures.push(format!(
|
||||
"{}:{}: example docs must teach generated ergonomic APIs, not `{token}`",
|
||||
path.display(),
|
||||
line_no + 1
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn example_docs_do_not_show_runtime_metadata_as_authoring_contract() {
|
||||
// req: dx/006 req: misc/006
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||
let examples = root.join("examples");
|
||||
let forbidden = [
|
||||
"data-hid",
|
||||
"data-sid",
|
||||
"data-aid",
|
||||
"data-fid",
|
||||
"[data-hid",
|
||||
"[data-sid",
|
||||
];
|
||||
let mut failures = Vec::new();
|
||||
scan_examples(&examples, &mut |path, text| {
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
|
||||
return;
|
||||
}
|
||||
for (line_no, line) in text.lines().enumerate() {
|
||||
if let Some(token) = forbidden.iter().find(|token| line.contains(*token)) {
|
||||
failures.push(format!(
|
||||
"{}:{}: example docs must describe generated authoring APIs, not runtime metadata `{token}`",
|
||||
path.display(),
|
||||
line_no + 1
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_e2e_does_not_shortcut_product_interactions() {
|
||||
// req: examples/005
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||
let browser_e2e = root.join("examples/techdemo/tests/browser_e2e.rs");
|
||||
let source = std::fs::read_to_string(browser_e2e).unwrap();
|
||||
|
||||
assert!(
|
||||
!source.contains("fetch(\"/\""),
|
||||
"browser E2E must drive UI, not post directly"
|
||||
);
|
||||
assert!(
|
||||
!source.contains("applyBatch(buffer"),
|
||||
"browser E2E must not apply wire batches manually"
|
||||
);
|
||||
}
|
||||
|
||||
fn scan_examples(dir: &Path, visit: &mut impl FnMut(&Path, &str)) {
|
||||
for entry in std::fs::read_dir(dir).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
scan_examples(&path, visit);
|
||||
continue;
|
||||
}
|
||||
if !is_example_source(&path) {
|
||||
continue;
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
visit(&path, &text);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_advanced_boundary_doc(text: &str) -> bool {
|
||||
text.contains("advanced/low-level north-star boundary sketch")
|
||||
}
|
||||
|
||||
fn is_example_source(path: &PathBuf) -> bool {
|
||||
matches!(
|
||||
path.extension().and_then(|ext| ext.to_str()),
|
||||
Some("rs" | "heml" | "html" | "md")
|
||||
)
|
||||
}
|
||||
|
||||
fn allowed_example_script(path: &Path, line: &str) -> bool {
|
||||
// req: examples/005
|
||||
line.contains(r#"<script src="/hemx.js" defer></script>"#)
|
||||
|| (path.ends_with("examples/techdemo/templates/app_shell.heml")
|
||||
&& line.contains(r#"<script src="/island.js" defer></script>"#))
|
||||
}
|
||||
|
||||
fn contains_inline_event_handler(line: &str) -> bool {
|
||||
let bytes = line.as_bytes();
|
||||
let mut i = 0;
|
||||
while i + 3 < bytes.len() {
|
||||
let boundary = bytes[i].is_ascii_whitespace() || bytes[i] == b'<';
|
||||
if boundary
|
||||
&& bytes[i + 1] == b'o'
|
||||
&& bytes[i + 2] == b'n'
|
||||
&& bytes[i + 3].is_ascii_lowercase()
|
||||
{
|
||||
let mut j = i + 4;
|
||||
while j < bytes.len() && bytes[j].is_ascii_lowercase() {
|
||||
j += 1;
|
||||
}
|
||||
while j < bytes.len() && bytes[j].is_ascii_whitespace() {
|
||||
j += 1;
|
||||
}
|
||||
if j < bytes.len() && bytes[j] == b'=' {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
use hemx_core::{
|
||||
Atom, Effect, GeneratedTarget, KeyedSlot, Payload, ResourceId, ResourceKind, ResourceRef, Slot,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn inspects_tuple_effects() {
|
||||
let count = Slot::<u32>::new(1);
|
||||
let user = Atom::<String>::new(2);
|
||||
|
||||
let inspected = hemx_test::run(|value| (count.text(value), user.set("alice")), 42);
|
||||
|
||||
assert!(inspected.has_slot(count));
|
||||
assert!(inspected.has_atom(user));
|
||||
assert!(inspected.contains(&Effect::Put {
|
||||
target: ResourceRef::unscoped(count.id()),
|
||||
payload: Payload::text(42),
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_keyed_slot_targets() {
|
||||
let rows = KeyedSlot::<u32, String>::new(9);
|
||||
let inspected = hemx_test::inspect(rows.append_text(7, String::from("row")));
|
||||
|
||||
assert!(inspected.has_keyed_slot(rows));
|
||||
assert_eq!(inspected.ops().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_generated_handle_form_bodies() {
|
||||
let handle = hemx_core::Handle::<()>::new(7);
|
||||
|
||||
let body = hemx_test::handle_form_body(handle, &[("title", "hello world"), ("tag", "a&b")]);
|
||||
|
||||
assert_eq!(body, "__h=7&title=hello+world&tag=a%26b");
|
||||
assert_eq!(hemx_test::unknown_handle_form_body(99), "__h=99");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_authoring_boundary_selectors() {
|
||||
assert_eq!(
|
||||
hemx_test::root_selector("techdemo"),
|
||||
r#"[data-hemx-root="techdemo"]"#
|
||||
);
|
||||
assert_eq!(
|
||||
hemx_test::island_selector("orbit"),
|
||||
r#"[data-hemx-island="orbit"]"#
|
||||
);
|
||||
assert_eq!(hemx_test::island_attribute_name(), "data-hemx-island");
|
||||
assert_eq!(hemx_test::island_event_name("orbit"), "hemx:island-orbit");
|
||||
assert_eq!(
|
||||
hemx_test::sse_endpoint_marker("/events"),
|
||||
r#"data-hemx-sse="/events""#
|
||||
);
|
||||
assert_eq!(hemx_test::any_root_selector(), "[data-hemx-root]");
|
||||
assert_eq!(
|
||||
hemx_test::root_element_selector("main", "docs"),
|
||||
r#"main[data-hemx-root="docs"]"#
|
||||
);
|
||||
assert_eq!(hemx_test::document_body_selector(), "body");
|
||||
assert_eq!(hemx_test::document_title_selector(), "title");
|
||||
assert_eq!(
|
||||
hemx_test::runtime_script_selector(),
|
||||
r#"script[src="/hemx.js"]"#
|
||||
);
|
||||
assert_eq!(
|
||||
hemx_test::target_selector(TestTarget(ResourceKind::Slot, 42)),
|
||||
r#"[data-sid="42"]"#
|
||||
);
|
||||
assert_eq!(
|
||||
hemx_test::handle_button_selector(hemx_core::Handle::<()>::new(7)),
|
||||
r#"button[data-hid="7"]"#
|
||||
);
|
||||
assert_eq!(hemx_test::article_selector(), "article");
|
||||
assert_eq!(hemx_test::strong_text_selector(), "strong");
|
||||
assert_eq!(hemx_test::small_text_selector(), "small");
|
||||
assert_eq!(hemx_test::escaped_markup_selector("b"), "b");
|
||||
assert_eq!(hemx_test::heading_selector("article", 1), "article h1");
|
||||
assert_eq!(hemx_test::list_item_selector("ul"), "ul li");
|
||||
assert_eq!(hemx_test::prose_selector("article"), "article p");
|
||||
assert_eq!(hemx_test::form_selector("header"), "header form");
|
||||
assert_eq!(
|
||||
hemx_test::select_options_selector("column"),
|
||||
r#"select[name="column"] > option"#
|
||||
);
|
||||
assert_eq!(hemx_test::class_selector("lane"), ".lane");
|
||||
assert_eq!(
|
||||
hemx_test::element_class_selector("span", "presence"),
|
||||
"span.presence"
|
||||
);
|
||||
assert_eq!(
|
||||
hemx_test::class_child_selector("columns", "section", "column"),
|
||||
".columns > section.column"
|
||||
);
|
||||
assert_eq!(
|
||||
hemx_test::class_descendant_selector("impact", "i"),
|
||||
".impact i"
|
||||
);
|
||||
assert_eq!(hemx_test::disabled_button_selector(), "button[disabled]");
|
||||
assert_eq!(
|
||||
hemx_test::nav_link_selector("/architecture"),
|
||||
r#"a[href="/architecture"]"#
|
||||
);
|
||||
assert_eq!(
|
||||
hemx_test::page_nav_link_selector("/docs"),
|
||||
r#"a[href="/docs"][data-hemx-nav]:not([data-hemx-handle])"#
|
||||
);
|
||||
assert_eq!(hemx_test::island_snapshot_marker(), "data-island-snapshot=");
|
||||
assert_eq!(
|
||||
hemx_test::island_readout_selector(),
|
||||
"[data-island-readout]"
|
||||
);
|
||||
assert_eq!(
|
||||
hemx_test::scoped_island_readout_selector("#probe-island"),
|
||||
"#probe-island [data-island-readout]"
|
||||
);
|
||||
assert_eq!(
|
||||
hemx_test::keyed_selector(".work-card", 4),
|
||||
r#".work-card[data-key="4"]"#
|
||||
);
|
||||
assert_eq!(hemx_test::keyed_items_selector("li"), "li[data-key]");
|
||||
|
||||
let probe = hemx_test::island_probe_script(
|
||||
"probe-island",
|
||||
"orbit",
|
||||
"1|1|1|probe waiting",
|
||||
"7|2|8|probe live",
|
||||
);
|
||||
assert!(probe.contains("probe-island"));
|
||||
assert!(probe.contains("probe live"));
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct TestTarget(ResourceKind, u32);
|
||||
|
||||
impl GeneratedTarget for TestTarget {
|
||||
fn __hemx_resource_id(self) -> ResourceId {
|
||||
ResourceId::new(self.0, self.1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user