Files
endorphin-tessera-gui/src/runtime/endor_host/tests.rs
T
2026-10-25 21:25:43 +08:00

5653 lines
175 KiB
Rust

use super::*;
use crate::document::{NamespaceState, RenderCommand};
const EXAMPLE: &str = include_str!("../../../examples/hello.endor");
const PAGE: &str = include_str!("../../../examples/page.endor");
#[test]
fn actual_mount_version_reads_still_bypass_measurement_cache() {
let (mut session, initial) = ReplSession::load(
r#"
class Versioned: RectBlock {
function measure(self: Versioned,mount: Mount,constraints: Constraints): MeasureResult {
return MeasureResult(mount.layoutVersion,20)
}
}
rect=Versioned(80,20)
page=Page()
page.render(rect)
"#,
"app.main",
1,
241,
)
.unwrap();
let next = session
.execute("rect.invalidate(\"paint\")", "app.main")
.unwrap()
.document
.unwrap();
assert!(next.frame.layout_version > initial.frame.layout_version);
assert!(next.frame.commands.iter().any(|command|matches!(command,RenderCommand::RoundedRect{width,..} if *width==next.frame.layout_version as f32)));
assert_eq!(source_measure_stat(&mut session, "sourceMeasureHits"), 0);
assert!(source_measure_stat(&mut session, "sourceMeasureMisses") >= 2);
}
#[test]
fn source_allocations_and_measurement_limits_settle_before_publication() {
let (mut session, initial) = ReplSession::load_with_viewport(
include_str!("../../../examples/layout-negotiation.endor"),
"app.main",
1,
240,
Some(crate::layout::Size {
width: 850.0,
height: 700.0,
}),
)
.unwrap();
let ids = initial
.frame
.mounts
.iter()
.map(|m| m.id)
.collect::<Vec<_>>();
let width0 = session
.execute("paragraph.mounts().at(0).bounds().width", "app.main")
.unwrap()
.value;
let width1 = session
.execute("paragraph.mounts().at(1).bounds().width", "app.main")
.unwrap()
.value;
assert_eq!(width0, "280");
assert_eq!(width1, "360");
let height = session
.execute("paragraph.mounts().at(0).bounds().height", "app.main")
.unwrap()
.value
.parse::<f32>()
.unwrap();
let y = session
.execute("tail.mounts().at(0).bounds().y", "app.main")
.unwrap()
.value
.parse::<f32>()
.unwrap();
assert!(height > 20.0);
assert!(y >= height + 12.0);
assert!(source_measure_stat(&mut session, "layoutPasses") >= 2);
let changed = session
.execute("wide.click()", "app.main")
.unwrap()
.document
.unwrap();
assert_eq!(
changed
.frame
.mounts
.iter()
.map(|m| m.id)
.collect::<Vec<_>>(),
ids
);
let wider = session
.execute("paragraph.mounts().at(0).bounds().height", "app.main")
.unwrap()
.value
.parse::<f32>()
.unwrap();
let moved = session
.execute("tail.mounts().at(0).bounds().y", "app.main")
.unwrap()
.value
.parse::<f32>()
.unwrap();
assert!(wider < height && moved < y);
assert_eq!(
session
.execute("paragraph.mounts().at(0).bounds().width", "app.main")
.unwrap()
.value,
"560"
);
assert_eq!(
session
.execute("paragraph.mounts().at(1).bounds().width", "app.main")
.unwrap()
.value,
"360"
);
}
#[test]
fn alternating_source_allocations_are_rejected_as_a_cycle() {
let source = r#"
class Alternating: Column {
function arrange(self: Alternating,mount: Mount,geometry: Bounds): Arrangement {
result=Column.arrange(self,mount,geometry)
result.children.at(0).width=geometry.height
result.children.at(0).height=geometry.width
return result
}
}
page=Page()
page.render(Alternating(0,0,RectBlock(120,60)))
"#;
let error = ReplSession::load(source, "app.main", 1, 1).err().unwrap();
assert!(
error.to_string().contains("did not converge (cycle)"),
"{error:#}"
);
}
#[test]
fn source_text_paint_rejects_incomplete_wrap_bounds() {
let source = r##"
class Invalid: TextBlock {
function paint(self: Invalid,mount: Mount,geometry: Bounds): DisplayList {
command=PaintCommand()
command.kind="text"
command.x=0
command.y=0
command.text="invalid"
command.size=1
command.color="#000000"
command.wrapWidth=40
return DisplayList(ObjectList(command))
}
}
page=Page()
page.render(Invalid("invalid"))
"##;
let error = ReplSession::load(source, "app.main", 1, 1).err().unwrap();
assert!(error.to_string().contains("wrapHeight"), "{error:#}");
}
#[test]
fn wrapped_text_tracks_each_occurrence_width_resize_and_sibling_geometry() {
let text = "中文段落随着宽度变化而换行,后面的对象跟随段落高度移动。".repeat(8);
for custom in [false, true] {
let constructor = if custom { "Wrapped" } else { "TextBlock" };
let source = format!(
r#"
class Wrapped: TextBlock {{
function measure(self: Wrapped,mount: Mount,constraints: Constraints): MeasureResult {{return TextBlock.measure(self,mount,constraints)}}
function paint(self: Wrapped,mount: Mount,geometry: Bounds): DisplayList {{return TextBlock.paint(self,mount,geometry)}}
}}
paragraph={constructor}("{text}")
tail=TextBlock("tail")
page=Page()
page.render(Column(0,8,Column(0,8,paragraph,tail),Column(50,8,paragraph)))
"#
);
let (mut session, initial) = ReplSession::load_with_viewport(
&source,
"app.main",
1,
230,
Some(crate::layout::Size {
width: 240.0,
height: 2000.0,
}),
)
.unwrap();
let ids: Vec<_> = initial.frame.mounts.iter().map(|m| m.id).collect();
let commands: Vec<_> = initial
.frame
.commands
.iter()
.filter_map(|command| match command {
RenderCommand::Text {
value,
wrap_bounds: Some(bounds),
..
} if value == &text => Some(*bounds),
_ => None,
})
.collect();
assert_eq!(commands.len(), 2);
assert_eq!(commands[0][0], 240.0);
assert_eq!(commands[1][0], 140.0);
assert!(commands[1][1] > commands[0][1]);
let tail = initial
.frame
.commands
.iter()
.find_map(|command| match command {
RenderCommand::Text { value, y, .. } if value == "tail" => Some(*y),
_ => None,
})
.unwrap();
assert!(tail >= commands[0][1] + 8.0);
let resized = session
.resize(Some(crate::layout::Size {
width: 400.0,
height: 2000.0,
}))
.unwrap()
.unwrap();
assert_eq!(
ids,
resized
.frame
.mounts
.iter()
.map(|m| m.id)
.collect::<Vec<_>>()
);
let heights: Vec<_> = resized
.frame
.commands
.iter()
.filter_map(|command| match command {
RenderCommand::Text {
value,
wrap_bounds: Some(bounds),
..
} if value == &text => Some(bounds[1]),
_ => None,
})
.collect();
assert!(heights[0] < commands[0][1]);
let direct = session
.execute(
"paragraph.measure(paragraph.mounts().at(0),Constraints(0,0,80,2000)).height",
"app.main",
)
.unwrap()
.value
.parse::<f32>()
.unwrap();
assert!(direct > heights[0]);
session
.execute("paragraph.setText(\"short\")", "app.main")
.unwrap();
assert_eq!(
session
.execute("paragraph.mounts().count", "app.main")
.unwrap()
.value,
"2"
);
assert_eq!(
session
.execute("paragraph.mounts().at(0).bounds().height", "app.main")
.unwrap()
.value,
"20"
);
}
}
#[test]
fn native_signatures_validate_registration_and_deliver_the_event_argument() {
let (mut session, _) = ReplSession::load(
include_str!("../../../examples/native-signatures.endor"),
"app.main",
1,
220,
)
.unwrap();
for invalid in [
r#"withEvent.on("click", ui.textOf)"#,
r#"withEvent.on("click", Page)"#,
r#"label.bind("text", received.set)"#,
r#"ui.after(ui,"bad",60000,received.set)"#,
"count.increment(1)",
] {
assert!(session.execute(invalid, "app.main").is_err(), "{invalid}");
assert!(
session.documents[&crate::window::MAIN_WINDOW]
.frame
.fault
.is_none()
);
}
let document = session
.execute("withEvent.click()", "app.main")
.unwrap()
.document
.unwrap();
assert!(
document.frame.commands.iter().any(
|command| matches!(command, RenderCommand::Text { value, .. } if value == "click")
)
);
assert_eq!(
session
.execute("received.value.kind", "app.main")
.unwrap()
.value,
r#""click""#
);
session.execute("withoutEvent.click()", "app.main").unwrap();
assert_eq!(
session.execute("count.value", "app.main").unwrap().value,
"1"
);
assert!(session.poll_tasks().unwrap().is_none());
let globals = session.candidate.globals.unwrap();
let Value::Object(received) = session
.candidate
.vm
.dict_get(globals, &Value::Text("received".into()))
.unwrap()
.unwrap()
else {
panic!("state")
};
let Value::Object(set) = session.candidate.vm.get_attr(received, "set").unwrap() else {
panic!("set")
};
assert_eq!(
session.candidate.vm.call_arity(set).unwrap(),
Some(endor_core::CallArity::exact(1))
);
}
#[test]
fn checked_fields_reject_native_constructor_side_effects_and_preserve_live_values() {
let (mut session, _) = ReplSession::load(
include_str!("../../../examples/field-contracts.endor"),
"app.main",
1,
210,
)
.unwrap();
for invalid in [
r#"counter.value = "wrong""#,
r#"CounterText("bad", "wrong")"#,
"Model(42)",
] {
let error = session.execute(invalid, "app.main").err().unwrap();
assert!(error.to_string().contains("field"), "{error:#}");
assert!(
session.documents[&crate::window::MAIN_WINDOW]
.frame
.fault
.is_none()
);
}
assert_eq!(
session.execute("counter.value", "app.main").unwrap().value,
"9"
);
session
.execute("class CheckedPage: Page { count: Number }", "app.main")
.unwrap();
let pages = session
.execute("ui.window().pages().count", "app.main")
.unwrap()
.value;
assert!(
session
.execute(r#"CheckedPage("wrong")"#, "app.main")
.is_err()
);
assert_eq!(
session
.execute("ui.window().pages().count", "app.main")
.unwrap()
.value,
pages
);
let updated = session
.execute("button.click()", "app.main")
.unwrap()
.document
.unwrap();
assert!(
updated
.frame
.commands
.iter()
.any(|command| matches!(command, RenderCommand::Text { value, .. } if value == "10"))
);
assert_eq!(
session.execute("counter.value", "app.main").unwrap().value,
"10"
);
}
#[test]
fn checked_fields_follow_shared_block_forwarding_and_binding_writes() {
let (mut session, _) = ReplSession::load(
r#"
class Numeric: TextBlock { value: Number }
class Textual: TextBlock { value: String }
block = Numeric("old", 9)
alias = block
page = Page()
page.render(block)
"#,
"app.main",
1,
211,
)
.unwrap();
session
.execute(r#"block = Textual("new", "kept")"#, "app.main")
.unwrap();
assert!(session.execute("alias.value = 42", "app.main").is_err());
assert_eq!(
session.execute("alias.value", "app.main").unwrap().value,
r#""kept""#
);
session
.execute(r#"alias.value = "updated""#, "app.main")
.unwrap();
assert_eq!(
session.execute("block.value", "app.main").unwrap().value,
r#""updated""#
);
assert!(
session
.execute("alias.bind(\"value\", ui.viewport)", "app.main")
.is_err()
);
}
#[test]
fn positional_insert_preserves_existing_slots_and_mounts_and_rejects_invalid_graphs() {
let source = r#"head=TextBlock("head")
tail=TextBlock("tail")
list=Column(24,8,head,tail)
page=Page()
page.render(list)"#;
let (mut session, initial) = ReplSession::load(source, "app.main", 1, 200).unwrap();
session
.execute("held=tail.mounts().at(0)", "app.main")
.unwrap();
let id = session
.execute("held.id", "app.main")
.unwrap()
.value
.parse::<u64>()
.unwrap();
let old = initial
.frame
.mounts
.iter()
.find(|mount| mount.id == id)
.unwrap();
let inserted = session
.execute("slot=list.insert(1,TextBlock(\"middle\"))", "app.main")
.unwrap();
assert_eq!(inserted.value, "2");
let document = inserted.document.unwrap();
let new = document
.frame
.mounts
.iter()
.find(|mount| mount.id == id)
.unwrap();
assert!(new.y > old.y);
assert_eq!(
session
.execute("tail.mounts().at(0).id", "app.main")
.unwrap()
.value,
id.to_string()
);
for invalid in [
"list.insert(4,head)",
"list.insert(0,list)",
"list.insert(-1,head)",
] {
assert!(session.execute(invalid, "app.main").is_err());
assert!(!session.is_faulted());
}
assert_eq!(
session.execute("held.mounted", "app.main").unwrap().value,
"true"
);
session.execute("list.remove(slot)", "app.main").unwrap();
assert_eq!(
session
.execute("tail.mounts().at(0).id", "app.main")
.unwrap()
.value,
id.to_string()
);
}
#[test]
fn tab_drop_preserves_page_identity_calls_source_methods_and_rejects_stale_targets() {
let (mut session, initial) = ReplSession::load(
include_str!("../../../examples/tab-placement.endor"),
"app.main",
1,
201,
)
.unwrap();
session.take_window_commands().unwrap();
let page = initial.frame.tabs[0].id;
let input = crate::events::Input {
window: 1,
session: 201,
layout_version: initial.frame.layout_version,
mount: page,
kind: "tab-drop".into(),
point: None,
press_point: None,
delta: None,
screen_position: None,
tab_drop: Some(crate::events::TabDrop {
window: 1,
layout_version: initial.frame.layout_version,
index: 3,
}),
};
session
.execute("held=list.mounts().at(0)", "app.main")
.unwrap();
let mount = session.execute("held.id", "app.main").unwrap().value;
session.dispatch(&input).unwrap();
assert_eq!(
session.execute("calls.value", "app.main").unwrap().value,
"1"
);
assert_eq!(
session
.execute("list.mounts().at(0).id", "app.main")
.unwrap()
.value,
mount
);
assert_eq!(
session
.execute("ui.window().pages().at(2)", "app.main")
.unwrap()
.value,
session.execute("first", "app.main").unwrap().value
);
let input = crate::events::Input {
layout_version: session.documents[&1].frame.layout_version,
tab_drop: Some(crate::events::TabDrop {
window: 2,
layout_version: session.documents[&2].frame.layout_version,
index: 0,
}),
..input
};
let mut stale = input.clone();
stale.tab_drop.as_mut().unwrap().layout_version += 1;
assert!(!session.accepts_tab_drop(&stale));
assert!(session.dispatch(&stale).is_err());
assert!(!session.is_faulted());
session.dispatch(&input).unwrap();
assert_eq!(
session.execute("calls.value", "app.main").unwrap().value,
"3"
);
assert_eq!(
session
.execute("first.window().id()", "app.main")
.unwrap()
.value,
"2"
);
assert_eq!(
session
.execute("other.pages().at(0)", "app.main")
.unwrap()
.value,
session.execute("first", "app.main").unwrap().value
);
assert_eq!(
session.execute("held.mounted", "app.main").unwrap().value,
"false"
);
}
#[test]
fn drop_respects_declined_source_moves_and_detach_queues_signed_desktop_position() {
let source = r#"class Declining: Page {
calls: Object
function moveToWindow(self: Declining, window: Window): Page { self.calls.increment() return self }
}
calls=ui.state(0)
page=Declining(calls)
page.render(TextBlock("source"))
other=Window()
target=Page()
target.render(TextBlock("target"))
target.open(other)"#;
let (mut session, initial) = ReplSession::load(source, "app.main", 1, 202).unwrap();
session.take_window_commands().unwrap();
let mut input = crate::events::Input {
window: 1,
session: 202,
layout_version: initial.frame.layout_version,
mount: initial.frame.tabs[0].id,
kind: "tab-drop".into(),
point: None,
press_point: None,
delta: None,
screen_position: None,
tab_drop: Some(crate::events::TabDrop {
window: 2,
layout_version: initial.windows[&2].frame.layout_version,
index: 0,
}),
};
session.dispatch(&input).unwrap();
assert_eq!(
session.execute("calls.value", "app.main").unwrap().value,
"1"
);
assert_eq!(
session
.execute("page.window().id()", "app.main")
.unwrap()
.value,
"1"
);
assert_eq!(
session
.execute("other.pages().count", "app.main")
.unwrap()
.value,
"1"
);
input.kind = "tab-detach".into();
input.tab_drop = None;
input.screen_position = Some([-400, 120]);
input.layout_version = session.documents[&1].frame.layout_version;
session.dispatch(&input).unwrap();
let commands = session.take_window_commands().unwrap();
assert!(commands.iter().any(|command| command.window == 3
&& command.action == crate::window::Action::MoveTo { x: -400, y: 120 }));
assert_eq!(
session
.execute("page.window().id()", "app.main")
.unwrap()
.value,
"3"
);
}
fn with_file_task_input(contents: &str, run: impl FnOnce(&std::path::Path)) {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("tessera-text-task-{}-{id}.txt", std::process::id()));
struct Cleanup(std::path::PathBuf);
impl Drop for Cleanup {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
let _cleanup = Cleanup(path.clone());
std::fs::write(&path, contents).unwrap();
run(&path);
}
fn file_task_source(path: &std::path::Path, tail: &str) -> String {
let path = serde_json::to_string(&path.to_string_lossy()).unwrap();
format!(
"class Loader {{\n target: TextBlock\n task: Object\n function apply(self: Loader): Unit {{ self.target.text = self.task.result() }}\n}}\ntarget = TextBlock(\"waiting\")\nloader = Loader(target,ui.state(0))\npage = Page()\npage.render(TextBlock(\"target stays hidden\"))\nloader.task = ui.readText(loader,\"read\",{path},loader.apply)\n{tail}"
)
}
fn file_task_state(session: &mut ReplSession) -> String {
let globals = session.candidate.globals.unwrap();
let Value::Object(loader) = session
.candidate
.vm
.dict_get(globals, &Value::Text("loader".into()))
.unwrap()
.unwrap()
else {
panic!("loader");
};
let Value::Object(task) = session.candidate.vm.get_attr(loader, "task").unwrap() else {
panic!("Task");
};
let definition = session
.candidate
.vm
.object_store()
.type_of(task)
.unwrap()
.unwrap();
let method = session
.candidate
.vm
.object_store()
.lookup_method(definition, "status")
.unwrap()
.unwrap();
let Value::Text(status) = session
.candidate
.vm
.call(method, &[Value::Object(task)])
.unwrap()
else {
panic!("status");
};
status
}
fn complete_file_task(session: &mut ReplSession) {
for _ in 0..1000 {
session.poll_tasks().unwrap();
if file_task_state(session) != "pending" {
return;
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
panic!("file task did not complete");
}
#[test]
fn file_task_updates_hidden_object_and_replacement_drops_old_delivery() {
with_file_task_input("old", |old| {
with_file_task_input("new UTF-8 文本", |new| {
let path = serde_json::to_string(&new.to_string_lossy()).unwrap();
let tail = format!(
"old = loader.task\nloader.task = ui.readText(loader,\"read\",{path},loader.apply)"
);
let (mut session, _) =
ReplSession::load(&file_task_source(old, &tail), "app.main", 282, 1).unwrap();
complete_file_task(&mut session);
assert_eq!(file_task_state(&mut session), "completed");
assert_eq!(
session.execute("target.text", "app.main").unwrap().value,
"\"new UTF-8 文本\""
);
assert_eq!(
session.execute("old.status()", "app.main").unwrap().value,
"\"cancelled\""
);
assert_eq!(
session
.execute("target.mounts().count", "app.main")
.unwrap()
.value,
"0"
);
})
});
}
#[test]
fn cancelled_file_task_never_runs_callback_and_missing_file_keeps_vm_live() {
with_file_task_input("ignored", |path| {
let (mut cancelled, _) = ReplSession::load(
&file_task_source(path, "loader.task.cancel()"),
"app.main",
283,
1,
)
.unwrap();
complete_file_task(&mut cancelled);
assert_eq!(file_task_state(&mut cancelled), "cancelled");
assert_eq!(
cancelled.execute("target.text", "app.main").unwrap().value,
"\"waiting\""
);
let missing = path.with_extension("missing");
let (mut failed, _) =
ReplSession::load(&file_task_source(&missing, ""), "app.main", 284, 1).unwrap();
complete_file_task(&mut failed);
assert_eq!(file_task_state(&mut failed), "failed");
assert!(!failed.is_faulted());
assert_ne!(
failed
.execute("loader.task.error()", "app.main")
.unwrap()
.value,
"\"\""
);
assert_eq!(
failed.execute("target.text", "app.main").unwrap().value,
"\"waiting\""
);
});
}
#[test]
fn callback_free_file_task_retains_result_after_completion() {
with_file_task_input("callback free", |path| {
let path = serde_json::to_string(&path.to_string_lossy()).unwrap();
let source = format!(
"class Holder {{ task: Object }}\nloader = Holder(ui.state(0))\npage = Page()\npage.render(TextBlock(\"file\"))\nloader.task = ui.readText(ui,\"read\",{path})"
);
let (mut session, _) = ReplSession::load(&source, "app.main", 285, 1).unwrap();
complete_file_task(&mut session);
assert_eq!(file_task_state(&mut session), "completed");
assert_eq!(
session
.execute("loader.task.result()", "app.main")
.unwrap()
.value,
"\"callback free\""
);
assert_eq!(
session
.execute("loader.task.error()", "app.main")
.unwrap()
.value,
"\"\""
);
});
}
#[test]
fn owned_tasks_replace_cancel_and_update_unmounted_objects() {
let source = r#"class Action {
state: Object
value: Number
function apply(self: Action): Unit { self.state.value = self.value }
}
state = ui.state(0)
hidden = TextBlock("not mounted")
first = Action(state, 1)
latest = Action(state, 2)
page = Page()
page.render(ui.textOf(state,"value"))
old = ui.after(hidden,"load",0,first.apply)
next = ui.after(hidden,"load",0,latest.apply)"#;
let (mut session, _) = ReplSession::load(source, "app.main", 1, 190).unwrap();
assert_eq!(
session.execute("old.status()", "app.main").unwrap().value,
"\"cancelled\""
);
assert_eq!(
session.execute("state.value", "app.main").unwrap().value,
"0"
);
assert!(session.poll_tasks().unwrap().is_some());
assert_eq!(
session.execute("state.value", "app.main").unwrap().value,
"2"
);
assert_eq!(
session.execute("next.status()", "app.main").unwrap().value,
"\"completed\""
);
session
.execute(
"cancelled=ui.after(ui,\"cancel\",0,first.apply)\ncancelled.cancel()",
"app.main",
)
.unwrap();
session.poll_tasks().unwrap();
assert_eq!(
session.execute("state.value", "app.main").unwrap().value,
"2"
);
assert_eq!(
session
.execute("cancelled.active()", "app.main")
.unwrap()
.value,
"false"
);
}
#[test]
fn tab_and_mount_tasks_cancel_while_block_and_app_owners_continue() {
let source = r#"class Action {
state: Object
function apply(self: Action): Unit { self.state.increment() }
}
state=ui.state(0)
action=Action(state)
leaf=TextBlock("owner")
page=Page()
page.render(leaf)"#;
let (mut session, _) = ReplSession::load(source, "app.main", 1, 191).unwrap();
session
.execute(
r#"held=leaf.mounts().at(0)
pageTask=ui.after(page,"page",0,action.apply)
mountTask=ui.after(held,"mount",0,action.apply)
blockTask=ui.after(leaf,"block",0,action.apply)
appTask=ui.after(ui,"app",0,action.apply)
page.close()
page.open()"#,
"app.main",
)
.unwrap();
for name in ["pageTask", "mountTask"] {
assert_eq!(
session
.execute(&format!("{name}.status()"), "app.main")
.unwrap()
.value,
"\"cancelled\""
);
}
session.poll_tasks().unwrap();
assert_eq!(
session.execute("state.value", "app.main").unwrap().value,
"2"
);
// Moving a Page preserves its scope; the replaced Mount is cancelled.
session
.execute(
r#"moving=ui.after(page,"move",0,action.apply)
oldMount=leaf.mounts().at(0)
oldMountTask=ui.after(oldMount,"move",0,action.apply)
other=page.detachToWindow()"#,
"app.main",
)
.unwrap();
assert_eq!(
session
.execute("oldMountTask.status()", "app.main")
.unwrap()
.value,
"\"cancelled\""
);
session.poll_tasks().unwrap();
assert_eq!(
session.execute("state.value", "app.main").unwrap().value,
"3"
);
session
.execute(
"closing=ui.after(other,\"close\",0,action.apply)\nother.close()",
"app.main",
)
.unwrap();
session.poll_tasks().unwrap();
assert_eq!(
session
.execute("closing.status()", "app.main")
.unwrap()
.value,
"\"cancelled\""
);
}
#[test]
fn orphan_task_scope_is_collectable_and_callback_failure_stops_remaining_work() {
let source = r#"class Action {
state: Object
function apply(self: Action): Unit { self.state.increment() }
function fail(self: Action): Unit { ui.window().resize(0,600) }
}
function orphan(): Task {
let owner=TextBlock("temporary")
return ui.after(owner,"temporary",0,ui.window().focus)
}
state=ui.state(0)
action=Action(state)
page=Page()
page.render(TextBlock("preserve"))
token=orphan()"#;
let (mut session, _) = ReplSession::load(source, "app.main", 1, 192).unwrap();
assert_eq!(
session.execute("token.active()", "app.main").unwrap().value,
"false"
);
session.poll_tasks().unwrap();
assert_eq!(
session.execute("token.status()", "app.main").unwrap().value,
"\"cancelled\""
);
session.execute("broken=ui.after(ui,\"fail\",0,action.fail)\nlater=ui.after(ui,\"later\",0,action.apply)","app.main").unwrap();
assert!(session.poll_tasks().is_err());
assert!(session.is_faulted());
let globals = session.candidate.globals.unwrap();
let state = match session
.candidate
.vm
.dict_get(globals, &Value::Text("state".into()))
.unwrap()
.unwrap()
{
Value::Object(state) => state,
_ => panic!("state object"),
};
assert_eq!(
session.candidate.vm.get_attr(state, "value").unwrap(),
Value::Number(0.into())
);
assert!(session.poll_tasks().unwrap().is_none());
assert!(session.execute("42", "app.main").is_err());
}
#[test]
fn source_annotations_classify_functions_and_reject_before_body() {
let (mut session, _) = ReplSession::load(
include_str!("../../../examples/function-types.endor"),
"app.main",
1,
180,
)
.unwrap();
for expression in [
"isInstance(identity,Function)",
"isInstance(ui.rect,Function)",
"isInstance(actions.read,Function)",
] {
assert_eq!(
session.execute(expression, "app.main").unwrap().value,
"true"
);
}
assert_eq!(
session
.execute("isInstance(RectBlock,Function)", "app.main")
.unwrap()
.value,
"false"
);
session
.execute(
"function checked(value: Number): Number { state.increment() return value }",
"app.main",
)
.unwrap();
let error = session
.execute("checked(\"wrong\")", "app.main")
.err()
.unwrap();
assert!(
error.to_string().contains("parameter value expects Number"),
"{error:#}"
);
assert_eq!(
session.execute("state.value", "app.main").unwrap().value,
"0"
);
assert_eq!(
session.execute("checked(7)", "app.main").unwrap().value,
"7"
);
session
.execute("function wrong(): Number { return \"wrong\" }", "app.main")
.unwrap();
let error = session.execute("wrong()", "app.main").err().unwrap();
assert!(
error.to_string().contains("return expects Number"),
"{error:#}"
);
assert!(
session
.execute("callNumber(RectBlock)", "app.main")
.is_err()
);
session
.execute(
"class Actions { function read(self: Actions): Number { return 9 } }",
"app.main",
)
.unwrap();
assert_eq!(
session
.execute("callNumber(savedFunction)", "app.main")
.unwrap()
.value,
"1"
);
assert_eq!(
session
.execute("isInstance(actions,Actions)", "app.main")
.unwrap()
.value,
"false"
);
session.execute("button.click()", "app.main").unwrap();
assert_eq!(
session.execute("state.value", "app.main").unwrap().value,
"2"
);
}
#[test]
fn stopped_window_teardown_detaches_mounts_without_running_source_hooks() {
let source = r#"class WatchedPage: Page {
marker: Object
function onUnmount(self: WatchedPage, mount: Mount): Unit { self.marker.increment() }
}
class Failure {
function run(self: Failure, event: Event): Unit { ui.window().resize(0,600) }
}
marker=ui.state(0)
mainPage=Page()
mainPage.render(TextBlock("main"))
other=Window()
button=Button("fail")
failure=Failure()
connection=button.on("click",failure.run)
page=WatchedPage(marker)
page.render(button)
page.open(other)"#;
let (mut session, initial) = ReplSession::load(source, "app.main", 1, 140).unwrap();
session
.execute("held=button.mounts().at(0)", "app.main")
.unwrap();
let globals = session.candidate.globals.unwrap();
let lookup = |session: &ReplSession, name: &str| match session
.candidate
.vm
.dict_get(globals, &Value::Text(name.into()))
.unwrap()
.unwrap()
{
Value::Object(object) => object,
_ => panic!("object"),
};
let held = lookup(&session, "held");
let window = lookup(&session, "other");
let marker = lookup(&session, "marker");
let mount = session
.candidate
.vm
.payload::<crate::mount::Mount>(held, "Mount")
.unwrap()
.geometry
.id;
assert!(
session
.dispatch(&crate::events::Input {
window: 2,
session: 140,
layout_version: initial.windows[&2].frame.layout_version,
mount,
kind: "click".into(),
point: None,
delta: None,
press_point: None,
tab_drop: None,
screen_position: None,
})
.is_err()
);
assert!(session.is_faulted());
session.retire_window(2).unwrap();
assert!(!session.accepts_window(2));
assert!(
!session
.candidate
.vm
.payload::<crate::mount::Mount>(held, "Mount")
.unwrap()
.mounted
);
assert!(
crate::window::pages(session.candidate.vm.object_store(), window)
.unwrap()
.is_empty()
);
assert_eq!(
session.candidate.vm.get_attr(marker, "value").unwrap(),
Value::Number(0.into())
);
assert!(session.execute("42", "app.main").is_err());
}
#[test]
fn native_windows_share_business_state_move_pages_and_release_closed_contexts() {
let (mut session, initial) = ReplSession::load(
include_str!("../../../examples/windows.endor"),
"app.main",
1,
130,
)
.unwrap();
assert_eq!(initial.windows.len(), 1);
let main_ids: std::collections::BTreeSet<_> =
initial.frame.mounts.iter().map(|mount| mount.id).collect();
assert!(
initial.windows[&2]
.frame
.mounts
.iter()
.all(|mount| !main_ids.contains(&mount.id))
);
assert_eq!(initial.windows[&2].frame.session, initial.frame.session);
let changed = session
.execute("count.increment()", "app.main")
.unwrap()
.document
.unwrap();
let has_one = |frame: &crate::document::Frame| {
frame
.commands
.iter()
.any(|command| matches!(command,RenderCommand::Text{value,..} if value=="1"))
};
assert!(has_one(&changed.frame) && has_one(&changed.windows[&2].frame));
session
.execute("oldRight = rightButton.mounts().at(0)", "app.main")
.unwrap();
let page = session.execute("right", "app.main").unwrap().value;
let moved = session
.execute("right.moveToWindow(ui.window())", "app.main")
.unwrap()
.document
.unwrap();
assert!(moved.windows[&2].frame.tabs.is_empty());
assert_eq!(session.execute("right", "app.main").unwrap().value, page);
assert_eq!(
session
.execute("right.window().id()", "app.main")
.unwrap()
.value,
"1"
);
assert!(session.execute("oldRight.bounds()", "app.main").is_err());
let globals = session.candidate.globals.unwrap();
let Value::Object(window) = session
.candidate
.vm
.dict_get(globals, &Value::Text("other".into()))
.unwrap()
.unwrap()
else {
panic!("Window");
};
let weak = session.candidate.vm.downgrade(window).unwrap();
let closed = session
.execute("other.close()", "app.main")
.unwrap()
.document
.unwrap();
assert!(closed.windows.is_empty());
assert!(session.execute("right.open(other)", "app.main").is_err());
session
.candidate
.vm
.dict_delete(globals, &Value::Text("other".into()))
.unwrap();
session.execute("0", "app.main").unwrap();
session.candidate.vm.collect_cycles();
assert!(session.candidate.vm.upgrade(weak).unwrap().is_none());
// An externally retained old leaf Mount must not retain the window context.
let detached = session
.execute("detached = right.detachToWindow()", "app.main")
.unwrap()
.document
.unwrap();
assert!(detached.windows.contains_key(&3));
session.execute("detached.close()", "app.main").unwrap();
assert_eq!(
session.execute("right.window()", "app.main").unwrap().value,
"nil"
);
assert_eq!(
session.execute("count.value", "app.main").unwrap().value,
"1"
);
session
.execute("right.open(ui.window())", "app.main")
.unwrap();
assert_eq!(session.execute("right", "app.main").unwrap().value, page);
}
#[test]
fn failed_input_after_window_close_cannot_leave_owned_mounts_in_closed_context() {
let (mut session, _) = ReplSession::load(
include_str!("../../../examples/windows.endor"),
"app.main",
1,
131,
)
.unwrap();
assert!(
session
.execute("other.close()\nui.window().resize(0,600)", "app.main")
.is_err()
);
assert!(!session.accepts_window(2));
let repaired = session.execute("right.window()", "app.main").unwrap();
assert_eq!(repaired.value, "nil");
assert!(repaired.document.unwrap().windows.is_empty());
assert_eq!(
session
.execute("right.mounts().count", "app.main")
.unwrap()
.value,
"0"
);
session
.execute("right.open(ui.window())", "app.main")
.unwrap();
assert_eq!(
session
.execute("right.window().id()", "app.main")
.unwrap()
.value,
"1"
);
}
#[test]
fn tab_projection_uses_page_methods_and_rejects_old_presentations() {
let source = r#"first = Page()
first.title = "First"
first.render(TextBlock("one"))
second = Page()
second.title = "Second"
second.render(TextBlock("two"))"#;
let (mut session, initial) = ReplSession::load(source, "app.main", 120, 1).unwrap();
assert_eq!(initial.frame.tabs.len(), 2);
assert_eq!(initial.frame.tabs[0].title, "First");
assert!(initial.frame.tabs[1].active);
let input = crate::events::Input {
window: crate::window::MAIN_WINDOW,
session: initial.frame.session,
layout_version: initial.frame.layout_version,
mount: initial.frame.tabs[0].id,
kind: "tab-activate".into(),
point: None,
delta: None,
press_point: None,
tab_drop: None,
screen_position: None,
};
let selected = session.dispatch(&input).unwrap().document.unwrap();
assert!(selected.frame.tabs[0].active);
assert!(selected.frame.layout_version > initial.frame.layout_version);
// Query the bridge directly: stale transport must be rejected before faulting a runtime.
assert!(
session
.bridge
.dispatch(&mut session.candidate.vm, &input)
.is_err()
);
let renamed = session
.execute(r#"second.title = "Renamed""#, "app.main")
.unwrap()
.document
.unwrap();
assert_eq!(renamed.frame.tabs[1].title, "Renamed");
assert_eq!(renamed.frame.tabs[0].id, initial.frame.tabs[0].id);
let closed = session
.dispatch(&crate::events::Input {
window: crate::window::MAIN_WINDOW,
session: renamed.frame.session,
layout_version: renamed.frame.layout_version,
mount: renamed.frame.tabs[1].id,
kind: "tab-close".into(),
point: None,
delta: None,
press_point: None,
tab_drop: None,
screen_position: None,
})
.unwrap()
.document
.unwrap();
assert_eq!(closed.frame.tabs.len(), 1);
assert!(closed.frame.tabs[0].active);
assert_eq!(
session
.execute("second.window()", "app.main")
.unwrap()
.value,
"nil"
);
let empty = session
.execute("first.close()", "app.main")
.unwrap()
.document
.unwrap();
assert!(empty.frame.tabs.is_empty());
assert!(empty.frame.layout_version > closed.frame.layout_version);
let reopened = session
.execute("second.open()", "app.main")
.unwrap()
.document
.unwrap();
assert_eq!(reopened.frame.tabs.len(), 1);
assert!(reopened.frame.tabs[0].id > initial.frame.tabs[1].id);
}
#[test]
fn managed_window_requests_are_coalesced_and_failed_input_discards_pending_requests() {
let mut session = ReplSession::new(111).unwrap();
session
.execute(
r#"window = ui.window()
window.resize(900,600)
window.resize(1000,700)
window.setTitle("Endor")
window.focus()"#,
"app.main",
)
.unwrap();
let requests = session.take_window_commands().unwrap();
assert_eq!(requests.len(), 3);
assert!(matches!(
requests[0].action,
crate::window::Action::Resize {
width: 1000.0,
height: 700.0
}
));
assert!(matches!(&requests[1].action,crate::window::Action::SetTitle(title) if title=="Endor"));
assert!(session.take_window_commands().unwrap().is_empty());
assert!(
session
.execute("window.resize(1400,900)\nwindow.resize(0,600)", "app.main")
.is_err()
);
assert!(session.take_window_commands().unwrap().is_empty());
assert!(session.execute("window.resize(0,600)", "app.main").is_err());
assert!(session.take_window_commands().unwrap().is_empty());
}
#[test]
fn window_page_roots_follow_open_close_and_creation_order() {
let source = r#"first = Page()
second = Page()
first.render(TextBlock("first"))
second.render(TextBlock("second"))
window = ui.window()"#;
let (mut session, _) = ReplSession::load(source, "app.main", 112, 1).unwrap();
assert_eq!(
session
.execute("window.pages().count", "app.main")
.unwrap()
.value,
"2"
);
let first = session.execute("first.window()", "app.main").unwrap().value;
assert_eq!(
first,
session
.execute("second.window()", "app.main")
.unwrap()
.value
);
session.execute("first.close()", "app.main").unwrap();
assert_eq!(
session.execute("first.window()", "app.main").unwrap().value,
"nil"
);
assert_eq!(
session
.execute("window.pages().count", "app.main")
.unwrap()
.value,
"1"
);
session.execute("first.open(window)", "app.main").unwrap();
assert_eq!(
session
.execute("window.pages().count", "app.main")
.unwrap()
.value,
"2"
);
assert_eq!(
session
.execute("window.pages().at(0)", "app.main")
.unwrap()
.value,
session.execute("second", "app.main").unwrap().value
);
assert!(session.execute("first.open(Object())", "app.main").is_err());
}
#[test]
fn closed_page_is_not_kept_alive_by_window_page_order() {
let (mut session, _) = ReplSession::load(
"page = Page()\npage.render(TextBlock(\"page\"))",
"app.main",
113,
1,
)
.unwrap();
let globals = session.candidate.globals.unwrap();
let Value::Object(page) = session
.candidate
.vm
.dict_get(globals, &Value::Text("page".into()))
.unwrap()
.unwrap()
else {
panic!("Page")
};
let weak = session.candidate.vm.downgrade(page).unwrap();
session.execute("page.close()", "app.main").unwrap();
session
.candidate
.vm
.dict_delete(globals, &Value::Text("page".into()))
.unwrap();
session.candidate.vm.collect_cycles();
assert!(session.candidate.vm.upgrade(weak).unwrap().is_none());
}
#[test]
fn rendering_cannot_enqueue_native_window_side_effects() {
let source = r##"class BadRect: RectBlock {
function measure(self: BadRect, mount: Mount, constraints: Constraints): MeasureResult {
ui.window().resize(900,600)
return MeasureResult(80,20)
}
}
page = Page()
page.render(BadRect(80,20,0,"#DDEEFF"))"##;
let error = ReplSession::load(source, "app.main", 114, 1).err().unwrap();
assert!(error.to_string().contains("read-only"));
}
fn source_measure_stat(session: &mut ReplSession, name: &str) -> i64 {
let Value::Object(function) = session
.candidate
.vm
.get_attr(session.bridge.ui, "stats")
.unwrap()
else {
panic!("stats function")
};
let Value::Object(stats) = session.candidate.vm.call(function, &[]).unwrap() else {
panic!("stats")
};
let Value::Number(value) = session.candidate.vm.get_attr(stats, name).unwrap() else {
panic!("stat number")
};
session.candidate.vm.release(stats).unwrap();
value.as_i64().unwrap()
}
fn click_named(session: &mut ReplSession, document: &RenderDocument, name: &str) -> RenderDocument {
let globals = session.candidate.globals.unwrap();
let Value::Object(block) = session
.candidate
.vm
.dict_get(globals, &Value::Text(name.into()))
.unwrap()
.unwrap()
else {
panic!("Block")
};
let mount = document
.frame
.mounts
.iter()
.find(|mount| mount.block_id == block.to_string())
.unwrap();
session
.dispatch(&crate::events::Input {
window: crate::window::MAIN_WINDOW,
session: session.id,
layout_version: document.frame.layout_version,
mount: mount.id,
kind: "click".into(),
point: None,
delta: None,
press_point: None,
tab_drop: None,
screen_position: None,
})
.unwrap()
.document
.unwrap()
}
#[test]
fn shared_intrinsic_desktop_does_not_repaint_other_windows_on_repl_input() {
let (mut session, _) = ReplSession::load(
r##"
class Painted: RectBlock {
tint: String
function paint(self: Painted, mount: Mount, geometry: Bounds): DisplayList {
drawing = RectBlock.paint(self,mount,geometry)
drawing.commands.at(0).color = self.tint
return drawing
}
}
item = Painted(80,20,0,"#DDEEFF","#ED6A5A")
page = Page()
page.render(item)
window2 = Window()
page2 = Page()
page2.render(item)
page2.moveToWindow(window2)
"##,
"app.main",
901,
1,
)
.unwrap();
assert!(
session
.execute("42", "app.main")
.unwrap()
.document
.is_none()
);
let changed = session
.execute("item.tint = \"#3868D9\"", "app.main")
.unwrap()
.document
.unwrap();
assert!(changed.frame.commands.iter().any(|command| matches!(
command, RenderCommand::RoundedRect { color, .. } if color == "#3868D9"
)));
assert!(
changed.windows[&2]
.frame
.commands
.iter()
.any(|command| matches!(
command, RenderCommand::RoundedRect { color, .. } if color == "#3868D9"
))
);
}
#[test]
fn source_measure_cache_reuses_independent_results_and_invalidates_model_changes() {
let (mut session, initial) = ReplSession::load_with_viewport(
include_str!("../../../examples/layout-cache.endor"),
"app.main",
101,
1,
Some(crate::layout::Size {
width: 800.0,
height: 600.0,
}),
)
.unwrap();
assert_eq!(source_measure_stat(&mut session, "sourceMeasureMisses"), 2);
let painted = click_named(&mut session, &initial, "blue");
assert!(painted.frame.commands.iter().any(
|command| matches!(command,RenderCommand::RoundedRect {color,..} if color=="#526AC7")
));
let passes = source_measure_stat(&mut session, "layoutPasses");
assert_eq!(
source_measure_stat(&mut session, "sourceMeasureHits"),
2 * passes
);
assert_eq!(source_measure_stat(&mut session, "measured"), 0);
let resized = click_named(&mut session, &painted, "expand");
assert!(
resized
.frame
.mounts
.iter()
.any(|mount| mount.width == 260.0 && mount.height == 60.0)
);
let passes = source_measure_stat(&mut session, "layoutPasses");
assert_eq!(
source_measure_stat(&mut session, "sourceMeasureHits"),
2 * passes - 1
);
assert_eq!(source_measure_stat(&mut session, "sourceMeasureMisses"), 1);
}
#[test]
fn viewport_updates_in_a_fresh_console_do_not_replace_the_preserved_page() {
let mut session = ReplSession::new_with_viewport(
104,
Some(crate::layout::Size {
width: 800.0,
height: 600.0,
}),
)
.unwrap();
assert!(
session
.execute("42", "app.main")
.unwrap()
.document
.is_none()
);
assert!(
session
.resize(Some(crate::layout::Size {
width: 360.0,
height: 240.0
}))
.unwrap()
.is_none()
);
let result = session.execute("ui.viewport().width", "app.main").unwrap();
assert_eq!(result.value, "360");
assert!(result.document.is_none());
}
#[test]
fn source_measure_cache_tracks_fields_of_an_existing_returned_result() {
let source = r##"class HeldRect: RectBlock {
result: MeasureResult
function measure(self: HeldRect, mount: Mount, constraints: Constraints): MeasureResult { return self.result }
}
result = MeasureResult(80,20)
rect = HeldRect(80,20,0,"#DDEEFF",result)
page = Page()
page.render(rect)"##;
let (mut session, initial) = ReplSession::load(source, "app.main", 103, 1).unwrap();
let id = initial
.frame
.mounts
.iter()
.find(|mount| mount.width == 80.0)
.unwrap()
.id;
let globals = session.candidate.globals.unwrap();
let Value::Object(result) = session
.candidate
.vm
.dict_get(globals, &Value::Text("result".into()))
.unwrap()
.unwrap()
else {
panic!("result")
};
session
.candidate
.vm
.set_attr(result, "width", Value::Number(140.into()))
.unwrap();
let next = session.refresh_document().unwrap().unwrap();
assert!(
next.frame
.mounts
.iter()
.any(|mount| mount.id == id && mount.width == 140.0)
);
assert_eq!(source_measure_stat(&mut session, "sourceMeasureMisses"), 1);
assert!(session.refresh_document().unwrap().is_none());
}
#[test]
fn viewport_resize_changes_constraints_and_preserves_mount_identity_without_source_input() {
let (mut session, initial) = ReplSession::load_with_viewport(
include_str!("../../../examples/layout-cache.endor"),
"app.main",
102,
1,
Some(crate::layout::Size {
width: 800.0,
height: 600.0,
}),
)
.unwrap();
let ids: Vec<_> = initial.frame.mounts.iter().map(|mount| mount.id).collect();
let document = session
.resize(Some(crate::layout::Size {
width: 360.0,
height: 240.0,
}))
.unwrap()
.unwrap();
assert_eq!(
ids,
document
.frame
.mounts
.iter()
.map(|mount| mount.id)
.collect::<Vec<_>>()
);
assert!(
document
.frame
.mounts
.iter()
.any(|mount| mount.width == 312.0 && mount.height == 24.0)
);
assert_eq!(session.inputs, 0);
assert!(
session
.resize(Some(crate::layout::Size {
width: 360.0,
height: 240.0
}))
.unwrap()
.is_none()
);
assert!(
session
.resize(Some(crate::layout::Size {
width: f32::NAN,
height: 240.0
}))
.is_err()
);
assert!(!session.is_faulted());
}
const SOURCE_LAYOUT: &str = include_str!("../../../examples/source-layout.endor");
#[test]
fn source_layout_uses_initial_mount_links_child_measurements_and_model_dependencies() {
let (mut session, initial) = ReplSession::load(SOURCE_LAYOUT, "app.main", 91, 1).unwrap();
let rect = initial
.frame
.mounts
.iter()
.find(|mount| mount.width == 200.0 && mount.height == 60.0)
.unwrap();
assert_eq!((rect.x, rect.y), (44.0, 24.0));
let id = rect.id;
assert!(initial.frame.commands.iter().any(|command| matches!(command,RenderCommand::Text { value,y,.. } if value.starts_with("尺寸来自") && *y == 100.0)));
let globals = session.candidate.globals.unwrap();
let Value::Object(metrics) = session
.candidate
.vm
.dict_get(globals, &Value::Text("metrics".into()))
.unwrap()
.unwrap()
else {
panic!("Metrics")
};
session
.candidate
.vm
.set_attr(metrics, "width", Value::Number(260.into()))
.unwrap();
let next = session.refresh_document().unwrap().unwrap();
let rect = next
.frame
.mounts
.iter()
.find(|mount| mount.id == id)
.unwrap();
assert_eq!((rect.x, rect.width), (44.0, 260.0));
assert!(session.refresh_document().unwrap().is_none());
}
#[test]
fn shared_block_source_measurements_belong_to_each_occurrence() {
let source = SOURCE_LAYOUT.replace(
"page.render(root)",
r#"otherMetrics = Metrics(140, 12)
other = OffsetColumn(24, 16, rect, otherMetrics)
page.render(Row(0, 0, root, other))"#,
);
let (mut session, initial) = ReplSession::load(&source, "app.main", 92, 1).unwrap();
let globals = session.candidate.globals.unwrap();
let Value::Object(rect) = session
.candidate
.vm
.dict_get(globals, &Value::Text("rect".into()))
.unwrap()
.unwrap()
else {
panic!("Rect")
};
let widths: Vec<_> = initial
.frame
.mounts
.iter()
.filter(|mount| mount.block_id == rect.to_string())
.map(|mount| mount.width)
.collect();
assert_eq!(widths, vec![200.0, 140.0]);
assert!(session.refresh_document().unwrap().is_none());
}
#[test]
fn layout_dependencies_changed_by_mount_hook_are_measured_again_before_publication() {
let source = r##"class Metrics { width: Number }
class InitialRect: RectBlock {
metrics: Metrics
function measure(self: InitialRect, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(self.metrics.width, 20)
}
function onMount(self: InitialRect, mount: Mount): Unit { self.metrics.width = 140 }
}
metrics = Metrics(80)
rect = InitialRect(80,20,0,"#DDEEFF",metrics)
page = Page()
page.render(rect)"##;
let (mut session, document) = ReplSession::load(source, "app.main", 93, 1).unwrap();
assert!(
document
.frame
.mounts
.iter()
.any(|mount| mount.width == 140.0 && mount.height == 20.0)
);
assert!(session.refresh_document().unwrap().is_none());
}
#[test]
fn source_layout_rejects_premature_geometry_writes_wrong_types_and_foreign_slots() {
for (before, after, message) in [
(
"return MeasureResult(parent.block.metrics.width, 60)",
"return mount.bounds()",
"geometry is unavailable during measurement",
),
(
"return MeasureResult(parent.block.metrics.width, 60)",
"parent.block.metrics.width = 260\nreturn MeasureResult(260,60)",
"read-only",
),
(
"return MeasureResult(parent.block.metrics.width, 60)",
"return Point(0,0)",
"measure return expects MeasureResult",
),
(
"positions.children.at(0).x = self.metrics.left",
"positions.children.at(0).slot = 999999",
"foreign child slot",
),
] {
let source = SOURCE_LAYOUT.replace(before, after);
let error = ReplSession::load(&source, "app.main", 94, 1).err().unwrap();
assert!(error.to_string().contains(message), "{error}");
}
}
#[test]
fn source_hit_filters_shape_falls_back_and_requires_matching_press_release() {
for (press, release, expected_hits, expected_background) in [
([64.0, 64.0], [64.0, 64.0], "1", "0"),
([144.0, 64.0], [144.0, 64.0], "0", "1"),
([64.0, 64.0], [144.0, 64.0], "0", "0"),
([9999.0, 9999.0], [9999.0, 9999.0], "0", "0"),
] {
let (mut session, initial) = ReplSession::load(
include_str!("../../../examples/source-hit.endor"),
"app.main",
81,
1,
)
.unwrap();
let circle = initial
.frame
.mounts
.iter()
.find(|mount| mount.interactive && mount.width == 160.0)
.unwrap();
session
.dispatch(&crate::events::Input {
window: crate::window::MAIN_WINDOW,
session: initial.frame.session,
layout_version: initial.frame.layout_version,
mount: circle.id,
kind: "click".into(),
point: Some(release),
delta: None,
press_point: Some(press),
tab_drop: None,
screen_position: None,
})
.unwrap();
assert_eq!(
session.execute("hits.value", "app.main").unwrap().value,
expected_hits
);
assert_eq!(
session
.execute("background.value", "app.main")
.unwrap()
.value,
expected_background
);
}
}
#[test]
fn source_hit_rejects_wrong_result_and_business_writes_before_callbacks() {
for (body, message) in [
("return 1", "hitTest return expects Bool"),
(
"self.hits.increment()\nreturn point.inCircle(40, 40, 40)",
"read-only",
),
] {
let source = include_str!("../../../examples/source-hit.endor")
.replace("return point.inCircle(40, 40, 40)", body);
let (mut session, initial) = ReplSession::load(&source, "app.main", 82, 1).unwrap();
let circle = initial
.frame
.mounts
.iter()
.find(|mount| mount.interactive && mount.width == 160.0)
.unwrap();
let error = session
.dispatch(&crate::events::Input {
window: crate::window::MAIN_WINDOW,
session: initial.frame.session,
layout_version: initial.frame.layout_version,
mount: circle.id,
kind: "click".into(),
point: Some([64.0, 64.0]),
delta: None,
press_point: Some([64.0, 64.0]),
tab_drop: None,
screen_position: None,
})
.err()
.unwrap();
assert!(error.to_string().contains(message), "{error}");
assert!(session.is_faulted());
let globals = session.candidate.globals.unwrap();
let Value::Object(hits) = session
.candidate
.vm
.dict_get(globals, &Value::Text("hits".into()))
.unwrap()
.unwrap()
else {
panic!("state")
};
assert_eq!(
session.candidate.vm.get_attr(hits, "value").unwrap(),
Value::Number(0.into())
);
}
}
#[test]
fn ordinary_block_subscription_updates_interactivity_after_disconnect() {
let source = r##"hits = ui.state(0)
rect = RectBlock(80, 40, 0, "#DDEEFF")
token = rect.on("click", hits.increment)
page = Page()
page.render(rect)"##;
let (mut session, initial) = ReplSession::load(source, "app.main", 83, 1).unwrap();
assert!(initial.frame.mounts.iter().any(|mount| mount.interactive));
let result = session.execute("token.disconnect()", "app.main").unwrap();
assert!(
result
.document
.unwrap()
.frame
.mounts
.iter()
.all(|mount| !mount.interactive)
);
}
fn resumable_paint_source(copies: usize, locals: usize) -> String {
let children = std::iter::repeat_n("item", copies)
.collect::<Vec<_>>()
.join(", ");
let locals = "probe = 2\n".repeat(locals);
format!(
"class Painted: RectBlock {{\n tint: String\n mounted: Object\n function onMount(self: Painted, mount: Mount): Unit {{ self.mounted.increment() }}\n function paint(self: Painted, mount: Mount, geometry: Bounds): DisplayList {{\n{locals}drawing = RectBlock.paint(self,mount,geometry)\ndrawing.commands.at(0).color = self.tint\nreturn drawing\n}}\n}}\nmounted = ui.state(0)\nitem = Painted(2,20,0,\"#DDEEFF\",\"#ED6A5A\",mounted)\npage = Page()\npage.render(Row(0,0,{children}))"
)
}
#[test]
fn collecting_lazy_helpers_does_not_invalidate_stable_layout_or_paint() {
for source in [
include_str!("../../../examples/flex-constraints.endor"),
include_str!("../../../examples/source-paint.endor"),
] {
let (mut session, initial) = ReplSession::load(source, "app.main", 281, 1).unwrap();
session
.bridge
.reclaim(&mut session.candidate.vm, &mut session.candidate.roots)
.unwrap();
assert!(session.refresh_document().unwrap().is_none());
assert_eq!(
session.documents[&crate::window::MAIN_WINDOW]
.frame
.layout_version,
initial.frame.layout_version
);
}
}
#[test]
fn layout_and_paint_prepare_keep_only_current_occurrence_managed_helpers() {
for copies in [160, 640] {
let (mut session, initial) =
ReplSession::load(&resumable_paint_source(copies, 0), "app.main", 280, 1).unwrap();
let before = session.candidate.vm.object_count();
let progress = session
.bridge
.begin_paint_frame(
&mut session.candidate.vm,
initial.ui_root.clone(),
initial.frame,
)
.unwrap();
let crate::source_paint::Progress::Pending(work) = progress else {
panic!("paint must yield");
};
// Each quantum may retain its current helper/results, not a Bounds
// and collector for every occurrence before reaching the first one.
assert!(session.candidate.vm.object_count() < before + 256);
session
.bridge
.cancel_paint_frame(&mut session.candidate.vm, crate::window::MAIN_WINDOW, *work)
.unwrap();
assert!(!session.candidate.vm.has_suspended_call());
session
.bridge
.set_viewport(
&mut session.candidate.vm,
Some(crate::layout::Size {
width: 900.0,
height: 650.0,
}),
)
.unwrap();
let root = session
.bridge
.prepare_frame(&mut session.candidate.vm)
.unwrap()
.unwrap();
let before = session.candidate.vm.object_count();
let progress = session
.bridge
.begin_layout_frame(&mut session.candidate.vm, root)
.unwrap();
let crate::ui_bridge::LayoutProgress::Pending(work) = progress else {
panic!("layout must yield");
};
assert!(session.candidate.vm.object_count() < before + 256);
session
.bridge
.cancel_layout_frame(&mut session.candidate.vm, *work)
.unwrap();
assert!(!session.candidate.vm.has_suspended_call());
assert!(!session.candidate.vm.object_store().is_read_only());
}
}
#[test]
fn pending_paint_retargets_without_replaying_completed_mount_hooks() {
let (mut session, initial) =
ReplSession::load(&resumable_paint_source(80, 0), "app.main", 278, 1).unwrap();
let viewports = std::collections::BTreeMap::new();
session
.execute_incremental("item.tint = \"#526AC7\"", "app.main", &viewports)
.unwrap();
for _ in 0..100 {
session
.resize_windows_incremental(&viewports, false)
.unwrap();
if session
.refresh
.as_ref()
.is_some_and(|work| work.paint.is_some())
{
break;
}
}
assert!(
session
.refresh
.as_ref()
.is_some_and(|work| work.paint.is_some())
);
assert!(session.candidate.vm.has_suspended_call());
assert_eq!(
session.documents[&crate::window::MAIN_WINDOW]
.frame
.layout_version,
initial.frame.layout_version
);
assert!(session.documents[&crate::window::MAIN_WINDOW].frame.commands.iter().all(|command| matches!(command, RenderCommand::RoundedRect { color, .. } if color == "#ED6A5A")));
let latest = std::collections::BTreeMap::from([(
crate::window::MAIN_WINDOW,
crate::layout::Size {
width: 900.0,
height: 650.0,
},
)]);
session.resize_windows_incremental(&latest, false).unwrap();
assert!(!session.candidate.vm.has_suspended_call());
let settled = session.resize_windows(&latest).unwrap().unwrap();
assert_eq!(settled.frame.commands.len(), 80);
assert!(settled.frame.commands.iter().all(
|command| matches!(command, RenderCommand::RoundedRect { color, .. } if color == "#526AC7")
));
assert_eq!(
session.execute("mounted.value", "app.main").unwrap().value,
"80"
);
assert_eq!(source_measure_stat(&mut session, "paintCancels"), 1);
assert!(source_measure_stat(&mut session, "paintSlices") > 1);
}
#[test]
fn paint_error_after_pause_closes_read_only_scope_and_preserves_last_picture() {
let (mut session, initial) =
ReplSession::load(&resumable_paint_source(1, 300), "app.main", 279, 1).unwrap();
let viewports = std::collections::BTreeMap::new();
session
.execute_incremental("item.tint = \"invalid-color\"", "app.main", &viewports)
.unwrap();
let mut paused = false;
let mut failed = false;
for _ in 0..100 {
match session.resize_windows_incremental(&viewports, false) {
Ok(_) => {
paused |= session
.refresh
.as_ref()
.is_some_and(|work| work.paint.is_some())
}
Err(_) => {
failed = true;
break;
}
}
}
assert!(paused && failed);
assert!(session.is_faulted());
assert!(!session.candidate.vm.has_suspended_call());
assert!(!session.candidate.vm.object_store().is_read_only());
assert_eq!(
session.documents[&crate::window::MAIN_WINDOW]
.frame
.layout_version,
initial.frame.layout_version
);
assert!(session.documents[&crate::window::MAIN_WINDOW].frame.commands.iter().all(|command| matches!(command, RenderCommand::RoundedRect { color, .. } if color == "#ED6A5A")));
}
#[test]
fn source_paint_can_rebind_old_plain_reference_to_new_result_without_mutating_it() {
let source = r##"class ReboundRect: RectBlock {
previous: ObjectList
function paint(self: ReboundRect, mount: Mount, geometry: Bounds): DisplayList {
drawing = self.previous
drawing = RectBlock.paint(self, mount, geometry)
return drawing
}
}
previous = ObjectList()
rect = ReboundRect(80, 40, 0, "#DDEEFF", previous)
page = Page()
page.render(rect)"##;
let (mut session, document) = ReplSession::load(source, "app.main", 75, 1).unwrap();
assert_eq!(document.frame.commands.len(), 1);
assert_eq!(
session.execute("previous.count", "app.main").unwrap().value,
"0"
);
}
#[test]
fn source_paint_overrides_native_commands_and_reacts_to_heap_field_writes() {
let (mut session, initial) = ReplSession::load(
include_str!("../../../examples/source-paint.endor"),
"app.main",
71,
1,
)
.unwrap();
initial.validate().unwrap();
assert_eq!(initial.frame.commands.iter().filter(|command| matches!(command, RenderCommand::RoundedRect { color, .. } if color == "#ED6A5A")).count(), 2);
assert!(
initial.frame.commands.iter().any(
|command| matches!(command, RenderCommand::Text { color, .. } if color == "#FFFFFF")
)
);
let globals = session.candidate.globals.unwrap();
let Value::Object(rect) = session
.candidate
.vm
.dict_get(globals, &Value::Text("rect".into()))
.unwrap()
.unwrap()
else {
panic!("Block")
};
// No Console/global binding writes or focus changes: only the property
// read by paint changes, proving the paint dependency is sufficient.
session
.candidate
.vm
.set_attr(rect, "tint", Value::Text("#526AC7".into()))
.unwrap();
let updated = session.refresh_document().unwrap().unwrap();
assert_eq!(updated.frame.commands.iter().filter(|command| matches!(command, RenderCommand::RoundedRect { color, .. } if color == "#526AC7")).count(), 2);
assert!(session.refresh_document().unwrap().is_none());
}
#[test]
fn source_paint_rejects_business_mutation_before_it_happens_and_restores_scope() {
let source = r##"class BadPaint: RectBlock {
tint: String
function paint(self: BadPaint, mount: Mount, geometry: Bounds): DisplayList {
self.tint = "#000000"
return RectBlock.paint(self, mount, geometry)
}
}
rect = BadPaint(100, 40, 0, "#DDEEFF", "#FF0000")
page = Page()
page.render(rect)"##;
let mut session = ReplSession::new(72).unwrap();
assert!(
session
.execute(source, "app.main")
.err()
.unwrap()
.to_string()
.contains("read-only")
);
assert!(session.is_faulted());
let globals = session.candidate.globals.unwrap();
let Value::Object(rect) = session
.candidate
.vm
.dict_get(globals, &Value::Text("rect".into()))
.unwrap()
.unwrap()
else {
panic!("Block")
};
assert_eq!(
session.candidate.vm.get_attr(rect, "tint").unwrap(),
Value::Text("#FF0000".into())
);
session
.candidate
.vm
.set_attr(rect, "tint", Value::Text("#526AC7".into()))
.unwrap();
let bad_color =
include_str!("../../../examples/source-paint.endor").replace("#ED6A5A", "invalid color");
assert!(ReplSession::load(&bad_color, "app.main", 73, 1).is_err());
}
#[test]
fn source_paint_is_stable_when_mount_notifications_overflow_the_change_batch() {
let fixture = include_str!("../../../examples/source-paint.endor");
let mut source = fixture[..fixture.find("root = Column").unwrap()].to_owned();
source.push_str("root = Column(0, 0");
for _ in 0..1100 {
source.push_str(", rect");
}
source.push_str(")\npage = Page()\npage.render(root)");
let (mut session, document) = ReplSession::load(&source, "app.main", 74, 1).unwrap();
assert_eq!(document.frame.commands.len(), 1100);
assert!(session.refresh_document().unwrap().is_none());
}
#[test]
fn constructor_options_and_subclass_fields_have_independent_contracts() {
let (mut session, document) = ReplSession::load(
include_str!("../../../examples/constructor-contracts.endor"),
"app.main",
166,
1,
)
.unwrap();
document.validate().unwrap();
for (expression, expected) in [
("rect.width", "180"),
("rect.radius", "0"),
("rect.tag", "\"省略 radius/color 后的字段\""),
("root.tag", "\"可变子项之后的字段\""),
("event.kind", "\"click\""),
("items.count", "2"),
("ui.isInstance(items.at(0), TaggedRect)", "true"),
] {
assert_eq!(
session.execute(expression, "app.main").unwrap().value,
expected,
"{expression}"
);
}
let blocks = session
.execute("ui.stats().blocks", "app.main")
.unwrap()
.value;
for (expression, expected) in [
("TaggedRect(\"bad\", 60, \"tag\")", "expects Number"),
("TaggedColumn(0, 0, 7, \"tag\")", "expects Block"),
("ui.rect(1, 2, \"bad\")", "expects Number"),
("ui.text(7)", "expects String"),
("TaggedEvent(7, \"tag\")", "expects String"),
("ui.isSubtype(7, Block)", "expects Type"),
] {
let error = session
.execute(expression, "app.main")
.err()
.expect("invalid typed constructor/API");
assert!(
error.to_string().contains(expected),
"{expression}: {error:#}"
);
}
assert_eq!(
session
.execute("ui.stats().blocks", "app.main")
.unwrap()
.value,
blocks
);
session.execute("button.click()", "app.main").unwrap();
assert_eq!(
session.execute("rect.color", "app.main").unwrap().value,
"\"#237A45\""
);
for name in [
"open",
"present",
"text",
"rect",
"spacer",
"row",
"column",
"button",
"page",
"state",
"textOf",
"stats",
"viewport",
"window",
"isInstance",
"isSubtype",
"implement",
"implements",
"supports",
"after",
] {
let Value::Object(function) = session
.candidate
.vm
.get_attr(session.bridge.ui, name)
.unwrap()
else {
panic!("native Function")
};
let signature = session
.candidate
.vm
.object_store()
.native_signature(function)
.unwrap()
.expect("published UI signature");
signature
.validate(session.candidate.vm.object_store())
.unwrap();
}
}
#[test]
fn source_overrides_accept_wider_inputs_and_more_specific_results() {
let source = include_str!("../../../examples/method-contracts.endor");
let (mut session, document) = ReplSession::load(source, "app.main", 164, 1).unwrap();
document.validate().unwrap();
assert_eq!(
session
.execute("rect.mounts().at(0).bounds().width", "app.main")
.unwrap()
.value,
"180"
);
session.execute("button.click()", "app.main").unwrap();
assert_eq!(
session
.execute("rect.mounts().at(0).bounds().width", "app.main")
.unwrap()
.value,
"260"
);
assert_eq!(
session.execute("count.value", "app.main").unwrap().value,
"1"
);
for (source, reason) in [
(
source.replace("mount: Object", "mount: Point"),
"override narrows a parameter type",
),
(
source.replace("): Sized {", "): Object {"),
"override widens the return type",
),
] {
let error = ReplSession::load(&source, "app.main", 165, 1)
.err()
.expect("invalid override");
assert!(error.to_string().contains(reason), "{error:#}");
}
}
#[test]
fn nullable_page_methods_union_event_options_and_nominal_mount_bounds_compose() {
let (mut session, _) = ReplSession::load(
include_str!("../../../examples/visual-protocols.endor"),
"app.main",
163,
1,
)
.unwrap();
for (expression, expected) in [
("context = rect.mounts().at(0)", None),
("ui.isInstance(context.bounds(), Bounds)", Some("true")),
("Point(0, 0).inside(context.bounds())", Some("false")),
(
"listener = root.on(\"click\", page.close, Point(0, 0).inCircle(0, 0, 1))",
None,
),
("listener.isConnected()", Some("true")),
("listener.disconnect()", Some("nil")),
("listener.isConnected()", Some("false")),
("page.close()", Some("nil")),
("page.window()", Some("nil")),
("page.open(page.window())", None),
("ui.isInstance(page.window(), Window)", Some("true")),
] {
let result = session.execute(expression, "app.main").unwrap();
if let Some(expected) = expected {
assert_eq!(result.value, expected, "{expression}");
}
}
let error = session
.execute("root.on(\"click\", page.close, 7)", "app.main")
.err()
.expect("invalid union input");
assert!(error.to_string().contains("Bool | String"), "{error:#}");
}
#[test]
fn native_contracts_reject_wrong_types_before_geometry_or_window_effects() {
let (mut session, _) = ReplSession::load(
include_str!("../../../examples/visual-protocols.endor"),
"app.main",
162,
1,
)
.unwrap();
session
.execute("context = rect.mounts().at(0)", "app.main")
.unwrap();
for (expression, expected) in [
("rect.measure(context, Point(0, 0))", "expects Constraints"),
(
"rect.measure(Point(0, 0), Constraints(0, 0, 120, 80))",
"expects Mount",
),
("rect.paint(context, Point(0, 0))", "expects Bounds"),
(
"Point(0, 0).inside(Constraints(0, 0, 120, 80))",
"expects Bounds",
),
("Point(0, 0).inCircle(0, 0, \"bad\")", "expects Number"),
("ui.window().resize(\"bad\", 700)", "expects Number"),
("rect.setColor(7)", "expects String"),
] {
let error = session
.execute(expression, "app.main")
.err()
.expect("invalid native call");
assert!(
error.to_string().contains(expected),
"{expression}: {error:#}"
);
}
assert_eq!(
session.execute("results.color", "app.main").unwrap().value,
"\"#DDEEFF\""
);
assert_eq!(
session
.execute(
"rect.measure(context, Constraints(0, 0, 120, 80)).width",
"app.main"
)
.unwrap()
.value,
"120"
);
}
#[test]
fn native_visual_methods_use_managed_values_and_match_real_geometry() {
let (mut session, initial) = ReplSession::load(
include_str!("../../../examples/visual-protocols.endor"),
"app.main",
61,
1,
)
.unwrap();
initial.validate().unwrap();
for (expression, expected) in [
("results.width", "120"),
("results.height", "60"),
("results.childX", "44"),
("results.childY", "54"),
("results.color", "\"#DDEEFF\""),
] {
assert_eq!(
session.execute(expression, "app.main").unwrap().value,
expected
);
}
assert!(initial.frame.commands.iter().any(|command| matches!(command, RenderCommand::RoundedRect { width: 200.0, height: 60.0, color, .. } if color == "#DDEEFF")));
session
.execute("context = rect.mounts().at(0)", "app.main")
.unwrap();
assert_eq!(
session
.execute("rect.hitTest(context, Point(1, 1))", "app.main")
.unwrap()
.value,
"true"
);
assert_eq!(
session
.execute("rect.hitTest(context, Point(-1, 1))", "app.main")
.unwrap()
.value,
"false"
);
assert!(
session
.execute(
"rect.measure(context, Constraints(200, 0, 100, 100))",
"app.main"
)
.is_err()
);
assert!(
session
.execute("root.paint(context, Bounds(0, 0, 10, 10))", "app.main")
.is_err()
);
session.execute("page.close()", "app.main").unwrap();
assert!(
session
.execute("rect.paint(context, Bounds(0, 0, 10, 10))", "app.main")
.is_err()
);
}
#[test]
fn explicit_invalidation_publishes_once_preserves_mount_and_remeasures_only_when_requested() {
let (mut session, _) = ReplSession::load(
include_str!("../../../examples/visual-protocols.endor"),
"app.main",
62,
1,
)
.unwrap();
session
.execute("context = rect.mounts().at(0)", "app.main")
.unwrap();
let original = session.execute("context", "app.main").unwrap().value;
let update = session
.execute("rect.invalidate(\"paint\")", "app.main")
.unwrap()
.document
.unwrap();
update.validate().unwrap();
assert_eq!(
session
.execute("ui.stats().measured", "app.main")
.unwrap()
.value,
"0"
);
assert!(
session
.execute("rect.invalidate(\"layout\")", "app.main")
.unwrap()
.document
.is_some()
);
assert_eq!(
session
.execute("ui.stats().measured", "app.main")
.unwrap()
.value,
"1"
);
assert_eq!(
session
.execute("rect.mounts().at(0)", "app.main")
.unwrap()
.value,
original
);
assert!(
session
.execute("results.width", "app.main")
.unwrap()
.document
.is_none()
);
assert!(
session
.execute("rect.invalidate(\"unknown\")", "app.main")
.is_err()
);
}
#[test]
fn mount_hooks_run_per_occurrence_settle_and_unmount_before_parent() {
let (mut session, initial) = ReplSession::load(
include_str!("../../../examples/mount-lifecycle.endor"),
"app.main",
51,
1,
)
.unwrap();
initial.validate().unwrap();
for (expression, expected) in [
("log.mounted.value", "2"),
("log.parentMounted", "1"),
("log.childMounted", "3"),
] {
assert_eq!(
session.execute(expression, "app.main").unwrap().value,
expected
);
}
session
.execute(
"saved = child.mounts().at(0)\nchild.setColor(\"#237A45\")",
"app.main",
)
.unwrap();
assert_eq!(
session
.execute("log.mounted.value", "app.main")
.unwrap()
.value,
"2"
);
let closed = session
.execute("page.close()", "app.main")
.unwrap()
.document
.unwrap();
assert!(closed.frame.mounts.is_empty());
assert_eq!(
session
.execute("log.unmounted.value", "app.main")
.unwrap()
.value,
"2"
);
assert_eq!(
session
.execute("log.childUnmounted", "app.main")
.unwrap()
.value,
"5"
);
assert_eq!(
session
.execute("log.parentUnmounted", "app.main")
.unwrap()
.value,
"6"
);
assert_eq!(
session.execute("saved.mounted", "app.main").unwrap().value,
"false"
);
assert!(session.execute("saved.bounds()", "app.main").is_err());
session.execute("page.open()", "app.main").unwrap();
assert_eq!(
session
.execute("log.mounted.value", "app.main")
.unwrap()
.value,
"4"
);
assert_eq!(
session.execute("saved.mounted", "app.main").unwrap().value,
"false"
);
}
#[test]
fn page_switch_replaces_mount_context_even_for_shared_root() {
let (mut session, _) = ReplSession::load(
include_str!("../../../examples/mount-lifecycle.endor"),
"app.main",
52,
1,
)
.unwrap();
session
.execute(
"saved = child.mounts().at(0)\nsecond = Page()\nsecond.render(root)",
"app.main",
)
.unwrap();
assert_eq!(
session
.execute("log.mounted.value", "app.main")
.unwrap()
.value,
"4"
);
assert_eq!(
session
.execute("log.unmounted.value", "app.main")
.unwrap()
.value,
"2"
);
assert_eq!(
session.execute("saved.mounted", "app.main").unwrap().value,
"false"
);
assert_eq!(
session
.execute("child.mounts().at(0).page()", "app.main")
.unwrap()
.value,
session.execute("second", "app.main").unwrap().value
);
session.execute("page.activate()", "app.main").unwrap();
assert_eq!(
session
.execute("log.mounted.value", "app.main")
.unwrap()
.value,
"6"
);
}
#[test]
fn lifecycle_hook_can_edit_container_without_session_lock_and_failure_stops_domain() {
let source = r#"class GrowingColumn: Column {
function onMount(self: GrowingColumn, mount: Mount): Unit { self.append(TextBlock("from hook")) }
}
root = GrowingColumn(0, 0)
page = Page()
page.render(root)"#;
let (_, document) = ReplSession::load(source, "app.main", 53, 1).unwrap();
assert!(document.frame.commands.iter().any(
|command| matches!(command, RenderCommand::Text { value, .. } if value == "from hook")
));
let mut session = ReplSession::new(54).unwrap();
let source = r#"class BrokenText: TextBlock {
function onMount(self: BrokenText, mount: Mount): Unit { self.setText(1) }
}
page = Page()
page.render(BrokenText("x"))"#;
assert!(session.execute(source, "app.main").is_err());
assert!(session.is_faulted());
}
fn event_session(extra: &str) -> (ReplSession, RenderDocument) {
let source = format!(
"{}\n{}",
include_str!("../../../examples/events.endor"),
extra
);
ReplSession::load(&source, "app.main", 41, 1).unwrap()
}
fn click_document(session: &mut ReplSession, document: &RenderDocument) -> RenderDocument {
let globals = session.candidate.globals.unwrap();
let Value::Object(button) = session
.candidate
.vm
.dict_get(globals, &Value::Text("button".into()))
.unwrap()
.unwrap()
else {
panic!("Button")
};
let mount = document
.frame
.mounts
.iter()
.find(|mount| mount.interactive && mount.block_id == button.to_string())
.unwrap();
session
.dispatch(&crate::events::Input {
window: crate::window::MAIN_WINDOW,
session: document.frame.session,
layout_version: document.frame.layout_version,
mount: mount.id,
kind: "click".into(),
point: None,
delta: None,
press_point: None,
tab_drop: None,
screen_position: None,
})
.unwrap()
.document
.unwrap()
}
#[test]
fn managed_event_route_orders_capture_method_listeners_and_bubble() {
let (mut session, initial) = event_session("");
click_document(&mut session, &initial).validate().unwrap();
for (field, expected) in [
("captureStep", "1"),
("methodStep", "2"),
("targetStep", "3"),
("bubbleStep", "4"),
] {
assert_eq!(
session
.execute(&format!("model.{field}"), "app.main")
.unwrap()
.value,
expected
);
}
assert_eq!(
session.execute("model.phase", "app.main").unwrap().value,
"\"bubble\""
);
assert_eq!(
session
.execute("target.isConnected()", "app.main")
.unwrap()
.value,
"true"
);
}
#[test]
fn disconnect_during_capture_skips_pending_listener_and_stop_preserves_current_group() {
let (mut session, initial) = event_session(
"function cancel(event: Event): Unit { target.disconnect() }\nconnection = root.on(\"click\", cancel, \"capture\")",
);
click_document(&mut session, &initial);
assert_eq!(
session
.execute("model.targetStep", "app.main")
.unwrap()
.value,
"0"
);
assert_eq!(
session
.execute("model.bubbleStep", "app.main")
.unwrap()
.value,
"3"
);
assert_eq!(
session
.execute("target.isConnected()", "app.main")
.unwrap()
.value,
"false"
);
let (mut session, initial) = event_session(
"function stop(event: Event): Unit { event.stopPropagation() }\nconnection = button.on(\"click\", stop, \"capture\")",
);
click_document(&mut session, &initial);
assert_eq!(
session
.execute("model.targetStep", "app.main")
.unwrap()
.value,
"3"
);
assert_eq!(
session
.execute("model.bubbleStep", "app.main")
.unwrap()
.value,
"0"
);
}
#[test]
fn immediate_stop_skips_target_method_and_code_dispatch_returns_cancelled_event() {
let (mut session, initial) = event_session(
"function stop(event: Event): Unit { event.stopImmediatePropagation() }\nconnection = button.on(\"click\", stop, \"capture\")",
);
click_document(&mut session, &initial);
assert_eq!(
session
.execute("model.count.value", "app.main")
.unwrap()
.value,
"1"
);
let (mut session, _) = event_session(
"function prevent(event: Event): Unit { event.preventDefault() }\nconnection = button.on(\"custom\", prevent)\nresult = button.dispatch(Event(\"custom\"))",
);
assert_eq!(
session
.execute("result.isDefaultPrevented()", "app.main")
.unwrap()
.value,
"true"
);
assert_eq!(
session.execute("result.phase", "app.main").unwrap().value,
"\"none\""
);
// Reusing a completed event is legal; its previous dispatch has no live guard.
session
.execute("button.dispatch(result)", "app.main")
.unwrap();
}
#[test]
fn event_failure_releases_dispatch_guard_and_token_does_not_retain_target() {
let (mut session, _) = event_session("");
session.execute("function fail(event: Event): Unit { page.render(1) }\nfailing = button.on(\"failure\", fail)\nfailedEvent = Event(\"failure\")", "app.main").unwrap();
assert!(
session
.execute("button.dispatch(failedEvent)", "app.main")
.is_err()
);
session
.execute(
"failing.disconnect()\nbutton.dispatch(failedEvent)",
"app.main",
)
.unwrap();
session
.execute(
"orphan = Button(\"unmounted\")\ntoken = orphan.on(\"click\", model.target)",
"app.main",
)
.unwrap();
let globals = session.candidate.globals.unwrap();
let Value::Object(orphan) = session
.candidate
.vm
.dict_get(globals, &Value::Text("orphan".into()))
.unwrap()
.unwrap()
else {
panic!("Block expected")
};
let weak = session.candidate.vm.downgrade(orphan).unwrap();
session
.candidate
.vm
.dict_delete(globals, &Value::Text("orphan".into()))
.unwrap();
session
.bridge
.reclaim(&mut session.candidate.vm, &mut session.candidate.roots)
.unwrap();
assert!(session.candidate.vm.upgrade(weak).unwrap().is_none());
assert_eq!(
session
.execute("token.isConnected()", "app.main")
.unwrap()
.value,
"false"
);
}
#[test]
fn source_native_subclasses_share_live_fields_and_release_replaced_storage() {
let (mut session, initial) = ReplSession::load(
include_str!("../../../examples/native-subclasses.endor"),
"app.main",
31,
1,
)
.unwrap();
initial.validate().unwrap();
assert_eq!(
session
.execute("isInstance(label, TextBlock)", "app.main")
.unwrap()
.value,
"true"
);
assert_eq!(
session
.execute("isInstance(page, Page)", "app.main")
.unwrap()
.value,
"true"
);
session.execute("alias = label", "app.main").unwrap();
let globals = session.candidate.globals.unwrap();
let Value::Object(label) = session
.candidate
.vm
.dict_get(globals, &Value::Text("label".into()))
.unwrap()
.unwrap()
else {
panic!("Block expected")
};
let path = session
.candidate
.vm
.object_store()
.attribute_path(label)
.unwrap();
let previous = session
.candidate
.vm
.downgrade(*path.last().unwrap())
.unwrap();
let updated = session
.execute(
r##"label = CustomStatus("replacement", 2, "#242422", "#237A45", "new field")"##,
"app.main",
)
.unwrap()
.document
.unwrap();
assert!(updated.frame.commands.iter().any(
|command| matches!(command, RenderCommand::Text { value, .. } if value == "new field")
));
session
.bridge
.reclaim(&mut session.candidate.vm, &mut session.candidate.roots)
.unwrap();
assert!(session.candidate.vm.upgrade(previous).unwrap().is_none());
assert_eq!(
session.execute("alias.message", "app.main").unwrap().value,
"\"new field\""
);
let updated = session
.execute("button.click()", "app.main")
.unwrap()
.document
.unwrap();
assert_eq!(updated.frame.commands.iter().filter(|command| matches!(command, RenderCommand::Text { value, .. } if value == "new field")).count(), 3);
let updated = session
.execute("alias.message = \"changed field\"", "app.main")
.unwrap()
.document
.unwrap();
assert!(updated.frame.commands.iter().any(
|command| matches!(command, RenderCommand::Text { value, .. } if value == "changed field")
));
}
#[test]
fn loaded_program_console_and_native_events_share_unique_source_ids() {
let (mut session, initial) = ReplSession::load(
include_str!("../../../examples/managed-types.endor"),
"app.main",
1,
1,
)
.unwrap();
initial.validate().unwrap();
assert_eq!(
session
.execute("isInstance(page, Block)", "app.main")
.unwrap()
.value,
"true"
);
let mount = initial
.frame
.mounts
.iter()
.find(|mount| mount.interactive)
.unwrap();
let event = crate::events::Input {
window: crate::window::MAIN_WINDOW,
session: 1,
layout_version: initial.frame.layout_version,
mount: mount.id,
kind: "click".into(),
point: None,
delta: None,
press_point: None,
tab_drop: None,
screen_position: None,
};
let updated = session.dispatch(&event).unwrap().document.unwrap();
updated.validate().unwrap();
assert_eq!(
updated
.frame
.commands
.iter()
.filter(|command| matches!(command,
RenderCommand::Text { value, .. } if value == "10"
))
.count(),
2
);
assert!(!session.is_faulted());
}
#[test]
fn scroll_occurrences_are_independent_and_clipped_pointer_input_is_ignored() {
let (mut session, initial) = ReplSession::load(
include_str!("../../../examples/scroll.endor"),
"app.main",
1,
240,
)
.unwrap();
let globals = session.candidate.globals.unwrap();
let Value::Object(middle) = session
.candidate
.vm
.dict_get(globals, &Value::Text("middle".into()))
.unwrap()
.unwrap()
else {
panic!("expected Block");
};
let target = initial
.frame
.mounts
.iter()
.find(|m| m.block_id == middle.to_string())
.unwrap();
let point = [
target.x + target.width / 2.0,
target.y + target.height / 2.0,
];
assert!(!target.contains(crate::geometry::Point::new(point[0], point[1])));
session
.dispatch(&crate::events::Input {
window: 1,
session: 240,
layout_version: initial.frame.layout_version,
mount: target.id,
kind: "click".into(),
point: Some(point),
press_point: Some(point),
tab_drop: None,
screen_position: None,
delta: None,
})
.unwrap();
assert_eq!(
session.execute("count.value", "app.main").unwrap().value,
"0"
);
session.execute("saved = scroller.mounts().at(0)\nsaved.scrollTo(0,100)\nsaved.scrollBy(0,20)\nsaved.scrollBy(0,30)","app.main").unwrap();
assert_eq!(
session
.execute("saved.scrollPosition().y", "app.main")
.unwrap()
.value,
"150"
);
assert_eq!(
session
.execute("scroller.mounts().at(1).scrollPosition().y", "app.main")
.unwrap()
.value,
"0"
);
session
.execute("last.mounts().at(0).scrollIntoView()", "app.main")
.unwrap();
let Value::Object(last) = session
.candidate
.vm
.dict_get(globals, &Value::Text("last".into()))
.unwrap()
.unwrap()
else {
panic!("expected Block");
};
let frame = &session.documents[&crate::window::MAIN_WINDOW].frame;
let target = frame
.mounts
.iter()
.find(|m| m.block_id == last.to_string())
.unwrap();
assert!(target.contains(crate::geometry::Point::new(
target.x + target.width / 2.0,
target.y + target.height / 2.0
)));
assert_eq!(frame.command_clips.len(), frame.commands.len());
session
.execute("scroller.height = 1000", "app.main")
.unwrap();
assert_eq!(
session
.execute("saved.scrollPosition().y", "app.main")
.unwrap()
.value,
"0"
);
assert_eq!(
session
.execute("saved.scrollPosition().maxY", "app.main")
.unwrap()
.value,
"0"
);
}
#[test]
fn new_viewport_cancels_pending_layout_and_restores_saved_mount_context() {
let children = std::iter::repeat_n("item", 40)
.collect::<Vec<_>>()
.join(", ");
let source = format!(
"class Wide: Row {{\n function measure(self: Wide, mount: Mount, constraints: Constraints): MeasureResult {{ return MeasureResult(340, 60) }}\n}}\nitem = RectBlock(2, 20)\npage = Page()\npage.render(Wide(0, 0, {children}))"
);
let (mut session, _) = ReplSession::load(&source, "app.main", 272, 1).unwrap();
session.execute("saved = item.mounts().at(0)\nreceived = ui.state(0)\ntoken = ui.after(saved, \"keep\", 60000, received.increment)", "app.main").unwrap();
let globals = session.candidate.globals.unwrap();
let Value::Object(saved) = session
.candidate
.vm
.dict_get(globals, &Value::Text("saved".into()))
.unwrap()
.unwrap()
else {
panic!("saved Mount");
};
let first = std::collections::BTreeMap::from([(
crate::window::MAIN_WINDOW,
crate::layout::Size {
width: 800.0,
height: 600.0,
},
)]);
for _ in 0..20 {
session.resize_windows_incremental(&first, false).unwrap();
if session.candidate.vm.has_suspended_call() {
break;
}
}
assert!(session.candidate.vm.has_suspended_call());
let latest = std::collections::BTreeMap::from([(
crate::window::MAIN_WINDOW,
crate::layout::Size {
width: 900.0,
height: 650.0,
},
)]);
assert!(
session
.resize_windows_incremental(&latest, false)
.unwrap()
.is_none()
);
assert!(!session.candidate.vm.has_suspended_call());
let mount = session
.candidate
.vm
.payload::<crate::mount::Mount>(saved, "Mount")
.unwrap();
assert!(mount.mounted);
assert!(mount.layout.is_none());
assert_eq!(mount.geometry.width, 2.0);
assert!(session.resize_windows(&latest).unwrap().is_some());
assert!(!session.is_faulted());
assert_eq!(
session.execute("token.status()", "app.main").unwrap().value,
"\"pending\""
);
assert_eq!(source_measure_stat(&mut session, "layoutCancels"), 1);
}
#[test]
fn candidate_initial_layout_retargets_without_reexecuting_program_or_hooks() {
let children = std::iter::repeat_n("item", 80)
.collect::<Vec<_>>()
.join(", ");
let source = format!(
"class Watched: RectBlock {{\n count: Object\n function onMount(self: Watched, mount: Mount): Unit {{ self.count.increment() }}\n}}\nbuilt = ui.state(0)\nbuilt.increment()\nmounted = ui.state(0)\nitem = Watched(2,20,mounted)\npage = Page()\npage.render(Row(0,0,{children}))"
);
let (mut candidate, document) =
ReplSession::load_incremental(&source, "app.main", 277, 1, None).unwrap();
assert!(document.is_none());
assert!(candidate.has_pending_refresh());
assert!(candidate.documents.is_empty());
let initial = std::collections::BTreeMap::new();
for _ in 0..20 {
candidate
.resize_windows_incremental(&initial, false)
.unwrap();
if candidate.candidate.vm.has_suspended_call() {
break;
}
}
assert!(candidate.candidate.vm.has_suspended_call());
assert!(candidate.documents.is_empty());
let latest = std::collections::BTreeMap::from([(
crate::window::MAIN_WINDOW,
crate::layout::Size {
width: 900.0,
height: 650.0,
},
)]);
candidate
.resize_windows_incremental(&latest, false)
.unwrap();
assert!(!candidate.candidate.vm.has_suspended_call());
let settled = candidate.resize_windows(&latest).unwrap().unwrap();
assert_eq!(settled.frame.mounts.last().unwrap().x, 158.0);
assert_eq!(
candidate.execute("built.value", "app.main").unwrap().value,
"1"
);
assert_eq!(
candidate
.execute("mounted.value", "app.main")
.unwrap()
.value,
"80"
);
assert_eq!(source_measure_stat(&mut candidate, "layoutCancels"), 1);
}
#[test]
fn incremental_input_retains_value_and_never_replays_source_while_retargeting() {
let children = std::iter::repeat_n("item", 80)
.collect::<Vec<_>>()
.join(", ");
let source = format!(
"count = ui.state(0)\nitem = RectBlock(2,20)\npage = Page()\npage.render(Row(0,0,{children}))"
);
let (mut session, initial) = ReplSession::load(&source, "app.main", 275, 1).unwrap();
let first = std::collections::BTreeMap::new();
let result = session
.execute_incremental(
"count.increment()\nitem.width = 3\ncount.value",
"app.main",
&first,
)
.unwrap();
assert_eq!(result.value, "1");
assert!(result.document.is_none());
assert!(session.has_pending_refresh());
assert!(session.take_window_commands().is_err());
assert!(session.execute("count.increment()", "app.main").is_err());
for _ in 0..20 {
session.resize_windows_incremental(&first, false).unwrap();
if session.candidate.vm.has_suspended_call() {
break;
}
}
assert!(session.candidate.vm.has_suspended_call());
assert_eq!(
session.documents[&crate::window::MAIN_WINDOW]
.frame
.layout_version,
initial.frame.layout_version
);
let latest = std::collections::BTreeMap::from([(
crate::window::MAIN_WINDOW,
crate::layout::Size {
width: 900.0,
height: 650.0,
},
)]);
session.resize_windows_incremental(&latest, false).unwrap();
let settled = session.resize_windows(&latest).unwrap().unwrap();
assert_eq!(settled.frame.mounts.last().unwrap().x, 237.0);
assert_eq!(result.value, "1");
assert_eq!(
session.execute("count.value", "app.main").unwrap().value,
"1"
);
assert_eq!(source_measure_stat(&mut session, "layoutCancels"), 1);
}
#[test]
fn incremental_event_runs_callback_once_before_publishing_its_layout() {
let children = std::iter::repeat_n("item", 80)
.collect::<Vec<_>>()
.join(", ");
let source = format!(
"count = ui.state(0)\nitem = RectBlock(2,20)\nbutton = Button(\"increment\")\nbutton.onClick(count.increment)\npage = Page()\npage.render(Column(0,0,button,ui.textOf(count,\"value\"),Row(0,0,{children})))"
);
let (mut session, initial) = ReplSession::load(&source, "app.main", 276, 1).unwrap();
let mount = initial
.frame
.mounts
.iter()
.find(|mount| mount.interactive)
.unwrap();
let input = crate::events::Input {
window: crate::window::MAIN_WINDOW,
session: session.id,
layout_version: initial.frame.layout_version,
mount: mount.id,
kind: "click".into(),
point: None,
press_point: None,
delta: None,
tab_drop: None,
screen_position: None,
};
let viewports = std::collections::BTreeMap::new();
let result = session.dispatch_incremental(&input, &viewports).unwrap();
assert!(result.document.is_none());
assert!(session.has_pending_refresh());
assert_eq!(
session.documents[&crate::window::MAIN_WINDOW]
.frame
.layout_version,
initial.frame.layout_version
);
let mut complete = false;
for _ in 0..1000 {
if session
.resize_windows_incremental(&viewports, false)
.unwrap()
.is_some()
{
complete = true;
break;
}
}
assert!(complete);
assert!(!session.has_pending_refresh());
assert_eq!(
session.execute("count.value", "app.main").unwrap().value,
"1"
);
assert!(source_measure_stat(&mut session, "refreshYields") > 0);
}
#[test]
fn large_intrinsic_layout_yields_and_retargets_without_geometry_loss() {
let children = std::iter::repeat_n("item", 80)
.collect::<Vec<_>>()
.join(", ");
let source = format!(
"item = RectBlock(2,20)\nrow = Row(0,0,{children})\npage = Page()\npage.render(row)"
);
let (mut session, initial) = ReplSession::load(&source, "app.main", 273, 1).unwrap();
let old_version = initial.frame.layout_version;
assert_eq!(initial.frame.mounts.last().unwrap().x, 158.0);
let first = std::collections::BTreeMap::from([(
crate::window::MAIN_WINDOW,
crate::layout::Size {
width: 800.0,
height: 600.0,
},
)]);
for _ in 0..20 {
session.resize_windows_incremental(&first, false).unwrap();
if session.candidate.vm.has_suspended_call() {
break;
}
}
assert!(session.candidate.vm.has_suspended_call());
assert_eq!(
session.documents[&crate::window::MAIN_WINDOW]
.frame
.layout_version,
old_version
);
let latest = std::collections::BTreeMap::from([(
crate::window::MAIN_WINDOW,
crate::layout::Size {
width: 900.0,
height: 650.0,
},
)]);
session.resize_windows_incremental(&latest, false).unwrap();
assert!(!session.candidate.vm.has_suspended_call());
let settled = session.resize_windows(&latest).unwrap().unwrap();
assert_eq!(settled.frame.mounts.last().unwrap().x, 158.0);
assert_eq!(settled.frame.mounts.last().unwrap().width, 2.0);
assert_eq!(source_measure_stat(&mut session, "layoutCancels"), 1);
assert!(source_measure_stat(&mut session, "layoutSlices") > 1);
}
#[test]
fn idle_tasks_wait_for_prior_callback_refresh_before_next_delivery() {
let (mut session, initial) = ReplSession::load(
"state = ui.state(0)\npage = Page()\npage.render(ui.textOf(state,\"value\"))\na = ui.after(ui,\"a\",0,state.increment)\nb = ui.after(ui,\"b\",0,state.increment)",
"app.main", 274, 1,
).unwrap();
let old_version = initial.frame.layout_version;
let viewports = std::collections::BTreeMap::new();
assert!(
session
.poll_tasks_incremental(&viewports)
.unwrap()
.is_none()
);
assert!(session.has_pending_refresh());
assert!(
session
.poll_tasks_incremental(&viewports)
.unwrap()
.is_none()
);
assert_eq!(
session.documents[&crate::window::MAIN_WINDOW]
.frame
.layout_version,
old_version
);
let first = session.resize_windows(&viewports).unwrap().unwrap();
assert!(first.frame.layout_version > old_version);
assert_eq!(
session.execute("state.value", "app.main").unwrap().value,
"1"
);
assert_eq!(
session.execute("a.status()", "app.main").unwrap().value,
"\"completed\""
);
assert_eq!(
session.execute("b.status()", "app.main").unwrap().value,
"\"pending\""
);
session.poll_tasks_incremental(&viewports).unwrap();
assert!(session.has_pending_refresh());
session.resize_windows(&viewports).unwrap();
assert_eq!(
session.execute("state.value", "app.main").unwrap().value,
"2"
);
assert_eq!(
session.execute("b.status()", "app.main").unwrap().value,
"\"completed\""
);
}
#[test]
fn incremental_viewport_refresh_retains_published_documents_until_complete() {
let (mut session, _) = ReplSession::load(
include_str!("../../../examples/flex-constraints.endor"),
"app.main",
271,
1,
)
.unwrap();
let old_version = session.documents[&crate::window::MAIN_WINDOW]
.frame
.layout_version;
let viewports = std::collections::BTreeMap::from([(
crate::window::MAIN_WINDOW,
crate::layout::Size {
width: 800.0,
height: 600.0,
},
)]);
assert!(
session
.resize_windows_incremental(&viewports, false)
.unwrap()
.is_none()
);
assert!(session.has_pending_refresh());
assert!(session.execute("blocked = 1", "app.main").is_err());
assert!(!session.is_faulted());
let mut completed = false;
for _ in 0..1000 {
if let Some(document) = session
.resize_windows_incremental(&viewports, false)
.unwrap()
{
assert!(document.frame.layout_version > old_version);
assert!(!session.has_pending_refresh());
completed = true;
break;
}
assert_eq!(
session.documents[&crate::window::MAIN_WINDOW]
.frame
.layout_version,
old_version
);
}
assert!(completed);
assert!(source_measure_stat(&mut session, "refreshYields") > 0);
}
#[test]
fn bridge_layout_can_return_pending_before_committing_a_complete_frame() {
let children = std::iter::repeat_n("item", 40)
.collect::<Vec<_>>()
.join(", ");
let source = format!(
"class Wide: Row {{\n function measure(self: Wide, mount: Mount, constraints: Constraints): MeasureResult {{ return MeasureResult(340, 60) }}\n}}\nitem = RectBlock(2, 20)\npage = Page()\npage.render(Wide(0, 0, {children}))"
);
let (mut session, _) = ReplSession::load(&source, "app.main", 270, 1).unwrap();
session
.bridge
.set_viewport(
&mut session.candidate.vm,
Some(crate::layout::Size {
width: 500.0,
height: 300.0,
}),
)
.unwrap();
let root = session
.bridge
.prepare_frame(&mut session.candidate.vm)
.unwrap()
.unwrap();
let mut progress = session
.bridge
.begin_layout_frame(&mut session.candidate.vm, root)
.unwrap();
assert!(matches!(
progress,
crate::ui_bridge::LayoutProgress::Pending(_)
));
assert!(session.candidate.vm.has_suspended_call());
loop {
match progress {
crate::ui_bridge::LayoutProgress::Pending(pending) => {
progress = session
.bridge
.resume_layout_frame(&mut session.candidate.vm, *pending)
.unwrap();
}
crate::ui_bridge::LayoutProgress::Complete(frame) => {
assert_eq!(frame.mounts.last().unwrap().x, 78.0);
assert!(!session.candidate.vm.has_suspended_call());
break;
}
}
}
}
#[test]
fn layout_slices_preserve_all_shared_occurrences() {
let children = std::iter::repeat_n("item", 40)
.collect::<Vec<_>>()
.join(", ");
let source = format!(
"class Wide: Row {{\n function measure(self: Wide, mount: Mount, constraints: Constraints): MeasureResult {{ return MeasureResult(340, 60) }}\n}}\nitem = RectBlock(2, 20)\npage = Page()\npage.render(Wide(0, 0, {children}))"
);
let (mut session, initial) = ReplSession::load(&source, "app.main", 269, 1).unwrap();
initial.validate().unwrap();
assert!(initial.frame.mounts.len() >= 41);
assert!(source_measure_stat(&mut session, "layoutSlices") > 1);
assert_eq!(
session
.execute("item.mounts().at(39).bounds().x", "app.main")
.unwrap()
.value,
"78"
);
}
#[test]
fn nested_flex_refreshes_natural_height_when_cross_width_changes() {
let (mut session, initial) = ReplSession::load(
r#"
class Narrow: Row {
function measure(self: Narrow, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(240, 400) }
}
paragraph = TextBlock("Changing the parent's width must refresh the natural height of this wrapping paragraph. The first wide measurement must not freeze a single line of text. Changing the parent's width must refresh the natural height of this wrapping paragraph.")
paragraph.flexShrink = 1
column = Column(0, 0, paragraph)
column.flex = 1
page = Page()
page.render(Narrow(10, 0, column))
"#,
"app.main",
268,
1,
)
.unwrap();
initial.validate().unwrap();
let height: f32 = session
.execute("paragraph.mounts().at(0).bounds().height", "app.main")
.unwrap()
.value
.parse()
.unwrap();
assert!(height > 20.0);
session
.execute("paragraph.setText(\"short\")", "app.main")
.unwrap();
assert_eq!(
session
.execute("paragraph.mounts().at(0).bounds().height", "app.main")
.unwrap()
.value,
"20"
);
}
#[test]
fn bounded_flex_redistributes_growth_and_scaled_shrink() {
let (mut session, initial) = ReplSession::load(
r#"
class Wide: Row {
function measure(self: Wide, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(340, 60) }
}
first = RectBlock(20, 20)
second = RectBlock(40, 20)
fixed = RectBlock(60, 20)
first.flex = 1
second.flex = 3
first.flexBasis = 100
second.flexBasis = 100
first.flexMax = 105
row = Wide(10, 10, first, second, fixed)
page = Page()
page.render(row)
"#,
"app.main",
267,
1,
)
.unwrap();
initial.validate().unwrap();
let width = |session: &mut ReplSession, name| {
session
.execute(&format!("{name}.mounts().at(0).bounds().width"), "app.main")
.unwrap()
.value
};
assert_eq!(width(&mut session, "first"), "105");
assert_eq!(width(&mut session, "second"), "135");
session.execute(
"first.flex = 0\nsecond.flex = 0\nfirst.flexMax = 300\nfirst.flexMin = 150\nfirst.flexBasis = 200\nsecond.flexBasis = 200\nfirst.flexShrink = 1\nsecond.flexShrink = 1",
"app.main",
).unwrap();
assert_eq!(width(&mut session, "first"), "150");
assert_eq!(width(&mut session, "second"), "90");
assert_eq!(width(&mut session, "fixed"), "60");
session.execute(
"first.flexMin = 0\nfirst.flexMax = 1000000\nfirst.flexBasis = \"auto\"\nsecond.flexBasis = \"auto\"",
"app.main",
).unwrap();
assert_eq!(width(&mut session, "first"), "20");
assert_eq!(width(&mut session, "second"), "40");
}
#[test]
fn native_flex_reflows_shared_text_per_occurrence() {
let (mut session, initial) = ReplSession::load(
r#"
paragraph = TextBlock("Shared text has one object identity and separate layout constraints in each occurrence. Changing the sibling weight reflows only the affected occurrence.")
paragraph.flex = 1
filler = RectBlock(20, 20)
filler.flex = 1
first = Row(10, 10, paragraph, filler)
second = Row(10, 10, paragraph, RectBlock(300, 20))
page = Page()
page.render(Column(10, 10, first, second))
"#,
"app.main",
266,
1,
)
.unwrap();
initial.validate().unwrap();
let height = |session: &mut ReplSession, index| -> f32 {
session
.execute(
&format!("paragraph.mounts().at({index}).bounds().height"),
"app.main",
)
.unwrap()
.value
.parse()
.unwrap()
};
let before = height(&mut session, 0);
let other = height(&mut session, 1);
assert!(before > other);
session.execute("filler.flex = 3", "app.main").unwrap();
assert!(height(&mut session, 0) > before);
assert_eq!(height(&mut session, 1), other);
}
#[test]
fn weighted_flex_remeasures_children_and_updates_live_weights() {
let (mut session, initial) = ReplSession::load(r#"
class Wide: Row {
function measure(self: Wide, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(340, 40) }
}
first = RectBlock(20, 20)
fixed = RectBlock(60, 20)
last = RectBlock(40, 20)
first.flex = 1
last.flex = 2
row = Wide(10, 10, first, fixed, last)
page = Page()
page.render(row)
"#, "app.main", 265, 1).unwrap();
initial.validate().unwrap();
assert_eq!(
session
.execute("first.mounts().at(0).bounds().width", "app.main")
.unwrap()
.value,
"80"
);
assert_eq!(
session
.execute("last.mounts().at(0).bounds().width", "app.main")
.unwrap()
.value,
"160"
);
session
.execute("first.flex = 3\nlast.flex = 1", "app.main")
.unwrap();
assert_eq!(
session
.execute("first.mounts().at(0).bounds().width", "app.main")
.unwrap()
.value,
"180"
);
assert_eq!(
session
.execute("last.mounts().at(0).bounds().width", "app.main")
.unwrap()
.value,
"60"
);
}
#[test]
fn main_alignment_updates_live_geometry_without_changing_sizes() {
let (mut session, initial) = ReplSession::load(r#"
class Wide: Row {
function measure(self: Wide, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(300, 60) }
}
first = RectBlock(20, 20)
second = RectBlock(40, 40)
row = Wide(10, 10, first, second)
page = Page()
page.render(row)
"#, "app.main", 264, 1).unwrap();
initial.validate().unwrap();
for (mode, first, second) in [
("start", "10", "40"),
("center", "115", "145"),
("end", "220", "250"),
("space-between", "10", "250"),
] {
session
.execute(&format!("row.mainAlign = \"{mode}\""), "app.main")
.unwrap();
assert_eq!(
session
.execute("first.mounts().at(0).bounds().x", "app.main")
.unwrap()
.value,
first
);
assert_eq!(
session
.execute("second.mounts().at(0).bounds().x", "app.main")
.unwrap()
.value,
second
);
assert_eq!(
session
.execute("first.mounts().at(0).bounds().width", "app.main")
.unwrap()
.value,
"20"
);
}
}
#[test]
fn scroll_axes_wrap_shared_text_and_clamp_only_the_enabled_offsets() {
let (mut session, initial) = ReplSession::load(
include_str!("../../../examples/scroll-axis.endor"),
"app.main",
260,
1,
)
.unwrap();
initial.validate().unwrap();
for expression in [
"vertical.mounts().at(0).scrollPosition().maxX",
"horizontal.mounts().at(0).scrollPosition().maxY",
] {
assert_eq!(session.execute(expression, "app.main").unwrap().value, "0");
}
session
.execute(
"vertical.mounts().at(0).scrollTo(100, 60)\nhorizontal.mounts().at(0).scrollTo(60, 100)",
"app.main",
)
.unwrap();
for (expression, expected) in [
("vertical.mounts().at(0).scrollPosition().x", "0"),
("vertical.mounts().at(0).scrollPosition().y", "60"),
("horizontal.mounts().at(0).scrollPosition().x", "60"),
("horizontal.mounts().at(0).scrollPosition().y", "0"),
] {
assert_eq!(
session.execute(expression, "app.main").unwrap().value,
expected
);
}
let vertical_width: f32 = session
.execute("paragraph.mounts().at(0).bounds().width", "app.main")
.unwrap()
.value
.parse()
.unwrap();
let horizontal_width: f32 = session
.execute("paragraph.mounts().at(1).bounds().width", "app.main")
.unwrap()
.value
.parse()
.unwrap();
assert!(vertical_width <= 244.0);
assert!(horizontal_width > vertical_width);
session
.execute("horizontal.axis = \"vertical\"", "app.main")
.unwrap();
assert_eq!(
session
.execute("horizontal.mounts().at(0).scrollPosition().x", "app.main")
.unwrap()
.value,
"0"
);
}
#[test]
fn scroll_axis_defaults_to_both_and_rejects_unknown_policy() {
let mut session = ReplSession::new(262).unwrap();
session
.execute(
"scroller = ScrollBlock(20, 10, TextBlock(\"default\"))",
"app.main",
)
.unwrap();
assert_eq!(
session.execute("scroller.axis", "app.main").unwrap().value,
"\"both\""
);
let error = ReplSession::load(
r#"
scroller = ScrollBlock(20, 10, TextBlock("bad"))
scroller.axis = "diagonal"
page = Page()
page.render(scroller)
"#,
"app.main",
263,
1,
)
.err()
.expect("invalid axis");
assert!(
error
.to_string()
.contains("ScrollBlock.axis expects both, vertical or horizontal"),
"{error:#}"
);
}
#[test]
fn scroll_axis_uses_source_allocated_viewport_instead_of_constructor_width() {
let source = r#"
class Allocator: Column {
function arrange(self: Allocator, mount: Mount, geometry: Bounds): Arrangement {
result = Column.arrange(self, mount, geometry)
result.children.at(0).width = 120
return result
}
}
text = TextBlock("This text must wrap inside the allocated viewport, not the original constructor width. This text must wrap inside the allocated viewport.")
scroller = ScrollBlock(300, 80, text)
scroller.padding = 10
scroller.axis = "vertical"
page = Page()
page.render(Allocator(0, 0, scroller))
"#;
let (mut session, initial) = ReplSession::load(source, "app.main", 261, 1).unwrap();
initial.validate().unwrap();
assert_eq!(
session
.execute("scroller.mounts().at(0).bounds().width", "app.main")
.unwrap()
.value,
"120"
);
let width: f32 = session
.execute("text.mounts().at(0).bounds().width", "app.main")
.unwrap()
.value
.parse()
.unwrap();
assert!(width <= 100.0);
assert_eq!(
session
.execute("scroller.mounts().at(0).scrollPosition().maxX", "app.main")
.unwrap()
.value,
"0"
);
}
#[test]
fn nested_scroll_into_view_and_source_paint_share_clipped_geometry() {
let mut session = ReplSession::new(241).unwrap();
let result = session
.execute(
r#"
class Painted: RectBlock {
function paint(self: Painted, mount: Mount, geometry: Bounds): DisplayList {
return RectBlock.paint(self,mount,geometry)
}
}
last = Button("last")
inner = ScrollBlock(160,100,Column(0,8,Painted(200,180),last))
outer = ScrollBlock(180,140,Column(0,8,RectBlock(180,180),inner))
page = Page()
page.render(outer)
"#,
"app.main",
)
.unwrap()
.document
.unwrap();
assert!(result.frame.command_clips.iter().any(Option::is_some));
session
.execute("last.mounts().at(0).scrollIntoView()", "app.main")
.unwrap();
assert_eq!(
session
.execute("inner.mounts().at(0).scrollPosition().y", "app.main")
.unwrap()
.value,
"128"
);
assert_eq!(
session
.execute("outer.mounts().at(0).scrollPosition().y", "app.main")
.unwrap()
.value,
"148"
);
assert_eq!(
session
.execute("last.mounts().at(0).hitTest(10,110)", "app.main")
.unwrap()
.value,
"true"
);
}
#[test]
fn click_and_dispatch_return_the_actual_delivered_event() {
let mut session = ReplSession::new(237).unwrap();
session
.execute(
r#"button = Button("event")
clicked = button.click()
dispatched = Event("click")
returned = button.dispatch(dispatched)"#,
"app.main",
)
.unwrap();
assert_eq!(
session
.execute("ui.isInstance(clicked, Event)", "app.main")
.unwrap()
.value,
"true"
);
assert_eq!(
session.execute("clicked.kind", "app.main").unwrap().value,
"\"click\""
);
let globals = session.candidate.globals.unwrap();
assert_eq!(
session
.candidate
.vm
.dict_get(globals, &Value::Text("returned".into()))
.unwrap(),
session
.candidate
.vm
.dict_get(globals, &Value::Text("dispatched".into()))
.unwrap()
);
}
#[derive(Debug)]
struct VisualRedirect {
target: ObjectRef,
signature: endor_core::CallSignature,
}
impl endor_core::NativeCallable for VisualRedirect {
fn signature(&self) -> Option<endor_core::CallSignature> {
Some(self.signature.clone())
}
fn invoke(
&self,
_: &mut endor_core::NativeContext<'_>,
arguments: &[Value],
) -> std::result::Result<endor_core::NativeResult, VmError> {
Ok(endor_core::NativeResult::Invoke {
function: self.target,
arguments: arguments.to_vec(),
})
}
}
#[test]
fn native_visual_identity_accepts_shared_window_registries_only() {
let mut session = ReplSession::new(238).unwrap();
session.execute("second = Window()", "app.main").unwrap();
let globals = session.candidate.globals.unwrap();
let Value::Object(window) = session
.candidate
.vm
.dict_get(globals, &Value::Text("second".into()))
.unwrap()
.unwrap()
else {
panic!("window")
};
let vm = &mut session.candidate.vm;
let desktop = vm
.payload::<crate::window::ManagedWindow>(window, "Window")
.unwrap()
.desktop;
let block = crate::ui_bridge::desktop_type(vm.object_store(), "Block", desktop).unwrap();
let function = vm
.object_store()
.lookup_method(block, "measure")
.unwrap()
.unwrap();
assert!(
crate::native_visual::is_intrinsic(vm.object_store(), function, "measure", desktop)
.unwrap()
);
assert!(
!crate::native_visual::is_intrinsic(vm.object_store(), function, "paint", desktop).unwrap()
);
let foreign = vm.new_object().unwrap();
let registry = vm.new_dict().unwrap();
vm.set_attr(foreign, "types", Value::Object(registry))
.unwrap();
assert!(
!crate::native_visual::is_intrinsic(vm.object_store(), function, "measure", foreign)
.unwrap()
);
vm.release(foreign).unwrap();
vm.release(registry).unwrap();
}
#[test]
fn replacing_base_visual_methods_changes_layout_and_same_identity_paint() {
let (mut session, _) = ReplSession::load(r#"
class Hooks {
function measure(self: Object, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(140, 70) }
function paint(self: Object, mount: Mount, geometry: Bounds): DisplayList { return DisplayList(ObjectList()) }
}
base = Block
rect = RectBlock(20, 10)
page = Page()
page.render(rect)
"#, "app.main", 235, 1).unwrap();
let globals = session.candidate.globals.unwrap();
let get = |name: &str| match session
.candidate
.vm
.dict_get(globals, &Value::Text(name.into()))
.unwrap()
.unwrap()
{
Value::Object(value) => value,
_ => panic!("object"),
};
let base = get("base");
let hooks = get("Hooks");
let Value::Object(measure) = session.candidate.vm.get_attr(hooks, "measure").unwrap() else {
panic!("measure")
};
let Value::Object(paint) = session.candidate.vm.get_attr(hooks, "paint").unwrap() else {
panic!("paint")
};
session
.candidate
.vm
.define_method(base, "measure", measure)
.unwrap();
session.refresh_document().unwrap().unwrap();
assert_eq!(
session
.execute("rect.mounts().at(0).bounds().width", "app.main")
.unwrap()
.value,
"140"
);
let original = session
.candidate
.vm
.object_store()
.lookup_method(base, "paint")
.unwrap()
.unwrap();
let signature = session
.candidate
.vm
.object_store()
.native_signature(original)
.unwrap()
.unwrap();
session
.candidate
.vm
.replace_payload(
original,
endor_core::ContextNativeFunction::new(
"redirect paint",
VisualRedirect {
target: paint,
signature,
},
vec![paint],
),
)
.unwrap();
let updated = session.refresh_document().unwrap().unwrap();
assert!(updated.frame.commands.is_empty());
}
#[test]
fn same_identity_event_payload_changes_invalidate_interactivity_cache() {
let mut session = ReplSession::new(236).unwrap();
session
.execute(
r#"
class Hooks { function onEvent(self: Object, event: Event): Unit {} }
base = Block
rect = RectBlock(20, 10)
"#,
"app.main",
)
.unwrap();
let globals = session.candidate.globals.unwrap();
let get = |name: &str| match session
.candidate
.vm
.dict_get(globals, &Value::Text(name.into()))
.unwrap()
.unwrap()
{
Value::Object(value) => value,
_ => panic!("object"),
};
let base = get("base");
let hooks = get("Hooks");
let rect = get("rect");
let Value::Object(target) = session.candidate.vm.get_attr(hooks, "onEvent").unwrap() else {
panic!("onEvent")
};
let vm = &mut session.candidate.vm;
let function = vm
.object_store()
.lookup_method(base, "onEvent")
.unwrap()
.unwrap();
let mut signature = vm
.object_store()
.native_signature(function)
.unwrap()
.unwrap();
signature.effect = endor_core::CallEffect::Mutating;
let mut cache = crate::snapshot::RenderCache::default();
let initial = cache.snapshot(vm.object_store(), rect).unwrap();
assert!(!initial.interactive);
vm.take_changes();
vm.replace_payload(
function,
endor_core::ContextNativeFunction::new(
"redirect event",
VisualRedirect { target, signature },
vec![target],
),
)
.unwrap();
cache.invalidate(vm.take_changes());
let updated = cache.snapshot(vm.object_store(), rect).unwrap();
assert!(updated.interactive);
assert_ne!(initial.protocol_key, updated.protocol_key);
assert_eq!(
session
.execute("ui.implements(base, EventTarget)", "app.main")
.unwrap()
.value,
"true"
);
}
#[test]
fn source_effect_queries_use_compiled_instructions_without_running_functions() {
let mut session = ReplSession::new(234).unwrap();
session
.execute(
r#"
function simple(value: Number): Number { return value }
function forward(value: Number): Number { return simple(value) }
function unknown(callback: Function): Object { return callback() }
function recursive(value: Number): Number { return recursive(value) }
marker = ui.state(0)
function impure(value: Number): Number { marker.increment() return value }
original = simple
class ReadOnlyModel {
function helper(self: ReadOnlyModel, value: Number): Number { return value }
function check(self: ReadOnlyModel, value: Number): Number { return self.helper(value) }
}
model = ReadOnlyModel()
"#,
"app.main",
)
.unwrap();
let globals = session.candidate.globals.unwrap();
for (name, expected) in [
("simple", Some(endor_core::CallEffect::ReadOnly)),
("forward", Some(endor_core::CallEffect::ReadOnly)),
("recursive", Some(endor_core::CallEffect::ReadOnly)),
("unknown", None),
("impure", None),
] {
let Value::Object(function) = session
.candidate
.vm
.dict_get(globals, &Value::Text(name.into()))
.unwrap()
.unwrap()
else {
panic!("function")
};
assert_eq!(
session
.candidate
.vm
.object_store()
.call_effect(function)
.unwrap(),
expected
);
}
let Value::Object(model) = session
.candidate
.vm
.dict_get(globals, &Value::Text("model".into()))
.unwrap()
.unwrap()
else {
panic!("model")
};
let Value::Object(bound) = session.candidate.vm.bind_attr(model, "check").unwrap() else {
panic!("bound")
};
assert_eq!(
session
.candidate
.vm
.object_store()
.call_effect(bound)
.unwrap(),
Some(endor_core::CallEffect::ReadOnly)
);
session.candidate.vm.release(bound).unwrap();
let Value::Object(unbound) = session.candidate.vm.get_attr(model, "check").unwrap() else {
panic!("method")
};
assert_eq!(session.candidate.vm.call_effect(unbound).unwrap(), None);
let Value::Object(forward) = session
.candidate
.vm
.dict_get(globals, &Value::Text("forward".into()))
.unwrap()
.unwrap()
else {
panic!("forward")
};
session.execute("simple = impure", "app.main").unwrap();
assert_eq!(session.candidate.vm.call_effect(forward).unwrap(), None);
session.execute("simple = original", "app.main").unwrap();
assert_eq!(
session.candidate.vm.call_effect(forward).unwrap(),
Some(endor_core::CallEffect::ReadOnly)
);
assert_eq!(
session.execute("marker.value", "app.main").unwrap().value,
"0"
);
}
#[test]
fn typed_method_shadows_invalidate_cached_ancestors_and_can_be_removed() {
let mut session = ReplSession::new(233).unwrap();
session
.execute(
"rect = RectBlock(40, 20)\nroot = Column(0, 0, rect)",
"app.main",
)
.unwrap();
let globals = session.candidate.globals.unwrap();
let lookup = |name: &str| match session
.candidate
.vm
.dict_get(globals, &Value::Text(name.into()))
.unwrap()
.unwrap()
{
Value::Object(value) => value,
_ => panic!("object"),
};
let rect = lookup("rect");
let root = lookup("root");
for name in ["paint", "measure", "onEvent"] {
let error = session
.execute(&format!("rect.{name} = 1"), "app.main")
.err()
.expect("typed method cannot be shadowed");
assert!(
error.to_string().contains("instance attribute shadows"),
"{error:#}"
);
}
let vm = &mut session.candidate.vm;
let storage = *vm
.object_store()
.attribute_path(rect)
.unwrap()
.last()
.unwrap();
let mut cache = crate::snapshot::RenderCache::default();
cache.snapshot(vm.object_store(), root).unwrap();
vm.take_changes();
cache.snapshot(vm.object_store(), root).unwrap();
assert_eq!(cache.visited, 0);
vm.set_attr(storage, "paint", Value::Nil).unwrap();
cache.invalidate(vm.take_changes());
assert!(matches!(
cache.snapshot(vm.object_store(), root),
Err(endor_core::VmError::TraitViolation { .. })
));
vm.del_attr(storage, "paint").unwrap();
cache.invalidate(vm.take_changes());
cache.snapshot(vm.object_store(), root).unwrap();
assert!(cache.visited > 0);
}
#[test]
fn presentable_type_contracts_reject_before_registration_or_body_effects() {
let mut session = ReplSession::new(232).unwrap();
session.execute("marker = ui.state(0)", "app.main").unwrap();
for (name, method) in [
(
"WrongParameter",
"function present(self: WrongParameter, window: Point): Block { marker.increment() return TextBlock(\"bad\") }",
),
(
"WrongResult",
"function present(self: WrongResult, window: Window): Number { marker.increment() return 7 }",
),
] {
let input = format!("class {name} {{ {method} }}\nui.implement({name}, Presentable)");
let error = session
.execute(&input, "app.main")
.err()
.expect("incompatible typed capability");
assert!(
error
.to_string()
.contains("trait Presentable method present"),
"{error:#}"
);
assert_eq!(
session
.execute(&format!("ui.implements({name}, Presentable)"), "app.main")
.unwrap()
.value,
"false"
);
assert_eq!(
session.execute("marker.value", "app.main").unwrap().value,
"0"
);
}
session.execute(r#"
class Good {
function present(self: Good, window: Object): TextBlock { return TextBlock("typed") }
}
ui.implement(Good, Presentable)
view = ui.present(Good())
"#, "app.main").unwrap();
assert_eq!(
session
.execute("ui.isInstance(view, TextBlock)", "app.main")
.unwrap()
.value,
"true"
);
assert!(!session.is_faulted());
}
#[test]
fn presentable_dispatch_preserves_source_identity_and_reactive_display() {
let (mut session, initial) = ReplSession::load(
include_str!("../../../examples/presentable.endor"),
"app.main",
1,
230,
)
.unwrap();
assert!(initial.frame.commands.iter().any(|command| matches!(command,RenderCommand::Text { value, .. } if value == "普通对象的显式展示能力")));
let globals = session.candidate.globals.unwrap();
let Value::Object(product) = session
.candidate
.vm
.dict_get(globals, &Value::Text("product".into()))
.unwrap()
.unwrap()
else {
panic!("expected Product");
};
let Value::Object(view) = session
.candidate
.vm
.dict_get(globals, &Value::Text("view".into()))
.unwrap()
.unwrap()
else {
panic!("expected Block");
};
assert_eq!(
session.candidate.vm.get_attr(view, "source").unwrap(),
Value::Object(product)
);
assert_eq!(
session
.execute("ui.supports(product,Presentable)", "app.main")
.unwrap()
.value,
"true"
);
let changed = session
.execute("product.name = \"updated\"", "app.main")
.unwrap()
.document
.unwrap();
assert!(
changed.frame.commands.iter().any(
|command| matches!(command,RenderCommand::Text { value, .. } if value == "updated")
)
);
assert!(session.execute("class Bad: RectBlock { function paint(self: Bad): DisplayList { return DisplayList(ObjectList()) } }","app.main").err().unwrap().to_string().contains("trait Visual method paint"));
assert!(!session.is_faulted());
}
#[test]
fn matching_present_method_does_not_implicitly_declare_presentable() {
let mut session = ReplSession::new(231).unwrap();
session
.execute(
r#"
class Data {
marker: Object
function present(self: Data, window: Window): Block {
self.marker.increment()
return TextBlock("custom")
}
}
marker = ui.state(0)
data = Data(marker)
fallback = ui.present(data)
"#,
"app.main",
)
.unwrap();
assert_eq!(
session.execute("marker.value", "app.main").unwrap().value,
"0"
);
assert_eq!(
session
.execute("ui.supports(data,Presentable)", "app.main")
.unwrap()
.value,
"false"
);
session
.execute(
"ui.implement(Data,Presentable)\ncustom = ui.open(data)",
"app.main",
)
.unwrap();
assert_eq!(
session.execute("marker.value", "app.main").unwrap().value,
"1"
);
let before = session.documents.clone();
assert!(
session
.execute(
"class Missing {}\nui.implement(Missing,Presentable)",
"app.main"
)
.is_err()
);
assert_eq!(
session.documents[&crate::window::MAIN_WINDOW]
.frame
.layout_version,
before[&crate::window::MAIN_WINDOW].frame.layout_version
);
}
#[test]
fn managed_property_bindings_update_hidden_objects_and_shared_window_geometry() {
let mut session = ReplSession::new(220).unwrap();
session
.execute(
r##"
class Model {
width: Number
function read(self: Model): Number { return self.width }
}
model = Model(80)
rect = RectBlock(1,20,0,"#DDEEFF")
token = rect.bind("width",model.read)
"##,
"app.main",
)
.unwrap();
assert_eq!(
session.execute("rect.width", "app.main").unwrap().value,
"80"
);
session.execute("model.width = 140", "app.main").unwrap();
assert_eq!(
session.execute("rect.width", "app.main").unwrap().value,
"140"
);
session.execute("first = Page()\nfirst.render(rect)\nother = Window()\nsecond = Page()\nsecond.open(other)\nsecond.render(rect)", "app.main").unwrap();
let updated = session
.execute("model.width = 260", "app.main")
.unwrap()
.document
.unwrap();
for frame in std::iter::once(&updated.frame)
.chain(updated.windows.values().map(|document| &document.frame))
{
assert!(frame.mounts.iter().any(|mount| mount.width == 260.0));
}
session
.execute("token.disconnect()\nmodel.width = 360", "app.main")
.unwrap();
assert_eq!(
session.execute("rect.width", "app.main").unwrap().value,
"260"
);
let direct = session
.execute("rect.width = 180", "app.main")
.unwrap()
.document
.unwrap();
assert!(direct.frame.mounts.iter().any(|mount| mount.width == 180.0));
}
#[test]
fn multiple_object_outputs_on_one_target_do_not_invalidate_each_other() {
let mut session = ReplSession::new(222).unwrap();
session
.execute(
r##"
class Record { value: Number }
function makeRecord(): Object { return Record(7) }
rect = RectBlock(1,20)
rect.first = Record(0)
rect.second = Record(0)
first = rect.bind("first",makeRecord)
second = rect.bind("second",makeRecord)
"##,
"app.main",
)
.unwrap();
assert_eq!(
session
.execute("rect.first.value", "app.main")
.unwrap()
.value,
"7"
);
assert_eq!(
session
.execute("rect.second.value", "app.main")
.unwrap()
.value,
"7"
);
assert!(!session.is_faulted());
}
#[test]
fn property_bindings_use_precise_names_and_repair_direct_output_changes() {
let (mut session, _) = ReplSession::load(
r#"
size = 80
function readWidth(): Number { return size }
rect = RectBlock(1,20)
hidden = RectBlock(1,20)
rect.bind("width",readWidth)
hidden.bind("width",readWidth)
page = Page()
page.render(rect)
"#,
"app.main",
903,
1,
)
.unwrap();
let count = |session: &mut ReplSession| {
session
.execute("ui.stats().bindingEvaluations", "app.main")
.unwrap()
.value
.parse::<usize>()
.unwrap()
};
let baseline = count(&mut session);
assert_eq!(baseline, 2);
assert!(
session
.execute("42", "app.main")
.unwrap()
.document
.is_none()
);
assert_eq!(count(&mut session), baseline);
session.execute("size = 120", "app.main").unwrap();
assert_eq!(count(&mut session), baseline + 2);
assert_eq!(
session.execute("hidden.width", "app.main").unwrap().value,
"120"
);
session.execute("rect.width = 200", "app.main").unwrap();
assert_eq!(count(&mut session), baseline + 3);
assert_eq!(
session.execute("rect.width", "app.main").unwrap().value,
"120"
);
assert_eq!(count(&mut session), baseline + 3);
}
#[test]
fn source_render_caches_do_not_root_a_page_block_waiting_frame_cycle() {
let (mut session, _) = ReplSession::load(
include_str!("../../../examples/gc-lifecycle.endor"),
"app.main",
902,
1,
)
.unwrap();
let baseline = session
.execute("ui.stats().objects", "app.main")
.unwrap()
.value;
session.execute("mountCycle()", "app.main").unwrap();
session.poll_tasks().unwrap();
assert_eq!(
session.execute("phase.value", "app.main").unwrap().value,
"1"
);
assert_eq!(
session
.execute("pending.value.status()", "app.main")
.unwrap()
.value,
"\"running\""
);
let globals = session.candidate.globals.unwrap();
let Value::Object(holder) = session
.candidate
.vm
.dict_get(globals, &Value::Text("holder".into()))
.unwrap()
.unwrap()
else {
panic!("holder")
};
let Value::Object(page) = session.candidate.vm.get_attr(holder, "value").unwrap() else {
panic!("page")
};
let rect = crate::block::resolve(session.candidate.vm.object_store(), page)
.unwrap()
.1
.children[0]
.block;
let page_weak = session.candidate.vm.object_store().downgrade(page).unwrap();
let rect_weak = session.candidate.vm.object_store().downgrade(rect).unwrap();
session.execute("holder.value.close()", "app.main").unwrap();
assert_eq!(
session
.execute("pending.value.status()", "app.main")
.unwrap()
.value,
"\"running\""
);
session.execute("releaseCycle()", "app.main").unwrap();
assert_eq!(
session
.execute("ui.stats().objects", "app.main")
.unwrap()
.value,
baseline
);
assert!(session.candidate.vm.upgrade(page_weak).unwrap().is_none());
assert!(session.candidate.vm.upgrade(rect_weak).unwrap().is_none());
}
#[test]
fn binding_tokens_do_not_retain_hidden_targets_and_compute_is_read_only() {
let mut session = ReplSession::new(221).unwrap();
session
.execute(
r##"
class Model {
width: Number
function read(self: Model): Number { return self.width }
function bad(self: Model): Number {
self.width = 999
return self.width
}
}
model = Model(80)
rect = RectBlock(1,20,0,"#DDEEFF")
token = rect.bind("width",model.read)
"##,
"app.main",
)
.unwrap();
let globals = session.candidate.globals.unwrap();
let Value::Object(target) = session
.candidate
.vm
.dict_get(globals, &Value::Text("rect".into()))
.unwrap()
.unwrap()
else {
panic!("expected Block");
};
let weak = session
.candidate
.vm
.object_store()
.downgrade(target)
.unwrap();
session
.candidate
.vm
.dict_delete(globals, &Value::Text("rect".into()))
.unwrap();
session.candidate.vm.collect_cycles();
assert!(session.candidate.vm.upgrade(weak).unwrap().is_none());
assert_eq!(
session
.execute("token.isConnected()", "app.main")
.unwrap()
.value,
"false"
);
assert!(
session
.execute(
"other = RectBlock(1,20)\nbadToken = other.bind(\"width\",model.bad)",
"app.main"
)
.is_err()
);
assert!(session.is_faulted());
let Value::Object(model) = session
.candidate
.vm
.dict_get(globals, &Value::Text("model".into()))
.unwrap()
.unwrap()
else {
panic!("expected model");
};
assert_eq!(
session.candidate.vm.get_attr(model, "width").unwrap(),
Value::Number(80.into())
);
}
#[test]
fn native_method_callbacks_update_two_explicit_bindings() {
let mut session = ReplSession::new(21).unwrap();
session.execute("counter = ui.state(9)\nfirst = ui.textOf(counter, \"value\")\nsecond = ui.textOf(counter, \"value\")\nbutton = ui.button(\"increment\")\nfunction increment(): Unit { counter.increment() }\nbutton.onClick(increment)\npage = ui.page()\npage.render(ui.row(0, 8, first, second, button))", "app.main").unwrap();
let updated = session
.execute("button.click()", "app.main")
.unwrap()
.document
.unwrap();
let texts: Vec<_> = updated
.frame
.commands
.iter()
.filter_map(|command| match command {
RenderCommand::Text { value, .. } => Some(value.as_str()),
_ => None,
})
.collect();
assert_eq!(&texts[..2], &["10", "10"]);
}
#[test]
fn closing_a_page_releases_its_root_without_retaining_it_through_shared_content() {
let mut session = ReplSession::new(22).unwrap();
session
.execute(
"card = ui.rect(10, 20)\npage = ui.page()\npage.render(card)",
"app.main",
)
.unwrap();
let globals = session.candidate.globals.unwrap();
let Value::Object(page) = session
.candidate
.vm
.dict_get(globals, &Value::Text("page".into()))
.unwrap()
.unwrap()
else {
panic!("expected Page")
};
let weak = session.candidate.vm.downgrade(page).unwrap();
session.execute("page.close()", "app.main").unwrap();
session
.execute("card.setColor(\"#FFEEDD\")", "app.main")
.unwrap();
assert!(
session
.execute("page.open()", "app.main")
.unwrap()
.document
.is_some()
);
session.execute("page.close()", "app.main").unwrap();
session
.candidate
.vm
.dict_delete(globals, &Value::Text("page".into()))
.unwrap();
session
.bridge
.reclaim(&mut session.candidate.vm, &mut session.candidate.roots)
.unwrap();
assert!(session.candidate.vm.upgrade(weak).unwrap().is_none());
assert!(
session
.candidate
.vm
.dict_get(globals, &Value::Text("card".into()))
.unwrap()
.is_some()
);
}
#[test]
fn mounted_aliases_update_nested_positions_unmount_and_reject_cycles() {
let mut session = ReplSession::new(10).unwrap();
session.execute("card = ui.rect(10, 20)\nother = card\ncontainer = ui.column(0, 0, other)\npage = ui.page()\npage.render(container)", "app.main").unwrap();
let identity = session.execute("other", "app.main").unwrap().value;
let updated = session
.execute("card = ui.rect(640, 200, 32)", "app.main")
.unwrap();
assert_eq!(updated.value, identity);
assert_eq!(
session.execute("other", "app.main").unwrap().value,
identity
);
let frame = updated.document.unwrap().frame;
assert!(matches!(
frame.commands[0],
RenderCommand::RoundedRect {
width: 640.0,
height: 200.0,
..
}
));
assert!(
session
.execute("card = ui.column(0, 0, other)", "app.main")
.is_err()
);
assert!(
session
.execute("other", "app.main")
.unwrap()
.document
.is_none()
);
let unmounted = session
.execute("page.unmount()", "app.main")
.unwrap()
.document
.unwrap();
assert!(unmounted.frame.commands.is_empty());
assert!(
session
.execute("card = ui.rect(80, 50)", "app.main")
.unwrap()
.document
.is_none()
);
let remounted = session
.execute("page.render(other)", "app.main")
.unwrap()
.document
.unwrap();
assert!(matches!(
remounted.frame.commands[0],
RenderCommand::RoundedRect {
width: 80.0,
height: 50.0,
..
}
));
}
#[test]
fn live_session_retains_objects_and_updates_only_after_mounting() {
let mut session = ReplSession::new(7).unwrap();
let created = session
.execute(
r##"title = ui.text("你好,Tessera", 2, "#243047")"##,
"app.main",
)
.unwrap();
assert!(created.document.is_none());
assert_eq!(
session.execute("title", "app.main").unwrap().value,
created.value
);
assert_eq!(
session.execute("_", "app.main").unwrap().value,
created.value
);
let rendered = session
.execute("page = ui.page()\npage.render(title)", "app.main")
.unwrap()
.document
.unwrap();
assert!(
matches!(&rendered.frame.commands[0], RenderCommand::Text { value, .. } if value == "你好,Tessera")
);
assert_eq!(rendered.root.as_ref().unwrap().kind, "repl-session");
assert!(
session
.execute(r#"title = ui.text("changed")"#, "app.main")
.unwrap()
.document
.is_some()
);
let updated = session
.execute("page.render(title)", "app.main")
.unwrap()
.document
.unwrap();
assert!(
matches!(&updated.frame.commands[0], RenderCommand::Text { value, .. } if value == "changed")
);
assert!(updated.source.contains("// Console input 6"));
assert!(
session
.execute("title", "app.main")
.unwrap()
.document
.is_none()
);
}
#[test]
fn live_session_compiles_before_execution_and_discards_failed_render() {
let mut session = ReplSession::new(8).unwrap();
session
.execute("value = 1\npage = ui.page()", "app.main")
.unwrap();
assert!(session.execute("value = 2\nmissing", "app.main").is_err());
assert_eq!(session.execute("value", "app.main").unwrap().value, "1");
assert!(
session
.execute(
"value = 3\npage.render(ui.text(\"discarded\"))\nui.rect(-1, 1, 0)",
"app.main"
)
.is_err()
);
let retained = session.execute("value", "app.main").unwrap();
assert_eq!(retained.value, "3");
assert!(retained.document.is_none());
// Failed publication leaves the Page/bindings usable, but it can only
// become visible through a later successful explicit render.
let recovered = session
.execute("page.render(ui.text(\"recovered\"))", "app.main")
.unwrap()
.document
.unwrap();
assert!(recovered.frame.commands.iter().any(|command| matches!(
command, RenderCommand::Text { value, .. } if value == "recovered"
)));
assert!(recovered.frame.commands.iter().all(|command| !matches!(
command, RenderCommand::Text { value, .. } if value == "discarded"
)));
session
.execute(
"function local(value: Number): Number { result = value result = 42 return result }",
"app.main",
)
.unwrap();
assert_eq!(
session.execute("local(40)", "app.main").unwrap().value,
"42"
);
}
#[test]
fn top_level_goal_script_renders_without_a_main_or_synthetic_wrapper() {
let document = compile(PAGE, "app.main", 9).unwrap();
assert_eq!(document.source, PAGE);
let tree = document.root.as_ref().unwrap();
assert_eq!(tree.kind, "script");
assert_eq!(tree.children.len(), 4);
assert_eq!(tree.children[0].name, "heading");
assert_eq!(tree.children[1].name, "rectangle");
assert_eq!(tree.children[2].name, "myui");
assert_eq!(tree.children[2].start_line, 3);
assert_eq!(tree.children[3].start_line, 4);
assert!(tree.id.contains("app.main@9"));
assert_eq!(document.frame.commands.len(), 2);
assert!(
matches!(&document.frame.commands[0], RenderCommand::Text { value, size: 2, x: 24.0, y: 24.0, .. } if value == "Hello Endor")
);
assert!(
matches!(&document.frame.commands[1], RenderCommand::RoundedRect { x: 24.0, y: 76.0, width: 240.0, height: 80.0, radius: 12.0, color } if color == "#DCE8FF")
);
assert_eq!(document.display_list(2.0).unwrap().commands().len(), 3);
let wrapped = compile(include_str!("../../../examples/main.endor"), "app.main", 10).unwrap();
assert_eq!(
serde_json::to_value(&document.frame.commands).unwrap(),
serde_json::to_value(&wrapped.frame.commands).unwrap()
);
// Separate VM instances render the same commands with independent
// managed object identities and presentation contexts.
assert_ne!(
document.frame.mounts[0].block_id,
wrapped.frame.mounts[0].block_id
);
}
#[test]
fn script_helpers_multiline_bindings_calls_and_member_continuations_use_aster() {
let source = r##"let color = "#242422" // comment with ( and )
function greeting(): Text {
return "Hello Endor"
}
function heading(value: Text): View {
return ui.text(value, 2, color)
}
let title =
heading(greeting())
let rectangle = ui.rect(
240, 80, 12,
"#DCE8FF"
)
let page = ui
.page()
page
.render(ui.column(24, 12, title, rectangle))
"##;
let document = compile(source, "app.main", 2).unwrap();
assert_eq!(document.frame.commands.len(), 2);
let tree = document.root.unwrap();
assert_eq!(tree.children[1].name, "greeting");
assert_eq!(tree.children[1].start_line, 2);
assert_eq!(tree.children[2].name, "heading");
assert_eq!(tree.children[2].start_line, 5);
assert_eq!(tree.children[3].start_line, 8);
assert_eq!(tree.children[3].end_line, 9);
assert_eq!(tree.children.last().unwrap().end_line, 17);
}
#[test]
fn script_diagnostics_refer_to_original_unicode_source_and_byte_ranges() {
for ending in ["\n", "\r\n", "\r"] {
let source = format!(
"let title = ui.text(\"中文\"){ending}// comment{ending}let broken = missing{ending}ui.page().render(title)"
);
let error = compile(&source, "app.main", 27).unwrap_err();
let diagnostic = error.downcast_ref::<frontend::SourceDiagnostic>().unwrap();
assert_eq!(diagnostic.location.line, 3);
assert_eq!(diagnostic.location.column, 14);
assert_eq!(diagnostic.diagnostic.span.slice(&source), Some("missing"));
assert!(diagnostic.origin.to_string().contains("submission 27"));
}
let error = compile("let page = ui.page()\npage.render(,)", "app.main", 1).unwrap_err();
let diagnostic = error.downcast_ref::<frontend::SourceDiagnostic>().unwrap();
assert_eq!(diagnostic.location.line, 2);
}
#[test]
fn pages_bind_render_methods_and_last_render_selects_the_snapshot() {
let source = r#"let first = ui.page()
let second = ui.page()
first.render(ui.text("first"))
second.render(ui.text("second"))
let paint = first.render
paint(ui.text("last"))"#;
let document = compile(source, "app.main", 1).unwrap();
assert_eq!(document.frame.commands.len(), 1);
assert!(
matches!(&document.frame.commands[0], RenderCommand::Text { value, .. } if value == "last")
);
}
#[test]
fn page_api_errors_and_late_failures_do_not_publish_partial_snapshots() {
let mut state = NamespaceState {
submission: 1,
..NamespaceState::default()
};
state
.commit(1, compile(PAGE, "app.main", 1).unwrap())
.unwrap();
for source in [
"ui.page()",
"let title = ui.text(\"no render\")",
"let page = ui.page(1)\npage.render(ui.text(\"x\"))",
"let page = ui.page()\npage.render()",
"let page = ui.page()\npage.render(\"not a node\")",
"let page = ui.page()\npage.render(ui.text(\"partial\"))\nui.missing()",
] {
state.submission = state.next_submission().unwrap();
assert!(
compile(source, "app.main", state.submission).is_err(),
"{source}"
);
assert_eq!(state.document.source, PAGE);
assert_eq!(state.revision, 1);
}
}
#[test]
fn page_creation_has_a_separate_live_object_budget() {
let mut source = String::new();
for index in 0..=ui_bridge::MAX_PAGES {
source.push_str(&format!("let page{index} = ui.page()\n"));
}
source.push_str("page0.render(ui.text(\"x\"))");
let error = compile(&source, "app.main", 1).unwrap_err();
assert!(error.to_string().contains("64 page budget"), "{error:#}");
}
#[test]
fn script_execution_is_limited_even_without_creating_ui_nodes() {
let mut source = "function text0(): Text { return \"Hello Endor\" }\n".to_owned();
for index in 1..=16 {
source.push_str(&format!(
"function text{index}(): Text {{ let unused = text{}() return text{}() }}\n",
index - 1,
index - 1
));
}
source.push_str("let title = ui.text(text16())\nui.page().render(title)");
let error = compile(&source, "app.main", 1).unwrap_err();
assert!(
error
.to_string()
.contains("execution limit reached: instructions"),
"{error:#}"
);
}
#[test]
fn literal_string_return_from_aster_can_be_displayed_by_the_host() {
let source = "function main(): Text { return \"Hello Endor\" }\nlet title = ui.text(main())\nui.page().render(title)";
let document = compile(source, "app.main", 1).unwrap();
assert!(
matches!(&document.frame.commands[0], RenderCommand::Text { value, .. } if value == "Hello Endor")
);
}
#[test]
fn developer_selection_resets_to_the_new_source_snapshot() {
let first = compile(PAGE, "app.main", 1).unwrap();
let second = compile(PAGE, "app.main", 2).unwrap();
let mut tools = crate::developer_tools::DeveloperToolsState::default();
tools.open = true;
tools.selected_node_id = Some(first.root.as_ref().unwrap().children[0].id.clone());
tools.document_updated(&second);
assert_eq!(
tools.selected_node_id.as_deref(),
Some(second.root.as_ref().unwrap().id.as_str())
);
}
#[test]
fn executes_aster_functions_and_renders_after_vm_cleanup() {
let document = compile(EXAMPLE, "app.main", 1).unwrap();
assert_eq!(document.source, EXAMPLE);
let tree = document.root.as_ref().unwrap();
assert_eq!(tree.kind, "module");
assert_eq!(tree.name, "app.main");
assert_eq!(tree.children[0].name, "heading");
assert_eq!(tree.children[1].name, "main");
assert_eq!(tree.children[1].start_line, 5);
assert_eq!(document.frame.commands.len(), 4);
assert!(
matches!(&document.frame.commands[0], RenderCommand::Text { value, size: 2, x: 24.0, y: 24.0, .. } if value == "Hello Endor")
);
assert!(matches!(
&document.frame.commands[1],
RenderCommand::RoundedRect {
x: 24.0,
y: 76.0,
width: 240.0,
height: 80.0,
..
}
));
let first_width = crate::text::measure("Aster VM", crate::text::TextStyle::ui(1)).width;
assert!(matches!(
&document.frame.commands[3],
RenderCommand::Text { x, y: 168.0, .. } if (*x - (24.0 + first_width + 12.0 + 8.0 + 12.0)).abs() < 0.01
));
// Candidate::close already proved zero remaining VM objects. Both
// redraws must still work from native data at different display scales.
assert_eq!(document.display_list(1.0).unwrap().commands().len(), 5);
assert_eq!(document.display_list(2.0).unwrap().commands().len(), 5);
}
#[test]
fn borrowed_ui_handles_survive_variables_helpers_and_reused_children() {
let source = r#"function wrap(child: View): View { return ui.row(4, 8, child, child) }
function main(): View { let child = ui.text("shared") return wrap(child) }"#;
let document = compile(source, "arbitrary.namespace", 3).unwrap();
assert_eq!(document.frame.commands.len(), 2);
assert!(matches!(
&document.frame.commands[0],
RenderCommand::Text { x: 4.0, y: 4.0, .. }
));
let width = crate::text::measure("shared", crate::text::TextStyle::ui(1)).width;
assert!(matches!(
&document.frame.commands[1],
RenderCommand::Text { x, y: 4.0, .. } if (*x - (4.0 + width + 8.0)).abs() < 0.01
));
}
#[test]
fn architecture_ranges_follow_crlf_and_cr_source_snapshots() {
for ending in ["\r\n", "\r"] {
let source = EXAMPLE.replace('\n', ending);
let document = compile(&source, "app.main", 1).unwrap();
assert_eq!(document.source, source);
assert_eq!(document.root.unwrap().children[1].start_line, 5);
}
}
#[test]
fn rejects_invalid_entry_objects_arguments_and_old_syntax() {
for source in [
"namespace app.main export scene Main { render { Text(\"old\") } }",
"function helper(): View { return ui.text(\"missing main\") }",
"function main(value: View): View { return value }",
"function main(): View { return \"wrong root\" }",
"function main(): View { return main }",
"function main(): View { return ui.text(1) }",
"function main(): View { return ui.text(\"x\", 1.0) }",
"function main(): View { return ui.text(\"x\", 9) }",
"function main(): View { return ui.rect(-1, 1) }",
"function main(): View { return ui.rect(1000001, 1) }",
"function main(): View { return ui.rect(1, 1, 0, \"#中中\") }",
"function main(): View { return ui.column(0, 0, ui.text) }",
"function main(): View { return unknown(1) }",
"function main(): View { return ui.missing() }",
] {
assert!(
compile(source, "app.main", 1).is_err(),
"accepted: {source}"
);
}
}
#[test]
fn source_diagnostics_keep_unicode_locations_and_submission_origin() {
let error = compile(
"function main(): View {\n let title = ui.text(\"中文\")\n return missing\n}",
"app.main",
17,
)
.unwrap_err();
let error = error.downcast_ref::<frontend::SourceDiagnostic>().unwrap();
assert_eq!(error.location.line, 3);
assert!(error.origin.to_string().contains("submission 17"));
}
#[test]
fn recursive_program_stops_at_the_vm_frame_limit() {
let error = compile("function main(): View { return main() }", "app.main", 1).unwrap_err();
assert!(
error
.to_string()
.contains("execution limit reached: frames")
);
}
fn nested_functions(levels: usize, shared: bool) -> String {
let mut source = "function node0(): View { return ui.text(\"x\") }\n".to_owned();
for index in 1..=levels {
let previous = index - 1;
if shared {
source.push_str(&format!("function node{index}(): View {{ let child = node{previous}() return ui.column(0, 0, child, child) }}\n"));
} else {
source.push_str(&format!("function node{index}(): View {{ return ui.column(0, 0, node{previous}(), node{previous}()) }}\n"));
}
}
source.push_str(&format!(
"function main(): View {{ return node{levels}() }}"
));
source
}
#[test]
fn construction_and_expanded_layout_have_separate_budgets() {
let error = compile(&nested_functions(12, false), "app.main", 1).unwrap_err();
assert!(error.to_string().contains("4096 node budget"), "{error:#}");
let error = compile(&nested_functions(14, true), "app.main", 2).unwrap_err();
assert!(
error.to_string().contains("expanded Block graph"),
"{error:#}"
);
}
#[test]
fn managed_ui_depth_is_not_truncated_at_64_levels() {
let mut source = "function node0(): View { return ui.text(\"x\") }\n".to_owned();
for index in 1..=64 {
source.push_str(&format!(
"function node{index}(): View {{ return ui.column(0, 0, node{}()) }}\n",
index - 1
));
}
source.push_str("function main(): View { return node64() }");
let document = compile(&source, "app.main", 1).unwrap();
assert_eq!(document.frame.commands.len(), 1);
}
#[test]
fn failed_updates_preserve_success_and_delete_invalidates_inflight_work() {
let mut state = NamespaceState::default();
state.submission = state.next_submission().unwrap();
assert_eq!(
state
.commit(state.submission, compile(EXAMPLE, "app.main", 1).unwrap())
.unwrap(),
Some(1)
);
state.submission = state.next_submission().unwrap();
assert!(compile("invalid", "app.main", state.submission).is_err());
assert_eq!(state.revision, 1);
assert_eq!(state.document.source, EXAMPLE);
let stale = state.submission;
let candidate = compile(EXAMPLE, "app.main", stale).unwrap();
state.submission = state.next_submission().unwrap();
assert!(state.commit(stale, candidate.clone()).unwrap().is_none());
let deleted = state.clear().unwrap();
assert!(state.commit(stale, candidate).unwrap().is_none());
assert_eq!(state.revision, deleted);
assert!(state.document.source.is_empty());
state.submission = state.next_submission().unwrap();
let candidate = compile(EXAMPLE, "app.main", state.submission).unwrap();
assert_eq!(
state.commit(state.submission, candidate).unwrap(),
Some(deleted + 1)
);
}
const EXIT_SOURCE: &str = r#"
class ExitText: TextBlock {
log: Object
function onUnmount(self: ExitText, mount: Mount): Unit {
self.log.increment()
}
}
log = ui.state(0)
text = ExitText("exit",1,log)
page = Page()
page.render(text)
second = Window()
other = Page()
other.render(text)
other.moveToWindow(second)
pending = ui.sleep(ui,"pending",30000)
"#;
fn exit_log(session: &ReplSession) -> Value {
let globals = session.candidate.globals.unwrap();
let Some(Value::Object(log)) = session
.candidate
.vm
.dict_get(globals, &Value::Text("log".into()))
.unwrap()
else {
panic!("log")
};
session.candidate.vm.get_attr(log, "value").unwrap()
}
#[test]
fn shutdown_unmounts_all_windows_once_and_cancels_tasks() {
let (mut session, _) = ReplSession::load(EXIT_SOURCE, "app.main", 701, 1).unwrap();
assert_eq!(exit_log(&session), Value::Number(0_i64.into()));
let (windows, mounts, tasks) = session.shutdown().unwrap();
assert_eq!(windows, 2);
assert!(mounts >= 2);
assert!(tasks >= 1);
assert_eq!(exit_log(&session), Value::Number(2_i64.into()));
session.shutdown().unwrap();
assert_eq!(exit_log(&session), Value::Number(2_i64.into()));
assert!(session.execute("42", "app.main").is_err());
}
#[test]
fn shutdown_stopped_runtime_never_reenters_source_hooks() {
let (mut session, _) = ReplSession::load(EXIT_SOURCE, "app.main", 702, 1).unwrap();
session.faulted = true;
session.shutdown().unwrap();
assert_eq!(exit_log(&session), Value::Number(0_i64.into()));
}
#[test]
fn shutdown_hooks_cannot_create_new_background_work() {
let source = EXIT_SOURCE.replace("self.log.increment()", "ui.sleep(ui,\"late\",30000)");
let (mut session, _) = ReplSession::load(&source, "app.main", 703, 1).unwrap();
let error = session.shutdown().unwrap_err();
assert!(format!("{error:#}").contains("runtime is exiting; new tasks are forbidden"));
assert!(session.is_faulted());
}