diff --git a/src/endor_host.rs b/src/endor_host.rs index d43c753..19d0aba 100644 --- a/src/endor_host.rs +++ b/src/endor_host.rs @@ -1379,5640 +1379,4 @@ impl Architecture { } #[cfg(test)] -mod tests { - 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::>(); - 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::() - .unwrap(); - let y = session - .execute("tail.mounts().at(0).bounds().y", "app.main") - .unwrap() - .value - .parse::() - .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::>(), - ids - ); - let wider = session - .execute("paragraph.mounts().at(0).bounds().height", "app.main") - .unwrap() - .value - .parse::() - .unwrap(); - let moved = session - .execute("tail.mounts().at(0).bounds().y", "app.main") - .unwrap() - .value - .parse::() - .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::>() - ); - 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::() - .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::() - .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::(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::(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::>() - ); - 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::>() - .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::>() - .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::(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::>() - .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::>() - .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::>() - .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::>() - .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::>() - .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::>() - .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 { - Some(self.signature.clone()) - } - fn invoke( - &self, - _: &mut endor_core::NativeContext<'_>, - arguments: &[Value], - ) -> std::result::Result { - 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::(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::() - .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::().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::().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::().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()); - } -} +mod tests; diff --git a/src/endor_host/tests.rs b/src/endor_host/tests.rs new file mode 100644 index 0000000..975b1f0 --- /dev/null +++ b/src/endor_host/tests.rs @@ -0,0 +1,5648 @@ +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::>(); + 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::() + .unwrap(); + let y = session + .execute("tail.mounts().at(0).bounds().y", "app.main") + .unwrap() + .value + .parse::() + .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::>(), + ids + ); + let wider = session + .execute("paragraph.mounts().at(0).bounds().height", "app.main") + .unwrap() + .value + .parse::() + .unwrap(); + let moved = session + .execute("tail.mounts().at(0).bounds().y", "app.main") + .unwrap() + .value + .parse::() + .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::>() + ); + 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::() + .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::() + .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::(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::(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::>() + ); + 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::>() + .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::>() + .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::(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::>() + .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::>() + .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::>() + .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::>() + .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::>() + .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::>() + .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 { + Some(self.signature.clone()) + } + fn invoke( + &self, + _: &mut endor_core::NativeContext<'_>, + arguments: &[Value], + ) -> std::result::Result { + 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::(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::() + .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::().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::().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::().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()); +}