//! 运行时集成回归与示例验收。测试经真实 Aster 前端、编译器、VM 和展示协议执行,不是手工伪造布局结果。 //! 覆盖共享对象/独立 Mount、源覆盖、绑定、窗口、任务、取消、二维/三维投影及像素。局部辅助函数负责取统计值、收敛刷新和构造可重复输入。 //! 阅读时先看每个测试提交的 Endor 程序,再看后续变更与最终断言;编译成功、回调成功、文档发布成功是不同检查点。 //! //! Rust 测试框架运行各 #[test],ReplSession 的测试同步入口负责排空实际 layout/paint,断言读取已完成文档。 //! r# 原始字符串中的 Endor 是验收夹具,保持其源码/行号不变;Rust 注释解释夹具意图和随后观察的行为。 use super::*; use crate::document::{NamespaceState, RenderCommand}; // 一次性模块执行/错误保留测试共用的真实示例,不在测试中另造只为通过断言的近似模块。 const EXAMPLE: &str = include_str!("../../../examples/hello.endor"); // 页面脚本基线供多个源加载、替换、生命周期测试复用,include_str 在编译时嵌入。 const PAGE: &str = include_str!("../../../examples/page.endor"); /// 可选性能基准:分别测世界 UI 和普通三维场景在 1x/2x DPI 下的相机交互更新、变更绘制和同列表缓存绘制。 /// 默认 ignore,需在已确认的构建环境中以 release 单测试线程运行,避免其他测试干扰;下面命令仅为运行说明。 /// cargo test --release --locked scene_interaction_performance -- --ignored --nocapture --test-threads=1 #[test] #[ignore] fn scene_interaction_performance() { use std::time::Instant; /// 对非空毫秒样本排序后返回均值、索引中位数和向上取整秩的 p95;调用方每组固定保留 30 次测量。 fn summary(mut values: Vec) -> String { // total_cmp 提供稳定全序,下面按排序后的样本索引取统计值,不修改被测场景数据。 values.sort_by(f64::total_cmp); format!( "mean={:.3} p50={:.3} p95={:.3}ms", values.iter().sum::() / values.len() as f64, values[values.len() / 2], values[(values.len() * 95).div_ceil(100) - 1] ) } // 每个场景/DPI 组合创建全新会话与像素缓冲,避免上一组合的相机状态和缓存直接污染下一组。 for (name, source) in [ ("world-ui", include_str!("../../../examples/world-ui.endor")), ("scene3d", include_str!("../../../examples/scene3d.endor")), ] { // 每个场景分别使用两种 DPI,比较相同逻辑布局在不同物理像素规模下的真实交互成本。 for dpi in [1., 2.] { // 逻辑视口保持 1024×800,后面仅物理像素倍率变化,避免混淆 DPI 与场景布局大小。 let (mut session, mut document) = ReplSession::load_with_viewport( source, "app.main", 1, 988, Some(crate::layout::Size { width: 1024., height: 800., }), ) .unwrap(); // 软件输出尺寸按逻辑大小乘 DPI;同一组合复用像素缓冲,不把分配计入每帧绘制时间。 let (width, height) = ((1024. * dpi) as u32, (800. * dpi) as u32); // u32 像素列表由真实软件渲染器覆盖,不用模拟绘图计数代替实际工作量。 let mut pixels = vec![0; (width * height) as usize]; // 三种样本分别统计派发/刷新、变化列表绘制和同列表再次绘制,不把它们混为一个延迟。 let mut updates = Vec::new(); // 相机刚变化后首次绘制耗时,和 source 更新/几何发布分开采样。 let mut draws = Vec::new(); // 同一 DisplayList 的第二次绘制,用于观察缓存复用路径,不再推进源程序。 let mut cached = Vec::new(); // 连续做 33 轮,前三轮预热;每轮使用最新发布版本构造事件,避免基准测到过期输入拒绝。 for sample in 0..33 { // 每次从最新帧找原始三维视口,身份与版本一致才测到有效 orbit 而非快速过期拒绝。 let target = document .frame .mounts .iter() .find(|mount| mount.scene3d_viewport) .unwrap(); // 选当前帧第一个三维视口,小幅 orbit;带中心锚点使世界面板等坐标映射路径也有完整输入。 let input = crate::events::Input { window: 1, session: document.frame.session, layout_version: document.frame.layout_version, mount: target.id, kind: "scene-orbit".into(), point: Some([target.x + target.width / 2., target.y + target.height / 2.]), delta: Some([1., 0.]), press_point: None, tab_drop: None, screen_position: None, }; // update 只计派发与完整文档刷新;display_list 构造在后面的绘制计时范围之外。 let start = Instant::now(); document = session.dispatch(&input).unwrap().document.unwrap(); // 统一转毫秒,sample>=3 后才保存预热之外的数值。 let update = start.elapsed().as_secs_f64() * 1000.; // 显示列表构造不包含在下面 render 计时里,避免把两种阶段成本混为绘制耗时。 let list = document.display_list(dpi).unwrap(); // 单独计首次变化帧的像素绘制时间。 let start = Instant::now(); crate::software_renderer::SoftwareRenderer::render( &list, &mut pixels, width, height, ) .unwrap(); // 第一次 render 测相机已变化时的绘制;立即用同一列表再画一次,单独统计缓存重用路径。 let draw = start.elapsed().as_secs_f64() * 1000.; // 不改变 list/像素尺寸直接再次 render,观察缓存命中绘制,不再发第二次相机输入。 let start = Instant::now(); crate::software_renderer::SoftwareRenderer::render( &list, &mut pixels, width, height, ) .unwrap(); // 三类样本按同一轮配对保存,仅舍弃预热数据,不改变真实执行的相机操作。 if sample >= 3 { updates.push(update); draws.push(draw); cached.push(start.elapsed().as_secs_f64() * 1000.); } } println!( "PERF {name} dpi={dpi} update {} changed_draw {} cached_draw {}", summary(updates), summary(draws), summary(cached) ); // 额外打印派生工作量指标辅助解释耗时,不把这些查询本身算进上面的三类样本。 for stat in ["measured", "arranged", "snapshotVisited"] { println!( "PERFSTAT {name} {stat}={}", session .execute(&format!("ui.stats().{stat}"), "app.main") .unwrap() .value ); } } } } #[test] /// 源 measure 直接读取 mount.layoutVersion 并将其作为宽度。 /// 只使 paint 失效后仍必须重新测量;通过新版本宽度和零缓存命中,防止把真实 Mount 读取错误缓存。 fn actual_mount_version_reads_still_bypass_measurement_cache() { // measure 显式读取 Mount.layoutVersion,将宿主出现版本变成真实依赖,不能按静态对象结果复用。 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(); // 即使只请求 paint 失效,版本依赖仍要求重新测量,不能无条件沿用上轮宽度。 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] /// 加载布局协商示例,验证同一段落两个出现位置分别获得 280/360 的宽度。 /// 加宽第一个出现位置后高度和后继 y 减小,第二个仍保持 360,且全部 Mount ID 不变。 fn source_allocations_and_measurement_limits_settle_before_publication() { // 同一 paragraph 在两个约束下展示,验收源布局协商而非给两个独立对象写固定宽度。 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(); // 保存所有出现身份,后面加宽只允许改变几何,不能销毁重建整棵 Mount 树。 let ids = initial .frame .mounts .iter() .map(|m| m.id) .collect::>(); // 通过源 Mount API 读取第一个出现实际布局宽,验证不是只在绘图命令中临时缩放。 let width0 = session .execute("paragraph.mounts().at(0).bounds().width", "app.main") .unwrap() .value; // 第二个出现属于同一业务对象,但有独立父约束,初始应为 360。 let width1 = session .execute("paragraph.mounts().at(1).bounds().width", "app.main") .unwrap() .value; assert_eq!(width0, "280"); assert_eq!(width1, "360"); // 较窄约束使段落多行,记录原高度用于加宽后的 reflow 比较。 let height = session .execute("paragraph.mounts().at(0).bounds().height", "app.main") .unwrap() .value .parse::() .unwrap(); // tail 在段落之后,段落高度变化必须传到后继 y,而不只是更新该段落绘制边界。 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); // 源按钮修改第一处宽度,再由正常刷新收敛和发布,不能直接从测试覆盖 Frame 几何。 let changed = session .execute("wide.click()", "app.main") .unwrap() .document .unwrap(); assert_eq!( changed .frame .mounts .iter() .map(|m| m.id) .collect::>(), ids ); // 变量名 wider 指加宽后的高度;文本行数减少时这个值应小于原 height。 let wider = session .execute("paragraph.mounts().at(0).bounds().height", "app.main") .unwrap() .value .parse::() .unwrap(); // 同步观察 tail 上移,证明父布局已在发布前消费新的段落高度。 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] /// 源 arrange 把子项宽高不断交换,构造不收敛的布局状态。 /// 加载必须报告 cycle,不能发布某个偶然中间态或依靠固定次数静默停止。 fn alternating_source_allocations_are_rejected_as_a_cycle() { // 源 arrange 在父宽高之间交替指定孩子尺寸,固定点不收敛,应作为 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] /// 源 paint 只提供 wrapWidth,省略成对需要的 wrapHeight。 /// 检查命令解码明确拒绝不完整文字边界,而非生成测量/绘制不一致的帧。 fn source_text_paint_rejects_incomplete_wrap_bounds() { // 只写 wrapWidth 不写 wrapHeight,构造解码层必须拒绝的半套文本边界,不能容错生成另一种命令。 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")) "##; // 源类型与函数合法,失败应准确指向遗漏的 wrapHeight,证明进入了实际 PaintCommand 解码。 let error = ReplSession::load(source, "app.main", 1, 1).err().unwrap(); assert!(error.to_string().contains("wrapHeight"), "{error:#}"); } #[test] /// 分别对原生 TextBlock 和委托原生方法的源子类运行相同测试。 /// 共享文字在不同内边距下独立换行;视口变宽只更新几何、保留 Mount,直接测量和 setText 也应同步反映尺寸。 fn wrapped_text_tracks_each_occurrence_width_resize_and_sibling_geometry() { // 长中文段落迫使换行,不能靠一个短词恰好装得下而漏测父约束对自然高度的影响。 let text = "中文段落随着宽度变化而换行,后面的对象跟随段落高度移动。".repeat(8); // 原生文字与源委托覆盖分别执行整套测试,检查两条入口在共享出现/重排时保持一致。 for custom in [false, true] { // 原生 TextBlock 与源委托子类应走同一测量/绘制语义,分别执行避免共享一个偶然缓存结果。 let constructor = if custom { "Wrapped" } else { "TextBlock" }; // 相同 paragraph 放到不同内边距容器,两个出现必须分别获得可用宽度和换行高度。 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))) "# ); // 初始逻辑宽 240、高度充足,测到换行而非可视裁剪导致的“只看到几行”。 let (mut session, initial) = ReplSession::load_with_viewport( &source, "app.main", 1, 230, Some(crate::layout::Size { width: 240.0, height: 2000.0, }), ) .unwrap(); // resize 后身份必须保持,缓存/几何更新不应改已捕获的 Mount ID。 let ids: Vec<_> = initial.frame.mounts.iter().map(|m| m.id).collect(); // 只取实际段落文本命令,排除 tail/其他文本,读取 wrap_bounds 的宽与高。 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); // 输入 viewport 已是页面可用区域,不再隐式扣侧栏。 // 第二个出现位置左右各有 50px 内边距,因此宽度比第一个少 100px。 assert_eq!(commands[0][0], 240.0); assert_eq!(commands[1][0], 140.0); assert!(commands[1][1] > commands[0][1]); // 文本绘制 y 也需跟随段落自然高度,不能只有 Mount 的虚拟几何更新。 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); // 只改变窗口逻辑宽,不重新执行 Endor 输入,验证 renderer/源协议在保留 VM 上重排。 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::>() ); // 两个父级仍相差 100 内边距,但分别在新宽度下重测,不能共享一个固定 wrapWidth。 let widths: Vec<_> = resized .frame .commands .iter() .filter_map(|command| match command { RenderCommand::Text { value, wrap_bounds: Some(bounds), .. } if value == &text => Some(bounds[0]), _ => None, }) .collect(); assert_eq!(widths, [400.0, 300.0]); // 同字串加宽后的实际绘制行高和原来比较,排除仅命令宽字段被改、字形仍按旧行排的情况。 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]); // 源代码显式在更窄 80 约束下 measure,验证协议返回高度也与 Frame 的宽约束相关。 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] /// 加载回调签名示例,逐项尝试不匹配的事件/绑定/任务回调及参数个数。 /// 注册期错误不能使会话 fault;合法零参数与 Event 参数回调均可执行,最后检查底层方法实际 arity。 fn native_signatures_validate_registration_and_deliver_the_event_argument() { // 使用带零参/单 Event 回调的真实示例,后续非法注册与合法分发都在同一份活 globals 中执行。 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() ); } // 合法单 Event 回调把 kind 保存到模型,渲染文字也应读取到 click,覆盖实际参数交付。 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()); // 最后直接查 VM 元数据,检查绑定状态方法 set 的实际显式 arity,不只看错误字符串。 let globals = session.candidate.globals.unwrap(); // received 是全局里真实状态对象,所有权仍由 globals 持有,这里只读取其句柄。 let Value::Object(received) = session .candidate .vm .dict_get(globals, &Value::Text("received".into())) .unwrap() .unwrap() else { panic!("state") }; // set 已捕获 receiver,只应接受一个新值,不能额外把 owner 计入未绑定参数。 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)", ] { // 三种错误分别覆盖已有字段写入、原生子类构造与普通类构造,均不能停掉健康域。 // 保留真实诊断检查 field,避免一个无关语法错误也被测试当作字段类型检查通过。 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(); // 用页面数量作为构造副作用基线,错误 CheckedPage 字段不能偷偷把新页注册到窗口。 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 ); // 前面错误不应破坏活值/回调,合法 click 仍把 9 递增成 10 并反映到实际文字命令。 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] /// 把 Numeric 视觉引用替换为 Textual,旧 alias 随共享转发指向新对象。 /// 字段检查必须按新类型执行;合法字符串赋值通过,数字写入和返回错误类型的绑定均被拒绝。 fn checked_fields_follow_shared_block_forwarding_and_binding_writes() { // alias 保留原包装身份,block 替换到不同字段类型子类后,后续字段校验应沿共享转发找真实新类型。 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(); // 旧 Numeric 类型不能成为绕过新 Textual 字符串字段契约的入口。 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(); // 合法 alias 写入必须从 block 另一名字可见,证明并非复制一份独立字段存储。 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] /// 在容器中间插入子项,检查新稳定槽、后继 y 变化及旧 Mount 身份保留。 /// 越界、负位置和自包含循环在修改前失败;移除新槽后旧出现位置仍有效。 fn positional_insert_preserves_existing_slots_and_mounts_and_rejects_invalid_graphs() { // 先建 head/tail 两条稳定组成边,中间插入新节点应只为新边分配 slot,而不改旧边身份。 let source = r#"head=TextBlock("head") tail=TextBlock("tail") list=Column(24,8,head,tail) page=Page() page.render(list)"#; // 保存初始完整几何,后续所有比较沿同一持久 VM,不重载源码制造看似相同的出现。 let (mut session, initial) = ReplSession::load(source, "app.main", 1, 200).unwrap(); session .execute("held=tail.mounts().at(0)", "app.main") .unwrap(); // 从真实源 Mount 查询稳定 ID,插入后仍应对应同一 tail 出现,只改变位置。 let id = session .execute("held.id", "app.main") .unwrap() .value .parse::() .unwrap(); // 旧 y 来自插入前发布帧,以便与新帧的同身份出现对比。 let old = initial .frame .mounts .iter() .find(|mount| mount.id == id) .unwrap(); // 插入位置 1 与新 slot 2 故意不同,防止把顺序下标误当稳定组成身份。 let inserted = session .execute("slot=list.insert(1,TextBlock(\"middle\"))", "app.main") .unwrap(); assert_eq!(inserted.value, "2"); // 成功结构编辑应产生完整新文档,而非只返回一个槽号却未安排刷新。 let document = inserted.document.unwrap(); // 同 ID 在新帧仍存在,表示保留出现身份;其 y 增加证明父布局实际插入了中间孩子。 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() ); // 表驱动覆盖位置边界与组合循环,合法插入后的旧 Mount 必须在全部失败尝试后仍有效。 for invalid in [ "list.insert(4,head)", "list.insert(0,list)", "list.insert(-1,head)", ] { // 超出插入缝、将自身包含成环和负位置都应在写入组成前失败,不把整个 VM 标故障。 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(); // 删除新边使用返回的稳定 slot,而非插入位置 1,旧 tail 仍保持同一 ID。 assert_eq!( session .execute("tail.mounts().at(0).id", "app.main") .unwrap() .value, id.to_string() ); } #[test] /// 先同窗重排再跨窗移动,确认调用 Page 源覆盖且保留 Page 业务身份。 /// 跨窗目标帧版本不匹配必须拒绝;合法迁移后旧 Mount 失效,新窗口页面表指向原 Page。 fn tab_drop_preserves_page_identity_calls_source_methods_and_rejects_stale_targets() { // 示例同时提供多个 Page 和独立窗口,Page 源覆盖把迁移/重排调用次数写进模型计数。 let (mut session, initial) = ReplSession::load( include_str!("../../../examples/tab-placement.endor"), "app.main", 1, 201, ) .unwrap(); session.take_window_commands().unwrap(); // Tab ID 与 Page 对象/Mount ID 不同,输入在标签路径用 mount 字段携带标签稳定身份。 let page = initial.frame.tabs[0].id; // 同窗插入缝 3 测向后移动时的下标修正,而不是把页面重新创建到列表末尾。 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(); // 同窗标签重排不应卸载内容,提前保留当前内容 Mount 以区分重排和跨窗迁移。 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(); // 合法跨窗会调用 moveToWindow 与 moveTab,业务 Page 身份不变,但旧出现应被卸载重建到目标窗。 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] /// Page.moveToWindow 覆盖只返回 self,模拟源程序拒绝跨窗移动。 /// 拖放不能强行改变窗口归属;随后 detach 使用独立入口,并排出带负桌面坐标的原生命令。 fn drop_respects_declined_source_moves_and_detach_queues_signed_desktop_position() { // moveToWindow 源覆盖只返回 self 而不实际移动,宿主不能因为回调无错误就假定迁移已经发生。 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)"#; // 初始命令先排空,最后只观察本次 detach 新产生的 MoveTo,而不是启动残留窗口命令。 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" ); // detach 是独立创建窗口入口,清掉 drop 元数据并添加负桌面坐标,覆盖多显示器布局。 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(); // 命令只登记不实际操作 OS;新资源应为窗口 3,坐标仍为有符号 -400/120。 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" ); } /// 为异步文件测试写入唯一临时 UTF-8 文件,再把路径交给闭包。 /// 进程号加原子序号防止并发冲突,Cleanup 析构保证断言 panic 时也尝试删除文件。 fn with_file_task_input(contents: &str, run: impl FnOnce(&std::path::Path)) { // 只需唯一递增值,不依赖它同步其他内存,Relaxed 足以保证不同测试取到不同序号。 static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); // 每个调用取不同后缀,嵌套 old/new 文件测试也不覆盖同一路径。 let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); // 临时目录加 PID/序号只隔离测试文件,不从源模型或当前工作目录推断文件位置。 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 { // 清理失败不再次 panic,避免覆盖原测试故障或在异常展开时二次崩溃。 fn drop(&mut self) { // 若 provider 尚在关闭句柄或文件已被移除,最佳努力清理失败不应遮住原断言失败。 let _ = std::fs::remove_file(&self.0); } } // 守卫必须在 run 之前创建,闭包 panic 时也随栈展开删除本例临时输入。 let _cleanup = Cleanup(path.clone()); std::fs::write(&path, contents).unwrap(); run(&path); } /// 生成 Loader 测试程序:readText 成功回调把 Task.result 写入未挂载的 target。 /// 路径先按 JSON 字符串转义再插入 Endor,tail 用于附加替换/取消等测试操作。 fn file_task_source(path: &std::path::Path, tail: &str) -> String { // 使用 JSON 字符串转义 Windows/Unix 路径中的斜杠和引号,避免把路径内容拼成 Endor 语法。 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}" ) } /// 直接从当前 VM globals 找到 loader.task,按托管类型的方法表调用 status。 /// 不通过 REPL execute 查询,避免状态观察自身引入一次输入、刷新或任务调度副作用。 fn file_task_state(session: &mut ReplSession) -> String { // 直接查同一个持久 VM 的真实 globals,不执行一条额外 Console 输入改变 transcript/刷新轮次。 let globals = session.candidate.globals.unwrap(); // loader 是测试程序保存任务的持有者,字典仍拥有它,这里只借身份定位 task 属性。 let Value::Object(loader) = session .candidate .vm .dict_get(globals, &Value::Text("loader".into())) .unwrap() .unwrap() else { panic!("loader"); }; // 即使 Task 已终态也可由 loader 保留;不能从 provider 弱索引推断其状态。 let Value::Object(task) = session.candidate.vm.get_attr(loader, "task").unwrap() else { panic!("Task"); }; // 查询实际 Task 类型方法,保持与源 task.status() 相同的受检查方法入口。 let definition = session .candidate .vm .object_store() .type_of(task) .unwrap() .unwrap(); // lookup_method 取得未绑定方法,所以下面 call 显式补一个 Task receiver。 let method = session .candidate .vm .object_store() .lookup_method(definition, "status") .unwrap() .unwrap(); // 返回值必须是语言字符串,状态接口若变成 Nil/对象不能被测试静默格式化成看似合法状态。 let Value::Text(status) = session .candidate .vm .call(method, &[Value::Object(task)]) .unwrap() else { panic!("status"); }; status } /// 最多轮询 1000 次,每轮推进任务并检查其是否离开 pending。 /// 短暂休眠让后台文件线程获得执行机会;取消/失败也算终态,超时则明确使测试失败。 fn complete_file_task(session: &mut ReplSession) { // 测试调用方依赖此有界等待推进后台文件结果;达到上限必须失败,不能无限挂住测试进程。 for _ in 0..1000 { // 后台只填 mailbox,必须显式让 owning VM poll 才能执行回调或完成无回调 Task。 session.poll_tasks().unwrap(); // 只要状态离开 pending 就交还调用方,让其按当前测试目的区分成功、取消或失败。 if file_task_state(session) != "pending" { // 不只 Completed,Cancelled/Failed 也结束等待,具体预期由各调用测试单独断言。 return; } std::thread::sleep(std::time::Duration::from_millis(1)); } panic!("file task did not complete"); } #[test] /// 同一 owner/key 连续发起两个不同文件读取,第二个必须取消旧任务。 /// 推进完成后仅新 UTF-8 内容写入 target;target 仍无 Mount,说明业务更新不依赖可见性。 fn file_task_updates_hidden_object_and_replacement_drops_old_delivery() { with_file_task_input("old", |old| { // 两个真实文件内容不同,才可从 target.text 判断究竟哪一代请求获得交付。 with_file_task_input("new UTF-8 文本", |new| { // 路径先转义再作为源字符串,中文文本用于覆盖 UTF-8 返回而非仅 ASCII 情形。 let path = serde_json::to_string(&new.to_string_lossy()).unwrap(); // 保留旧 token 后同 owner/key 再读新文件,替换必须即时取消旧项而不靠旧结果晚到顺序碰巧正确。 let tail = format!( "old = loader.task\nloader.task = ui.readText(loader,\"read\",{path},loader.apply)" ); // 源加载只创建任务,complete_file_task 才在后续轮询交付,target 从未被挂到页面。 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] /// 分别覆盖显式取消和 provider 读取不存在文件失败。 /// 二者都不能运行成功回调;后者需保留错误文本但不使共享 VM fault,目标文字仍为 waiting。 fn cancelled_file_task_never_runs_callback_and_missing_file_keeps_vm_live() { with_file_task_input("ignored", |path| { // 在同一输入创建并 cancel,后台即使已经读完也不能越过失效租约执行成功回调。 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\"" ); // 另一个未创建的后缀路径用于 provider 真实失败,不伪造任务 error 字段。 let missing = path.with_extension("missing"); // 新独立会话区分 provider 错误与前一例主动取消状态,错误不应停整个 VM。 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] /// 创建不带回调的 readText,完成后直接读取任务结果。 /// 确认 result 的托管保留不依赖成功回调,且 error 为空字符串。 fn callback_free_file_task_retains_result_after_completion() { with_file_task_input("callback free", |path| { // 同一文件接口省略 callback,仅靠保留的 Task.result 接收文字,检查 await 风格 API 的存值语义。 let path = serde_json::to_string(&path.to_string_lossy()).unwrap(); // Holder 只保存 token,页面展示无关文本;成功结果不能依赖 target 可见或回调消费才存活。 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})" ); // 实际 VM 管理 Task 的拥有图,后台线程从不直接调用 Loader 或写源字段。 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] /// 以隐藏 Block 为 owner,用相同 key 替换定时任务。 /// 旧任务立即取消,新回调仅在 poll 后写入 2;另一个被显式取消的任务不能再改变状态。 fn owned_tasks_replace_cancel_and_update_unmounted_objects() { // hidden Block 没有 Mount 仍可作业务 owner,两个动作故意写不同数值以观察同键替换胜出者。 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)"#; // 创建任务本身不交付,先看旧 status 和初始 0,再 poll 验证只有最新动作写 2。 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(); // 被主动 cancel 的第三任务不能覆盖此前完成结果,token.active 也必须投影为 false。 assert_eq!( session.execute("state.value", "app.main").unwrap().value, "2" ); assert_eq!( session .execute("cancelled.active()", "app.main") .unwrap() .value, "false" ); } #[test] /// 并列创建 Page/Mount/Block/ui 作用域任务,再关闭并重开页面。 /// 页面与出现位置任务取消,业务对象和应用任务继续;跨窗迁移保留 Page 作用域但取消旧 Mount,关窗取消窗口任务。 fn tab_and_mount_tasks_cancel_while_block_and_app_owners_continue() { // 相同 Action/共享 count 给不同 owner 类型建任务,计数差异直接揭示哪些作用域还获得交付资格。 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)"#; // 先发布 leaf 的真实出现,再从源 mounts() 获取具体 owner,不能手写一个未挂载 Mount。 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(); // 两种展示 owner 都在关闭时失去资格,按各自 token 分别核对,避免只取消其中一种。 for name in ["pageTask", "mountTask"] { // close 后立刻 open 不应复活旧资格;页面与旧出现的任务都已在关闭边界被取消。 assert_eq!( session .execute(&format!("{name}.status()"), "app.main") .unwrap() .value, "\"cancelled\"" ); } session.poll_tasks().unwrap(); // 只有 Block/ui 两个 owner 仍有效,count=2 同时排除错误地取消所有后台工作或全部继续的实现。 assert_eq!( session.execute("state.value", "app.main").unwrap().value, "2" ); // 跨窗移动保留 Page 自身作用域;原出现位置会被替换,因此旧 Mount 的任务必须取消。 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(); // 窗口 owner 关闭即失效,即使源变量 other 还保留真实窗口对象也不能继续回调。 session.poll_tasks().unwrap(); assert_eq!( session .execute("closing.status()", "app.main") .unwrap() .value, "\"cancelled\"" ); } #[test] /// 只返回任务令牌而不保留临时 owner,检查弱作用域允许 owner 回收并取消任务。 /// 随后第一个回调失败应停止共享执行域,后续递增不发生;通过 VM 直接读值验证状态仍为 0。 fn orphan_task_scope_is_collectable_and_callback_failure_stops_remaining_work() { // orphan 只返回 Task,owner 无外部根;后半段先排失败回调再排递增,检查共享故障域是否停止后续工作。 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()"#; // 加载后的回收边界应剪掉不可达 owner/作用域环,token 本身不反向强持有 owner。 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()); // 故障域已拒绝 execute,直接只读 VM 属性验证值仍为 0,而不为了观察又执行用户代码。 let globals = session.candidate.globals.unwrap(); // 原共享状态对象仍由 globals 持有;读取它不创建新根或恢复后续被停用的任务。 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] /// 验证源函数、原生函数和绑定方法属于 Function,而类型构造器不属于 Function。 /// 错误参数在函数体副作用前拒绝,错误返回在返回契约检查时拒绝;重定义同名类也不能改变旧实例/方法身份。 fn source_annotations_classify_functions_and_reject_before_body() { // 示例同时含源函数、宿主工厂和绑定方法,类型对象虽可构造但不是 Function,需分别测试。 let (mut session, _) = ReplSession::load( include_str!("../../../examples/function-types.endor"), "app.main", 1, 180, ) .unwrap(); // 分别查询源函数、原生工厂和绑定方法,三者都应进入 Function 名义类型体系。 for expression in [ "isInstance(identity,Function)", "isInstance(ui.rect,Function)", "isInstance(actions.read,Function)", ] { // 三种真实 callable 都应满足同一个 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(); // 参数错误必须在进入 checked 的 increment 之前拒绝,因此紧接着断言 state 仍为 0。 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(); // 重定义名字只建立新 Type,不改变旧实例 actions 与此前 savedFunction 的真实身份/行为。 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] /// 先由子窗口按钮回调制造运行时故障,再从宿主回收该窗口。 /// Mount 和页面根必须脱离,onUnmount 的 marker 不可递增;已停止 VM 不能因清理重新执行源代码。 fn stopped_window_teardown_detaches_mounts_without_running_source_hooks() { // marker 只在 onUnmount 增长,子窗 click 另触发非法 resize 故障,用两条独立状态观察纯宿主退役。 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(); // 故障后只能只读观察真实对象,不能经 execute 调用户函数来检查清理结果。 let globals = session.candidate.globals.unwrap(); // lookup 仅从原 globals 借出对象身份,持有关系没有被测试添加新的长期根改变。 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"), }; // held 故意保留旧叶 Mount,退役必须使它 mounted=false,而不能仅把它从窗口显示列表删去。 let held = lookup(&session, "held"); // 真实窗口资源用于检查 Page 根是否解除拥有关系,和文档副本是否删窗分别验证。 let window = lookup(&session, "other"); // marker 保持零证明故障清理没有重新进入 onUnmount 源代码。 let marker = lookup(&session, "marker"); // 用目标 payload 的实际几何 ID 构造输入,当前 session/副窗版本来自初始发布值。 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()); // 专用 retire_window 不调用源 Window.close/卸载钩子;正常生命周期与故障资源清理不能混为一路。 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] /// 加载多窗口示例,确认窗口共享业务计数但使用不重叠的 Mount。 /// 迁移保留 Page 身份、替换旧 Mount;关闭并清除全局引用后 Window 可回收,保留旧叶 Mount 也不能留住窗口上下文。 fn native_windows_share_business_state_move_pages_and_release_closed_contexts() { // 真实多窗口示例在同一个 VM 共享 count,每窗只派生独立 Presentation/Mount,而非复制业务模型。 let (mut session, initial) = ReplSession::load( include_str!("../../../examples/windows.endor"), "app.main", 1, 130, ) .unwrap(); assert_eq!(initial.windows.len(), 1); // 全会话出现 ID 高水位应避免不同窗重号,防止输入凭窗口/出现值误指到另一展示。 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); // 同一共享 count 更新应在两个窗的完整文档里同时看到新文字,而不是只刷新 current。 let changed = session .execute("count.increment()", "app.main") .unwrap() .document .unwrap(); // 仅过滤实际文字命令判断状态投影,不从局部 ui.stats 或源 state 对象推断画面已同步。 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(); // 记录 Page 的 Console 对象身份表示,迁移后必须仍为同一个业务页。 let page = session.execute("right", "app.main").unwrap().value; // 正常源方法迁移到主窗,旧副窗 Page 表清空,新 Mount 应在新上下文重建。 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(); // 读取不额外 retain Window,避免测试自己保活对象而掩盖/制造 GC 结论。 let Value::Object(window) = session .candidate .vm .dict_get(globals, &Value::Text("other".into())) .unwrap() .unwrap() else { panic!("Window"); }; // 弱身份只用于回收后探测,不能因为闭窗时仍有 Rust ObjectRef 数值就延长寿命。 let weak = session.candidate.vm.downgrade(window).unwrap(); // 关闭后的最终完整文档应删除副窗,向已关闭资源 open 也必须被拒绝。 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(); // oldRight 仍可达不应通过出现的旧上下文反向保留整个 Window/desktop/Page 注册表。 assert!(session.candidate.vm.upgrade(weak).unwrap().is_none()); // 外部仍持有旧叶 Mount 时,关闭窗口的上下文也必须可释放;下一次 detach 创建新的资源编号。 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] /// 一次输入先关闭子窗口,再用非法 resize 使后续语句失败。 /// 先前关闭副作用仍存在,下一次成功输入必须清除残留展示;Page 可重新打开到主窗口。 fn failed_input_after_window_close_cannot_leave_owned_mounts_in_closed_context() { // 从真实多窗堆开始,输入前一条 close 成功、后一条 resize 失败,验证 REPL 非事务语义的恢复。 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(); // 已清理 Page 仍可按业务身份重开到主窗口,不能把正常关闭误当整个 Page 值被永久销毁。 assert_eq!( session .execute("right.window().id()", "app.main") .unwrap() .value, "1" ); } #[test] /// 依次通过事件激活页面、修改 title、关闭页面、清空及重开。 /// 检查标签反映真实 Page 方法,稳定业务 ID 与递增展示 ID 各自正确,旧版本事件被桥接层拒绝。 fn tab_projection_uses_page_methods_and_rejects_old_presentations() { // 页面标题/打开顺序来自真实 Window 注册表,最后一次 render 使 second 初始选中。 let source = r#"first = Page() first.title = "First" first.render(TextBlock("one")) second = Page() second.title = "Second" second.render(TextBlock("two"))"#; // 保存初始 Tab ID 和版本供后续重命名/重开/过期输入比较,不按标题作为身份。 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); // 标签分派用 mount 字段承载 Tab ID,kind 明确为 tab-activate,不能误入内容几何命中。 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, }; // 真实页面 activate 切换选中投影并推进版本,随后同一输入应由桥接层拒绝。 let selected = session.dispatch(&input).unwrap().document.unwrap(); assert!(selected.frame.tabs[0].active); assert!(selected.frame.layout_version > initial.frame.layout_version); // 直接检查桥接层的版本拒绝:过期传输在进入源回调之前失败,不应被当成运行时故障。 assert!( session .bridge .dispatch(&mut session.candidate.vm, &input) .is_err() ); // 只改 title 不改 Page 身份,标签文字更新但第一页原稳定 Tab ID 保持。 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); // 按最新版本关闭 second,注册关系和焦点选择应同步到第一页,不使用静态 sidebar 假数据。 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" ); // 最后一页关闭也必须发布版本更高的空标签帧,不能把 Some(None) 当作无刷新工作。 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); // 相同 Page 重开分配新展示 Tab ID,历史旧输入不能因业务对象相同再次变成有效。 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] /// 同次成功输入的两次 resize 合并为最后尺寸,并保留 title/focus 命令。 /// 命令只消费一次;失败输入之前积累的 resize 也必须丢弃,不能送到原生窗口。 fn managed_window_requests_are_coalesced_and_failed_input_discards_pending_requests() { // 空持久会话已有托管主 Window,命令登记不需要先 render 页面,也不在测试实际打开 OS 窗口。 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(); // 一次取完成功输入命令,两个 Resize 合并为最后一个,title/focus 保留独立动作顺序。 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()); // 输入先排合法 resize 再遇非法尺寸,待发布 outbox 整批丢弃,不能泄漏前半条原生命令。 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] /// 检查 Window.pages 真实持有打开页面,close 解除归属,重新 open 追加到当前顺序末尾。 /// 页面窗口关系保持双向一致,向非 Window 对象打开则明确失败。 fn window_page_roots_follow_open_close_and_creation_order() { // 创建顺序与 render/open 登记关系明确分开,Page 存活不代表永远属于窗口页面表。 let source = r#"first = Page() second = Page() first.render(TextBlock("first")) second.render(TextBlock("second")) window = ui.window()"#; // 所有查询保留同一真实 globals,close/open 后的窗口关系必须从资源模型更新而来。 let (mut session, _) = ReplSession::load(source, "app.main", 112, 1).unwrap(); assert_eq!( session .execute("window.pages().count", "app.main") .unwrap() .value, "2" ); // 两页应指同一 Window;保存显示身份进行比较,不用标题或数组顺序猜归属。 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(); // 重开追加到注册表末尾,second 因此仍在 at(0),而不是按最初创建顺序强行插回 first。 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] /// 为 Page 建立弱句柄,close 后删除唯一全局绑定并运行循环回收。 /// 弱升级应失败,证明窗口的页面顺序记录不会把已关闭页面意外作为强根保留。 fn closed_page_is_not_kept_alive_by_window_page_order() { // 简单页面保证唯一显式根就是全局 page;关闭+删绑定后窗口的顺序映射不得成为隐藏强根。 let (mut session, _) = ReplSession::load( "page = Page()\npage.render(TextBlock(\"page\"))", "app.main", 113, 1, ) .unwrap(); // 直接删 globals 避免用“page=nil”产生别名替换语义干扰这个生命周期断言。 let globals = session.candidate.globals.unwrap(); // 借出身份后只建弱观察,不在测试手工 retain 新拥有根。 let Value::Object(page) = session .candidate .vm .dict_get(globals, &Value::Text("page".into())) .unwrap() .unwrap() else { panic!("Page") }; // close 解除 Window→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] /// 源 measure 内尝试 resize 原生窗口,加载应报告 read-only。 /// 窗口命令也属于副作用,不能因其延后执行而绕过布局只读保护。 fn rendering_cannot_enqueue_native_window_side_effects() { // measure 返回值本身合法,唯一违规是排入延后 Resize,证明 outbox 也是受只读保护的副作用。 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"))"##; // 真实加载会运行 source measure,在候选发布前发现错误,不能等 OS 执行命令时才拒绝。 let error = ReplSession::load(source, "app.main", 114, 1).err().unwrap(); assert!(error.to_string().contains("read-only")); } /// 直接调用 ui.stats 取得某个数字计数,读取后释放返回的 stats 托管对象。 /// 辅助函数避免经 REPL 查询统计带来额外输入/刷新,使缓存命中与测量次数的断言可比较。 fn source_measure_stat(session: &mut ReplSession, name: &str) -> i64 { // 布局/缓存回归共用此直接 VM 助手,绕开 Console transcript 和一次输入引起的额外刷新。 let Value::Object(function) = session .candidate .vm .get_attr(session.bridge.ui, "stats") .unwrap() else { panic!("stats function") }; // 返回 stats 对象有新的拥有根,读完数字字段必须显式释放,不能让观察指标自身泄漏。 let Value::Object(stats) = session.candidate.vm.call(function, &[]).unwrap() else { panic!("stats") }; // 要求确为 Number,不能将缺失字段/错误 API 值格式化成零而让缓存断言误通过。 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() } /// 从 globals 中按名称找到业务 Block,再在给定完整文档中按 block_id 找对应 Mount。 /// 构造携带当前会话与该文档版本的 click,返回派发后发布的新文档,供后续动作沿新版本继续。 fn click_named(session: &mut ReplSession, document: &RenderDocument, name: &str) -> RenderDocument { // 缓存示例测试通过此助手模拟按钮点击;globals 是当前 VM 的业务对象名字表。 let globals = session.candidate.globals.unwrap(); // 按调用方给出的名字取得 Block 引用;缺失或非对象表示测试夹具已与按钮约定不符。 let Value::Object(block) = session .candidate .vm .dict_get(globals, &Value::Text(name.into())) .unwrap() .unwrap() else { panic!("Block") }; // 将业务 Block 对应到当前文档的出现位置;本助手适用于这些按钮只有一个 Mount 的夹具。 let mount = document .frame .mounts .iter() .find(|mount| mount.block_id == block.to_string()) .unwrap(); // 使用当前 session/layout_version 派发,避免复用旧文档造成过期事件被丢弃。 // 不提供坐标,直接验证指定按钮的 click 回调;要求返回新文档供下一次点击使用。 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] /// 同一个源 paint 对象出现在两个窗口,先执行与显示无关的表达式。 /// 无关输入应不产生文档;修改 paint 实际读取的 tint 后,两个窗口都必须发布新颜色。 fn shared_intrinsic_desktop_does_not_repaint_other_windows_on_repl_input() { // 同一 Painted 实例挂到两个窗口,paint 读取共享 tint,建立跨窗口的字段依赖。 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(); // 执行不读写业务对象的表达式,检查 worker 不为无关 REPL 输入重新发布桌面。 assert!( session .execute("42", "app.main") .unwrap() .document .is_none() ); // 修改 paint 实际读取的字段;返回的桌面文档应同时包含两处更新。 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] /// 加载缓存示例并用真实 click 分别改变颜色与尺寸。 /// 纯颜色更新复用全部测量,尺寸依赖变化只让受影响的结果失效;按布局 pass 数核对命中而非写死单次计数。 fn source_measure_cache_reuses_independent_results_and_invalidates_model_changes() { // 以固定视口加载真实缓存示例;CachedRect 和 ViewportBand 分别提供可缓存的源测量。 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); // 调用上面的事件助手触发颜色按钮;事件路径与正常 UI 输入相同。 let painted = click_named(&mut session, &initial, "blue"); // 核对颜色已变化,排除因按钮未生效而导致的“没有重新测量”。 assert!(painted.frame.commands.iter().any( |command| matches!(command,RenderCommand::RoundedRect {color,..} if color=="#526AC7") )); // 同一次更新可能经历多个布局 pass;每个 pass 都应命中两项测量,实际重测数应为零。 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); // 接着沿新文档点击尺寸按钮,改动 CachedRect.measure 读取的宽度模型。 let resized = click_named(&mut session, &painted, "expand"); // 检查依赖更新已反映为 260×60 的矩形几何。 assert!( resized .frame .mounts .iter() .any(|mount| mount.width == 260.0 && mount.height == 60.0) ); // 此次只有一项测量失效一次;其余 pass/对象仍命中,因此总命中数为 2×pass−1。 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] /// 新建尚未 render 的 Console VM,执行普通表达式和 resize。 /// 两者都不应产生发布文档,避免把 reset 后保留的画面替换成空白。 fn viewport_updates_in_a_fresh_console_do_not_replace_the_preserved_page() { // 构造未执行 page.render 的新会话,用来复现 Console reset 后仍保留旧画面的情形。 let mut session = ReplSession::new_with_viewport( 104, Some(crate::layout::Size { width: 800.0, height: 600.0, }), ) .unwrap(); // 普通表达式没有新页面,返回值不能携带空白 RenderDocument。 assert!( session .execute("42", "app.main") .unwrap() .document .is_none() ); // 原生 resize 更新视口状态,但没有页面可发布时仍应返回 None。 assert!( session .resize(Some(crate::layout::Size { width: 360.0, height: 240.0 })) .unwrap() .is_none() ); // 读取 VM 暴露的视口宽度,证明 resize 已生效而非被直接忽略;查询也不产生文档。 let result = session.execute("ui.viewport().width", "app.main").unwrap(); assert_eq!(result.value, "360"); assert!(result.document.is_none()); } #[test] /// 源 measure 返回先前已创建的 MeasureResult,不在调用期间新建结果。 /// 直接改其 width 后刷新必须失效并更新相同 Mount,证明返回值解码读取也进入依赖;再次刷新保持稳定。 fn source_measure_cache_tracks_fields_of_an_existing_returned_result() { // measure 直接返回全局已有结果;用此夹具验证解码结果字段时也记录依赖。 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)"##; // 首次发布建立 width=80 的测量缓存,随后绕过 REPL 直接写堆字段。 let (mut session, initial) = ReplSession::load(source, "app.main", 103, 1).unwrap(); // 记住原出现位置 ID;宽度变更必须更新该 Mount,而不是新建另一个来掩盖缓存问题。 let id = initial .frame .mounts .iter() .find(|mount| mount.width == 80.0) .unwrap() .id; // 从业务全局表提取 result 对象,与 measure 返回的引用保持一致。 let globals = session.candidate.globals.unwrap(); // 要求夹具中的 result 确实是 VM 对象,便于通过 set_attr 触发写入观察。 let Value::Object(result) = session .candidate .vm .dict_get(globals, &Value::Text("result".into())) .unwrap() .unwrap() else { panic!("result") }; // 仅改已有返回对象的 width;源函数本体未读取该字段,依赖应来自返回值解码。 session .candidate .vm .set_attr(result, "width", Value::Number(140.into())) .unwrap(); // 主动刷新消费字段变化,再检查同一 Mount 的尺寸及恰好一次缓存失效。 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] /// 在有限窗口约束下加载缓存示例,再缩窄真实可用视口。 /// 原 Mount ID 不变,内容宽度按根 padding 改变,且输入计数仍为 0;重复尺寸不发布,NaN 尺寸失败但不停止 VM。 fn viewport_resize_changes_constraints_and_preserves_mount_identity_without_source_input() { // 固定初始视口,让示例的 ViewportBand 建立对 ui.viewport().width 的测量依赖。 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(); // viewport 已是实际页面可用区域,初始与 resize 后都只扣 Endor 根左右各 24px 的 padding。 assert!( initial .frame .mounts .iter() .any(|mount| mount.width == 752.0 && mount.height == 24.0) ); // 保存完整 Mount 顺序和身份,后续检查 resize 只更新几何而不重建出现位置。 let ids: Vec<_> = initial.frame.mounts.iter().map(|mount| mount.id).collect(); // 模拟原生窗口缩小;该入口应更新布局约束,不执行一条新的 Endor 用户输入。 let document = session .resize(Some(crate::layout::Size { width: 360.0, height: 240.0, })) .unwrap() .unwrap(); // 逐项比较原 Mount ID,捕获 resize 导致生命周期重建或排序改变的问题。 assert_eq!( ids, document .frame .mounts .iter() .map(|mount| mount.id) .collect::>() ); // 新宽度 360 扣除左右各 24 的 padding,应发布 312 的内容宽度。 assert!( document .frame .mounts .iter() .any(|mount| mount.width == 312.0 && mount.height == 24.0) ); // resize 不计作 REPL 输入;相同视口重复通知也不应再发布文档。 assert_eq!(session.inputs, 0); assert!( session .resize(Some(crate::layout::Size { width: 360.0, height: 240.0 })) .unwrap() .is_none() ); // 非法 NaN 在视口边界被拒绝;这是参数错误,不应把仍可用的 VM 标成 faulted。 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] /// 检查首次 source measure/arrange 已可读取结构关系与子测量结果,产出预期位置。 /// 直接改 Metrics.width 后通过依赖刷新同一 Mount,随后无变化刷新不再发布。 fn source_layout_uses_initial_mount_links_child_measurements_and_model_dependencies() { // 复用 source-layout 示例,经加载入口执行首次测量、排列和发布。 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; // 文字的 y 坐标同时验证排列对子测量高度与间距的使用。 assert!(initial.frame.commands.iter().any(|command| matches!(command,RenderCommand::Text { value,y,.. } if value.starts_with("尺寸来自") && *y == 100.0))); // 取出示例的 Metrics 模型,直接写入源测量读过的字段。 let globals = session.candidate.globals.unwrap(); // 夹具必须提供 Metrics 对象;类型不符应立即失败而非跳过依赖测试。 let Value::Object(metrics) = session .candidate .vm .dict_get(globals, &Value::Text("metrics".into())) .unwrap() .unwrap() else { panic!("Metrics") }; // 通过 VM 字段写入通知依赖系统;没有重新执行整段页面源码。 session .candidate .vm .set_attr(metrics, "width", Value::Number(260.into())) .unwrap(); // 刷新应重算相关几何并发布新文档。 let next = session.refresh_document().unwrap().unwrap(); // 按旧 ID 找回同一矩形,核对位置仍为 44、宽度更新为 260。 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] /// 把同一 rect 放入具有不同 Metrics 的两个父容器。 /// 发布宽度必须分别为 200/140,不能把共享 Block 的一份测量结果误用于两个出现位置。 fn shared_block_source_measurements_belong_to_each_occurrence() { // 保留原父容器,再创建另一份 Metrics;两个父容器共享同一 rect 业务对象。 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))"#, ); // 在同一页面发布两个出现位置,使错误的按 Block 缓存策略可以被观察到。 let (mut session, initial) = ReplSession::load(&source, "app.main", 92, 1).unwrap(); // 从全局表取得共享 rect 的业务身份,避免按几何误匹配父容器。 let globals = session.candidate.globals.unwrap(); // 要求共享对象存在,后续用其 ID 同时筛选两个 Mount。 let Value::Object(rect) = session .candidate .vm .dict_get(globals, &Value::Text("rect".into())) .unwrap() .unwrap() else { panic!("Rect") }; // 保留文档出现顺序,收集同一 Block 在不同父约束/模型上下文中的测量宽度。 let widths: Vec<_> = initial .frame .mounts .iter() .filter(|mount| mount.block_id == rect.to_string()) .map(|mount| mount.width) .collect(); // 两处分别采用各自 Metrics 的 200/140,后续空刷新确认没有持续失效。 assert_eq!(widths, vec![200.0, 140.0]); assert!(session.refresh_document().unwrap().is_none()); } #[test] /// measure 读取宽度 80,onMount 随后把该依赖写成 140。 /// 首次发布必须已经重测为 140,不能把回调前的旧几何暴露出去,且后续刷新应稳定。 fn layout_dependencies_changed_by_mount_hook_are_measured_again_before_publication() { // 首次 measure 读取 80,首次 onMount 再改为 140,构造发布前依赖失效的时序。 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)"##; // load 完成生命周期与布局收敛后才返回文档,调用方不应见到中间状态。 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] /// 逐项修改示例,覆盖测量期提前读 bounds、只读范围业务写入、错误返回类型和伪造 child slot。 /// 每个非法程序都要在发布前给出对应诊断,不让不完整或不属于当前容器的排列通过。 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] /// 表驱动检查圆形内点击、圆外但底层可点、按下释放命中不同形状,以及页面外点击。 /// 实际提交仍指向圆 Mount,worker 必须重新按源 hitTest 与 press_point 复核并选择合法目标。 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(); // 选中可交互的圆 Mount 作为客户端声明目标;宿主还必须用源 hitTest 复核真实坐标。 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] /// 把 hitTest 改为返回 Number 或试图递增业务计数。 /// 派发应报告 Bool 契约/只读错误并停止运行时,但业务写入与 click 回调均未发生,hits 保持 0。 fn source_hit_rejects_wrong_result_and_business_writes_before_callbacks() { // 分别让 hitTest 返回错误类型或试图改业务状态,验证两种错误都先于 click 回调被拦截。 for (body, message) in [ ("return 1", "hitTest return expects Bool"), ( "self.hits.increment()\nreturn point.inCircle(40, 40, 40)", "read-only", ), ] { // 只替换 hitTest 函数体,保留页面、监听器和命中区域这些有效前提。 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()); // 会话 faulted 后直接读取 VM,避免 execute 的停止检查遮蔽真实业务值。 let globals = session.candidate.globals.unwrap(); // 取原 hits 状态对象,核对非法写入与 click 回调均未把零改为一。 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] /// 普通 RectBlock 通过 click 订阅变为可交互;断开令牌后新文档应移除所有交互标记。 /// 检验命中资格依赖实际监听器状态,不只由构造类型决定。 fn ordinary_block_subscription_updates_interactivity_after_disconnect() { // 普通矩形通过连接令牌订阅 click,初始命中资格来自监听器而非专用 Button 类型。 let source = r##"hits = ui.state(0) rect = RectBlock(80, 40, 0, "#DDEEFF") token = rect.on("click", hits.increment) page = Page() page.render(rect)"##; // 加载时建立连接并发布,先确认至少一个 Mount 被标记为可交互。 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) ); } /// 生成可调规模的源 paint 压力程序:copies 控制共享对象出现次数,locals 控制单次 paint 指令量。 /// onMount 计数检测取消/恢复是否重复生命周期,tint 让各出现位置绘制结果可统一断言。 fn resumable_paint_source(copies: usize, locals: usize) -> String { // 下方布局/绘制切片测试调用本生成器;重复同一业务引用,扩大 Mount 数而不扩大业务对象数。 let children = std::iter::repeat_n("item", copies) .collect::>() .join(", "); // 重复无副作用的局部赋值以消耗 VM 指令预算,让单个 paint 也能跨片暂停。 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] /// 分别加载含源布局/源绘制的示例并主动 reclaim 临时辅助对象。 /// 随后无业务变化刷新必须为 None,版本不变,证明缓存未把短命 helper 当作业务失效依赖。 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(); // 显式执行桥接层回收,保留会话业务根;回收短命 helper 不应被解释为业务状态变化。 session .bridge .reclaim(&mut session.candidate.vm, &mut session.candidate.roots) .unwrap(); // 检查没有发布动作,再比对保存的版本,防止仅返回 None 却内部替换文档。 assert!(session.refresh_document().unwrap().is_none()); assert_eq!( session.documents[&crate::window::MAIN_WINDOW] .frame .layout_version, initial.frame.layout_version ); } } #[test] /// 用 160/640 个出现位置强制布局和绘制让出一次执行。 /// 首次 Pending 时托管对象增长必须小于固定阈值,不能预建整树 Bounds/collector;显式取消后续体和只读范围都应关闭。 fn layout_and_paint_prepare_keep_only_current_occurrence_managed_helpers() { // 以两种明显不同的树规模验证准备阶段的增量内存界限,阈值不随总出现次数增长。 for copies in [160, 640] { // 先构造有效完整帧,随后直接调用桥接分片入口以观察首次 Pending 的中间状态。 let (mut session, initial) = ReplSession::load(&resumable_paint_source(copies, 0), "app.main", 280, 1).unwrap(); // 记录开始绘制前的 VM 对象数;只计算本次准备与第一片新增的托管辅助对象。 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"); }; // 每片只保留当前出现位置的辅助对象/结果;不能在首次让出前为所有位置预建 Bounds 和 collector。 assert!(session.candidate.vm.object_count() < before + 256); // 取消未完成帧并检查 VM 续体释放,随后才能独立测试布局路径。 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(); // 准备页面根但不一次性完成布局,后续观察 begin_layout_frame 的第一片。 let root = session .bridge .prepare_frame(&mut session.candidate.vm) .unwrap() .unwrap(); // 重新取布局阶段基线,排除前一绘制测试已有对象对增长量的影响。 let before = session.candidate.vm.object_count(); // 只启动布局第一片,不预先为整棵树创建 Bounds/collector。 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] /// 修改共享 tint 后逐片推进到 paint 挂起,检查旧完整文档仍可见。 /// 换视口取消未发布绘制并重算,最终 80 条命令全为新色,onMount 仍恰好 80 次,取消与切片统计可观察。 fn pending_paint_retargets_without_replaying_completed_mount_hooks() { // 80 个出现位置已完成首次 onMount;后续只改颜色,不应再调用这些生命周期钩子。 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(); // 只在刷新状态已有 paint 续体时停止,不能把仍在布局中的暂停误算为绘制暂停。 if session .refresh .as_ref() .is_some_and(|work| work.paint.is_some()) { break; } } // 确认刷新状态与 VM 实际暂停调用一致,随后验证尚未发布半成品。 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"))); // 在旧 paint 暂停时提供新尺寸,迫使刷新取消旧目标并基于最新约束重新准备。 let latest = std::collections::BTreeMap::from([( crate::window::MAIN_WINDOW, crate::layout::Size { width: 900.0, height: 650.0, }, )]); // 先只执行一次重定向,确认旧的 VM 暂停调用已被清除。 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") )); // 业务挂载计数仍为首次的 80;重算绘制不能重放已完成的 onMount。 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] /// 利用长 paint 先产生暂停,再在解码时遇到非法颜色。 /// 必须同时验证曾挂起及最终失败;故障关闭 VM 续体/只读范围,保留旧文档版本和原始绘制颜色。 fn paint_error_after_pause_closes_read_only_scope_and_preserves_last_picture() { // 单个 paint 内放入 300 次局部赋值,让命令解码报错之前有机会真实暂停。 let (mut session, initial) = ReplSession::load(&resumable_paint_source(1, 300), "app.main", 279, 1).unwrap(); // 保持当前视口,将变量收敛到“暂停后绘制出错”这一条路径。 let viewports = std::collections::BTreeMap::new(); // 业务字段本身可接受字符串;非法颜色会在后续 paint 命令解码时被拒绝。 session .execute_incremental("item.tint = \"invalid-color\"", "app.main", &viewports) .unwrap(); // 累计是否曾观察到 paint 续体,防止只测到同步失败。 let mut paused = false; // 另记最终是否失败,两项必须同时成立才覆盖暂停后的错误清理。 let mut failed = false; // 有界推进至错误;每个成功切片检查刷新状态,错误分支立即结束推进。 for _ in 0..100 { // 成功仍可能表示尚未完成,不能以 Ok 直接判定新帧已经发布。 match session.resize_windows_incremental(&viewports, false) { // 累计 paint 曾挂起的事实;后续阶段清除 refresh 时也保留该证据。 Ok(_) => { paused |= session .refresh .as_ref() .is_some_and(|work| work.paint.is_some()) } // 记录错误并停止使用这次已故障的刷新,后面的断言检查清理结果。 Err(_) => { failed = true; break; } } } // 要求两个阶段均被覆盖;故障后 VM 不能残留暂停调用或只读范围。 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] /// paint 局部变量先引用旧 ObjectList,再重新绑定到新 DisplayList。 /// 允许局部引用重绑不等于允许修改旧业务对象;绘制成功后 previous 仍应为空。 fn source_paint_can_rebind_old_plain_reference_to_new_result_without_mutating_it() { // 局部 drawing 先引用旧列表、后改指向新 DisplayList;用于区分引用重绑与旧对象字段写入。 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)"##; // 加载过程执行 paint;成功生成一条命令,证明局部变量赋值未被只读策略误拒绝。 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] /// 源 paint 修改原生基类返回的命令颜色,检查共享对象各出现位置均使用覆盖结果。 /// 直接写堆上的 tint 而不执行 REPL,只靠 paint 的字段依赖就应触发重绘并在下一轮稳定。 fn source_paint_overrides_native_commands_and_reacts_to_heap_field_writes() { // 加载共享矩形的源绘制示例,让 paint 对 tint 的读取建立真实依赖。 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") ) ); // 直接取得被 paint 读取的业务矩形对象,不通过重新执行页面源码来刷新。 let globals = session.candidate.globals.unwrap(); // 对象身份必须来自原会话,使 set_attr 只改变已有字段并触发其依赖。 let Value::Object(rect) = session .candidate .vm .dict_get(globals, &Value::Text("rect".into())) .unwrap() .unwrap() else { panic!("Block") }; // 不经 Console、不写全局绑定、不改焦点,只改变 paint 读过的属性,单独验证该读依赖足以使帧失效。 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] /// paint 尝试写 self.tint,必须在写入前被只读保护拒绝并令会话 fault。 /// 原业务颜色不变,错误展开后宿主可正常直接写属性;另测非法颜色命令的发布拒绝。 fn source_paint_rejects_business_mutation_before_it_happens_and_restores_scope() { // paint 正在只读范围内执行;夹具尝试修改早已存在的 rect.tint,而非刚创建的绘制结果。 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)"##; // 先建立会话再执行,错误后仍能检查候选 VM 中的对象与保护范围。 let mut session = ReplSession::new(72).unwrap(); // 页面加载触发首帧 paint,要求诊断明确来自 read-only 保护。 assert!( session .execute(source, "app.main") .err() .unwrap() .to_string() .contains("read-only") ); // 绘制失败应停止当前运行时;但停止状态不能替代检查写入确实没有发生。 assert!(session.is_faulted()); // 绕过已停止会话的 execute 入口,直接取原业务对象验证错误后的真实状态。 let globals = session.candidate.globals.unwrap(); // 全局 rect 应已在 paint 前构造完毕,必须保留其原身份与字段值。 let Value::Object(rect) = session .candidate .vm .dict_get(globals, &Value::Text("rect".into())) .unwrap() .unwrap() else { panic!("Block") }; // tint 仍是构造时的红色,说明非法写入在发生前被阻止,而非失败后仅停止后续回调。 assert_eq!( session.candidate.vm.get_attr(rect, "tint").unwrap(), Value::Text("#FF0000".into()) ); // 宿主直接写字段应恢复为可写,验证异常展开已退出 paint 的只读范围。 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] /// 生成 1100 个共享 rect 出现位置,使 Mount 变更通知超过单批容量。 /// 首次仍需完整产出 1100 条命令,随后无变更刷新为 None,避免通知溢出造成永久重绘。 fn source_paint_is_stable_when_mount_notifications_overflow_the_change_batch() { // 复用源 paint 类与共享 rect 的定义,保留实际依赖读取行为。 let fixture = include_str!("../../../examples/source-paint.endor"); // 截去原根容器及后续页面代码,随后构造大量共享出现位置。 let mut source = fixture[..fixture.find("root = Column").unwrap()].to_owned(); // 创建一个无内边距和间距的 Column,使命令数量直接对应共享 rect 的出现次数。 source.push_str("root = Column(0, 0"); // 1100 次出现使 Mount 通知超过单批容量;每次追加的是同一业务对象引用。 for _ in 0..1100 { source.push_str(", rect"); } // 补齐页面入口,确保所有出现位置都经过正常挂载和源绘制。 source.push_str(")\npage = Page()\npage.render(root)"); // 完成初始发布后检查没有遗漏命令,随后无变化刷新应为 None,不能持续误判失效。 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] /// 检查可选原生构造参数、可变子项和源子类字段在同一构造中的独立分段。 /// 错误类型在分配前拒绝,Block 数量不增;合法回调有效,并逐一验证已公开 UI 方法的 native signature。 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}" ); } // 保存非法构造前的 Block 数量,后面检查类型拒绝是否发生在原生分配副作用之前。 let blocks = session .execute("ui.stats().blocks", "app.main") .unwrap() .value; // 分别破坏原生参数、可变子项、源子类构造和 Type 参数,要求报告对应契约。 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"), ] { // 错误调用必须返回 Err;不能靠无输出或错误对象被悄悄忽略来通过。 let error = session .execute(expression, "app.main") .err() .expect("invalid typed constructor/API"); assert!( error.to_string().contains(expected), "{expression}: {error:#}" ); } // 所有错误尝试结束后,业务 Block 数必须等于基线。 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\"" ); // 逐个检查公开 UI 入口的 native signature,防止仅部分函数注册了契约元数据。 for name in [ "open", "present", "text", "rect", "spacer", "row", "column", "button", "page", "state", "textOf", "stats", "viewport", "window", "isInstance", "isSubtype", "implement", "implements", "supports", "after", ] { // 从当前桥接 ui 对象取得真实函数引用,避免验证一份与实际发布函数无关的描述。 let Value::Object(function) = session .candidate .vm .get_attr(session.bridge.ui, name) .unwrap() else { panic!("native Function") }; // 读取对象存储中绑定到该 native 函数的签名;缺失元数据也必须使测试失败。 let signature = session .candidate .vm .object_store() .native_signature(function) .unwrap() .expect("published UI signature"); // 使用同一 VM 类型表验证签名引用与结构,确保这些契约可由运行时实际使用。 signature .validate(session.candidate.vm.object_store()) .unwrap(); } } #[test] /// 加载满足参数逆变/返回协变的视觉方法覆盖,验证测量和点击都按动态类型执行。 /// 再分别缩窄参数、放宽返回类型,检查类定义被对应 override 诊断拒绝。 fn source_overrides_accept_wider_inputs_and_more_specific_results() { // 示例中 WideRect 放宽 measure 参数为 Object,返回更具体的 Sized,保留合法覆写基线。 let source = include_str!("../../../examples/method-contracts.endor"); // 正常加载时解析覆写契约并执行动态 measure,先确认合法定义可发布。 let (mut session, document) = ReplSession::load(source, "app.main", 164, 1).unwrap(); // 初始几何通过源 Mount API 读取,不能只凭类定义成功判定动态调用正确。 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] /// 把 Mount/Bounds 的名义类型、事件 Bool|String 选项及可空 Page.window/open 参数串联执行。 /// 合法空值和 union 分支互通,Number 选项则必须以 Bool | String 契约诊断拒绝。 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(); // 按顺序执行:先保存 Mount,再测试 Bounds、联合选项、断连及可空页面窗口。 // None 表示只要求调用成功;Some 才对序列化后的返回值作精确断言。 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")), ] { // 各表达式共享前一步建立的 context/listener;顺序是本用例的一部分。 let result = session.execute(expression, "app.main").unwrap(); // 只比较显式声明的预期值,赋值语句等步骤主要用于建立后续检查条件。 if let Some(expected) = expected { assert_eq!(result.value, expected, "{expression}"); } } // 数值 7 不属于 Bool | String 的任何分支,注册应给出联合类型诊断。 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] /// 向 measure/paint、几何方法、resize 和 setColor 传递错误名义类型。 /// 检查每项错误点名所需类型,业务颜色保持不变,随后合法 Constraints 测量仍返回受限宽度。 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(); // 保存属于 rect 的真实 Mount;错误用例只破坏指定参数,不混入失效上下文。 session .execute("context = rect.mounts().at(0)", "app.main") .unwrap(); // 覆盖 measure/paint 的名义类型、几何运算、窗口尺寸及颜色参数,分别要求对应类型名。 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:#}" ); } // 先核对示例保存的原始颜色结果仍正确,再执行合法 Constraints 测量确认会话继续可用。 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] /// 执行示例内直接调用的原生视觉协议,比较托管 MeasureResult/Arrangement/DisplayList 与真实发布命令。 /// 局部命中允许点内/点外判断,但非法约束、错属 Mount 和已卸载上下文不得用于绘制。 fn native_visual_methods_use_managed_values_and_match_real_geometry() { // 示例直接调用原生 measure/arrange/paint,并保存托管结果,供本测试与真实首帧对比。 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 ); } // 实际发布的矩形使用页面布局分配的 200×60,而直接受限测量返回 120;两种上下文不能混淆。 assert!(initial.frame.commands.iter().any(|command| matches!(command, RenderCommand::RoundedRect { width: 200.0, height: 60.0, color, .. } if color == "#DDEEFF"))); // 取已发布 rect 的 Mount,后续命中与绘制检查都用这个有效的所属上下文。 session .execute("context = rect.mounts().at(0)", "app.main") .unwrap(); // 本地坐标 (1,1) 位于矩形内,(-1,1) 位于外部,原生 hitTest 应分别返回 true/false。 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" ); // Constraints 的最小宽度大于最大宽度,即使参数类型正确也必须拒绝。 assert!( session .execute( "rect.measure(context, Constraints(200, 0, 100, 100))", "app.main" ) .is_err() ); // rect 的 Mount 不属于 root,类型正确仍不能越过所属对象检查。 assert!( session .execute("root.paint(context, Bounds(0, 0, 10, 10))", "app.main") .is_err() ); // 关闭页面后旧 Mount 已卸载,原 rect 也不能继续用这个上下文绘制。 session.execute("page.close()", "app.main").unwrap(); assert!( session .execute("rect.paint(context, Bounds(0, 0, 10, 10))", "app.main") .is_err() ); } #[test] /// 先 invalidate paint 再 invalidate layout,对比 measured 为 0 和 1。 /// 两者都可发布但不替换 Mount;无关查询不发布,未知失效类别明确报错。 fn explicit_invalidation_publishes_once_preserves_mount_and_remeasures_only_when_requested() { // 以稳定已发布页面为起点,单独观察显式 paint/layout 失效请求的不同工作量。 let (mut session, _) = ReplSession::load( include_str!("../../../examples/visual-protocols.endor"), "app.main", 62, 1, ) .unwrap(); // 在源全局保存原 Mount 引用,供失效后的身份比较使用。 session .execute("context = rect.mounts().at(0)", "app.main") .unwrap(); // 读取原引用的显示值作为身份基线,避免仅凭相同几何误认同一出现位置。 let original = session.execute("context", "app.main").unwrap().value; // 仅使 paint 失效也应发布新文档,但 measured 统计必须为零。 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" ); // 显式 layout 失效应发布并重新测量,随后统计要求恰好一个测量。 assert!( session .execute("rect.invalidate(\"layout\")", "app.main") .unwrap() .document .is_some() ); assert_eq!( session .execute("ui.stats().measured", "app.main") .unwrap() .value, "1" ); // 两种失效都应保留原 Mount 身份,不通过销毁重建来刷新。 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] /// 加载共享子项生命周期示例,通过日志计数检查每个出现位置各自挂载。 /// 普通颜色更新不重复 onMount;关闭时子先父后卸载,保存的旧 Mount 永久失效,重开创建新出现位置。 fn mount_hooks_run_per_occurrence_settle_and_unmount_before_parent() { // 共享 child 在树中出现两次;生命周期日志区分业务对象与每个独立出现位置。 let (mut session, initial) = ReplSession::load( include_str!("../../../examples/mount-lifecycle.endor"), "app.main", 51, 1, ) .unwrap(); // 首帧已完成钩子引起的状态变化,文档应有效且日志顺序已经稳定。 initial.validate().unwrap(); // 父先挂载,两个 child 出现随后挂载;最后一个子通知序号为 3。 for (expression, expected) in [ ("log.mounted.value", "2"), ("log.parentMounted", "1"), ("log.childMounted", "3"), ] { // 逐项读取真实业务日志,避免只比较总次数而遗漏父子通知顺序。 assert_eq!( session.execute(expression, "app.main").unwrap().value, expected ); } // 保存旧 Mount 后仅改颜色;这是普通数据刷新,不能再次调用 onMount。 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" ); // 关闭页面应发布空 Mount 树,并对两个 child 出现各执行一次 onUnmount。 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" ); // 最后一个 child 卸载序号为 5,父卸载为 6,验证卸载阶段子先父后的顺序。 assert_eq!( session .execute("log.childUnmounted", "app.main") .unwrap() .value, "5" ); assert_eq!( session .execute("log.parentUnmounted", "app.main") .unwrap() .value, "6" ); // 保留的旧句柄可查询 mounted=false,但不允许再读取有效布局边界。 assert_eq!( session.execute("saved.mounted", "app.main").unwrap().value, "false" ); assert!(session.execute("saved.bounds()", "app.main").is_err()); // 重开页面创建新出现位置,总挂载次数增为 4;旧句柄仍不能复活。 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] /// 让两个页面使用同一业务根,然后切换活动页面。 /// 每次切换都卸载旧上下文并挂载新上下文;共享 Block 不能让旧 Mount 或 page() 指向继续复用。 fn page_switch_replaces_mount_context_even_for_shared_root() { // 先打开包含共享 child 的初始页面,建立两个有效 Mount 及其日志基线。 let (mut session, _) = ReplSession::load( include_str!("../../../examples/mount-lifecycle.endor"), "app.main", 52, 1, ) .unwrap(); // 保存旧上下文,再让第二个页面使用同一业务根;活动页面切换仍必须重建 Mount 上下文。 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" ); // 新 Mount.page() 必须属于 second,防止业务对象相同却残留旧页面关系。 assert_eq!( session .execute("child.mounts().at(0).page()", "app.main") .unwrap() .value, session.execute("second", "app.main").unwrap().value ); // 再激活原页面也会创建新的两个出现位置,总挂载数应由 4 变为 6。 session.execute("page.activate()", "app.main").unwrap(); assert_eq!( session .execute("log.mounted.value", "app.main") .unwrap() .value, "6" ); } #[test] /// onMount 向容器 append 新文字,最终文档应包含该结构变化,验证回调后继续收敛。 /// 另一个 onMount 传入错误 setText 参数,错误应停止共享运行时而非忽略生命周期失败。 fn lifecycle_hook_can_edit_container_without_session_lock_and_failure_stops_domain() { // GrowingColumn 的 onMount 修改自身子项,验证生命周期回调允许正常结构编辑并继续收敛。 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(); // 比较真实文字命令,确认 append 已进入最终布局/绘制结果。 assert!(document.frame.commands.iter().any( |command| matches!(command, RenderCommand::Text { value, .. } if value == "from hook") )); // 另建独立会话测试错误钩子,避免前一合法结构变更影响失败路径。 let mut session = ReplSession::new(54).unwrap(); // BrokenText 钩子给 setText 传数字,故障必须向会话传播,不能静默忽略生命周期错误。 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()); } /// 在标准事件示例后附加 extra 语句,创建独立会话和初始完整文档。 /// 各路由测试只需描述额外监听器,即可共享相同父子树、计数器与默认事件配置。 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() } /// 按全局 button 的业务身份找给定文档中的可交互 Mount,模拟原生 click。 /// 事件使用该文档的会话与布局版本,返回派发后的完整文档,避免用过期目标绕过真实输入检查。 fn click_document(session: &mut ReplSession, document: &RenderDocument) -> RenderDocument { // 事件测试助手从当前会话的名字表取得 button,保持业务身份与输入目标一致。 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") }; // 同时按 Block ID 与 interactive 筛选,确保测试输入指向当前文档中的有效按钮出现位置。 let mount = document .frame .mounts .iter() .find(|mount| mount.interactive && mount.block_id == button.to_string()) .unwrap(); // 携带传入文档的 session/layout_version 派发 click;要求有发布结果供调用方继续验证。 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] /// 通过原生输入路径触发一次 click,检查捕获、目标方法、目标监听器、冒泡依次记为 1/2/3/4。 /// 派发结束后的模型保留最后阶段信息,订阅令牌仍连接。 fn managed_event_route_orders_capture_method_listeners_and_bubble() { // 基线事件夹具保留捕获、目标方法、目标监听器和冒泡四个阶段,不追加额外监听器。 let (mut session, initial) = event_session(""); // 通过模拟原生 click 进入宿主路由,并验证回调产生的新文档结构有效。 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 ); } // 模型保存的最后阶段应是 bubble;监听令牌仍连接,正常派发不自动退订。 assert_eq!( session.execute("model.phase", "app.main").unwrap().value, "\"bubble\"" ); assert_eq!( session .execute("target.isConnected()", "app.main") .unwrap() .value, "true" ); } #[test] /// 第一场景在捕获中断开尚未调用的目标监听器,必须跳过它但继续冒泡。 /// 第二场景在目标捕获中 stopPropagation,当前目标组仍执行,祖先冒泡被阻止。 fn disconnect_during_capture_skips_pending_listener_and_stop_preserves_current_group() { // 在祖先捕获中追加断开 target 的回调;断开发生在目标监听器尚未轮到执行之前。 let (mut session, initial) = event_session( "function cancel(event: Event): Unit { target.disconnect() }\nconnection = root.on(\"click\", cancel, \"capture\")", ); // 实际派发一次;捕获与目标方法仍执行,目标监听器跳过后冒泡成为第 3 步。 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" ); // 另建会话,在目标捕获组调用 stopPropagation;不沿用前一场景的已断开监听器。 let (mut session, initial) = event_session( "function stop(event: Event): Unit { event.stopPropagation() }\nconnection = button.on(\"click\", stop, \"capture\")", ); // 重新派发后目标同组监听器仍为第 3 步,但祖先冒泡保持未执行的 0。 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] /// 目标捕获使用 stopImmediatePropagation,应连目标默认方法也跳过。 /// 代码派发 custom 事件可返回同一带 preventDefault 标记的 Event,结束后 phase 为 none,并允许再次派发。 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\")", ); // 总计数只能为祖先捕获贡献的 1,区分立即停止与仅阻止祖先冒泡。 click_document(&mut session, &initial); assert_eq!( session .execute("model.count.value", "app.main") .unwrap() .value, "1" ); // 单独测试源码 dispatch:custom 监听器调用 preventDefault,返回 Event 保留取消标记。 let (mut session, _) = event_session( "function prevent(event: Event): Unit { event.preventDefault() }\nconnection = button.on(\"custom\", prevent)\nresult = button.dispatch(Event(\"custom\"))", ); // 取消标记在返回对象上可见;派发退出后 Event.phase 应恢复 none。 assert_eq!( session .execute("result.isDefaultPrevented()", "app.main") .unwrap() .value, "true" ); assert_eq!( session.execute("result.phase", "app.main").unwrap().value, "\"none\"" ); // 已完成派发的 Event 可以重用;前次派发的活跃守卫必须已经解除。 session .execute("button.dispatch(result)", "app.main") .unwrap(); } #[test] /// 回调失败后断开故障监听器,再派发同一 Event,验证活跃派发守卫已释放。 /// 另外保留订阅 token 却删除隐藏目标,回收后目标弱升级失败且 token 断开,证明它不强持有目标。 fn event_failure_releases_dispatch_guard_and_token_does_not_retain_target() { // 复用有效事件页面,追加一个会因 page.render 参数错误而失败的独立事件监听器。 let (mut session, _) = event_session(""); // 保留 failedEvent 句柄,错误后必须能重新派发同一对象以验证活跃守卫释放。 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() ); // 先移除故障回调,再用同一 Event 成功派发;若守卫泄漏,会误报重复派发。 session .execute( "failing.disconnect()\nbutton.dispatch(failedEvent)", "app.main", ) .unwrap(); // 创建未挂载的按钮并保留订阅 token,随后只删除业务全局强引用。 session .execute( "orphan = Button(\"unmounted\")\ntoken = orphan.on(\"click\", model.target)", "app.main", ) .unwrap(); // 取得名字表,便于准确删除 orphan,而保留 token 和其回调模型。 let globals = session.candidate.globals.unwrap(); // 取目标对象句柄以创建弱引用;Rust 局部句柄不应成为 VM 的持久业务根。 let Value::Object(orphan) = session .candidate .vm .dict_get(globals, &Value::Text("orphan".into())) .unwrap() .unwrap() else { panic!("Block expected") }; // 弱句柄用于确认目标确实回收,不只检查 token 对外显示已断开。 let weak = session.candidate.vm.downgrade(orphan).unwrap(); // 删除 orphan 的最后显式全局强引用;不删除 token,专门检查 token 不强持有目标。 session .candidate .vm .dict_delete(globals, &Value::Text("orphan".into())) .unwrap(); // 沿真实桥接根集合执行回收,测试弱订阅与 GC 的组合行为。 session .bridge .reclaim(&mut session.candidate.vm, &mut session.candidate.roots) .unwrap(); // 目标弱升级必须失败,且仍存活的 token 查询为未连接。 assert!(session.candidate.vm.upgrade(weak).unwrap().is_none()); assert_eq!( session .execute("token.isConnected()", "app.main") .unwrap() .value, "false" ); } #[test] /// 加载原生视觉子类,把旧 label 引用保存到 alias 后替换为新的源实例。 /// 共享转发应让 alias 读写新字段、更新多个绘制位置,同时旧属性存储的弱句柄可回收。 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(); // 子类实例仍应满足 TextBlock/Page 原生名义类型,后续才测试替换与字段转发。 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" ); // 额外保留旧业务引用,替换 label 后 alias 应通过共享转发访问新的属性存储。 session.execute("alias = label", "app.main").unwrap(); // 取当前 label 对象,以其真实属性路径定位替换前的底层存储。 let globals = session.candidate.globals.unwrap(); // 要求目标是原夹具中的 Block 对象,而非 Console 展示文本或另建实例。 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(); // 用同名源实例替换 label,首次新文档应已显示新字段内容。 let updated = session .execute( r##"label = CustomStatus("replacement", 2, "#242422", "#237A45", "new field")"##, "app.main", ) .unwrap() .document .unwrap(); // 验证替换已经进入绘制结果,再检查旧存储回收与 alias 读写转发。 assert!(updated.frame.commands.iter().any( |command| matches!(command, RenderCommand::Text { value, .. } if value == "new field") )); // 回收不再从共享业务身份可达的旧存储;alias 不能让过时属性存储继续存活。 session .bridge .reclaim(&mut session.candidate.vm, &mut session.candidate.roots) .unwrap(); // 旧存储应消失,但 alias.message 必须仍读取替换后的 new field。 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); // 最后从旧 alias 写入字段,新文档也应反映 changed field,覆盖双向转发。 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] /// 完整源码加载后执行 Console 类型查询,再用原生 click 调用原程序中的源回调。 /// 两个共享文字都应变为 10 且 VM 健康,覆盖混合入口中源 ID/编译单元身份不冲突的行为。 fn loaded_program_console_and_native_events_share_unique_source_ids() { // 先加载完整源码编译单元,保留其中源回调;随后追加 Console 单元并触发原生事件。 let (mut session, initial) = ReplSession::load( include_str!("../../../examples/managed-types.endor"), "app.main", 1, 1, ) .unwrap(); // 在已有程序上执行 Console 类型查询,验证混合输入可以继续引用原对象和类型。 initial.validate().unwrap(); assert_eq!( session .execute("isInstance(page, Block)", "app.main") .unwrap() .value, "true" ); // 从初始帧找可交互按钮,用原帧版本构造原生 click,回调仍来自最初加载的源码。 let mount = initial .frame .mounts .iter() .find(|mount| mount.interactive) .unwrap(); // 固定会话 ID 与加载一致,避免过期输入拒绝掩盖源 ID/编译单元冲突。 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(); // 两处共享文字都应变为 10,同时 VM 保持健康,证明跨入口回调没有落到错误源码单元。 assert_eq!( updated .frame .commands .iter() .filter(|command| matches!(command, RenderCommand::Text { value, .. } if value == "10" )) .count(), 2 ); assert!(!session.is_faulted()); } #[test] /// 向被裁剪的子项中心发送 click,虽在其原始矩形内也不得触发计数。 /// 随后滚动同一 ScrollBlock 的一个出现位置、scrollIntoView 并扩大容器,分别检查独立偏移、可见性及范围回落到零。 fn scroll_occurrences_are_independent_and_clipped_pointer_input_is_ignored() { // 加载两个窗口共享 root/scroller 的实际示例;主窗口 frame 用来构造坐标输入。 let (mut session, initial) = ReplSession::load( include_str!("../../../examples/scroll.endor"), "app.main", 1, 240, ) .unwrap(); // 从当前 VM 全局表取业务按钮,后续通过对象身份定位它的主窗口出现位置。 let globals = session.candidate.globals.unwrap(); // middle 位于滚动内容的中段,初始未滚动时中心应被容器裁剪。 let Value::Object(middle) = session .candidate .vm .dict_get(globals, &Value::Text("middle".into())) .unwrap() .unwrap() else { panic!("expected Block"); }; // 只在主窗口发布帧中找 middle 的 Mount,避免选到另一个窗口的同一业务对象。 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, ]; // 先验证夹具确实产生裁剪外目标;contains 同时考虑有效可见区域,不能只测原始宽高。 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(); // 业务计数仍为零,证明即使客户端直接提供该 Mount,也不能绕过裁剪触发 click。 assert_eq!( session.execute("count.value", "app.main").unwrap().value, "0" ); // 在第一个 Mount 连续 scrollTo/scrollBy,验证同次源码输入中相对滚动累加到 150。 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" ); // 第二个出现位置仍为零,偏移属于 Mount,不应共享到同一 ScrollBlock 的另一窗口。 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(); // 仍通过业务身份查找 last,避免用旧文档的过期几何判断 scrollIntoView。 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; // 重新定位底部按钮的最新出现几何,不能沿用滚动前的 target。 let target = frame .mounts .iter() .find(|m| m.block_id == last.to_string()) .unwrap(); // 按钮中心现在应在有效可见区域内,证明 scrollIntoView 真正改变投影而非只改状态字段。 assert!(target.contains(crate::geometry::Point::new( target.x + target.width / 2.0, target.y + target.height / 2.0 ))); // 每条命令都应有对应裁剪项,包含源 paint 生成的命令。 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] /// 为已有 Mount 建立长延时任务,再开始会挂起的 resize 布局并替换视口目标。 /// 取消应恢复旧几何/挂载身份、清除临时 layout,最终重新收敛且原任务仍 pending,避免把放弃的准备当成卸载。 fn new_viewport_cancels_pending_layout_and_restores_saved_mount_context() { // 重复 40 个同一 item 引用,使源布局有足够工作量进入可暂停路径。 let children = std::iter::repeat_n("item", 40) .collect::>() .join(", "); // Wide.measure 提供固定尺寸,夹具让挂起发生在布局而非业务程序构造期间。 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}))" ); // 先完整发布初始页面,后面的取消需要恢复到这份已生效的 Mount 上下文。 let (mut session, _) = ReplSession::load(&source, "app.main", 272, 1).unwrap(); // 保存已挂载 Mount,并以它为 owner 注册长延时任务;任务状态用来检测是否误当卸载。 session.execute("saved = item.mounts().at(0)\nreceived = ui.state(0)\ntoken = ui.after(saved, \"keep\", 60000, received.increment)", "app.main").unwrap(); // 读取源 saved 引用,取消后直接检查其原生 Mount payload。 let globals = session.candidate.globals.unwrap(); // 要求保留的是实际 Mount 对象;不使用重新查询出的新引用掩盖身份丢失。 let Value::Object(saved) = session .candidate .vm .dict_get(globals, &Value::Text("saved".into())) .unwrap() .unwrap() else { panic!("saved Mount"); }; // 提交第一个视口目标,启动一轮尚未完成的 resize 布局。 let first = std::collections::BTreeMap::from([( crate::window::MAIN_WINDOW, crate::layout::Size { width: 800.0, height: 600.0, }, )]); // 有界推进到 VM 真正挂起,确保接下来的新尺寸会取消活跃续体。 for _ in 0..20 { session.resize_windows_incremental(&first, false).unwrap(); // 观察到真实暂停调用即停止推进,避免把旧布局完成后再 resize 算作取消测试。 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, }, )]); // 首次重定向还不发布新帧,同时必须清除旧 VM 暂停调用。 assert!( session .resize_windows_incremental(&latest, false) .unwrap() .is_none() ); assert!(!session.candidate.vm.has_suspended_call()); // 直接查看保存句柄的 payload:应恢复已挂载标志、清除临时 layout 并保留旧宽度。 let mount = session .candidate .vm .payload::(saved, "Mount") .unwrap(); // 取消准备工作不等于卸载;恢复状态后才继续完整收敛新布局。 assert!(mount.mounted); assert!(mount.layout.is_none()); assert_eq!(mount.geometry.width, 2.0); // 完成最新视口下的发布,再确认会话健康、原 owner 任务仍 pending,且只取消过一次。 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] /// 增量加载含 80 个出现位置的新候选,在初始布局尚未发布时替换视口目标。 /// 候选文档在完成前始终为空;重新收敛后构建计数仍为 1、挂载计数恰好 80,证明取消只重做布局。 fn candidate_initial_layout_retargets_without_reexecuting_program_or_hooks() { // 80 个共享出现位置使首次候选布局跨片执行,为发布前的视口替换提供可观察窗口。 let children = std::iter::repeat_n("item", 80) .collect::>() .join(", "); // built 仅在程序顶层递增,mounted 在每个 onMount 递增,分别检测源码和生命周期是否重放。 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(); // 以 VM 的暂停调用作为取消前提,不能仅凭 has_pending_refresh 判断当前阶段。 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()); // 最后一个宽 2 的子项位于 79×2=158;正确几何之外还要核对程序仅构建一次、挂载恰好 80 次。 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] /// 一次输入递增计数、改宽度并返回值,源执行结束后布局继续 Pending。 /// 此时禁止新 mutation/命令投影,保留旧文档;换视口后最终几何更新,但返回值和递增都只发生一次。 fn incremental_input_retains_value_and_never_replays_source_while_retargeting() { // 构造 80 个共享 item 的 Row,确保一次宽度更新需要分片布局。 let children = std::iter::repeat_n("item", 80) .collect::>() .join(", "); // count 记录顶层输入执行次数;item.width 更新后影响全部出现位置。 let source = format!( "count = ui.state(0)\nitem = RectBlock(2,20)\npage = Page()\npage.render(Row(0,0,{children}))" ); // 先完整发布旧页面,后续 Pending 期间必须继续保留其版本。 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(); // 业务结果此时已经是 1,但没有新文档;待刷新时不能消费窗口命令或提交另一条修改。 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(); // 只在 VM 实际挂起时结束推进,保证换视口取消的是活跃工作。 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, }, )]); // 先触发重定向,再同步收敛至新帧,不能重新调用 execute_incremental。 session.resize_windows_incremental(&latest, false).unwrap(); // 宽度已变为 3,最后子项 x=79×3=237;返回值及当前 count 都仍为 1,排除源码重放。 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] /// 点击回调先改变业务计数,再通过多次增量刷新完成大树布局。 /// 挂起期间旧文档版本不变,最终计数为 1 且 refreshYields 大于零,避免分片时重放 click。 fn incremental_event_runs_callback_once_before_publishing_its_layout() { // 重复 80 个 item 增加刷新工作量,按钮回调只负责递增 count。 let children = std::iter::repeat_n("item", 80) .collect::>() .join(", "); // 计数通过 ui.textOf 可视化,点击后必须产生真实布局/绘制刷新而非仅后台字段更新。 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(); // 事件身份与初始文档一致;无坐标的直接目标 click 专注测试回调与增量刷新衔接。 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(); // 回调已被调用,但第一步增量派发尚未完成新文档,应留下 pending 刷新。 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; // 反复推进同一刷新;不得重新 dispatch input,否则会重复执行业务回调。 for _ in 0..1000 { // 只有返回 Some(document) 才标记完成,None 仍表示分片尚未发布。 if session .resize_windows_incremental(&viewports, false) .unwrap() .is_some() { complete = true; break; } } // 必须完成并清除 pending;最后计数仍为 1,统计中应实际观察到过让出。 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] /// 只用原生布局的大树也应进入可恢复路径。 /// Pending 期间保留旧帧,换视口取消后重算仍产出最后子项 x=158/width=2,并记录切片与取消次数。 fn large_intrinsic_layout_yields_and_retargets_without_geometry_loss() { // 用 80 次共享引用放大原生 Row 的布局,测试不依赖源方法覆盖也能分片。 let children = std::iter::repeat_n("item", 80) .collect::>() .join(", "); // 业务对象只有一个 item,所有出现各占宽 2,最后一个的预期 x 为 158。 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(); // 记录已发布版本,Pending 时不能提前更换对外文档。 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, }, )]); // 推进直到 VM 真正暂停;若只有刷新标记而无暂停,就没有覆盖续体取消。 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()); // 最终仍为 x=158、width=2;取消一次且布局跨多片,证明不是绕开分片直接复用旧帧。 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] /// 两个立即就绪任务都递增同一状态,首次 poll 只交付第一个并启动刷新。 /// 刷新未完成时重复 poll 不交付第二个;第一帧完成后再 poll 才发布第二次递增。 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(); // 第一次 poll 交付 a 并启动刷新;尚无完成文档,has_pending_refresh 应为真。 assert!( session .poll_tasks_incremental(&viewports) .unwrap() .is_none() ); assert!(session.has_pending_refresh()); // 第二次 poll 不能越过未完成刷新再交付 b。 assert!( session .poll_tasks_incremental(&viewports) .unwrap() .is_none() ); assert_eq!( session.documents[&crate::window::MAIN_WINDOW] .frame .layout_version, old_version ); // 完成第一回调对应的文档,核对版本前进、状态为 1、a 完成而 b 仍 pending。 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\"" ); // 第一帧完成后才允许 b 交付;再收敛其刷新,应看到状态为 2 且 b 完成。 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] /// 启动增量 resize 后尝试普通 mutation,应拒绝但不使 VM fault。 /// 逐次推进时每个未完成返回都保持旧文档版本,只有整体完成才一次发布新版本并清除 pending。 fn incremental_viewport_refresh_retains_published_documents_until_complete() { // 源 flex 约束示例提供多阶段布局工作,用来检验视口刷新期间的发布原子性。 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; // 窗口 1 的新逻辑尺寸是本轮刷新目标,循环中保持该目标不变。 let viewports = std::collections::BTreeMap::from([( crate::window::MAIN_WINDOW, crate::layout::Size { width: 800.0, height: 600.0, }, )]); // 第一步启动刷新并返回 None;此时普通修改应被拒绝,但不应让健康 VM fault。 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; // 逐片推进直到发布;每次 None 后立即确认旧文档版本仍保持。 for _ in 0..1000 { // 只在完整新文档返回时检查版本增加与 pending 清除,并停止推进。 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] /// 绕过会话同步包装,直接调用桥接 begin_layout_frame 观察首个 Pending。 /// 循环把工作所有权传回 resume,最终 Complete 才断言完整几何,且 VM 不再残留挂起调用。 fn bridge_layout_can_return_pending_before_committing_a_complete_frame() { // 40 次共享出现加上源 Row.measure,让桥接布局入口返回可恢复工作。 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}))" ); // 先建立有效 VM/桥接状态,再绕过会话同步包装直接测试桥接分片接口。 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(); // 只准备根节点,后续 begin_layout_frame 才启动可分片布局。 let root = session .bridge .prepare_frame(&mut session.candidate.vm) .unwrap() .unwrap(); // 保存工作枚举所有权,每次 Pending 都要把续体重新交回 resume。 let mut progress = session .bridge .begin_layout_frame(&mut session.candidate.vm, root) .unwrap(); // 首次必须为 Pending 且 VM 有暂停调用,否则未测到桥接的恢复契约。 assert!(matches!( progress, crate::ui_bridge::LayoutProgress::Pending(_) )); assert!(session.candidate.vm.has_suspended_call()); // 按返回状态继续推进,只有 Complete 分支允许读取最终几何。 loop { // Pending 携带尚未完成的独占工作;Complete 则携带可检查的完整帧。 match progress { // 将本片返回的工作移动回桥接,不复制也不重新创建布局,保留已完成片段。 crate::ui_bridge::LayoutProgress::Pending(pending) => { progress = session .bridge .resume_layout_frame(&mut session.candidate.vm, *pending) .unwrap(); } // 最后子项位置为 39×2=78;完成后 VM 不得遗留暂停调用。 crate::ui_bridge::LayoutProgress::Complete(frame) => { assert_eq!(frame.mounts.last().unwrap().x, 78.0); assert!(!session.candidate.vm.has_suspended_call()); break; } } } } #[test] /// 同一 item 在 Row 中重复 40 次,布局需跨多个 VM 切片。 /// 完整帧不能丢失出现位置,第 40 个的 x=78 且 Mount 数量覆盖容器与全部子项。 fn layout_slices_preserve_all_shared_occurrences() { // 同一业务 item 重复出现 40 次;丢失某个 Mount 会直接改变数量或末项几何。 let children = std::iter::repeat_n("item", 40) .collect::>() .join(", "); // 源父测量配合原生 Row 排列,跨多个 VM 布局切片验证出现位置保留。 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(); // 完整帧至少包含容器及 40 个子项,最后一个出现的源 bounds.x 必须为 78。 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] /// 有限 Row 把带换行文字的 flex Column 缩窄,文字自然高度必须重新测量,不能沿用早期宽约束下的一行高度。 /// 将文字改短后高度回到 20,确认新求解不把先前分配冻结为永久尺寸。 fn nested_flex_refreshes_natural_height_when_cross_width_changes() { // 父 Row 只提供 240 宽,内部 flex Column 的文字需在最终分配宽度下重新测量自然高度。 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(); // 先确认发布文档有效,再通过源 Mount API 读取真实排列后的文字高度。 initial.validate().unwrap(); // 把源返回的数值转为浮点,比较是否多于一行 20;不能冻结首次宽测量得到的单行高度。 let height: f32 = session .execute("paragraph.mounts().at(0).bounds().height", "app.main") .unwrap() .value .parse() .unwrap(); // 长文字在窄约束下必须换行;随后改短以检验新一轮求解没有沿用旧的分配高度。 assert!(height > 20.0); // 只改原 paragraph 的文字,下一帧应重测为单行 20,而非替换整棵布局树。 session .execute("paragraph.setText(\"short\")", "app.main") .unwrap(); assert_eq!( session .execute("paragraph.mounts().at(0).bounds().height", "app.main") .unwrap() .value, "20" ); } #[test] /// 给两个 flex 子项显式 basis、不同增长权重和最大宽度,检查达到上限后剩余空间再分配。 /// 随后切换为收缩并让首项触及最小值,再恢复 auto basis,验证每次新求解都重新计算自然尺寸。 fn bounded_flex_redistributes_growth_and_scaled_shrink() { // 父宽 340 扣两侧 padding 20、两个 gap 共 20、固定项 60,留给两个 flex 项的总宽为 240。 // 各 basis 为 100,增长权重 1:3;首项达到 max=105 后,多余空间应重新分配给第二项。 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(); // 确认最终帧合法,以下查询必须读取出现位置几何,不能只读业务 width 自然值。 initial.validate().unwrap(); // 本测试闭包按业务名查询首个 Mount 的宽度,供三轮求解共用。 let width = |session: &mut ReplSession, name| { session .execute(&format!("{name}.mounts().at(0).bounds().width"), "app.main") .unwrap() .value }; // 增长阶段分配为 105/135,总宽仍为 240;首项被上限冻结后剩余增长量转给第二项。 assert_eq!(width(&mut session, "first"), "105"); assert_eq!(width(&mut session, "second"), "135"); // 切换为无增长、等收缩权重且 basis 均为 200;首项 min=150 触底后,其余收缩应由第二项承担。 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(); // 收缩结果为 150/90,固定项仍为 60;不能把固定项也拿来吸收缺口。 assert_eq!(width(&mut session, "first"), "150"); assert_eq!(width(&mut session, "second"), "90"); assert_eq!(width(&mut session, "fixed"), "60"); // 恢复 auto basis 并移除首项界限,求解应从自然宽 20/40 重新开始,不沿用前次分配。 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] /// 同一段文字分别出现在两个 Row;首个 Row 的可伸缩兄弟权重改变会挤窄文字。 /// 只第一个出现位置的换行高度增加,第二个保持原高度,证明约束与自然尺寸缓存按 Mount 隔离。 fn native_flex_reflows_shared_text_per_occurrence() { // 同一 paragraph 分别挂到两个 Row;只有首个 Row 的兄弟 filler 具有后续会改动的增长权重。 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(); // 先验证完整发布,再按 Mount 索引分别读取共享文字的两个出现高度。 initial.validate().unwrap(); // 闭包返回指定出现位置的最终高度,显式转 f32 供换行高度比较使用。 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); // 增大首个 Row 兄弟的权重以挤窄文字;只有第一个出现应增高,第二个必须保持基线。 session.execute("filler.flex = 3", "app.main").unwrap(); assert!(height(&mut session, 0) > before); assert_eq!(height(&mut session, 1), other); } #[test] /// 有限宽度 Row 先扣 padding、gap 与固定子项,再把 240 剩余宽度按 1:2 分配。 /// 修改权重为 3:1 后同一出现位置实时改为 180/60,验证分配结果反过来约束子测量。 fn weighted_flex_remeasures_children_and_updates_live_weights() { // 父宽 340 扣除 padding 20、gap 20 和固定项 60,剩余 240 由 first/last 按增长权重分配。 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(); // 有效首帧中权重 1:2 应分得 80/160,检查 Mount 宽度而不是自然业务 width。 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" ); // 在同一会话把权重改成 3:1,依赖变化应重新分配为 180/60,不能沿用首次分配缓存。 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] /// 表驱动切换 start/center/end/space-between,逐项比较两个子项的 x。 /// 子项宽度始终为自然宽度,mainAlign 只分配剩余间隙,不应被误当成 flex 拉伸。 fn main_alignment_updates_live_geometry_without_changing_sizes() { // 固定父宽 300、左右 padding 各 10,两子项自然宽 20/40、基础间距 10,留出可对齐的剩余空间。 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(); // 确认基线可发布,后面只改 mainAlign 属性,不重新创建容器或子项。 initial.validate().unwrap(); // 枚举四种主轴对齐模式及两个预期 x:center 平分剩余空间,end 全放前部,space-between 放入项间。 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(); // 先核对首项和第二项位置,再检查首项自然宽仍为 20;对齐不应变成 flex 拉伸。 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] /// 同一 RectBlock 分别放在 Row 和 Column,以两个父容器的 crossAlign 得到独立坐标。 /// alignSelf 显式值覆盖各父策略,恢复 auto 后再次继承,且修改一个父容器不影响另一个出现位置。 fn cross_alignment_is_live_and_specific_to_each_parent_occurrence() { // 共享矩形分别出现在 Row 与 Column,交叉轴方向不同;父策略分别为 center 与 end。 let (mut session, initial) = ReplSession::load(r#" class Tall: Row { function measure(self: Tall, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(200, 100) } } class Wide: Column { function measure(self: Wide, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(200, 100) } } shared = RectBlock(20, 20) row = Tall(10, 0, shared) column = Wide(10, 0, shared) row.crossAlign = "center" column.crossAlign = "end" page = Page() page.render(Column(0, 0, row, column)) "#, "app.main", 294, 1).unwrap(); // 先验证初始帧,随后核对两个出现位置各自的坐标,包含第二容器在外层 Column 中的偏移。 initial.validate().unwrap(); // 首项在 Row 的 y 上居中,次项在 Column 的 x 上靠末端,不能把两个父上下文合并。 for (expression, expected) in [ ("shared.mounts().at(0).bounds().x", "10"), ("shared.mounts().at(0).bounds().y", "40"), ("shared.mounts().at(1).bounds().x", "170"), ("shared.mounts().at(1).bounds().y", "110"), ] { // 逐表达式读取源 Mount 几何,让位置比较覆盖实际发布布局而非静态属性。 assert_eq!( session.execute(expression, "app.main").unwrap().value, expected ); } // 业务对象上的显式 start 覆盖两个父容器的默认策略,两处交叉轴位置都应回到 padding 起点。 session .execute("shared.alignSelf = \"start\"", "app.main") .unwrap(); assert_eq!( session .execute("shared.mounts().at(0).bounds().y", "app.main") .unwrap() .value, "10" ); assert_eq!( session .execute("shared.mounts().at(1).bounds().x", "app.main") .unwrap() .value, "10" ); // 恢复 auto 后重新继承各自父策略;只把 Row 改为 end,Column 的另一出现应仍保持原来的 end。 session .execute( "shared.alignSelf = \"auto\"\nrow.crossAlign = \"end\"", "app.main", ) .unwrap(); assert_eq!( session .execute("shared.mounts().at(0).bounds().y", "app.main") .unwrap() .value, "70" ); assert_eq!( session .execute("shared.mounts().at(1).bounds().x", "app.main") .unwrap() .value, "170" ); } #[test] /// 源 arrange 主动把第一个子项 y 改为 7,后续原生 crossAlign 不能覆盖源给定位置。 /// 另一子项比父交叉轴更宽时按起点溢出,center 不应产生负起点。 fn cross_alignment_preserves_source_placements_and_starts_overflow() { // 一个源 Row.arrange 显式指定 y=7,另一个源 Column.measure 只分配宽 10;分别覆盖源位置优先及自然子项溢出。 let (mut session, initial) = ReplSession::load(r#" class Placed: Row { function arrange(self: Placed, mount: Mount, geometry: Bounds): Arrangement { result = Row.arrange(self, mount, geometry) result.children.at(0).y = 7 return result } } class Small: Column { function measure(self: Small, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(10, 30) } } first = RectBlock(20, 20) second = RectBlock(40, 20) placed = Placed(0, 0, first) placed.crossAlign = "end" small = Small(0, 0, second) small.crossAlign = "center" page = Page() page.render(Column(0, 0, placed, small)) "#, "app.main", 295, 1).unwrap(); // 通过正常加载收敛并验证文档,以下检查读取最终 Mount 坐标而非临时 Arrangement。 initial.validate().unwrap(); // 即使父 crossAlign=end,源 arrange 已给定的 y=7 也不能被后续原生对齐覆盖。 assert_eq!( session .execute("first.mounts().at(0).bounds().y", "app.main") .unwrap() .value, "7" ); // 子宽 40 大于父宽 10,center 应从起点溢出,不能用负剩余空间计算出负偏移。 assert_eq!( session .execute("second.mounts().at(0).bounds().x", "app.main") .unwrap() .value, "0" ); } #[test] /// 带不对称 margin 的共享子项分别参与有限 flex Row 和自然尺寸 Row。 /// 检查定位、交叉轴居中、剩余宽度与容器固有尺寸;增加右 margin 同时挤窄 flex 兄弟并扩大自然父宽度。 fn margins_participate_in_intrinsic_size_alignment_and_flex_allocation() { // 同一 first 带左右 3/7、上下 5/15 的 margin,分别参与有限分配与自然尺寸两种父布局。 let (mut session, initial) = ReplSession::load(r#" class Wide: Row { function measure(self: Wide, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(200, 100) } } first = RectBlock(20, 20) first.marginLeft = 3 first.marginRight = 7 first.marginTop = 5 first.marginBottom = 15 second = RectBlock(20, 20) second.flex = 1 row = Wide(10, 10, first, second) row.crossAlign = "center" intrinsic = Row(10, 10, first, RectBlock(30, 30)) page = Page() page.render(Column(0, 0, row, intrinsic)) "#, "app.main", 296, 1).unwrap(); // 先验证文档,随后比较位置、兄弟分配和自然父尺寸,覆盖 margin 进入盒模型的各处。 initial.validate().unwrap(); // 有限 Row 的首项 x=10+3,后项 x=10+3+20+7+10;剩余宽 140 给 flex 项。 // 自然 Row 宽为 20 padding+30 首项外宽+10 gap+30 后项,高为 20 padding+40 最大外高。 for (expression, expected) in [ ("first.mounts().at(0).bounds().x", "13"), ("first.mounts().at(0).bounds().y", "35"), ("second.mounts().at(0).bounds().x", "50"), ("second.mounts().at(0).bounds().width", "140"), ("intrinsic.mounts().at(0).bounds().width", "90"), ("intrinsic.mounts().at(0).bounds().height", "60"), ] { // 逐表达式核对真实几何,错误信息保留表达式以区分定位与尺寸求解问题。 assert_eq!( session.execute(expression, "app.main").unwrap().value, expected, "{expression}" ); } // 将共享 first 的右 margin 增加 10,两个出现位置都需刷新,但有限父和自然父的变化方向不同。 session .execute("first.marginRight = 17", "app.main") .unwrap(); // 有限 Row 总宽不变,flex 兄弟让出 10;自然 Row 则增加 10,证明 margin 不是只影响绘制偏移。 assert_eq!( session .execute("second.mounts().at(0).bounds().width", "app.main") .unwrap() .value, "130" ); assert_eq!( session .execute("intrinsic.mounts().at(0).bounds().width", "app.main") .unwrap() .value, "100" ); } #[test] /// 在固定尺寸 Overlay 内嵌带边框/不对称 padding 的 Column,子项还有 margin。 /// 比较三层绝对坐标与边框绘制命令,再动态改变 min/max,验证对齐与内容偏移使用同一盒模型。 fn nested_overlay_uses_border_boxes_and_asymmetric_content_insets() { // 固定 Overlay 中放置固定 Column,Column 有非对称 padding/border,子项另有 margin,构造三层偏移。 let (mut session, initial) = ReplSession::load( r##" child = RectBlock(20, 10) child.marginLeft = 5 child.marginTop = 4 column = Column(0, 0, child) column.boxWidth = 200 column.boxHeight = 120 column.paddingLeft = 11 column.paddingTop = 7 column.borderLeft = 3 column.borderTop = 2 column.borderColor = "#FF0000" overlay = Overlay(10, 0, column) overlay.boxWidth = 300 overlay.boxHeight = 200 overlay.mainAlign = "center" overlay.crossAlign = "end" page = Page() page.render(overlay) "##, "app.main", 297, 1, ) .unwrap(); // 完成布局后逐层查几何,让容器对齐、内容内缩和子项 margin 使用同一坐标基准。 initial.validate().unwrap(); // Column 的外框在 (50,70),子项 x=50+3+11+5=69、y=70+2+7+4=83。 for (expression, expected) in [ ("overlay.mounts().at(0).bounds().width", "300"), ("column.mounts().at(0).bounds().x", "50"), ("column.mounts().at(0).bounds().y", "70"), ("column.mounts().at(0).bounds().width", "200"), ("child.mounts().at(0).bounds().x", "69"), ("child.mounts().at(0).bounds().y", "83"), ] { // 核对边框盒尺寸和绝对坐标,避免只看像素颜色而遗漏布局数值偏差。 assert_eq!( session.execute(expression, "app.main").unwrap().value, expected, "{expression}" ); } // 红色左边框必须在 Column 外框左缘生成宽 3、高 120 的命令,不能画到内容起点。 assert!(initial.frame.commands.iter().any(|command| matches!(command, RenderCommand::RoundedRect { x, y, width, height, color, .. } if *x == 50.0 && *y == 70.0 && *width == 3.0 && *height == 120.0 && color == "#FF0000"))); // 同一 Column 的显式尺寸还要受 maxWidth/minHeight 夹取,字段更新后应变成 160×140。 session .execute("column.maxWidth = 160\ncolumn.minHeight = 140", "app.main") .unwrap(); assert_eq!( session .execute("column.mounts().at(0).bounds().width", "app.main") .unwrap() .value, "160" ); assert_eq!( session .execute("column.mounts().at(0).bounds().height", "app.main") .unwrap() .value, "140" ); } #[test] /// 文字 boxWidth=fill 在有限父宽中获得整个边框盒,真正换行宽度再扣左右 padding。 /// 另构造无界 Column 主轴上的 fill 高度,应明确拒绝无法定义的有限分配。 fn fill_wraps_text_inside_finite_column_and_rejects_unbounded_axis() { // 有限宽 160 的 Column 为 fill 文字分配边框盒,文字自身 padding 再减少实际排字宽度。 let (mut session, initial) = ReplSession::load(r#" text = TextBlock("Text wrapping follows the finite border box rather than the original unbounded column width.") text.boxWidth = "fill" text.paddingLeft = 8 text.paddingRight = 12 column = Column(0, 0, text) column.boxWidth = 160 page = Page() page.render(column) "#, "app.main", 298, 1).unwrap(); // 确认文档有效,并通过 Mount 查询核对 fill 后的外框宽度恰为 160。 initial.validate().unwrap(); assert_eq!( session .execute("text.mounts().at(0).bounds().width", "app.main") .unwrap() .value, "160" ); // 排字起点右移 8,wrap 宽为 160−8−12=140;不能把外框宽直接当作文字可用宽。 assert!(initial.frame.commands.iter().any(|command| matches!(command, RenderCommand::Text { x, wrap_bounds: Some([width, _]), .. } if *x == 8.0 && *width == 140.0))); // 另一程序让自然高度 Column 中的子项 boxHeight=fill;主轴无有限分配,无法定义填满目标。 let error = ReplSession::load( r#" rect = RectBlock(10, 10) rect.boxHeight = "fill" page = Page() page.render(Column(0, 0, rect)) "#, "app.main", 299, 1, ) .err() .expect("unbounded fill must fail"); // 错误应明确说明 fill 缺少有限父分配,不能以任意其他加载失败替代该检查。 assert!( format!("{error:#}").contains("fill requires a finite parent allocation"), "{error:#}" ); } #[test] /// 让子按钮超出带左边框且 overflow=clip 的固定 Row。 /// 子 Mount 保留原几何,但 clip 仅覆盖父内容区域;命中在原按钮矩形内也应因裁剪而失败。 fn clipping_a_box_limits_descendant_paint_and_hit_geometry() { // 固定 Row 宽 60 且左边框宽 2,前项先占宽 100,后按钮保留位于父内容区之外的原几何。 let (_, initial) = ReplSession::load(r#" class Overflow: RectBlock { function measure(self: Overflow, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(100, 20) } } child = Button("outside") child.boxWidth = 100 row = Row(0, 0, Overflow(100, 20), child) row.boxWidth = 60 row.boxHeight = 40 row.borderLeft = 2 row.overflow = "clip" page = Page() page.render(row) "#, "app.main", 300, 1).unwrap(); // 确认发布结构合法,随后从完整帧定位唯一可交互按钮。 initial.validate().unwrap(); // 按 interactive 找后按钮;裁剪不能通过删除 Mount 或篡改其原位置来实现。 let child = initial .frame .mounts .iter() .find(|mount| mount.interactive) .unwrap(); // 有效裁剪只覆盖扣去边框的父内容区 [2,0,58,40],子项 x 仍应在 100 之外。 assert_eq!(child.clip, Some([2.0, 0.0, 58.0, 40.0])); assert!(child.x >= 100.0); // 选择子矩形内部点调用整帧命中测试,仍必须因父裁剪而找不到目标。 assert!( initial .frame .hit_test(crate::geometry::Point::new(child.x + 1.0, child.y + 1.0)) .is_none() ); } #[test] /// 两个 fill 子项分享扣去 margin/gap/固定项后的主轴空间,其中一项触及 maxWidth 后重新分配。 /// 交叉轴 stretch 仍受 maxHeight 和 alignSelf 限制;增大父宽后首项吸收新增空间。 fn main_axis_fill_shares_remaining_space_and_stretch_respects_box_limits() { // 父宽 240 扣 padding 20、gap 10、首项 margin 5 和固定项 40,留给两项 fill 的总宽为 165。 // 第二项 maxWidth=50 后首项吸收剩余 115;交叉轴 stretch 另受 maxHeight/alignSelf 限制。 let (mut session, initial) = ReplSession::load( r#" first = RectBlock(10, 10) first.boxWidth = "fill" first.marginLeft = 5 first.maxHeight = 30 second = RectBlock(10, 10) second.boxWidth = "fill" second.maxWidth = 50 fixed = RectBlock(40, 20) fixed.alignSelf = "center" row = Row(10, 5, first, second, fixed) row.boxWidth = 240 row.boxHeight = 80 row.crossAlign = "stretch" page = Page() page.render(row) "#, "app.main", 301, 1, ) .unwrap(); // 先验证整体文档,再独立检查主轴分配、交叉轴限制和固定项居中位置。 initial.validate().unwrap(); // 首项高度被 maxHeight 夹为 30,第二项填满内容高 60;固定项显式 center 得到 y=30。 for (expression, expected) in [ ("first.mounts().at(0).bounds().width", "115"), ("second.mounts().at(0).bounds().width", "50"), ("first.mounts().at(0).bounds().height", "30"), ("second.mounts().at(0).bounds().height", "60"), ("fixed.mounts().at(0).bounds().y", "30"), ] { // 从各自 Mount 读取最终分配,不能把业务自然尺寸当作 fill/stretch 的结果。 assert_eq!( session.execute(expression, "app.main").unwrap().value, expected, "{expression}" ); } // 父宽增加 40,第二项仍卡在最大宽 50,新增空间应全部给首项,得到 155。 session.execute("row.boxWidth = 280", "app.main").unwrap(); assert_eq!( session .execute("first.mounts().at(0).bounds().width", "app.main") .unwrap() .value, "155" ); } #[test] /// 通过真实 VM 加载盒布局示例并验证完整文档。 /// 共享文字有两个 Mount,父盒宽度不同产生不同分配,防止示例只在说明文档中成立。 fn box_layout_example_runs_through_the_real_source_pipeline() { // 直接加载仓库中的盒布局示例,以真实 VM、桥接布局和文档发布检验示例可执行性。 let (mut session, initial) = ReplSession::load( include_str!("../../../examples/box-layout.endor"), "app.main", 302, 1, ) .unwrap(); // 共享文字必须有两个 Mount,才能比较不同父盒约束下的独立分配。 initial.validate().unwrap(); assert_eq!( session .execute("shared.mounts().count", "app.main") .unwrap() .value, "2" ); // 取得第一个出现的最终宽度并转为数值,避免字符串字典序比较。 let left = session .execute("shared.mounts().at(0).bounds().width", "app.main") .unwrap() .value .parse::() .unwrap(); // 第二个出现单独读宽,较窄父盒应得到更小分配,不能共享第一次测量结果。 let right = session .execute("shared.mounts().at(1).bounds().width", "app.main") .unwrap() .value .parse::() .unwrap(); assert!(left > right); } #[test] /// 固定 Overlay 按内容高度拉伸子项,再受 margin/maxHeight 限制得到 50。 /// 改为显式 boxHeight 后使用 15,证明显式尺寸优先于 stretch,覆盖层不按线性子项重排。 fn overlay_stretch_allocates_each_occurrence_without_reflowing_its_siblings() { // Overlay 高 80 扣上下 padding 10 和 child 顶 margin 3,stretch 原可分配 67,再受 maxHeight=50 限制。 let (mut session, initial) = ReplSession::load( r#" child = RectBlock(20, 10) child.marginTop = 3 child.maxHeight = 50 overlay = Overlay(5, 0, child, RectBlock(10, 10)) overlay.boxWidth = 100 overlay.boxHeight = 80 overlay.crossAlign = "stretch" page = Page() page.render(overlay) "#, "app.main", 303, 1, ) .unwrap(); // 文档有效后核对 child 高 50 且 y=5+3=8,验证 margin 在拉伸与定位中一致使用。 initial.validate().unwrap(); assert_eq!( session .execute("child.mounts().at(0).bounds().height", "app.main") .unwrap() .value, "50" ); assert_eq!( session .execute("child.mounts().at(0).bounds().y", "app.main") .unwrap() .value, "8" ); // 改为显式高度 15 后应优先采用该值,父 stretch 不得再次覆盖它。 session.execute("child.boxHeight = 15", "app.main").unwrap(); assert_eq!( session .execute("child.mounts().at(0).bounds().height", "app.main") .unwrap() .value, "15" ); } #[test] /// 两个有限视口共享同一 Scene2D,默认相机独立,出现位置各有变换和裁剪。 /// 点击先经变换命中;只改首个相机不测量业务对象,显式共享 camera 后两边才同步缩放。 fn two_canvas_viewports_share_a_scene_with_independent_explicit_cameras() { // 两个有限 CanvasViewport 共享同一 Scene2D/button,初始只修改 first 的默认相机。 let (mut session, initial) = ReplSession::load( r#" hits = ui.state(0) button = Button("shared") button.onClick(hits.increment) button.x = 40 button.y = 20 scene = Scene2D(button) first = CanvasViewport(200, 100, scene) second = CanvasViewport(200, 100, scene) first.camera.x = 20 first.camera.zoom = 2 page = Page() page.render(Row(0, 20, first, second)) "#, "app.main", 304, 1, ) .unwrap(); // 共享业务按钮应拥有两个 Mount;相机和裁剪属于各出现上下文,而非按钮自身。 initial.validate().unwrap(); assert_eq!( session .execute("button.mounts().count", "app.main") .unwrap() .value, "2" ); // 按文档顺序收集两处交互 Mount,分别核对缩放变换与左右面板裁剪。 let buttons: Vec<_> = initial .frame .mounts .iter() .filter(|mount| mount.interactive) .collect(); // 首面板 zoom=2,第二面板默认相机无额外变换;两者 clip 分别位于 x=0 和 x=220。 assert_eq!(buttons.len(), 2); assert_eq!(buttons[0].view.unwrap().zoom, 2.0); assert!(buttons[1].view.is_none()); assert_eq!(buttons[0].clip, Some([0.0, 0.0, 200.0, 100.0])); assert_eq!(buttons[1].clip, Some([220.0, 0.0, 200.0, 100.0])); // 保存首个出现身份,后续改相机应保留该 ID。 let first_id = buttons[0].id; // 选取经首相机投影后位于按钮内的屏幕坐标,命中必须先还原场景位置。 let point = [45.0, 45.0]; // 先核对整帧命中选择,再沿原生派发路径发送同一坐标,确保几何与事件路由一致。 assert_eq!( initial .frame .hit_test(crate::geometry::Point::new(point[0], point[1])) .unwrap() .id, first_id ); // 有效文档版本下点击首个出现,只有共享业务计数递增一次。 session .dispatch(&crate::events::Input { window: crate::window::MAIN_WINDOW, session: initial.frame.session, layout_version: initial.frame.layout_version, mount: first_id, kind: "click".into(), point: Some(point), press_point: Some(point), delta: None, tab_drop: None, screen_position: None, }) .unwrap(); assert_eq!( session.execute("hits.value", "app.main").unwrap().value, "1" ); // 只修改 first.camera 平移/缩放,第二面板不能被共享场景对象带着改变。 let changed = session .execute("first.camera.x = -40\nfirst.camera.zoom = 1", "app.main") .unwrap() .document .unwrap(); // 重新发布后的交互 Mount 仍按各面板独立投影,几何结构应有效。 changed.validate().unwrap(); // 从新文档重新取出现位置,不能借用旧帧中的 view 判断更新是否生效。 let buttons: Vec<_> = changed .frame .mounts .iter() .filter(|mount| mount.interactive) .collect(); // 首个 Mount 身份保持、平移变为 40;第二个仍无额外变换,业务测量数为零。 assert_eq!(buttons[0].id, first_id); assert_eq!(buttons[0].view.unwrap().x, 40.0); assert!(buttons[1].view.is_none()); assert_eq!(source_measure_stat(&mut session, "measured"), 0); // 显式把 second.camera 指向 first.camera 后再缩放,此时两个面板才应共享相机状态。 let shared = session .execute( "second.camera = first.camera\nfirst.camera.zoom = 2", "app.main", ) .unwrap() .document .unwrap(); // 验证新帧并检查所有交互出现都采用 zoom=2,区分显式共享和初始默认独立。 shared.validate().unwrap(); assert!( shared .frame .mounts .iter() .filter(|mount| mount.interactive) .all(|mount| mount.view.is_some_and(|view| view.zoom == 2.0)) ); } #[test] /// 嵌套二维相机的缩放相乘、平移复合,检查最终变换和相交裁剪。 /// 手势只驱动内层相机,外层不动;最后软件绘制采样确认固定 HUD 与变换后内容均位于预期像素。 fn nested_canvas_projects_pixels_and_hits_while_overlay_ui_stays_fixed() { // 内相机缩放 0.5、外相机缩放 2,嵌套面板位置参与复合变换;HUD 是外部 Overlay 的独立兄弟。 let (mut session, initial) = ReplSession::load( r##" button = Button("nested") button.boxWidth = 20 button.boxHeight = 20 button.x = 10 button.y = 10 inner = CanvasViewport(80, 60, Scene2D(button)) inner.camera.zoom = 0.5 inner.x = 20 inner.y = 20 outer = CanvasViewport(200, 200, Scene2D(inner)) outer.camera.zoom = 2 hud = RectBlock(10, 10, 0, "#0000FF") page = Page() page.render(Overlay(0, 0, outer, hud)) "##, "app.main", 305, 1, ) .unwrap(); // 先确认发布结构,再定位嵌套按钮,检查复合投影及可见区域。 initial.validate().unwrap(); // 唯一可交互节点来自最内 Scene2D,携带两层相机叠加后的有效变换。 let button = initial .frame .mounts .iter() .find(|mount| mount.interactive) .unwrap(); // 最终 zoom=1,Transform 按 point×zoom+(x,y) 投影,平移为 (20,20);clip 在视图坐标中比较。 assert_eq!( button.view.unwrap(), crate::viewport::Transform { zoom: 1.0, x: 20.0, y: 20.0 } ); assert_eq!(button.clip, Some([40.0, 40.0, 160.0, 120.0])); // 屏幕点 (55,55) 同时用于交互命中和选取最内层可控相机。 let point = crate::geometry::Point::new(55.0, 55.0); assert_eq!(initial.frame.hit_test(point).unwrap().id, button.id); // camera_at 应优先命中内层视口,平移手势不能误控制外层相机。 let inner_id = initial.frame.camera_at(point).unwrap().id; // 构造针对内层视口的 viewport-pan,delta 通过宿主相机手势入口消费。 let input = crate::events::Input { window: 1, session: initial.frame.session, layout_version: initial.frame.layout_version, mount: inner_id, kind: "viewport-pan".into(), delta: Some([10.0, 0.0]), point: None, press_point: None, tab_drop: None, screen_position: None, }; // 派发后内相机 x 应变为 10,外相机 x 仍为零,验证手势目标选择。 session.dispatch(&input).unwrap(); assert_eq!( session.execute("inner.camera.x", "app.main").unwrap().value, "10" ); assert_eq!( session.execute("outer.camera.x", "app.main").unwrap().value, "0" ); // 显式使 HUD paint 失效以取得最新完整文档,再检验真实像素而非仅投影字段。 let document = session .execute("hud.invalidate(\"paint\")", "app.main") .unwrap() .document .unwrap(); // 分配 200×200 软件输出缓冲,1x DPI 下采样点与逻辑坐标一致。 let mut pixels = vec![0; 200 * 200]; // 执行真实光栅化以核对叠加层与变换后内容,补足仅检查投影字段无法覆盖的绘制路径。 crate::software_renderer::SoftwareRenderer::render( &document.display_list(1.0).unwrap(), &mut pixels, 200, 200, ) .unwrap(); // HUD 原点蓝色保持固定;场景采样与远处背景不同,证明平移后的内容确实被绘出。 assert_eq!(pixels[5 * 200 + 5], 0x0000FF); assert_ne!(pixels[55 * 200 + 45], pixels[190 * 200 + 190]); } #[test] /// 在负世界 x 处放红矩形,经相机平移和二倍缩放映射到有限视口。 /// 实际像素需显示固定蓝色覆盖层、可见红块和视口外背景,检验命令投影与裁剪最终进入光栅化。 fn canvas_world_paint_is_zoomed_and_clipped_in_the_software_renderer() { // 红矩形位于负世界 x,经相机平移与二倍缩放进入 50×50 视口;蓝色 HUD 不随相机变化。 let (_, initial) = ReplSession::load( r##" rect = RectBlock(20, 20, 0, "#FF0000") rect.x = -10 rect.y = 10 view = CanvasViewport(50, 50, Scene2D(rect)) view.camera.x = -10 view.camera.zoom = 2 page = Page() page.render(Overlay(0, 0, view, RectBlock(10, 10, 0, "#0000FF"))) "##, "app.main", 306, 1, ) .unwrap(); // 文档通过结构验证后再做像素测试,区分投影数据有效与实际绘制正确。 initial.validate().unwrap(); // 输出 80×80,大于有限视口,留出明确的视口外背景采样点。 let mut pixels = vec![0; 80 * 80]; // 用 1x 显示列表运行软件绘制,包含世界投影、面板裁剪和 Overlay 绘制顺序。 crate::software_renderer::SoftwareRenderer::render( &initial.display_list(1.0).unwrap(), &mut pixels, 80, 80, ) .unwrap(); // 依次核对固定蓝层、可见红块、视口外背景以及矩形右侧背景,捕获缩放或裁剪遗漏。 assert_eq!(pixels[5 * 80 + 5], 0x0000FF); assert_eq!(pixels[25 * 80 + 5], 0xFF0000); assert_eq!(pixels[55 * 80 + 5], pixels[79 * 80 + 79]); assert_eq!(pixels[25 * 80 + 45], pixels[79 * 80 + 79]); } #[test] /// 加载场景视口示例并确认共享按钮出现两次。 /// 再实际构造 ScrollView,检查它保持 ScrollBlock 名义类型兼容,而非只在文本说明中存在的别名。 fn scene_viewport_example_and_scroll_view_alias_run_as_real_source() { // 直接执行场景视口示例,确认共享 increment 按钮在两个视口中各有一个出现。 let (mut session, initial) = ReplSession::load( include_str!("../../../examples/scene-viewports.endor"), "app.main", 307, 1, ) .unwrap(); // 先检查示例文档和出现数量,再单独验证 ScrollView 的实际构造与名义类型兼容。 initial.validate().unwrap(); assert_eq!( session .execute("increment.mounts().count", "app.main") .unwrap() .value, "2" ); // 源码构造 ScrollView 实例并保存在全局,不只是检查名字可查询或文档宣称存在别名。 session .execute( "testScroll = ScrollView(100, 60, Row(0, 0, TextBlock(\"scroll\")))", "app.main", ) .unwrap(); // 新实例仍应满足 ScrollBlock 名义类型,保证兼容原有滚动 API。 assert_eq!( session .execute("ui.isInstance(testScroll, ScrollBlock)", "app.main") .unwrap() .value, "true" ); } #[test] /// 场景内容在负坐标,按当前 Mount 的有限尺寸 fitView。 /// 比较相机 zoom/pan 与矩形投影后的 48px 留白,验证适配的是场景边界而非业务对象原始正坐标假设。 fn fit_view_uses_finite_scene_bounds_and_this_mounts_size() { // 场景矩形跨负坐标,视口固定为 296×196,测试 fitView 不假设世界内容从原点向正方向展开。 let (mut session, initial) = ReplSession::load( r#" rect = RectBlock(100, 50) rect.x = -100 rect.y = -25 view = CanvasViewport(296, 196, Scene2D(rect)) page = Page() page.render(view) "#, "app.main", 308, 1, ) .unwrap(); // 基线文档有效后,针对视口的具体 Mount 调用 fitView,使用该出现位置的有限尺寸。 initial.validate().unwrap(); // fitView 更新相机并发布新文档,后续投影必须从新帧取值。 let document = session .execute("view.mounts().at(0).fitView()", "app.main") .unwrap() .document .unwrap(); // 验证适配后的完整文档,再比较相机缩放和平移结果。 document.validate().unwrap(); // 扣两边各 48 的留白后,宽约束允许 zoom=2;x/y 还需补偿负世界边界以居中放置内容。 for (expression, expected) in [ ("view.camera.zoom", "2"), ("view.camera.x", "-124"), ("view.camera.y", "-49"), ] { // 直接读取源相机状态,排除只在绘制命令上临时移动而未更新业务相机的实现。 assert_eq!( session.execute(expression, "app.main").unwrap().value, expected ); } // 从适配后的帧找场景矩形,其逻辑几何保持 100×50,仅视图变换改变。 let rect = document .frame .mounts .iter() .find(|mount| mount.width == 100.0 && mount.height == 50.0) .unwrap(); // 用出现位置附带的 view 投影矩形左上角,应落在 (48,48),与相机字段断言互相核对。 let screen = rect .view .unwrap() .point(crate::geometry::Point::new(rect.x, rect.y)); assert_eq!(screen, crate::geometry::Point::new(48.0, 48.0)); } #[test] /// 同一场景用透视和正交相机各显示一次,前景蓝盒应遮住后景红盒。 /// 比较拾取距离、交点和实际中心像素;移动前景并只修改透视相机后,两视图露出后景且相机 z 保持独立。 fn real_scene3d_renders_depth_with_independent_perspective_and_orthographic_cameras() { // 透视与正交视口共享前蓝后红两个盒子,ambient=1 便于用准确材质色比较遮挡像素。 let (mut session, initial) = ReplSession::load( r##" back = Box3D(2, 2, 2, "#FF0000") front = Box3D(1, 1, 1, "#0000FF") front.z = 1.5 scene = Scene3D(front, back) scene.ambient = 1 perspective = Viewport3D(120, 100, scene) orthographic = Viewport3D(120, 100, scene) orthographic.camera = OrthographicCamera() orthographic.camera.height = 4 page = Page() page.render(Row(0, 10, perspective, orthographic)) "##, "app.main", 309, 1, ) .unwrap(); // 确认首帧合法,再从文档命令中取两套实际三维视图,不重新构造近似渲染数据。 initial.validate().unwrap(); // 只保留 Scene3D 命令里的 view,按左右视口顺序用于相机和射线断言。 let commands: Vec<_> = initial .frame .commands .iter() .filter_map(|command| { // 三维命令携带渲染/拾取使用的同一场景快照,其他 UI 命令不参与本组比较。 if let RenderCommand::Scene3D { view, .. } = command { Some(view) } else { // 跳过容器等非三维命令,避免把显示列表全部命令数误当作视口数量。 None } }) .collect(); assert_eq!(commands.len(), 2); // 透视视口中心射线应先击中前盒 z=2 的正面,距离相机 3,不能依场景子项次序选择后盒。 let hit = commands[0].pick(0.5, 0.5, 1.2).unwrap(); assert!((hit.distance - 3.0).abs() < 1e-5); assert_eq!(hit.point, crate::scene3d::math::Vec3::new(0., 0., 2.)); // 第二个视图必须保留显式正交投影 height=4,不能因共享 scene 误用透视相机。 assert!(matches!( commands[1].camera.projection, crate::scene3d::math::Projection::Orthographic { height: 4.0 } )); // 输出 250×100,覆盖两个宽 120、间距 10 的视口,左右中心点分别位于 x=60/190。 let mut pixels = vec![0; 250 * 100]; // 首帧实际光栅化验证深度遮挡,两视口中心均应显示前盒蓝色。 crate::software_renderer::SoftwareRenderer::render( &initial.display_list(1.).unwrap(), &mut pixels, 250, 100, ) .unwrap(); assert_eq!(pixels[50 * 250 + 60], 0x0000FF); assert_eq!(pixels[50 * 250 + 190], 0x0000FF); // 把前盒移出中心,同时只把透视相机 z 改为 8;正交相机应保持初始 z=5。 let updated = session .execute("front.x=10\nperspective.camera.z=8", "app.main") .unwrap() .document .unwrap(); // 新文档必须有效,再检查命令快照里的两台相机位置。 updated.validate().unwrap(); // 从更新后的 Scene3D 命令收集 z,验证共享的是场景而非两个默认相机对象。 let cameras: Vec<_> = updated .frame .commands .iter() .filter_map(|command| { // 仅三维视图提供 camera.position,仍按发布顺序比较左右相机。 if let RenderCommand::Scene3D { view, .. } = command { Some(view.camera.position.z) } else { // 非场景命令不提供相机数据,不参与预期的 [8,5] 列表。 None } }) .collect(); assert_eq!(cameras, vec![8., 5.]); // 复用像素缓冲绘制新帧;两个中心都应露出后盒红色,不能保留上一帧深度或颜色。 crate::software_renderer::SoftwareRenderer::render( &updated.display_list(1.).unwrap(), &mut pixels, 250, 100, ) .unwrap(); assert_eq!(pixels[50 * 250 + 60], 0xFF0000); assert_eq!(pixels[50 * 250 + 190], 0xFF0000); } #[test] /// 先通过 Mount.pick3D 查询中心交点与真实 mesh 材质,再从原生输入派发同位置 click。 /// 回调写 event.hit.mesh.color 后业务对象及最终像素都变绿,证明拾取结果保留正确托管业务身份。 fn ray_pick_returns_managed_mesh_and_native_pointer_event_updates_its_material() { // 源 Actions 回调通过 event.hit.mesh 改色,验证拾取结果能映射回原托管业务 Mesh。 let (mut session, initial) = ReplSession::load( r##" class Actions { function select(self: Actions, event: Event): Unit { event.hit.mesh.color = "#00FF00" } } mesh = Box3D(2,2,2,"#FF0000") scene = Scene3D(mesh) scene.ambient = 1 view = Viewport3D(100,100,scene) actions = Actions() view.onClick(actions.select) page = Page() page.render(view) "##, "app.main", 310, 1, ) .unwrap(); // 先在源 API 查询中心拾取:交点 z=1 且材质为红,建立原生输入前的拾取基线。 initial.validate().unwrap(); assert_eq!( session .execute("view.mounts().at(0).pick3D(50,50).point.z", "app.main") .unwrap() .value, "1" ); assert_eq!( session .execute("view.mounts().at(0).pick3D(50,50).mesh.color", "app.main") .unwrap() .value, "\"#FF0000\"" ); // 显式请求重绘取得最新帧,确保随后原生事件携带当前布局版本。 let frame = session .execute("view.invalidate(\"paint\")", "app.main") .unwrap() .document .unwrap() .frame; // 以 scene3d_viewport 标记选实际三维视口 Mount,而非场景内部网格或普通按钮。 let mount = frame .mounts .iter() .find(|mount| mount.scene3d_viewport) .unwrap(); // 原生 click 的按下/释放均在视口中心;宿主应附带对应 Hit3D 交给源回调。 let updated = session .dispatch(&crate::events::Input { window: 1, session: frame.session, layout_version: frame.layout_version, mount: mount.id, kind: "click".into(), point: Some([50., 50.]), press_point: Some([50., 50.]), delta: None, tab_drop: None, screen_position: None, }) .unwrap() .document .unwrap(); // 从原全局 mesh 读到绿色,证明 event.hit.mesh 没有指向脱离业务对象的临时副本。 assert_eq!( session.execute("mesh.color", "app.main").unwrap().value, "\"#00FF00\"" ); // 再准备 100×100 软件像素缓冲,检验业务材质改动确实进入绘制依赖与最终渲染。 let mut pixels = vec![0; 100 * 100]; // 绘制派发返回的新文档,中心像素必须为绿色,与源字段查询结果一致。 crate::software_renderer::SoftwareRenderer::render( &updated.display_list(1.).unwrap(), &mut pixels, 100, 100, ) .unwrap(); assert_eq!(pixels[50 * 100 + 50], 0x00FF00); } #[test] /// 运行内置三维示例及其旋转动作,再替换为源构造的自定义三角网格。 /// 另检查 Group3D 自循环和越界三角索引的明确错误,非法场景不能进入发布。 fn scene3d_example_custom_geometry_and_cycle_diagnostics_use_real_source() { // 执行仓库的三维示例,用源 Actions.rotate 验证实际交互业务可更新场景。 let (mut session, document) = ReplSession::load( include_str!("../../../examples/scene3d.endor"), "app.main", 311, 1, ) .unwrap(); // 首帧结构必须有效,动作执行后 cube.rotationY 应更新为 40。 document.validate().unwrap(); session.execute("actions.rotate()", "app.main").unwrap(); assert_eq!( session.execute("cube.rotationY", "app.main").unwrap().value, "40" ); // 在原会话替换为三个顶点、一个三角面的自定义 Geometry3D,要求产生有效新文档。 session.execute(r##" geometry=Geometry3D(ObjectList(Vec3(-1,-1,0),Vec3(1,-1,0),Vec3(0,1,0)),ObjectList(Triangle3D(0,1,2))) custom=Mesh3D(geometry,"#112233") scene.children=ObjectList(custom) "##,"app.main").unwrap().document.unwrap().validate().unwrap(); // 独立程序令 Group3D.children 包含自己,必须明确拒绝场景图循环而非无限遍历。 let error = ReplSession::load( r##" group=Group3D() scene=Scene3D(group) group.children=ObjectList(group) page=Page() page.render(Viewport3D(100,100,scene)) "##, "app.main", 312, 1, ) .err() .unwrap(); // 检查完整错误链指向三维图循环,避免任意解析错误也通过。 assert!( format!("{error:#}").contains("cyclic 3D scene graph"), "{error:#}" ); // 另一个程序仅给一个顶点却引用索引 0/1/2,应在 Geometry3D 缓冲验证时拒绝。 let error = ReplSession::load( r##" geometry=Geometry3D(ObjectList(Vec3(0,0,0)),ObjectList(Triangle3D(0,1,2))) "##, "app.main", 313, 1, ) .err() .unwrap(); // 区分非法几何缓冲与前一场景循环诊断,确保两条边界都有覆盖。 assert!( format!("{error:#}").contains("invalid Geometry3D buffers"), "{error:#}" ); } #[test] /// 旋转 Panel3D 的 UI 以独立 100x100 分辨率布局,检查 toView/toLocal、射线命中和纹理像素。 /// 点击及源 paint 字段修改保持响应;加入前景实体后同一点只命中外层三维视口,UI 不可穿透遮挡。 fn world_ui_panel_renders_live_endor_and_ray_hits_respect_mesh_occlusion() { // 源 Tile.paint 读取 tint 并创建命令,UI 在旋转 Panel3D 的 100×100 纹理空间布局,再投影到 200×200 视口。 let (mut session, initial) = ReplSession::load( r##" class Tile: Button { tint: String function paint(self: Tile, mount: Mount, geometry: Bounds): DisplayList { fill=PaintCommand() fill.kind="roundedRect" fill.x=geometry.x fill.y=geometry.y fill.width=geometry.width fill.height=geometry.height fill.radius=0 fill.color=self.tint return DisplayList(ObjectList(fill)) } } count=ui.state(0) button=Tile("world UI","#FF0000") button.onClick(count.increment) panel=Panel3D(2,2,button) panel.uiWidth=100 panel.uiHeight=100 panel.rotationY=25 scene=Scene3D(panel) scene.ambient=1 view=Viewport3D(200,200,scene) page=Page() page.render(view) "##, "app.main", 314, 1, ) .unwrap(); // 先验证发布文档,再定位携带 panel 投影上下文的可交互按钮,排除普通屏幕 UI。 initial.validate().unwrap(); // 取得面板内真实 Mount;其逻辑宽高应按 UI 分辨率,而不是面板世界尺寸 2×2。 let button = initial .frame .mounts .iter() .find(|mount| mount.interactive && mount.panel.is_some()) .unwrap(); assert_eq!((button.width, button.height), (100., 100.)); // 将纹理中心 (50,50) 经旋转面板投影到屏幕,后面命中、输入和像素采样使用同一位置。 let point = button .panel .as_ref() .unwrap() .project_point(crate::geometry::Point::new(50., 50.)) .unwrap(); // 允许浮点误差,但投影中心必须落在屏幕 (100,100),整帧命中也必须选择该 UI Mount。 assert!((point.x - 100.).abs() < 0.001 && (point.y - 100.).abs() < 0.001); assert_eq!(initial.frame.hit_test(point).unwrap().id, button.id); assert_eq!( session .execute("button.mounts().at(0).toView(50,50).x", "app.main") .unwrap() .value, "100" ); // 从源 Mount.toLocal 反投影屏幕中心,数值解析后应近似恢复纹理坐标 50,与 toView 互相校验。 let local = session .execute("button.mounts().at(0).toLocal(100,100).x", "app.main") .unwrap() .value .parse::() .unwrap(); assert!((local - 50.).abs() < 0.001); // 分配完整视口像素缓冲,接下来验证面板纹理确实被三维软件渲染器采样。 let mut pixels = vec![0; 200 * 200]; // 绘制初始纹理,中心像素应为源 tint 的红色,不只检查 UI 文档中有矩形命令。 crate::software_renderer::SoftwareRenderer::render( &initial.display_list(1.).unwrap(), &mut pixels, 200, 200, ) .unwrap(); assert_eq!(pixels[100 * 200 + 100], 0xFF0000); // 显式重绘取得最新布局版本;原按钮 Mount 身份保留,用该新版本构造随后点击。 let latest = session .execute("button.invalidate(\"paint\")", "app.main") .unwrap() .document .unwrap(); // 原生输入携带投影后的屏幕坐标,宿主需通过面板射线映射到源按钮再调用回调。 let input = crate::events::Input { window: 1, session: latest.frame.session, layout_version: latest.frame.layout_version, mount: button.id, kind: "click".into(), point: Some([point.x, point.y]), press_point: Some([point.x, point.y]), delta: None, tab_drop: None, screen_position: None, }; // 点击后共享 count 恰好递增一次,证明命中最终到达真实源回调。 session.dispatch(&input).unwrap(); assert_eq!( session.execute("count.value", "app.main").unwrap().value, "1" ); // 只改 paint 读取过的 tint;依赖系统应重新生成纹理并发布,不能要求重建面板。 let updated = session .execute("button.tint=\"#00FF00\"", "app.main") .unwrap() .document .unwrap(); // 新文档有效后复用缓冲绘制,屏幕中心应从红变绿,验证源字段到三维纹理的更新链。 updated.validate().unwrap(); crate::software_renderer::SoftwareRenderer::render( &updated.display_list(1.).unwrap(), &mut pixels, 200, 200, ) .unwrap(); assert_eq!(pixels[100 * 200 + 100], 0x00FF00); // 在面板前方加入实体蓝盒,保持原 UI 仍挂载,单独检验深度遮挡对命中和像素的影响。 let blocked = session .execute( r##" blocker=Box3D(1.2,1.2,1.2,"#0000FF") blocker.z=1.5 scene.children=ObjectList(panel,blocker) "##, "app.main", ) .unwrap() .document .unwrap(); // 遮挡后的文档仍合法,从新帧重取 UI Mount,不使用旧的可见性快照。 blocked.validate().unwrap(); // 按钮虽仍存在且 interactive,但屏幕中心已被近处网格挡住。 let button = blocked .frame .mounts .iter() .find(|mount| mount.panel.is_some() && mount.interactive) .unwrap(); // 面板 UI 的 contains 应因遮挡失败;整帧命中回落到外层三维视口,不能穿透到按钮。 assert!(!button.contains(point)); assert!(blocked.frame.hit_test(point).unwrap().scene3d_viewport); // 最终像素也必须显示前景蓝盒,与命中采用同一场景深度关系。 crate::software_renderer::SoftwareRenderer::render( &blocked.display_list(1.).unwrap(), &mut pixels, 200, 200, ) .unwrap(); assert_eq!(pixels[100 * 200 + 100], 0x0000FF); } #[test] /// 把可交互的内层 Viewport3D 作为旋转世界面板的 UI 内容。 /// 中心点 camera_at/hit_test 必须找到内层视口,click 的 hit.mesh 更新内层立方体并通过两层表面呈现绿像素。 fn nested_world_ui_can_embed_another_3d_viewport_and_preserve_ray_dispatch() { // 外三维场景的旋转面板以另一 Viewport3D 为 UI 内容;内场景立方体颜色由源 click 的 hit.mesh 修改。 let (mut session, initial) = ReplSession::load( r##" class Actions { function select(self: Actions,event: Event): Unit { event.hit.mesh.color="#00FF00" } } cube=Box3D(2,2,2,"#0000FF") inner_scene=Scene3D(cube) inner_scene.ambient=1 inner=Viewport3D(100,100,inner_scene) actions=Actions() inner.onClick(actions.select) panel=Panel3D(2,2,inner) panel.uiWidth=100 panel.uiHeight=100 panel.rotationY=20 outer=Viewport3D(200,200,Scene3D(panel)) page=Page() page.render(outer) "##, "app.main", 315, 1, ) .unwrap(); // 先确认两层表面发布有效,再找同时具有 scene3d_viewport 与 panel 上下文的内层视口。 initial.validate().unwrap(); // 必须选内层视口,而非外层 200×200 视口,否则事件可能修改错误场景。 let inner = initial .frame .mounts .iter() .find(|mount| mount.scene3d_viewport && mount.panel.is_some()) .unwrap(); // 两层中心投影到屏幕 (100,100);相机选择和普通命中均应优先进入内层视口。 let point = crate::geometry::Point::new(100., 100.); assert_eq!(initial.frame.camera_at(point).unwrap().id, inner.id); assert_eq!(initial.frame.hit_test(point).unwrap().id, inner.id); // 先检查未点击时穿过面板纹理采样到内立方体蓝色,建立渲染基线。 let mut pixels = vec![0; 200 * 200]; // 对完整外帧光栅化,覆盖内视口渲染、面板纹理采样与外场景投影的组合。 crate::software_renderer::SoftwareRenderer::render( &initial.display_list(1.).unwrap(), &mut pixels, 200, 200, ) .unwrap(); assert_eq!(pixels[100 * 200 + 100], 0x0000FF); // 以外部屏幕坐标向内层 Mount 派发,宿主必须先反投影面板再执行内场景射线拾取。 let updated = session .dispatch(&crate::events::Input { window: 1, session: initial.frame.session, layout_version: initial.frame.layout_version, mount: inner.id, kind: "click".into(), point: Some([100., 100.]), press_point: Some([100., 100.]), delta: None, tab_drop: None, screen_position: None, }) .unwrap() .document .unwrap(); // 回调改色后要求有效新文档,中心绿像素证明内 Mesh 更新经过两层表面正确呈现。 updated.validate().unwrap(); crate::software_renderer::SoftwareRenderer::render( &updated.display_list(1.).unwrap(), &mut pixels, 200, 200, ) .unwrap(); assert_eq!(pixels[100 * 200 + 100], 0x00FF00); } #[test] /// 先让 Panel.content 指回包含自身的视口,检查跨 UI/场景组合循环。 /// 再构造九层合法无环嵌套,检查超过八层表面预算的诊断;两种边界分别验证,避免把深度错误混同循环。 fn world_ui_cycles_and_surface_nesting_are_rejected_before_publication() { // panel.content 指回包含 panel 的 view,形成跨 UI/三维场景的组合环,加载时必须拒绝。 let error = ReplSession::load( r##" panel=Panel3D(2,2,Button("cycle")) view=Viewport3D(200,200,Scene3D(panel)) panel.content=view page=Page() page.render(view) "##, "app.main", 316, 1, ) .err() .unwrap(); // 检查完整诊断链明确报告 Block composition 循环,不能把任意布局错误当成循环保护。 assert!( format!("{error:#}").contains("cyclic Block composition"), "{error:#}" ); // 另一用例从独立 seed 开始生成无环链,只用来检验表面嵌套深度上限。 let mut source = String::from("seed=Button(\"deep\")\n"); // 逐层让新 panel 包含前一 view,九层超过允许的八层,但没有引用自身或未来节点。 for index in 0..9 { // 第一层使用 seed,后续使用前层视口名字,维持单向无环依赖。 let child = if index == 0 { "seed".to_string() } else { // 引用已构造的前层,避免用循环错误遮蔽真正的深度限制。 format!("view{}", index - 1) }; // 追加一层固定 UI 分辨率的 Panel3D 与对应 Viewport3D,层数由 index 精确控制。 source.push_str(&format!("panel{index}=Panel3D(2,2,{child})\npanel{index}.uiWidth=100\npanel{index}.uiHeight=100\nview{index}=Viewport3D(100,100,Scene3D(panel{index}))\n")); } // 仅把最外层 view8 放入页面,加载遍历才能真实经过全部九层。 source.push_str("page=Page()\npage.render(view8)\n"); // 要求第二程序失败且诊断为 nesting exceeds 8,与前一循环诊断区分。 let error = ReplSession::load(&source, "app.main", 317, 1) .err() .unwrap(); assert!( format!("{error:#}").contains("3D UI viewport nesting exceeds 8"), "{error:#}" ); } #[test] /// 同一按钮被两个世界面板共享,两个面板指定不同 UI 分辨率。 /// 两个 Mount 的宽高分别服从各自纹理,投影中心位置及命中 ID 也独立,最终整帧仍可软件绘制。 fn one_ui_object_has_independent_mounts_and_texture_sizes_in_two_world_panels() { // 两个世界面板共享同一 Button,但各自纹理分辨率为 100×80 和 200×120,业务身份不决定布局大小。 let (mut session, initial) = ReplSession::load( r##" shared=Button("shared UI") left=Panel3D(2,2,shared) left.x=-1.2 left.uiWidth=100 left.uiHeight=80 right=Panel3D(2,2,shared) right.x=1.2 right.uiWidth=200 right.uiHeight=120 view=Viewport3D(400,240,Scene3D(left,right)) view.camera.z=6 page=Page() page.render(view) "##, "app.main", 318, 1, ) .unwrap(); // 验证文档有效,再按出现索引核对共享对象的两个 Mount 及其独立宽高。 initial.validate().unwrap(); assert_eq!( session .execute("shared.mounts().count", "app.main") .unwrap() .value, "2" ); assert_eq!( session .execute("shared.mounts().at(0).bounds().width", "app.main") .unwrap() .value, "100" ); assert_eq!( session .execute("shared.mounts().at(1).bounds().width", "app.main") .unwrap() .value, "200" ); assert_eq!( session .execute("shared.mounts().at(0).bounds().height", "app.main") .unwrap() .value, "80" ); assert_eq!( session .execute("shared.mounts().at(1).bounds().height", "app.main") .unwrap() .value, "120" ); // 从发布帧收集面板内可交互 Mount,后面用各自 panel 投影各自纹理中心。 let mounts: Vec<_> = initial .frame .mounts .iter() .filter(|mount| mount.panel.is_some() && mount.interactive) .collect(); // 左纹理中心为 (50,40),经左面板变换得到屏幕命中点。 let first = mounts[0] .panel .as_ref() .unwrap() .project_point(crate::geometry::Point::new(50., 40.)) .unwrap(); // 右纹理中心为 (100,60),不能沿用左纹理分辨率求中心。 let second = mounts[1] .panel .as_ref() .unwrap() .project_point(crate::geometry::Point::new(100., 60.)) .unwrap(); // 先检查左右投影顺序,再核对各屏幕点命中各自 Mount,防止共享对象把投影上下文合并。 assert!(first.x < second.x); assert_eq!(initial.frame.hit_test(first).unwrap().id, mounts[0].id); assert_eq!(initial.frame.hit_test(second).unwrap().id, mounts[1].id); // 整帧输出覆盖两面板,最终 render 成功检查不同纹理尺寸可同时进入软件绘制。 let mut pixels = vec![0; 400 * 240]; // 使用真实完整显示列表光栅化;本用例检查渲染可完成,不对这里未采样的像素颜色作结论。 crate::software_renderer::SoftwareRenderer::render( &initial.display_list(1.).unwrap(), &mut pixels, 400, 240, ) .unwrap(); } #[test] /// 以实际有限窗口约束加载世界 UI 示例,重复相同视口/任务轮询应保持空闲。 /// 检查面板内二维视口确实存在,执行 turn/fit/reset 并比较业务相机值,最后尝试完整光栅化。 fn world_ui_example_runs_with_window_constraints_and_programmable_actions() { // 以实际 1024×800 有限窗口加载 world-ui 示例,避免只在无界默认布局中验证可执行性。 let (mut session, document) = ReplSession::load_with_viewport( include_str!("../../../examples/world-ui.endor"), "app.main", 319, 1, Some(crate::layout::Size { width: 1024., height: 800., }), ) .unwrap(); // 先验证初始完整文档,再模拟重复原生尺寸通知和空闲任务轮询。 document.validate().unwrap(); // 更新表与加载视口完全相同,相同尺寸不应让示例持续刷新。 let viewports = std::collections::BTreeMap::from([( crate::window::MAIN_WINDOW, crate::layout::Size { width: 1024., height: 800., }, )]); // 重复三轮验证空闲稳定性;每轮 resize 与 poll 都应返回 None。 for _ in 0..3 { // 相同视口必须保持空闲,后续任务轮询也不能凭空产生文档。 assert!( session .resize_windows_incremental(&viewports, true) .unwrap() .is_none(), "unchanged viewport must stay idle" ); assert!( session .poll_tasks_incremental(&viewports) .unwrap() .is_none() ); } // 首帧必须实际包含面板内 CanvasViewport,防止示例加载了近似空页面而误通过。 assert!( document .frame .mounts .iter() .any(|mount| mount.panel.is_some() && mount.canvas_viewport) ); // 依次执行示例 turn/fit/reset 业务动作,要求最终有有效文档,并检查 reset 后相机/面板状态。 session .execute("actions.turn()\nactions.fit()\nactions.reset()", "app.main") .unwrap() .document .unwrap() .validate() .unwrap(); assert_eq!( session .execute("panel.rotationY", "app.main") .unwrap() .value, "30" ); assert_eq!( session.execute("view.camera.z", "app.main").unwrap().value, "7" ); // 最后绘制保存的初始 document;这里只验证示例初始帧可光栅化,动作后的状态由上方查询核对。 let mut pixels = vec![0; 1024 * 800]; // 输出与加载逻辑尺寸一致的 1x 软件帧,覆盖示例多层 UI/三维命令组合。 crate::software_renderer::SoftwareRenderer::render( &document.display_list(1.).unwrap(), &mut pixels, 1024, 800, ) .unwrap(); } #[test] /// 同一段落进入纵向/横向滚动容器,禁止轴的最大范围与偏移必须为 0。 /// 允许轴保留请求偏移,文字约束按滚动轴不同而独立换行;改策略后原横向偏移应立即清零。 fn scroll_axes_wrap_shared_text_and_clamp_only_the_enabled_offsets() { // 加载共享 paragraph 分别进入纵向、横向 ScrollBlock 的示例,测量约束由各自允许轴决定。 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", ] { // 逐项查询源滚动状态,避免只检查 UI 看起来没有位移。 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(); // 分别核对两处偏移:vertical=(0,60)、horizontal=(60,0),不能共享一组滚动状态。 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"), ] { // 检查各轴投影更新后的实际状态,而非仅接受 scrollTo 调用成功。 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(); // 核对有限宽约束及两个出现的差异,随后改 axis 检查已有偏移随策略更新。 assert!(vertical_width <= 244.0); assert!(horizontal_width > vertical_width); // 把原横向容器改为纵向后,已存在的 x=60 必须立即夹回零。 session .execute("horizontal.axis = \"vertical\"", "app.main") .unwrap(); assert_eq!( session .execute("horizontal.mounts().at(0).scrollPosition().x", "app.main") .unwrap() .value, "0" ); } #[test] /// 未指定 axis 的 ScrollBlock 默认为 both。 /// 把已挂载容器策略写为 diagonal 必须给出允许集合诊断,不能默认为某个方向。 fn scroll_axis_defaults_to_both_and_rejects_unknown_policy() { // 新会话中直接构造未挂载 ScrollBlock,用于核对无需布局即可读取的默认 axis。 let mut session = ReplSession::new(262).unwrap(); // 不传 axis,构造后应公开默认 both,不能依赖首次 render 才补齐默认字段。 session .execute( "scroller = ScrollBlock(20, 10, TextBlock(\"default\"))", "app.main", ) .unwrap(); assert_eq!( session.execute("scroller.axis", "app.main").unwrap().value, "\"both\"" ); // 独立加载把 axis 写为 diagonal 的已渲染容器,验证布局读取策略时明确拒绝未知值。 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"); // 诊断应列出 both/vertical/horizontal 允许集合,不得静默退回默认策略。 assert!( error .to_string() .contains("ScrollBlock.axis expects both, vertical or horizontal"), "{error:#}" ); } #[test] /// 源 arrange 把构造宽度 300 的纵向 scroller 改分配为 120。 /// 文字实际宽度需扣两侧 padding 得到至多 100,横向滚动范围为零,不能继续按构造参数测量。 fn scroll_axis_uses_source_allocated_viewport_instead_of_constructor_width() { // 源 Allocator.arrange 把 scroller 的实际分配改为 120,与构造宽 300 刻意不同以暴露旧约束复用。 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(); // 先验证外层 scroller 宽确为 120,再检查内文字宽不超过扣 padding 后的 100。 initial.validate().unwrap(); assert_eq!( session .execute("scroller.mounts().at(0).bounds().width", "app.main") .unwrap() .value, "120" ); // 从文字的具体 Mount 读最终宽度,避免只看到 scroller 改宽却漏掉内容未重测。 let width: f32 = session .execute("text.mounts().at(0).bounds().width", "app.main") .unwrap() .value .parse() .unwrap(); assert!(width <= 100.0); // axis=vertical 必须同时保持 maxX=0,不能因为内容仍按构造宽度展开而产生横向范围。 assert_eq!( session .execute("scroller.mounts().at(0).scrollPosition().maxX", "app.main") .unwrap() .value, "0" ); } #[test] /// 嵌套两个 ScrollBlock,并让其中部分内容使用源 paint。 /// 对末尾按钮 scrollIntoView 要联动内外偏移 128/148;绘制 clip 与新命中几何必须使用同一滚动投影。 fn nested_scroll_into_view_and_source_paint_share_clipped_geometry() { // 独立会话构造两层滚动容器,源 Painted 委托原生 paint,以检验源命令也共享同一裁剪投影。 let mut session = ReplSession::new(241).unwrap(); // 首帧即应有非空 clip 数据,避免后面的命中检查建立在没有裁剪的近似布局上。 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)); // 对最深 last 的 Mount 请求 scrollIntoView,需要联动内、外两个祖先而非只滚最近一层。 session .execute("last.mounts().at(0).scrollIntoView()", "app.main") .unwrap(); // inner 应滚到 128、outer 滚到 148;这组偏移把 last 送入两个裁剪区域的交集。 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" ); // 新屏幕点 (10,110) 应命中 last,核对偏移状态与最终输入几何一致。 assert_eq!( session .execute("last.mounts().at(0).hitTest(10,110)", "app.main") .unwrap() .value, "true" ); } #[test] /// 直接调用 Button.click 得到名义 Event,dispatch 显式事件后返回值应与输入为同一托管对象。 /// 通过底层 globals 值比较身份,避免仅比较 repr 造成不同对象也通过。 fn click_and_dispatch_return_the_actual_delivered_event() { // 不需页面挂载即可测试源码 click/dispatch 的返回对象契约。 let mut session = ReplSession::new(237).unwrap(); // click 自动创建事件;dispatch 则接收预先保存的 dispatched,并把返回值保存在 returned。 session .execute( r#"button = Button("event") clicked = button.click() dispatched = Event("click") returned = button.dispatch(dispatched)"#, "app.main", ) .unwrap(); // 先验证 click 返回名义 Event 且 kind=click,再检查显式 dispatch 的对象身份。 assert_eq!( session .execute("ui.isInstance(clicked, Event)", "app.main") .unwrap() .value, "true" ); assert_eq!( session.execute("clicked.kind", "app.main").unwrap().value, "\"click\"" ); // 直接读 VM 全局字典里的 Value,比 repr 更严格地比较 returned 与 dispatched 是否为同一托管对象。 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)] /// 测试用原生转发器:保留公开签名,要求 VM 继续调用 target,用于模拟相同函数身份下实现变化。 struct VisualRedirect { // 下方方法替换测试指定的源实现;ContextNativeFunction 的 roots 另负责保活。 target: ObjectRef, // 对外沿用原签名,使本用例只改变实现,不借参数契约变化制造缓存失效。 signature: endor_core::CallSignature, } impl endor_core::NativeCallable for VisualRedirect { /// 返回原方法签名副本,使转发器仍参与同一参数/返回/副作用契约验证。 fn signature(&self) -> Option { // VM 查询 native 元数据时调用;复制契约,避免把测试转发器当成未声明签名的函数。 Some(self.signature.clone()) } /// 不直接递归执行 target;返回 Invoke 请求,由 VM 在其正常调用栈/预算中处理参数和结果。 fn invoke( &self, _: &mut endor_core::NativeContext<'_>, arguments: &[Value], ) -> std::result::Result { // 由 VM 调用此 native payload;参数复制到拥有所有权的 Invoke 请求,供正常栈/切片机制继续执行源函数。 Ok(endor_core::NativeResult::Invoke { function: self.target, arguments: arguments.to_vec(), }) } } #[test] /// 从同一 desktop 的第二窗口查到 Block.measure,检查原生快速路径认的是方法与注册表组合。 /// 同函数换成 paint 名称或外来 types 表都不能被认作 intrinsic,避免只凭对象形状或方法名跳过源执行。 fn native_visual_identity_accepts_shared_window_registries_only() { // 建立同一 desktop 下的会话和第二窗口,验证窗口共享类型注册表可以使用相同原生快速路径。 let mut session = ReplSession::new(238).unwrap(); // 真实 Window 构造注册第二窗口,不手工拼造一个看似相同的 desktop。 session.execute("second = Window()", "app.main").unwrap(); // 从全局取得第二窗口,沿其 ManagedWindow payload 找到实际所属 desktop。 let globals = session.candidate.globals.unwrap(); // 必须为真实 Window 对象,保证后面的注册表比较来自正常窗口生命周期。 let Value::Object(window) = session .candidate .vm .dict_get(globals, &Value::Text("second".into())) .unwrap() .unwrap() else { panic!("window") }; // 后续只操作候选 VM,统一使用它的对象存储来查询类型与原生方法身份。 let vm = &mut session.candidate.vm; // 第二窗口记录的 desktop 是合法共享范围,不能用窗口对象自身替代注册表 owner。 let desktop = vm .payload::(window, "Window") .unwrap() .desktop; // 在该 desktop 的 types 中查 Block,取得其当前注册的 measure 方法。 let block = crate::ui_bridge::desktop_type(vm.object_store(), "Block", desktop).unwrap(); // 保存真实方法函数身份,随后分别改变方法名和注册表上下文来测试拒绝条件。 let function = vm .object_store() .lookup_method(block, "measure") .unwrap() .unwrap(); // 方法身份、名称和 desktop 同时匹配才应视为 intrinsic。 assert!( crate::native_visual::is_intrinsic(vm.object_store(), function, "measure", desktop) .unwrap() ); // 同一函数不能因是 native 就被当成另一协议 paint 的快速实现。 assert!( !crate::native_visual::is_intrinsic(vm.object_store(), function, "paint", desktop).unwrap() ); // 构造外来 owner,即便它也有 types 字段,也不属于该函数的原注册身份。 let foreign = vm.new_object().unwrap(); // 空类型字典模拟结构相似的外来注册表,检验 intrinsic 判断不能仅检查字段形状。 let registry = vm.new_dict().unwrap(); // 将字典挂到外来 owner 后,measure 身份检查仍必须返回 false。 vm.set_attr(foreign, "types", Value::Object(registry)) .unwrap(); assert!( !crate::native_visual::is_intrinsic(vm.object_store(), function, "measure", foreign) .unwrap() ); // 释放测试创建的 owner 和字典根,避免无关托管对象残留。 vm.release(foreign).unwrap(); vm.release(registry).unwrap(); } #[test] /// 替换 Block.measure 为源方法后既有实例应按新方法得到宽度 140。 /// 再保持原 paint 对象身份、只替换 native payload 为转发器,刷新也必须识别实现改变并产生空命令。 fn replacing_base_visual_methods_changes_layout_and_same_identity_paint() { // 加载原生矩形和备用源 Hooks;后面在已有实例上替换基类方法,验证运行时协议缓存失效。 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(); // 名字表保存基类与 Hooks 类型,后续只提取句柄,不重新执行类定义。 let globals = session.candidate.globals.unwrap(); // 本地查询闭包要求所有目标均为对象;它只读全局字典,不建立新业务实例。 let get = |name: &str| match session .candidate .vm .dict_get(globals, &Value::Text(name.into())) .unwrap() .unwrap() { // 正常分支返回已有对象身份,供下面 define_method/replace_payload 操作。 Value::Object(value) => value, // 夹具缺少对象时立即失败,不能将非对象值当作方法句柄继续。 _ => panic!("object"), }; // 原 Block 基类是方法替换目标,rect 已经使用过它的原生视觉协议。 let base = get("base"); // 备用 Hooks 提供 width=140 的 measure 和返回空列表的 paint 两个源实现。 let hooks = get("Hooks"); // 取待安装的源 measure 函数,类型不符表示夹具破坏。 let Value::Object(measure) = session.candidate.vm.get_attr(hooks, "measure").unwrap() else { panic!("measure") }; // 保留备用源 paint,稍后通过 native 转发器调用而不是直接替换函数引用。 let Value::Object(paint) = session.candidate.vm.get_attr(hooks, "paint").unwrap() else { panic!("paint") }; // 直接替换 Block.measure 定义,已有 rect 的下一次刷新也必须读取新实现。 session .candidate .vm .define_method(base, "measure", measure) .unwrap(); // 刷新消费方法变更,再从同一 rect 的 Mount 核对宽度已变为 140。 session.refresh_document().unwrap().unwrap(); assert_eq!( session .execute("rect.mounts().at(0).bounds().width", "app.main") .unwrap() .value, "140" ); // 取得原 Block.paint 的函数对象,接下来刻意保持其 ObjectRef 不变。 let original = session .candidate .vm .object_store() .lookup_method(base, "paint") .unwrap() .unwrap(); // 沿用原 paint 签名,只更换 native payload,检验缓存是否跟踪实现版本而非仅句柄。 let signature = session .candidate .vm .object_store() .native_signature(original) .unwrap() .unwrap(); // 转发器返回 Invoke 到 Hooks.paint,roots 中的 paint 保活目标;原函数身份仍相同。 session .candidate .vm .replace_payload( original, endor_core::ContextNativeFunction::new( "redirect paint", VisualRedirect { target: paint, signature, }, vec![paint], ), ) .unwrap(); // 新实现返回空 DisplayList,刷新应发布空 commands,证明没有错误复用旧 intrinsic paint。 let updated = session.refresh_document().unwrap().unwrap(); assert!(updated.frame.commands.is_empty()); } #[test] /// 保持 Block.onEvent 的函数句柄不变,替换为有副作用的转发 payload。 /// 通过 VM changes 使 RenderCache 失效后 rect 从不交互变为交互,protocol_key 也应变化,证明缓存追踪实现版本。 fn same_identity_event_payload_changes_invalidate_interactivity_cache() { // 独立会话仅构造未挂载 rect 和 Hooks,用 RenderCache 直接观察交互协议快照。 let mut session = ReplSession::new(236).unwrap(); // Hooks.onEvent 是后续转发目标,原 Block.onEvent 则保持原函数对象身份。 session .execute( r#" class Hooks { function onEvent(self: Object, event: Event): Unit {} } base = Block rect = RectBlock(20, 10) "#, "app.main", ) .unwrap(); // 从同一全局表取基类、备用 Hooks 与待检查矩形。 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"), }; // Block 基类拥有要更换 payload 的 onEvent 方法。 let base = get("base"); // 备用源类型提供实际 onEvent 调用目标。 let hooks = get("Hooks"); // 待快照对象起初没有有效交互行为,更新后应被识别为可交互。 let rect = get("rect"); // 取备用源回调作为 VisualRedirect 的目标,后面保持引用可达。 let Value::Object(target) = session.candidate.vm.get_attr(hooks, "onEvent").unwrap() else { panic!("onEvent") }; // 借用候选 VM 统一执行方法查询、payload 变更与 changes 收集。 let vm = &mut session.candidate.vm; // 取原 onEvent 函数;修改 payload 而非替换这个函数身份。 let function = vm .object_store() .lookup_method(base, "onEvent") .unwrap() .unwrap(); // 复制原 native 契约,再将 effect 标记为 Mutating,使新实现的交互能力可被协议查询识别。 let mut signature = vm .object_store() .native_signature(function) .unwrap() .unwrap(); signature.effect = endor_core::CallEffect::Mutating; // 创建独立快照缓存,隔离会话发布流程,专门测试协议键和 VM changes 的衔接。 let mut cache = crate::snapshot::RenderCache::default(); // 首份快照应不交互,作为相同 rect 在实现变化前的基线。 let initial = cache.snapshot(vm.object_store(), rect).unwrap(); assert!(!initial.interactive); // 丢弃构造阶段变化,只让随后 payload 替换产生的 changes 驱动失效。 vm.take_changes(); // 在原函数句柄内安装转发器,vec![target] 保活被调用的源函数。 vm.replace_payload( function, endor_core::ContextNativeFunction::new( "redirect event", VisualRedirect { target, signature }, vec![target], ), ) .unwrap(); // 将新实现产生的变化批次送入缓存,不能靠手工清空整个 cache 掩盖依赖遗漏。 cache.invalidate(vm.take_changes()); // 对同一个 rect 重新取快照,interactive 和 protocol_key 都应反映实现版本变化。 let updated = cache.snapshot(vm.object_store(), rect).unwrap(); assert!(updated.interactive); assert_ne!(initial.protocol_key, updated.protocol_key); // 源侧 ui.implements 也应报告 EventTarget,核对公开协议查询与内部快照判定一致。 assert_eq!( session .execute("ui.implements(base, EventTarget)", "app.main") .unwrap() .value, "true" ); } #[test] /// 查询简单、转发、递归、动态回调和有副作用函数的静态 call_effect。 /// 绑定方法可借助接收者识别只读性,未绑定方法保守未知;重绑被调用全局函数后结果应失效,marker 始终为 0 证明查询未执行源码。 fn source_effect_queries_use_compiled_instructions_without_running_functions() { // 独立会话只定义待分析函数,不调用这些函数;marker 用来检测静态查询是否意外执行业务代码。 let mut session = ReplSession::new(234).unwrap(); // 夹具包含直接返回、全局转调、动态回调、自递归及状态写入;另定义可按已知接收者解析的 helper 方法。 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(); // 只读直返、可解析转调和无写入递归应识别 ReadOnly;未知回调和业务写入都不能证明只读,返回 None。 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), ] { // 每次取同一 VM 中真实源函数,类型不符即失败,不能跳过某一分析场景。 let Value::Object(function) = session .candidate .vm .dict_get(globals, &Value::Text(name.into())) .unwrap() .unwrap() else { panic!("function") }; // 调用 call_effect 只查询效果,不执行函数;尤其递归函数没有终止条件,不应被试运行。 assert_eq!( session .candidate .vm .object_store() .call_effect(function) .unwrap(), expected ); } // 取具体接收者,绑定方法的 self 类型与 helper 查找都由该对象提供上下文。 let Value::Object(model) = session .candidate .vm .dict_get(globals, &Value::Text("model".into())) .unwrap() .unwrap() else { panic!("model") }; // bind_attr 生成捕获 model 的方法,静态分析因能解析 self.helper 而可判定只读。 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) ); // 释放 bind_attr 返回的临时拥有引用,不把分析用绑定方法长期留在 VM 根中。 session.candidate.vm.release(bound).unwrap(); // get_attr 取未绑定方法;没有具体接收者时对 self.helper 的解析应保守返回未知。 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); // 保留原 forward 函数身份,随后只重绑它在全局中读取的 simple,检验分析依赖失效。 let Value::Object(forward) = session .candidate .vm .dict_get(globals, &Value::Text("forward".into())) .unwrap() .unwrap() else { panic!("forward") }; // 将被调全局名改指向写状态函数,forward 不再能被证明只读。 session.execute("simple = impure", "app.main").unwrap(); assert_eq!(session.candidate.vm.call_effect(forward).unwrap(), None); // 恢复原绑定后应重新识别 ReadOnly,避免未知结果被永久缓存。 session.execute("simple = original", "app.main").unwrap(); assert_eq!( session.candidate.vm.call_effect(forward).unwrap(), Some(endor_core::CallEffect::ReadOnly) ); // marker 必须始终为零,证明所有效果查询及失效重算都没有执行 impure 的函数体。 assert_eq!( session.execute("marker.value", "app.main").unwrap().value, "0" ); } #[test] /// 语言层拒绝用普通实例字段遮蔽有契约的 paint/measure/onEvent。 /// 底层绕过语言写入存储模拟方法阴影,缓存需失效至祖先并报 TraitViolation;删除阴影后可再次生成合法快照。 fn typed_method_shadows_invalidate_cached_ancestors_and_can_be_removed() { // 先构造 root 包含 rect 的父子关系,用祖先快照观察子对象方法阴影是否向上使缓存失效。 let mut session = ReplSession::new(233).unwrap(); // 无需页面发布,RenderCache 将直接从业务根生成快照,隔离发布调度的影响。 session .execute( "rect = RectBlock(40, 20)\nroot = Column(0, 0, rect)", "app.main", ) .unwrap(); // 取当前业务对象句柄,不重新构造树来制造失效。 let globals = session.candidate.globals.unwrap(); // 局部只读查询闭包要求命名值是对象,供 rect/root 两次查找复用。 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"); // 分别尝试 paint、measure、onEvent 阴影,错误必须来自 instance attribute shadows 契约保护。 for name in ["paint", "measure", "onEvent"] { // 使用普通源赋值,先验证高层 API 在写入前就阻止非法遮蔽。 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; // 沿属性转发链找到末尾实际存储,向它写入可绕过实例层的 typed method 阴影检查。 let storage = *vm .object_store() .attribute_path(rect) .unwrap() .last() .unwrap(); // 独立缓存先生成 root 快照,第二次无变化访问应完全命中。 let mut cache = crate::snapshot::RenderCache::default(); cache.snapshot(vm.object_store(), root).unwrap(); // 丢弃构造阶段变化;后面的变化批次应只来自人为注入的阴影。 vm.take_changes(); cache.snapshot(vm.object_store(), root).unwrap(); // visited=0 确认已有命中缓存,后续错误检查才真正覆盖了缓存失效。 assert_eq!(cache.visited, 0); // 底层存储注入 paint=nil,消费 VM changes 后从 root 快照必须看到子项 TraitViolation。 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] /// 为参数/返回契约不兼容的 present 方法声明 Presentable,注册必须失败且方法体 marker 不执行。 /// 参数更宽、返回更具体的合法方法可注册,并实际产出 TextBlock,运行时保持健康。 fn presentable_type_contracts_reject_before_registration_or_body_effects() { // 独立会话用 marker 观测注册过程是否误执行 present 方法体。 let mut session = ReplSession::new(232).unwrap(); // 先创建计数状态,再让不兼容的方法体尝试递增;注册失败时计数必须不变。 session.execute("marker = ui.state(0)", "app.main").unwrap(); // 两类错误分别缩窄 window 参数为 Point 和把返回改为 Number,均不满足 Presentable 方法契约。 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)"); // 注册必须在调用方法体前失败;诊断应精确指出 Presentable.present。 let error = session .execute(&input, "app.main") .err() .expect("incompatible typed capability"); assert!( error .to_string() .contains("trait Presentable method present"), "{error:#}" ); // 失败不能留下半注册能力,随后 implements 查询应为 false。 assert_eq!( session .execute(&format!("ui.implements({name}, Presentable)"), "app.main") .unwrap() .value, "false" ); // 方法体没有试运行或副作用,marker 仍为零。 assert_eq!( session.execute("marker.value", "app.main").unwrap().value, "0" ); } // 合法类放宽参数为 Object、返回具体 TextBlock;显式 implement 后 ui.present 应真正调用它。 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(); // 返回值满足 TextBlock 名义类型,且前面的注册错误不应停止会话。 assert_eq!( session .execute("ui.isInstance(view, TextBlock)", "app.main") .unwrap() .value, "true" ); assert!(!session.is_faulted()); } #[test] /// 加载显式 Presentable 示例,检查视图的 source 字段仍指原 Product 对象。 /// 改变 product.name 可响应式更新显示;另验证错误 Visual 覆盖在定义时拒绝,不破坏现有会话。 fn presentable_dispatch_preserves_source_identity_and_reactive_display() { // 加载显式能力展示示例,视图包含原 Product 引用及响应式字段显示。 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 == "普通对象的显式展示能力"))); // 从同一名字表取原业务 product 和生成的视图,直接比较 VM 对象身份。 let globals = session.candidate.globals.unwrap(); // 原 Product 是展示能力的接收者,后续 view.source 必须指回它。 let Value::Object(product) = session .candidate .vm .dict_get(globals, &Value::Text("product".into())) .unwrap() .unwrap() else { panic!("expected Product"); }; // 视图是可挂载的 Block,不能把复制出来的展示数据当作业务源对象。 let Value::Object(view) = session .candidate .vm .dict_get(globals, &Value::Text("view".into())) .unwrap() .unwrap() else { panic!("expected Block"); }; // source 字段需保存同一 ObjectRef;另用 supports 检查公开能力判定。 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" ); // 只改原 product.name,已建立的显示依赖应发布 updated 文字,不重新调用页面构造。 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") ) ); // 错误 Visual 覆写缺少必要参数,类定义时应拒绝,既有健康会话仍可继续。 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] /// 仅有同名且签名匹配的 present 方法不自动声明能力,ui.present 走回退且 marker 不增。 /// 显式 implement 后 ui.open 才执行自定义方法;缺失实现的注册错误不改变已发布文档。 fn matching_present_method_does_not_implicitly_declare_presentable() { // 新会话定义签名看似匹配的 present,但不注册能力,检测是否存在隐式鸭子类型调用。 let mut session = ReplSession::new(231).unwrap(); // ui.present(data) 应使用回退;自定义方法若被误调用会使 marker 增加。 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(); // 回退不执行自定义方法,marker=0 且 supports=false,区分方法存在与能力声明。 assert_eq!( session.execute("marker.value", "app.main").unwrap().value, "0" ); assert_eq!( session .execute("ui.supports(data,Presentable)", "app.main") .unwrap() .value, "false" ); // 显式注册后用 ui.open(data) 走实际展示路径,方法应恰好执行一次。 session .execute( "ui.implement(Data,Presentable)\ncustom = ui.open(data)", "app.main", ) .unwrap(); assert_eq!( session.execute("marker.value", "app.main").unwrap().value, "1" ); // 保存已发布文档集合,接下来注册缺少 present 的类型必须失败且不替换现有帧。 let before = session.documents.clone(); // Missing 没有实现能力方法,注册失败属于可恢复输入错误,不能发布半成品页面。 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] /// 隐藏 rect 的 width 绑定先随模型更新,挂载到两个窗口后同一业务值更新两个窗口几何。 /// 断开令牌后模型变化不再传递,直接写目标属性仍正常发布。 fn managed_property_bindings_update_hidden_objects_and_shared_window_geometry() { // 先在未挂载 rect 上建立 width 绑定,验证绑定求值不依赖可见 Mount。 let mut session = ReplSession::new(220).unwrap(); // model.read 读取 width 并驱动 rect.width,token 保留连接用于后续断开。 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" ); // 隐藏对象也必须跟随依赖字段写入,从 80 更新为 140。 session.execute("model.width = 140", "app.main").unwrap(); assert_eq!( session.execute("rect.width", "app.main").unwrap().value, "140" ); // 将同一 rect 挂到两个窗口,之后一次业务 width 更新应传播到两份出现几何。 session.execute("first = Page()\nfirst.render(rect)\nother = Window()\nsecond = Page()\nsecond.open(other)\nsecond.render(rect)", "app.main").unwrap(); // 把模型改为 260,正常刷新返回主窗口及其他窗口的新文档。 let updated = session .execute("model.width = 260", "app.main") .unwrap() .document .unwrap(); // 把主 frame 与 windows 中各帧统一检查,每个窗口都应出现宽 260 的目标。 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)); } // 断开后再改模型为 360,目标应留在 260,证明订阅写入已停止。 session .execute("token.disconnect()\nmodel.width = 360", "app.main") .unwrap(); assert_eq!( session.execute("rect.width", "app.main").unwrap().value, "260" ); // 直接给目标 width=180 仍应正常发布;断开绑定不能把目标属性变成不可写。 let direct = session .execute("rect.width = 180", "app.main") .unwrap() .document .unwrap(); assert!(direct.frame.mounts.iter().any(|mount| mount.width == 180.0)); } #[test] /// 同一目标两个属性各绑定一个返回新 Record 的计算。 /// 二者都应稳定为 value=7,不能因对象输出写入更新目标版本而相互触发无穷重算。 fn multiple_object_outputs_on_one_target_do_not_invalidate_each_other() { // 独立会话把两个不同属性绑定到同一个创建新 Record 的计算,检验输出对象变化的精确依赖。 let mut session = ReplSession::new(222).unwrap(); // 每次 makeRecord 都分配新身份,若绑定误读整个目标版本,两项会因对方写入而无穷重算。 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(); // 两项都应收敛到 value=7 且 VM 健康;不能只保证其中一个绑定最终被写入。 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] /// 可见和隐藏 rect 都绑定到读取全局 size 的函数,用 bindingEvaluations 比较精确失效。 /// 无关 REPL 结果不重算,size 改变只各算一次;直接破坏一个输出仅重算该绑定并恢复正确值。 fn property_bindings_use_precise_names_and_repair_direct_output_changes() { // 可见 rect 与隐藏 hidden 都读取全局 size,同一目标名依赖必须在两种可见性下生效。 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(); // 查询累计 bindingEvaluations 并转整数;该只读统计查询本身不应让绑定重新求值。 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); // 普通 42 结果不改 size,也不应发布或增加求值数。 assert!( session .execute("42", "app.main") .unwrap() .document .is_none() ); assert_eq!(count(&mut session), baseline); // 改变被读取的全局名,两项应各重算一次,包括未挂载 hidden。 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" ); // 直接破坏一个绑定输出,只需重算 rect 这一项并恢复 120,不能把 hidden 一起误失效。 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] /// 加载 GC 示例后建立 Page/Block/等待协程之间的循环,先确认任务真的停在 await。 /// 关闭页面后任务仍可独立存活;释放业务引用后对象数回基线、弱 Page/Block 失效,证明渲染缓存未额外持根。 fn source_render_caches_do_not_root_a_page_block_waiting_frame_cycle() { // 真实 GC 示例中 Page、CycleRect 与 await 任务形成环,measure/paint 也读取环中的 Page 字段。 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; // 创建临时页并启动协程;随后 poll 推进到 sleep await,确保存在等待帧而非仅有排队任务。 session.execute("mountCycle()", "app.main").unwrap(); session.poll_tasks().unwrap(); // phase=1 且任务 running 证明源码已执行到 await,尚未恢复到后续 phase=2。 assert_eq!( session.execute("phase.value", "app.main").unwrap().value, "1" ); assert_eq!( session .execute("pending.value.status()", "app.main") .unwrap() .value, "\"running\"" ); // 取 holder 中保留的临时页面,创建弱观察句柄而不添加新的持久根。 let globals = session.candidate.globals.unwrap(); // holder 是外部状态容器,value 指向要释放的临时 Page。 let Value::Object(holder) = session .candidate .vm .dict_get(globals, &Value::Text("holder".into())) .unwrap() .unwrap() else { panic!("holder") }; // 取 Page 原身份,关闭与清理后应无法通过弱句柄升级。 let Value::Object(page) = session.candidate.vm.get_attr(holder, "value").unwrap() else { panic!("page") }; // 解析页面内容树的第一个 CycleRect,同时观察页面与子项是否都能回收。 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(); // 子项也用弱引用检查,防止只回收页面而缓存仍意外保留业务 Block。 let rect_weak = session.candidate.vm.object_store().downgrade(rect).unwrap(); // 仅关闭展示不等于释放业务引用,pending 仍 running;不能把这种存活误判为缓存泄漏。 session.execute("holder.value.close()", "app.main").unwrap(); assert_eq!( session .execute("pending.value.status()", "app.main") .unwrap() .value, "\"running\"" ); // 释放 holder/pending 的外部引用后,再检查对象数恢复且两个弱句柄均失效。 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] /// 仅保留绑定 token,删除隐藏目标并回收,目标弱句柄应失效且 token 断开。 /// 另用会写模型的计算函数创建绑定,写入前只读保护失败并停止 VM,原 width 仍为 80。 fn binding_tokens_do_not_retain_hidden_targets_and_compute_is_read_only() { // 同一会话先测试 token 对隐藏目标的弱持有,再测试计算函数的只读约束。 let mut session = ReplSession::new(221).unwrap(); // model.read 是合法计算,model.bad 则试写 width=999,后者只在第二阶段被绑定调用。 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(); // 保留名字表以删除 rect 的强全局引用,同时继续保留 token 和 model。 let globals = session.candidate.globals.unwrap(); // 取得原隐藏 rect 的真实身份,后续不能用另建目标代替 GC 观察。 let Value::Object(target) = session .candidate .vm .dict_get(globals, &Value::Text("rect".into())) .unwrap() .unwrap() else { panic!("expected Block"); }; // 弱句柄用于证明目标真正消失,token 不能反向强持有该对象。 let weak = session .candidate .vm .object_store() .downgrade(target) .unwrap(); // 删除 rect 的全局根,随后显式循环回收,token 仍留在 globals。 session .candidate .vm .dict_delete(globals, &Value::Text("rect".into())) .unwrap(); // 回收后目标弱升级失败且 token.isConnected=false,验证弱连接清理。 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" ); // 另建目标绑定 model.bad,计算开始时必须处于只读范围,业务写入应在发生前被拦截。 assert!( session .execute( "other = RectBlock(1,20)\nbadToken = other.bind(\"width\",model.bad)", "app.main" ) .is_err() ); assert!(session.is_faulted()); // 会话 faulted 后直接读取 VM 的 model;不能用普通 execute 查询遮蔽原值检查。 let Value::Object(model) = session .candidate .vm .dict_get(globals, &Value::Text("model".into())) .unwrap() .unwrap() else { panic!("expected model"); }; // width 仍为 80,证明错误没有先执行写入再停止后续工作。 assert_eq!( session.candidate.vm.get_attr(model, "width").unwrap(), Value::Number(80.into()) ); } #[test] /// 两个 textOf 绑定读取同一 counter,按钮调用源辅助函数再执行原生 increment。 /// 点击后两条文字命令都必须更新为 10,覆盖跨源回调与原生写入的依赖传播。 fn native_method_callbacks_update_two_explicit_bindings() { // 独立会话让 first/second 各自绑定同一 counter.value,测试源回调转调原生 increment 的依赖传播。 let mut session = ReplSession::new(21).unwrap(); // 初始计数为 9,按钮调用源函数 increment;两条显示文字通过不同绑定读取同一状态。 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(); // 源码 click 执行回调后应发布新文档,不手工刷新文本或重新建立绑定。 let updated = session .execute("button.click()", "app.main") .unwrap() .document .unwrap(); // 按发布顺序收集文字值,前两个对应 first/second,排除后面的按钮标题。 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] /// 保留 card 业务对象,关闭/重开页面确认隐藏内容修改仍可显示。 /// 再次关闭并删除 Page 全局绑定后回收,Page 弱句柄应失效但 card 仍存在,子项不能反向强持有页面。 fn closing_a_page_releases_its_root_without_retaining_it_through_shared_content() { // 页面与 card 分别保存在全局;用这种可独立持有的内容检验子项不会反向保活 Page。 let mut session = ReplSession::new(22).unwrap(); // 先正常挂载 card,建立展示根与内容关系,再开始关闭/重开流程。 session .execute( "card = ui.rect(10, 20)\npage = ui.page()\npage.render(card)", "app.main", ) .unwrap(); // 后面只删 page 名字,刻意保留 card,以分离页面回收与共享内容存活。 let globals = session.candidate.globals.unwrap(); // 取原页面身份,弱观察对象必须是这次已挂载的 Page。 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(); // 第一次关闭后仍保留 page 全局引用,允许修改隐藏内容并重开验证页面可复用。 session.execute("page.close()", "app.main").unwrap(); // 隐藏时改原 card;后面重开仍应正常发布,不要求重新创建内容。 session .execute("card.setColor(\"#FFEEDD\")", "app.main") .unwrap(); assert!( session .execute("page.open()", "app.main") .unwrap() .document .is_some() ); // 第二次关闭后删除 page 的全局根,进入真正的页面回收检查。 session.execute("page.close()", "app.main").unwrap(); // 只删除页面全局引用,card 全局引用继续保留。 session .candidate .vm .dict_delete(globals, &Value::Text("page".into())) .unwrap(); // 沿真实桥接根集合回收,Page 弱升级必须失败而 card 仍可从 globals 找到。 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] /// 保存视觉对象 alias 并嵌入容器,给 card 赋新视觉值应沿共享引用更新嵌套出现位置且身份不变。 /// 包含自身的替换被拒绝;unmount 后更新不发布,重新 render 使用最新值。 fn mounted_aliases_update_nested_positions_unmount_and_reject_cycles() { // card、other 与容器子项引用同一共享业务身份,检验替换如何传播到嵌套已挂载位置。 let mut session = ReplSession::new(10).unwrap(); // 先建立 alias 和嵌套树,随后只对 card 重新赋视觉值。 session.execute("card = ui.rect(10, 20)\nother = card\ncontainer = ui.column(0, 0, other)\npage = ui.page()\npage.render(container)", "app.main").unwrap(); // 保存 other 的共享身份展示值,替换后新返回值与旧 alias 都应保持该身份。 let identity = session.execute("other", "app.main").unwrap().value; // card 改为 640×200 的矩形,业务身份保持而实际视觉存储/几何更新。 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 ); // 从替换产生的发布文档检查嵌套绘制尺寸,不只比较 alias 的 repr。 let frame = updated.document.unwrap().frame; assert!(matches!( frame.commands[0], RenderCommand::RoundedRect { width: 640.0, height: 200.0, .. } )); // 替换值若包含自身 alias 会形成组合环,必须拒绝;随后只读查询不应产生文档。 assert!( session .execute("card = ui.column(0, 0, other)", "app.main") .is_err() ); assert!( session .execute("other", "app.main") .unwrap() .document .is_none() ); // page.unmount 清除展示根并发布空 commands,但不删除仍在全局的共享业务对象。 let unmounted = session .execute("page.unmount()", "app.main") .unwrap() .document .unwrap(); assert!(unmounted.frame.commands.is_empty()); // 未挂载时更新业务值不发布画面,随后重新 render 需采用最新 80×50 几何。 assert!( session .execute("card = ui.rect(80, 50)", "app.main") .unwrap() .document .is_none() ); // 通过旧 alias 重新挂载,证明别名转发在卸载期间也保持最新值。 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] /// 创建文字但未挂载时只保留 VM 值,title 与最后结果 _ 都可跨输入读取。 /// 挂载后引用替换自动更新画面,成功文档保留 Console transcript;无关查询不重复发布。 fn live_session_retains_objects_and_updates_only_after_mounting() { // 新会话先创建隐藏文字,测试跨 Console 输入的业务全局和最后结果 _ 保留。 let mut session = ReplSession::new(7).unwrap(); // 仅构造 title 不调用 render,因此应返回对象结果但没有文档。 let created = session .execute( r##"title = ui.text("你好,Tessera", 2, "#243047")"##, "app.main", ) .unwrap(); // title 和 _ 都应能在后续输入读回同一结果,隐藏对象不因无画面而消失。 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 ); // 创建 Page 并挂载已有 title,首次可见文档应显示最初中文文本。 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") ); // 会话文档结构树是 repl-session,区别于单次完整源码编译的 script/module 根。 assert_eq!(rendered.root.as_ref().unwrap().kind, "repl-session"); // 给已挂载 title 赋新视觉值应自动发布更新,不需要显式重复 render。 assert!( session .execute(r#"title = ui.text("changed")"#, "app.main") .unwrap() .document .is_some() ); // 再显式 render,核对最新文字及 Console 输入历史;最后只读查询不重复发布。 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] /// 编译期错误不能执行同输入前面的 value=2;运行期错误则保留已经发生的 value=3。 /// 失败输入的待发布 render 被丢弃,后续显式 render 可恢复;另检查函数局部赋值正常工作。 fn live_session_compiles_before_execution_and_discards_failed_render() { // 新会话区分编译期拒绝与运行期部分业务写入;页面发布采用独立成功边界。 let mut session = ReplSession::new(8).unwrap(); // 建立 value=1 与可用 Page,后续失败输入不能破坏已建立的全局。 session .execute("value = 1\npage = ui.page()", "app.main") .unwrap(); // 同次输入含未定义名称,完整编译失败时前面的 value=2 不得执行。 assert!(session.execute("value = 2\nmissing", "app.main").is_err()); assert_eq!(session.execute("value", "app.main").unwrap().value, "1"); // 另一输入先写 value=3、请求 render,再用负尺寸触发运行期错误;业务写入与待发布帧要分别处理。 assert!( session .execute( "value = 3\npage.render(ui.text(\"discarded\"))\nui.rect(-1, 1, 0)", "app.main" ) .is_err() ); // 失败前完成的 value=3 仍保留,但后续查询不能自动发布失败输入的 discarded 内容。 let retained = session.execute("value", "app.main").unwrap(); assert_eq!(retained.value, "3"); assert!(retained.document.is_none()); // 发布失败后 Page/绑定仍可用,但失败输入的待显示内容不能自动发布,需后续成功的显式 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" ))); // 恢复帧只能包含成功 render 的内容,不能混入失败输入的待显示命令。 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] /// 普通顶层 page 脚本直接经 Aster 执行,源码、结构树节点和原始行位置必须保留。 /// 比较绘制几何及兼容 main 入口的命令等价性;两个独立 VM 的业务对象身份应不同。 fn top_level_goal_script_renders_without_a_main_or_synthetic_wrapper() { // 单次 compile 直接接收 PAGE 顶层源码,源快照必须原样保留,不能插入宿主包装函数。 let document = compile(PAGE, "app.main", 9).unwrap(); assert_eq!(document.source, PAGE); // 结构树应保留四个顶层语句、名称及原始行号,ID 带当前提交来源。 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); // 另加载兼容 main 示例,序列化比较两种入口的绘制命令等价;不同 VM 对象身份仍应独立。 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() ); // 不同 VM 可产生相同绘图命令,但其托管对象身份与展示上下文必须独立。 assert_ne!( document.frame.mounts[0].block_id, wrapped.frame.mounts[0].block_id ); } #[test] /// 提交含括号注释、辅助函数、多行赋值/调用及跨行成员链的完整源程序。 /// 检查渲染成功与结构树起止行,避免用宿主按行切割或简单括号统计代替 Aster 语法解析。 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)) "##; // 完整交给 Aster 解析、编译和执行,不能由宿主逐行切割或计数括号来模拟。 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] /// 分别使用 LF/CRLF/CR,在前面含中文的脚本中制造 missing 引用。 /// 诊断行列、字节 span 切片和提交来源都需对应原文;另检查语法错误的原始行号。 fn script_diagnostics_refer_to_original_unicode_source_and_byte_ranges() { // 分别测试 LF/CRLF/CR,中文前缀使字符列与字节偏移不同,二者都需对应原始文本。 for ending in ["\n", "\r\n", "\r"] { // 在第三行构造 missing 引用,换行形式变化不能让诊断误指向宿主生成位置。 let source = format!( "let title = ui.text(\"中文\"){ending}// comment{ending}let broken = missing{ending}ui.page().render(title)" ); // compile 应返回真实 frontend 诊断,而非包装后失去来源的普通字符串。 let error = compile(&source, "app.main", 27).unwrap_err(); // 读取行列、字节 span 与 submission 来源,直接切片原文以验证范围。 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(); // 语法诊断也必须保留 SourceDiagnostic 类型与第二行位置。 let diagnostic = error.downcast_ref::().unwrap(); assert_eq!(diagnostic.location.line, 2); } #[test] /// 两个 Page 交替 render 后,把 first.render 保存成函数再调用。 /// 绑定方法必须仍持有原接收者,最后一次 render 选择显示内容 last。 fn pages_bind_render_methods_and_last_render_selects_the_snapshot() { // 先在两个 Page 分别 render,再把 first.render 保存成 paint 调用,检验绑定方法保留接收者。 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"))"#; // 最终 compile 只发布最后一次被选择的页面快照,应只有 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] /// 先发布成功文档,再逐项提交无 render、错误 Page 参数、错误根和 render 后失败的源码。 /// 每个候选都必须失败,已发布 source 与 revision 保持原值,不能出现部分帧。 fn page_api_errors_and_late_failures_do_not_publish_partial_snapshots() { // NamespaceState 保存最后成功文档与 revision,失败候选不能修改它们。 let mut state = NamespaceState { submission: 1, ..NamespaceState::default() }; // 先提交 PAGE 成功基线,后续每轮失败都与这个已发布状态比较。 state .commit(1, compile(PAGE, "app.main", 1).unwrap()) .unwrap(); // 分别覆盖无 render、构造/调用参数错、根类型错以及 render 后继续执行失败。 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()", ] { // 为每个候选取新提交令牌,但不因提交请求本身提升已发布 revision。 state.submission = state.next_submission().unwrap(); // 要求每个源码候选都失败,随后核对文档 source 与 revision 保持成功基线。 assert!( compile(source, "app.main", state.submission).is_err(), "{source}" ); assert_eq!(state.document.source, PAGE); assert_eq!(state.revision, 1); } } #[test] /// 保留超过 MAX_PAGES 的页面引用后再请求显示,检查明确的 64 page budget 错误。 /// 页面资源预算独立于最终画了多少节点,不能靠只显示一个页面绕过。 fn page_creation_has_a_separate_live_object_budget() { // 动态生成并保留不同全局 Page 名字,使所有页面同时存活,不能被临时值回收绕过预算。 let mut source = String::new(); // 包含上界,创建 MAX_PAGES+1 个页面,明确越过页面活对象预算。 for index in 0..=ui_bridge::MAX_PAGES { source.push_str(&format!("let page{index} = ui.page()\n")); } // 最终只显示 page0,证明页面分配预算独立于可见页面数及绘制节点数。 source.push_str("page0.render(ui.text(\"x\"))"); // 加载应明确报告 64 page budget,而不是等到渲染图展开预算才失败。 let error = compile(&source, "app.main", 1).unwrap_err(); assert!(error.to_string().contains("64 page budget"), "{error:#}"); } #[test] /// 生成每层重复调用前一函数的指数展开计算,最后才创建文字。 /// 即使尚未触及 UI 节点预算,指令总预算仍应终止计算并报告 instructions。 fn script_execution_is_limited_even_without_creating_ui_nodes() { // 底层 text0 只返回字符串,后续逐层重复调用它,以大量纯计算消耗 VM 指令预算。 let mut source = "function text0(): Text { return \"Hello Endor\" }\n".to_owned(); // 每层调用前层两次形成指数工作量,但直到最后都不分配 UI 节点。 for index in 1..=16 { source.push_str(&format!( "function text{index}(): Text {{ let unused = text{}() return text{}() }}\n", index - 1, index - 1 )); } // 最后才把 text16 结果交给 ui.text,指令限制应在构造画面前截断计算。 source.push_str("let title = ui.text(text16())\nui.page().render(title)"); // 检查 instructions 预算诊断,区分 UI 节点预算或递归帧深度限制。 let error = compile(&source, "app.main", 1).unwrap_err(); assert!( error .to_string() .contains("execution limit reached: instructions"), "{error:#}" ); } #[test] /// 源 main 返回普通文字,再传给 ui.text 并挂载。 /// 验证 VM 的字符串结果可正确被宿主解码成发布命令,而非要求它已经是视觉对象。 fn literal_string_return_from_aster_can_be_displayed_by_the_host() { // main 返回普通 Text,再由 ui.text 转为视觉对象,检验源值与宿主视觉对象转换边界。 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] /// 为相同源码生成两个不同提交的结构树,开发工具先选中旧子节点。 /// 接收新文档后旧 ID 不再有效,选择应回退到新树根,避免沿用旧提交位置。 fn developer_selection_resets_to_the_new_source_snapshot() { // 同一源码的旧提交结构树,节点 ID 含提交身份。 let first = compile(PAGE, "app.main", 1).unwrap(); // 新提交生成不同节点 ID,即使源内容未变也不能沿用旧选择。 let second = compile(PAGE, "app.main", 2).unwrap(); // 模拟开发工具已打开且选中旧子节点,再通过文档更新入口恢复选择。 let mut tools = crate::developer_tools::DeveloperToolsState::default(); // 设置真实旧选择状态,避免默认空选择直接回退根而未覆盖旧 ID 失效。 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] /// 通过兼容模块/main 示例检查结构树、布局、文字实际测量和命令位置。 /// compile 路径清理候选 VM 后,两种显示倍率仍能从拥有的数据生成显示列表,证明发布不依赖悬空 VM 句柄。 fn executes_aster_functions_and_renders_after_vm_cleanup() { // 兼容模块/main 示例经过单次 compile,返回时候选 VM 已清理,发布文档必须拥有绘制所需数据。 let document = compile(EXAMPLE, "app.main", 1).unwrap(); assert_eq!(document.source, EXAMPLE); // 模块结构树应保留 namespace、函数名及原始行位置,独立于之后的 VM 生命周期。 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, .. } )); // 使用实际文字度量推导后续文字 x,避免写死字体相关宽度导致位置断言失真。 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 )); // compile 已清理候选 VM;以下重绘只能使用发布的原生数据,两种显示倍率都不能再依赖 VM 对象。 assert_eq!(document.display_list(1.0).unwrap().commands().len(), 5); assert_eq!(document.display_list(2.0).unwrap().commands().len(), 5); } #[test] /// 辅助函数接收借用 child 并把它放入 Row 两次。 /// 编译完成后两个文字命令各自有正确几何,覆盖变量/返回值/共享子项之间的托管引用有效性。 fn borrowed_ui_handles_survive_variables_helpers_and_reused_children() { // main 局部 child 传给 helper,helper 在 Row 中复用两次,考察借用/返回/共享引用的所有权衔接。 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, .. } )); // 按真实字体测量首文字宽,第二出现 x 应为起点 4+该宽+gap 8。 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] /// 把同一模块示例换成 CRLF 或 CR,源快照必须逐字保留。 /// 结构树的 main 起始行仍为 5,检查行定位对不同换行形式一致。 fn architecture_ranges_follow_crlf_and_cr_source_snapshots() { // 对同一模块分别使用 CRLF 与 CR,保证原样源快照和语句行号都支持不同换行形式。 for ending in ["\r\n", "\r"] { // 只替换换行,不修改模块语义或插入包装,main 起始行仍应为 5。 let source = EXAMPLE.replace('\n', ending); // 完整编译后既比较 source 逐字一致,也核对结构树定位。 let document = compile(&source, "app.main", 1).unwrap(); assert_eq!(document.source, source); assert_eq!(document.root.unwrap().children[1].start_line, 5); } } #[test] /// 表驱动拒绝旧 namespace/scene 语法、缺失或有参 main、错误根类型和各类非法 UI 参数。 /// 同时覆盖尺寸/字号/颜色预算与未知名称,确保兼容入口不会默默接受错误程序。 fn rejects_invalid_entry_objects_arguments_and_old_syntax() { // 独立编译每个非法程序,覆盖旧语法、main 契约、宿主参数及未知 API/名称。 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] /// 在中文字符串之后制造未知引用,检查 SourceDiagnostic 仍指向第三行和 submission 17。 /// 避免宿主包装源代码后把错误定位成合成代码位置。 fn source_diagnostics_keep_unicode_locations_and_submission_origin() { // 中文字符串后第三行引用 missing,真实 frontend 错误必须保留原源位置和提交 17 来源。 let error = compile( "function main(): View {\n let title = ui.text(\"中文\")\n return missing\n}", "app.main", 17, ) .unwrap_err(); // 下转为结构化 SourceDiagnostic,而非从渲染后的文本猜测行号。 let error = error.downcast_ref::().unwrap(); assert_eq!(error.location.line, 3); assert!(error.origin.to_string().contains("submission 17")); } #[test] /// 无终止条件的 main 自递归必须被 VM 帧预算截断,报告 frames 而非让宿主栈溢出。 fn recursive_program_stops_at_the_vm_frame_limit() { // 无终止条件递归 main,必须由 VM 调用帧上限终止;不应递归穿透到宿主栈溢出。 let error = compile("function main(): View { return main() }", "app.main", 1).unwrap_err(); assert!( error .to_string() .contains("execution limit reached: frames") ); } /// 生成二叉视觉树程序。shared=true 每层只构造一个 child 再复用两次,false 则分别递归构造。 /// 用于把业务对象数量增长和展示出现位置展开增长分开测试,最终由 main 返回最高层节点。 fn nested_functions(levels: usize, shared: bool) -> String { // 下方预算测试调用本生成器;叶函数只创建一个文字,从 level1 开始逐层生成二叉组合。 let mut source = "function node0(): View { return ui.text(\"x\") }\n".to_owned(); // 按层定义函数,所有引用都指向已定义的前一层,排除名称解析失败干扰预算测试。 for index in 1..=levels { // 当前层使用的子层编号,与 shared 分支共同控制业务对象数和出现展开数。 let previous = index - 1; // 先求一次 child 再放入容器两次,使业务对象线性增长、展示出现指数增长。 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")); } } // main 只返回最高层,调用方通过 levels/shared 参数选择要触发的资源预算。 source.push_str(&format!( "function main(): View {{ return node{levels}() }}" )); source } #[test] /// 不共享子项的树先触发 4096 构造节点预算;共享树对象较少但展开出现位置更多。 /// 后者需报告 expanded Block graph,证明两类预算分别约束对象分配和渲染规模。 fn construction_and_expanded_layout_have_separate_budgets() { // 不共享的 12 层二叉树先超出 4096 构造节点预算,验证分配阶段限制。 let error = compile(&nested_functions(12, false), "app.main", 1).unwrap_err(); assert!(error.to_string().contains("4096 node budget"), "{error:#}"); // 共享的 14 层仅少量业务对象,仍应因出现位置展开过大被另一预算拒绝。 let error = compile(&nested_functions(14, true), "app.main", 2).unwrap_err(); assert!( error.to_string().contains("expanded Block graph"), "{error:#}" ); } #[test] /// 构造 64 层嵌套容器包住一个文字,完整编译后仍应有一条文字命令。 /// 验证允许的深度不会因宿主遍历截断而悄悄丢掉叶节点。 fn managed_ui_depth_is_not_truncated_at_64_levels() { // 生成单链而非二叉展开,隔离深度可用性与宽度/节点数量预算。 let mut source = "function node0(): View { return ui.text(\"x\") }\n".to_owned(); // 64 层每层只包一个 Column,允许的深度必须把唯一叶文字保留到最终帧。 for index in 1..=64 { source.push_str(&format!( "function node{index}(): View {{ return ui.column(0, 0, node{}()) }}\n", index - 1 )); } // main 从第 64 层返回根,确保完整遍历所有层。 source.push_str("function main(): View { return node64() }"); // 正常编译后应恰有一条文字命令,不能静默截断深层子项。 let document = compile(&source, "app.main", 1).unwrap(); assert_eq!(document.frame.commands.len(), 1); } #[test] /// 直接测试 NamespaceState 的提交令牌:失败编译保留旧文档,新 submission 使旧候选失效。 /// clear 后旧候选同样不能复活画面;随后新令牌的合法候选可继续递增 revision。 fn failed_updates_preserve_success_and_delete_invalidates_inflight_work() { // 直接使用命名空间状态机模拟异步候选,不依赖真实后台线程即可检查提交令牌的时序边界。 let mut state = NamespaceState::default(); // 先取新令牌并成功提交,建立 revision=1 的已发布基线。 state.submission = state.next_submission().unwrap(); assert_eq!( state .commit(state.submission, compile(EXAMPLE, "app.main", 1).unwrap()) .unwrap(), Some(1) ); // 第二次提交的编译失败,应保留上一成功文档和 revision。 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; // 候选本身有效,但后续更新 submission 后应因令牌过期而被忽略。 let candidate = compile(EXAMPLE, "app.main", stale).unwrap(); // 模拟更新请求先到达,旧合法候选也不能再覆盖当前文档。 state.submission = state.next_submission().unwrap(); assert!(state.commit(stale, candidate.clone()).unwrap().is_none()); // clear 模拟删除画面;旧候选在删除后同样不能复活,revision 保持 clear 返回值。 let deleted = state.clear().unwrap(); assert!(state.commit(stale, candidate).unwrap().is_none()); assert_eq!(state.revision, deleted); assert!(state.document.source.is_empty()); // 清理后仍可接受新的合法令牌,不能把一次 clear 当作永久关闭命名空间。 state.submission = state.next_submission().unwrap(); // 在新令牌下重新编译,成功 commit 应从 deleted revision 再递增一次。 let candidate = compile(EXAMPLE, "app.main", state.submission).unwrap(); assert_eq!( state.commit(state.submission, candidate).unwrap(), Some(deleted + 1) ); } // 退出 fixture 在两个窗口展示同一文字,以 log 统计每个出现位置的卸载,并保留一个待取消任务。 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) "#; /// 直接读取退出测试中的 log 托管状态,不通过 execute,因关闭后普通输入已经被禁止。 fn exit_log(session: &ReplSession) -> Value { // 退出测试调用此助手,直接读取仍保留的业务全局,不经过已禁止输入的 execute。 let globals = session.candidate.globals.unwrap(); // 要求夹具中的 log 存在且为状态对象,避免关闭后缺失日志被当作零。 let Some(Value::Object(log)) = session .candidate .vm .dict_get(globals, &Value::Text("log".into())) .unwrap() else { panic!("log") }; // 只读 value 用于比较 onUnmount 执行次数,不恢复或推进任何源代码。 session.candidate.vm.get_attr(log, "value").unwrap() } #[test] /// 两个窗口共享文字并保有 pending Task,shutdown 返回窗口/Mount/任务清理统计。 /// 两个出现位置各调用一次 onUnmount,重复 shutdown 不再调用,关闭后的 execute 被拒绝。 fn shutdown_unmounts_all_windows_once_and_cancels_tasks() { // 两个窗口共享 ExitText,每处独立 Mount;夹具另有待取消 sleep,覆盖退出的三类资源。 let (mut session, _) = ReplSession::load(EXIT_SOURCE, "app.main", 701, 1).unwrap(); // 退出前卸载次数为零,避免把加载期行为算作 shutdown 的清理结果。 assert_eq!(exit_log(&session), Value::Number(0_i64.into())); // 真实 shutdown 返回资源清理统计;窗口应恰好两处,Mount/任务至少覆盖夹具创建的资源。 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())); // 重复退出必须幂等,不能再执行两个 onUnmount;随后普通 execute 必须拒绝。 session.shutdown().unwrap(); assert_eq!(exit_log(&session), Value::Number(2_i64.into())); assert!(session.execute("42", "app.main").is_err()); } #[test] /// 手动将会话标为 faulted 后 shutdown,资源仍需清理但 log 保持 0。 /// 验证停止域的退出清理不能借生命周期回调重新执行源代码。 fn shutdown_stopped_runtime_never_reenters_source_hooks() { // 建立正常挂载状态,再人为标记 faulted,隔离“已停止运行时退出”这条路径。 let (mut session, _) = ReplSession::load(EXIT_SOURCE, "app.main", 702, 1).unwrap(); // 停止域只允许资源清理,不得为了卸载通知重新进入源 onUnmount。 session.faulted = true; // 退出仍应成功,但日志保持零,证明没有执行夹具的源卸载钩子。 session.shutdown().unwrap(); assert_eq!(exit_log(&session), Value::Number(0_i64.into())); } #[test] /// 把退出 onUnmount 改成启动 sleep;关闭期间必须拒绝新任务并报告 exiting 诊断。 /// 错误使运行时 fault,避免退出清理反而产生无法结束的后台工作。 fn shutdown_hooks_cannot_create_new_background_work() { // 把正常卸载计数替换为创建 sleep,构造退出回调试图增加后台任务的非法行为。 let source = EXIT_SOURCE.replace("self.log.increment()", "ui.sleep(ui,\"late\",30000)"); // 加载时没有触发卸载,程序应先正常挂载,错误预期发生在 shutdown 内。 let (mut session, _) = ReplSession::load(&source, "app.main", 703, 1).unwrap(); // 检查退出阶段明确禁止新任务并 fault,会话不能因清理回调反而遗留新后台工作。 let error = session.shutdown().unwrap_err(); assert!(format!("{error:#}").contains("runtime is exiting; new tasks are forbidden")); assert!(session.is_faulted()); } #[test] /// 以有限 400x300 视口比较 window/vertical/horizontal:普通页受窗口限制,延伸页仅沿声明轴增长。 /// append 新子项后延伸尺寸连同 gap 实时更新,覆盖页面布局模式的旧兼容入口。 fn page_modes_grow_on_the_declared_axis_and_follow_live_children() { // 同样两个 450×350 子项与 gap=10,比较 window 有限尺寸和旧延伸模式的单轴内容增长。 for (mode, expected) in [ ("window", (400.0, 300.0)), ("vertical", (400.0, 710.0)), ("horizontal", (910.0, 300.0)), ] { // 每轮独立构造页面,模式是唯一变化,避免前一轮 append 留下的结构影响后续预期。 let source = format!( r#"page=Page() page.mode="{mode}" page.gap=10 page.render(RectBlock(450,350)) page.append(RectBlock(450,350))"# ); // 统一有限视口 400×300,让 window 模式有明确上限,延伸模式只在指定轴超出。 let (mut session, document) = ReplSession::load_with_viewport( &source, "app.main", 1, 300, Some(crate::layout::Size { width: 400.0, height: 300.0, }), ) .unwrap(); // 首个 Mount 是页面根,核对实际发布的表面布局尺寸而非仅 page.mode 字段。 let root = &document.frame.mounts[0]; assert_eq!((root.width, root.height), expected, "{mode}"); // 仅纵向模式再追加一项,内容高应为 3×350+2×10=1070。 if mode == "vertical" { // 沿当前会话追加并取新文档,检查实时子项变化已进入延伸高度。 let next = session .execute("page.append(RectBlock(450,350))", "app.main") .unwrap() .document .unwrap(); assert_eq!(next.frame.mounts[0].height, 1070.0); } // 横向模式追加后宽应为 3×450+2×10=1370,不能错误沿纵轴增长。 if mode == "horizontal" { // 同样用真实 append 触发刷新,核对新根宽度而非重新加载预设尺寸。 let next = session .execute("page.append(RectBlock(450,350))", "app.main") .unwrap() .document .unwrap(); assert_eq!(next.frame.mounts[0].width, 1370.0); } } } #[test] /// Canvas 中两次使用同一按钮,业务 ID 相同但 Mount ID 不同,均接受带符号世界坐标。 /// 修改位置更新两处;切到另一普通页再回来,原 Page 的 pan/zoom 与页面身份仍保留。 fn canvas_positions_are_live_signed_and_shared_without_copying_objects() { // 旧 canvas Page 模式两次放入同一按钮,带负位置与非默认 pan/zoom,用来检验业务共享和页面状态保留。 let (mut session, initial) = ReplSession::load_with_viewport( r#" item=Button("same object") item.x=-120 item.y=90 page=Page() page.mode="canvas" page.render(item) page.append(item) page.panX=-160 page.zoom=2 "#, "app.main", 1, 301, Some(crate::layout::Size { width: 400.0, height: 300.0, }), ) .unwrap(); // 只取按钮出现,要求业务 block_id 相同、Mount ID 不同;不能通过复制业务对象实现两处显示。 let mounts: Vec<_> = initial .frame .mounts .iter() .filter(|mount| mount.interactive) .collect(); // 两处出现均保留负世界坐标,表面另记录 panX=-160 和 zoom=2。 assert_eq!(mounts.len(), 2); assert_eq!(mounts[0].block_id, mounts[1].block_id); assert_ne!(mounts[0].id, mounts[1].id); assert_eq!((mounts[0].x, mounts[0].y), (-120.0, 90.0)); assert_eq!(initial.frame.surface.pan.x, -160.0); assert_eq!(initial.frame.surface.zoom, 2.0); // 写同一业务 item 的 x/y,两处出现都应更新为 (500,-60),不能只移动首个 Mount。 let next = session .execute("item.x=500\nitem.y=-60", "app.main") .unwrap() .document .unwrap(); assert!( next.frame .mounts .iter() .filter(|m| m.interactive) .all(|m| m.x == 500.0 && m.y == -60.0) ); // 保存原 canvas 页身份,后面切换到普通页面再返回,必须保留同一页状态。 let old_page = next.frame.surface.page.clone(); // 新建普通 Page 并 render,当前表面切为 Window 模式,但不销毁原 canvas 页面业务状态。 let window = session .execute( "other=Page()\nother.render(TextBlock(\"other\"))", "app.main", ) .unwrap() .document .unwrap(); assert_eq!(window.frame.surface.mode, crate::page::Mode::Window); // 激活原页后核对 page 身份、pan 和 zoom 全部恢复,不以新建页面代替旧页。 let restored = session .execute("page.activate()", "app.main") .unwrap() .document .unwrap(); assert_eq!(restored.frame.surface.page, old_page); assert_eq!(restored.frame.surface.pan.x, -160.0); assert_eq!(restored.frame.surface.zoom, 2.0); } #[test] /// 通过 ui.newTab 加载内置起始页并确认二维视口存在。 /// 逐个按画面文字位置点击示例入口,检查它们都使用 Window 表面、仅延伸示例带 ScrollBlock,再用真实 tab-activate 回到首页。 fn bundled_start_page_uses_independent_layout_scroll_and_scene_viewports() { // 以 1024×800 窗口初始化会话,测试实际内置起始页在有限约束下的交互入口。 let mut session = ReplSession::new_with_viewport( 302, Some(crate::layout::Size { width: 1024.0, height: 800.0, }), ) .unwrap(); // 通过公开 ui.newTab 创建起始页,后续每次派发都沿最新文档继续,避免版本过期。 let mut document = session .execute("ui.newTab()", "app.main") .unwrap() .document .unwrap(); // 起始页采用 Window 表面且实际包含 CanvasViewport,二维场景应由独立视口承载。 assert_eq!(document.frame.surface.mode, crate::page::Mode::Window); assert!( document .frame .mounts .iter() .any(|mount| mount.canvas_viewport) ); // 依次点击普通窗口、纵向延伸和横向延伸入口;只有后两者应出现独立 ScrollBlock。 for (label, scrolling) in [("窗口页面", false), ("纵向延伸", true), ("横向延伸", true)] { // 先保存首页标签 ID,进入示例后用真实 tab-activate 返回它,而不是重新创建首页。 let home_tab = document .frame .tabs .iter() .find(|tab| tab.title == "自由画布") .unwrap() .id; // 按当前绘制文字找到入口屏幕位置,检验可见标签确实对应可点击目标。 let point = document .frame .commands .iter() .find_map(|command| match command { // 匹配目标标签后在字内取一点,不用硬编码按钮 Mount 或跳过真实命中检查。 RenderCommand::Text { x, y, value, .. } if value == label => { Some(crate::geometry::Point::new(x + 2.0, y + 2.0)) } // 其他文字/绘制命令都不参与此次入口定位。 _ => None, }) .unwrap(); // 整帧 hit_test 必须从标签坐标找到有效按钮,保证显示和交互几何一致。 let mount = document .frame .hit_test(point) .expect("button label must be clickable") .id; // 使用当前 session/layout_version 与屏幕坐标派发原生 click,并保存新发布文档。 document = session .dispatch(&crate::events::Input { window: 1, session: document.frame.session, layout_version: document.frame.layout_version, mount, kind: "click".into(), point: Some([point.x, point.y]), press_point: Some([point.x, point.y]), delta: None, tab_drop: None, screen_position: None, }) .unwrap() .document .unwrap(); // 三个示例均使用 Window 表面;延伸由内部滚动容器实现,不能退回旧表面模式。 assert_eq!( document.frame.surface.mode, crate::page::Mode::Window, "{label}" ); // 检查 scroll 上下文是否与当前入口预期一致,并要求整个帧没有 fault。 assert_eq!( document .frame .mounts .iter() .any(|mount| mount.scroll.is_some()), scrolling, "{label}" ); assert!(document.frame.fault.is_none()); // 以示例文档当前版本激活保存的首页标签,不复用进入示例前的旧布局版本。 let input = crate::events::Input { window: 1, session: document.frame.session, layout_version: document.frame.layout_version, mount: home_tab, kind: "tab-activate".into(), point: None, press_point: None, delta: None, tab_drop: None, screen_position: None, }; // 返回首页后的新文档作为下一轮入口查找基准,覆盖连续来回切页的版本更新。 document = session.dispatch(&input).unwrap().document.unwrap(); } }