From 742fee42955a33ebc176dabb7d3c4acfda288b95 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 18:03:00 +0800 Subject: [PATCH] docs: explain effects, bindings, publication and shutdown test paths --- src/runtime/endor_host/tests.rs | 256 ++++++++++++++++++++++++++++++++ 1 file changed, 256 insertions(+) diff --git a/src/runtime/endor_host/tests.rs b/src/runtime/endor_host/tests.rs index a5e1d36..222abf0 100644 --- a/src/runtime/endor_host/tests.rs +++ b/src/runtime/endor_host/tests.rs @@ -35,6 +35,7 @@ fn scene_interaction_performance() { ("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( @@ -323,6 +324,7 @@ page.render(Invalid("invalid")) 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" }; @@ -476,6 +478,8 @@ fn native_signatures_validate_registration_and_deliver_the_event_argument() { 220, ) .unwrap(); + // 枚举非法注册/调用,每轮都检查错误可恢复且旧文档未被标为故障。 + // 每个非法注册/调用独立检查恢复性,覆盖错误回调与显式参数数目。 for invalid in [ r#"withEvent.on("click", ui.textOf)"#, r#"withEvent.on("click", Page)"#, @@ -550,6 +554,7 @@ fn checked_fields_reject_native_constructor_side_effects_and_preserve_live_value 210, ) .unwrap(); + // 同一会话依次尝试字段写入和两类构造错误,便于后续核对原值与页面资源没有副作用。 for invalid in [ r#"counter.value = "wrong""#, r#"CounterText("bad", "wrong")"#, @@ -703,6 +708,7 @@ fn positional_insert_preserves_existing_slots_and_mounts_and_rejects_invalid_gra .value, id.to_string() ); + // 表驱动覆盖位置边界与组合循环,合法插入后的旧 Mount 必须在全部失败尝试后仍有效。 for invalid in [ "list.insert(4,head)", "list.insert(0,list)", @@ -981,9 +987,11 @@ fn file_task_state(session: &mut ReplSession) -> String { /// 最多轮询 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; @@ -1186,6 +1194,7 @@ fn tab_and_mount_tasks_cancel_while_block_and_app_owners_continue() { "app.main", ) .unwrap(); + // 两种展示 owner 都在关闭时失去资格,按各自 token 分别核对,避免只取消其中一种。 for name in ["pageTask", "mountTask"] { // close 后立刻 open 不应复活旧资格;页面与旧出现的任务都已在关闭边界被取消。 assert_eq!( @@ -1307,6 +1316,7 @@ fn source_annotations_classify_functions_and_reject_before_body() { 180, ) .unwrap(); + // 分别查询源函数、原生工厂和绑定方法,三者都应进入 Function 名义类型体系。 for expression in [ "isInstance(identity,Function)", "isInstance(ui.rect,Function)", @@ -6939,7 +6949,9 @@ fn same_identity_event_payload_changes_invalidate_interactivity_cache() { /// 查询简单、转发、递归、动态回调和有副作用函数的静态 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#" @@ -6959,7 +6971,9 @@ fn source_effect_queries_use_compiled_instructions_without_running_functions() { "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)), @@ -6967,6 +6981,7 @@ fn source_effect_queries_use_compiled_instructions_without_running_functions() { ("unknown", None), ("impure", None), ] { + // 每次取同一 VM 中真实源函数,类型不符即失败,不能跳过某一分析场景。 let Value::Object(function) = session .candidate .vm @@ -6976,6 +6991,7 @@ fn source_effect_queries_use_compiled_instructions_without_running_functions() { else { panic!("function") }; + // 调用 call_effect 只查询效果,不执行函数;尤其递归函数没有终止条件,不应被试运行。 assert_eq!( session .candidate @@ -6986,6 +7002,7 @@ fn source_effect_queries_use_compiled_instructions_without_running_functions() { expected ); } + // 取具体接收者,绑定方法的 self 类型与 helper 查找都由该对象提供上下文。 let Value::Object(model) = session .candidate .vm @@ -6995,6 +7012,7 @@ fn source_effect_queries_use_compiled_instructions_without_running_functions() { else { panic!("model") }; + // bind_attr 生成捕获 model 的方法,静态分析因能解析 self.helper 而可判定只读。 let Value::Object(bound) = session.candidate.vm.bind_attr(model, "check").unwrap() else { panic!("bound") }; @@ -7007,11 +7025,14 @@ fn source_effect_queries_use_compiled_instructions_without_running_functions() { .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 @@ -7021,13 +7042,16 @@ fn source_effect_queries_use_compiled_instructions_without_running_functions() { 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" @@ -7038,14 +7062,18 @@ fn source_effect_queries_use_compiled_instructions_without_running_functions() { /// 语言层拒绝用普通实例字段遮蔽有契约的 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 @@ -7053,12 +7081,18 @@ fn typed_method_shadows_invalidate_cached_ancestors_and_can_be_removed() { .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() @@ -7068,24 +7102,31 @@ fn typed_method_shadows_invalidate_cached_ancestors_and_can_be_removed() { "{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(); @@ -7096,8 +7137,11 @@ fn typed_method_shadows_invalidate_cached_ancestors_and_can_be_removed() { /// 为参数/返回契约不兼容的 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", @@ -7108,7 +7152,9 @@ fn presentable_type_contracts_reject_before_registration_or_body_effects() { "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() @@ -7119,6 +7165,7 @@ fn presentable_type_contracts_reject_before_registration_or_body_effects() { .contains("trait Presentable method present"), "{error:#}" ); + // 失败不能留下半注册能力,随后 implements 查询应为 false。 assert_eq!( session .execute(&format!("ui.implements({name}, Presentable)"), "app.main") @@ -7126,11 +7173,13 @@ fn presentable_type_contracts_reject_before_registration_or_body_effects() { .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") } @@ -7138,6 +7187,7 @@ fn presentable_type_contracts_reject_before_registration_or_body_effects() { ui.implement(Good, Presentable) view = ui.present(Good()) "#, "app.main").unwrap(); + // 返回值满足 TextBlock 名义类型,且前面的注册错误不应停止会话。 assert_eq!( session .execute("ui.isInstance(view, TextBlock)", "app.main") @@ -7152,6 +7202,7 @@ fn presentable_type_contracts_reject_before_registration_or_body_effects() { /// 加载显式 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", @@ -7159,8 +7210,11 @@ fn presentable_dispatch_preserves_source_identity_and_reactive_display() { 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 @@ -7170,6 +7224,7 @@ fn presentable_dispatch_preserves_source_identity_and_reactive_display() { else { panic!("expected Product"); }; + // 视图是可挂载的 Block,不能把复制出来的展示数据当作业务源对象。 let Value::Object(view) = session .candidate .vm @@ -7179,6 +7234,7 @@ fn presentable_dispatch_preserves_source_identity_and_reactive_display() { else { panic!("expected Block"); }; + // source 字段需保存同一 ObjectRef;另用 supports 检查公开能力判定。 assert_eq!( session.candidate.vm.get_attr(view, "source").unwrap(), Value::Object(product) @@ -7190,6 +7246,7 @@ fn presentable_dispatch_preserves_source_identity_and_reactive_display() { .value, "true" ); + // 只改原 product.name,已建立的显示依赖应发布 updated 文字,不重新调用页面构造。 let changed = session .execute("product.name = \"updated\"", "app.main") .unwrap() @@ -7200,6 +7257,7 @@ fn presentable_dispatch_preserves_source_identity_and_reactive_display() { |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()); } @@ -7208,7 +7266,9 @@ fn presentable_dispatch_preserves_source_identity_and_reactive_display() { /// 仅有同名且签名匹配的 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#" @@ -7226,6 +7286,7 @@ fn matching_present_method_does_not_implicitly_declare_presentable() { "app.main", ) .unwrap(); + // 回退不执行自定义方法,marker=0 且 supports=false,区分方法存在与能力声明。 assert_eq!( session.execute("marker.value", "app.main").unwrap().value, "0" @@ -7237,6 +7298,7 @@ fn matching_present_method_does_not_implicitly_declare_presentable() { .value, "false" ); + // 显式注册后用 ui.open(data) 走实际展示路径,方法应恰好执行一次。 session .execute( "ui.implement(Data,Presentable)\ncustom = ui.open(data)", @@ -7247,7 +7309,9 @@ fn matching_present_method_does_not_implicitly_declare_presentable() { session.execute("marker.value", "app.main").unwrap().value, "1" ); + // 保存已发布文档集合,接下来注册缺少 present 的类型必须失败且不替换现有帧。 let before = session.documents.clone(); + // Missing 没有实现能力方法,注册失败属于可恢复输入错误,不能发布半成品页面。 assert!( session .execute( @@ -7268,7 +7332,9 @@ fn matching_present_method_does_not_implicitly_declare_presentable() { /// 隐藏 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##" @@ -7287,22 +7353,28 @@ fn managed_property_bindings_update_hidden_objects_and_shared_window_geometry() 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(); @@ -7310,6 +7382,7 @@ fn managed_property_bindings_update_hidden_objects_and_shared_window_geometry() session.execute("rect.width", "app.main").unwrap().value, "260" ); + // 直接给目标 width=180 仍应正常发布;断开绑定不能把目标属性变成不可写。 let direct = session .execute("rect.width = 180", "app.main") .unwrap() @@ -7322,7 +7395,9 @@ fn managed_property_bindings_update_hidden_objects_and_shared_window_geometry() /// 同一目标两个属性各绑定一个返回新 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##" @@ -7337,6 +7412,7 @@ fn multiple_object_outputs_on_one_target_do_not_invalidate_each_other() { "app.main", ) .unwrap(); + // 两项都应收敛到 value=7 且 VM 健康;不能只保证其中一个绑定最终被写入。 assert_eq!( session .execute("rect.first.value", "app.main") @@ -7358,6 +7434,7 @@ fn multiple_object_outputs_on_one_target_do_not_invalidate_each_other() { /// 可见和隐藏 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 @@ -7374,6 +7451,7 @@ page.render(rect) 1, ) .unwrap(); + // 查询累计 bindingEvaluations 并转整数;该只读统计查询本身不应让绑定重新求值。 let count = |session: &mut ReplSession| { session .execute("ui.stats().bindingEvaluations", "app.main") @@ -7382,8 +7460,10 @@ page.render(rect) .parse::() .unwrap() }; + // 初次两项各求值一次,记录基线后用无关表达式检查按全局名字精确失效。 let baseline = count(&mut session); assert_eq!(baseline, 2); + // 普通 42 结果不改 size,也不应发布或增加求值数。 assert!( session .execute("42", "app.main") @@ -7392,12 +7472,14 @@ page.render(rect) .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!( @@ -7411,6 +7493,7 @@ page.render(rect) /// 加载 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", @@ -7418,12 +7501,15 @@ fn source_render_caches_do_not_root_a_page_block_waiting_frame_cycle() { 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" @@ -7435,7 +7521,9 @@ fn source_render_caches_do_not_root_a_page_block_waiting_frame_cycle() { .value, "\"running\"" ); + // 取 holder 中保留的临时页面,创建弱观察句柄而不添加新的持久根。 let globals = session.candidate.globals.unwrap(); + // holder 是外部状态容器,value 指向要释放的临时 Page。 let Value::Object(holder) = session .candidate .vm @@ -7445,16 +7533,21 @@ fn source_render_caches_do_not_root_a_page_block_waiting_frame_cycle() { 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 @@ -7463,6 +7556,7 @@ fn source_render_caches_do_not_root_a_page_block_waiting_frame_cycle() { .value, "\"running\"" ); + // 释放 holder/pending 的外部引用后,再检查对象数恢复且两个弱句柄均失效。 session.execute("releaseCycle()", "app.main").unwrap(); assert_eq!( session @@ -7479,7 +7573,9 @@ fn source_render_caches_do_not_root_a_page_block_waiting_frame_cycle() { /// 仅保留绑定 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##" @@ -7498,7 +7594,9 @@ fn binding_tokens_do_not_retain_hidden_targets_and_compute_is_read_only() { "app.main", ) .unwrap(); + // 保留名字表以删除 rect 的强全局引用,同时继续保留 token 和 model。 let globals = session.candidate.globals.unwrap(); + // 取得原隐藏 rect 的真实身份,后续不能用另建目标代替 GC 观察。 let Value::Object(target) = session .candidate .vm @@ -7508,17 +7606,20 @@ fn binding_tokens_do_not_retain_hidden_targets_and_compute_is_read_only() { 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!( @@ -7528,6 +7629,7 @@ fn binding_tokens_do_not_retain_hidden_targets_and_compute_is_read_only() { .value, "false" ); + // 另建目标绑定 model.bad,计算开始时必须处于只读范围,业务写入应在发生前被拦截。 assert!( session .execute( @@ -7537,6 +7639,7 @@ fn binding_tokens_do_not_retain_hidden_targets_and_compute_is_read_only() { .is_err() ); assert!(session.is_faulted()); + // 会话 faulted 后直接读取 VM 的 model;不能用普通 execute 查询遮蔽原值检查。 let Value::Object(model) = session .candidate .vm @@ -7546,6 +7649,7 @@ fn binding_tokens_do_not_retain_hidden_targets_and_compute_is_read_only() { else { panic!("expected model"); }; + // width 仍为 80,证明错误没有先执行写入再停止后续工作。 assert_eq!( session.candidate.vm.get_attr(model, "width").unwrap(), Value::Number(80.into()) @@ -7556,19 +7660,25 @@ fn binding_tokens_do_not_retain_hidden_targets_and_compute_is_read_only() { /// 两个 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(); @@ -7579,14 +7689,18 @@ fn native_method_callbacks_update_two_explicit_bindings() { /// 保留 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 @@ -7596,8 +7710,11 @@ fn closing_a_page_releases_its_root_without_retaining_it_through_shared_content( 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(); @@ -7608,12 +7725,15 @@ fn closing_a_page_releases_its_root_without_retaining_it_through_shared_content( .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) @@ -7633,9 +7753,13 @@ fn closing_a_page_releases_its_root_without_retaining_it_through_shared_content( /// 保存视觉对象 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(); @@ -7644,6 +7768,7 @@ fn mounted_aliases_update_nested_positions_unmount_and_reject_cycles() { session.execute("other", "app.main").unwrap().value, identity ); + // 从替换产生的发布文档检查嵌套绘制尺寸,不只比较 alias 的 repr。 let frame = updated.document.unwrap().frame; assert!(matches!( frame.commands[0], @@ -7653,6 +7778,7 @@ fn mounted_aliases_update_nested_positions_unmount_and_reject_cycles() { .. } )); + // 替换值若包含自身 alias 会形成组合环,必须拒绝;随后只读查询不应产生文档。 assert!( session .execute("card = ui.column(0, 0, other)", "app.main") @@ -7665,12 +7791,14 @@ fn mounted_aliases_update_nested_positions_unmount_and_reject_cycles() { .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") @@ -7678,6 +7806,7 @@ fn mounted_aliases_update_nested_positions_unmount_and_reject_cycles() { .document .is_none() ); + // 通过旧 alias 重新挂载,证明别名转发在卸载期间也保持最新值。 let remounted = session .execute("page.render(other)", "app.main") .unwrap() @@ -7697,13 +7826,16 @@ fn mounted_aliases_update_nested_positions_unmount_and_reject_cycles() { /// 创建文字但未挂载时只保留 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, @@ -7713,6 +7845,7 @@ fn live_session_retains_objects_and_updates_only_after_mounting() { session.execute("_", "app.main").unwrap().value, created.value ); + // 创建 Page 并挂载已有 title,首次可见文档应显示最初中文文本。 let rendered = session .execute("page = ui.page()\npage.render(title)", "app.main") .unwrap() @@ -7721,7 +7854,9 @@ fn live_session_retains_objects_and_updates_only_after_mounting() { 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") @@ -7729,6 +7864,7 @@ fn live_session_retains_objects_and_updates_only_after_mounting() { .document .is_some() ); + // 再显式 render,核对最新文字及 Console 输入历史;最后只读查询不重复发布。 let updated = session .execute("page.render(title)", "app.main") .unwrap() @@ -7751,12 +7887,16 @@ fn live_session_retains_objects_and_updates_only_after_mounting() { /// 编译期错误不能执行同输入前面的 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( @@ -7765,6 +7905,7 @@ fn live_session_compiles_before_execution_and_discards_failed_render() { ) .is_err() ); + // 失败前完成的 value=3 仍保留,但后续查询不能自动发布失败输入的 discarded 内容。 let retained = session.execute("value", "app.main").unwrap(); assert_eq!(retained.value, "3"); assert!(retained.document.is_none()); @@ -7777,9 +7918,11 @@ fn live_session_compiles_before_execution_and_discards_failed_render() { 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 }", @@ -7796,8 +7939,10 @@ fn live_session_compiles_before_execution_and_discards_failed_render() { /// 普通顶层 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); @@ -7807,6 +7952,7 @@ fn top_level_goal_script_renders_without_a_main_or_synthetic_wrapper() { 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") @@ -7815,6 +7961,7 @@ fn top_level_goal_script_renders_without_a_main_or_synthetic_wrapper() { 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(), @@ -7831,6 +7978,7 @@ fn top_level_goal_script_renders_without_a_main_or_synthetic_wrapper() { /// 提交含括号注释、辅助函数、多行赋值/调用及跨行成员链的完整源程序。 /// 检查渲染成功与结构树起止行,避免用宿主按行切割或简单括号统计代替 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" @@ -7849,8 +7997,10 @@ let page = ui 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); @@ -7865,18 +8015,24 @@ page /// 分别使用 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); } @@ -7885,12 +8041,14 @@ fn script_diagnostics_refer_to_original_unicode_source_and_byte_ranges() { /// 两个 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!( @@ -7902,13 +8060,16 @@ paint(ui.text("last"))"#; /// 先发布成功文档,再逐项提交无 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\")", @@ -7917,7 +8078,9 @@ fn page_api_errors_and_late_failures_do_not_publish_partial_snapshots() { "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}" @@ -7931,11 +8094,15 @@ fn page_api_errors_and_late_failures_do_not_publish_partial_snapshots() { /// 保留超过 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:#}"); } @@ -7944,7 +8111,9 @@ fn page_creation_has_a_separate_live_object_budget() { /// 生成每层重复调用前一函数的指数展开计算,最后才创建文字。 /// 即使尚未触及 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", @@ -7952,7 +8121,9 @@ fn script_execution_is_limited_even_without_creating_ui_nodes() { 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 @@ -7966,7 +8137,9 @@ fn script_execution_is_limited_even_without_creating_ui_nodes() { /// 源 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") @@ -7977,11 +8150,16 @@ fn literal_string_return_from_aster_can_be_displayed_by_the_host() { /// 为相同源码生成两个不同提交的结构树,开发工具先选中旧子节点。 /// 接收新文档后旧 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(), @@ -7993,14 +8171,17 @@ fn developer_selection_resets_to_the_new_source_snapshot() { /// 通过兼容模块/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") @@ -8015,6 +8196,7 @@ fn executes_aster_functions_and_renders_after_vm_cleanup() { .. } )); + // 使用实际文字度量推导后续文字 x,避免写死字体相关宽度导致位置断言失真。 let first_width = crate::text::measure("Aster VM", crate::text::TextStyle::ui(1)).width; assert!(matches!( &document.frame.commands[3], @@ -8029,14 +8211,17 @@ fn executes_aster_functions_and_renders_after_vm_cleanup() { /// 辅助函数接收借用 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], @@ -8048,8 +8233,11 @@ function main(): View { let child = ui.text("shared") return wrap(child) }"#; /// 把同一模块示例换成 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); @@ -8060,6 +8248,7 @@ fn architecture_ranges_follow_crlf_and_cr_source_snapshots() { /// 表驱动拒绝旧 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\") }", @@ -8076,6 +8265,7 @@ fn rejects_invalid_entry_objects_arguments_and_old_syntax() { "function main(): View { return unknown(1) }", "function main(): View { return ui.missing() }", ] { + // 本组只要求拒绝,不据此声称某个特定诊断阶段;失败消息列出被意外接受的源码。 assert!( compile(source, "app.main", 1).is_err(), "accepted: {source}" @@ -8087,12 +8277,14 @@ fn rejects_invalid_entry_objects_arguments_and_old_syntax() { /// 在中文字符串之后制造未知引用,检查 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")); @@ -8101,6 +8293,7 @@ fn source_diagnostics_keep_unicode_locations_and_submission_origin() { #[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 @@ -8112,15 +8305,21 @@ fn recursive_program_stops_at_the_vm_frame_limit() { /// 生成二叉视觉树程序。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}() }}" )); @@ -8131,8 +8330,10 @@ fn nested_functions(levels: usize, shared: bool) -> String { /// 不共享子项的树先触发 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"), @@ -8144,14 +8345,18 @@ fn construction_and_expanded_layout_have_separate_budgets() { /// 构造 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); } @@ -8160,7 +8365,9 @@ fn managed_ui_depth_is_not_truncated_at_64_levels() { /// 直接测试 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 @@ -8168,19 +8375,26 @@ fn failed_updates_preserve_success_and_delete_invalidates_inflight_work() { .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(), @@ -8209,7 +8423,9 @@ 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 @@ -8218,6 +8434,7 @@ fn exit_log(session: &ReplSession) -> Value { else { panic!("log") }; + // 只读 value 用于比较 onUnmount 执行次数,不恢复或推进任何源代码。 session.candidate.vm.get_attr(log, "value").unwrap() } @@ -8225,13 +8442,17 @@ fn exit_log(session: &ReplSession) -> Value { /// 两个窗口共享文字并保有 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()); @@ -8241,8 +8462,11 @@ fn shutdown_unmounts_all_windows_once_and_cancels_tasks() { /// 手动将会话标为 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())); } @@ -8251,8 +8475,11 @@ fn shutdown_stopped_runtime_never_reenters_source_hooks() { /// 把退出 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()); @@ -8262,11 +8489,13 @@ fn shutdown_hooks_cannot_create_new_background_work() { /// 以有限 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}" @@ -8274,6 +8503,7 @@ 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", @@ -8285,9 +8515,12 @@ page.append(RectBlock(450,350))"# }), ) .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() @@ -8295,7 +8528,9 @@ page.append(RectBlock(450,350))"# .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() @@ -8310,6 +8545,7 @@ page.append(RectBlock(450,350))"# /// 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") @@ -8331,18 +8567,21 @@ page.zoom=2 }), ) .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() @@ -8355,7 +8594,9 @@ page.zoom=2 .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\"))", @@ -8365,6 +8606,7 @@ page.zoom=2 .document .unwrap(); assert_eq!(window.frame.surface.mode, crate::page::Mode::Window); + // 激活原页后核对 page 身份、pan 和 zoom 全部恢复,不以新建页面代替旧页。 let restored = session .execute("page.activate()", "app.main") .unwrap() @@ -8379,6 +8621,7 @@ page.zoom=2 /// 通过 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 { @@ -8387,11 +8630,13 @@ fn bundled_start_page_uses_independent_layout_scroll_and_scene_viewports() { }), ) .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 @@ -8400,8 +8645,10 @@ fn bundled_start_page_uses_independent_layout_scroll_and_scene_viewports() { .iter() .any(|mount| mount.canvas_viewport) ); + // 依次点击普通窗口、纵向延伸和横向延伸入口;只有后两者应出现独立 ScrollBlock。 for (label, scrolling) in [("窗口页面", false), ("纵向延伸", true), ("横向延伸", true)] { + // 先保存首页标签 ID,进入示例后用真实 tab-activate 返回它,而不是重新创建首页。 let home_tab = document .frame .tabs @@ -8409,22 +8656,27 @@ fn bundled_start_page_uses_independent_layout_scroll_and_scene_viewports() { .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, @@ -8441,11 +8693,13 @@ fn bundled_start_page_uses_independent_layout_scroll_and_scene_viewports() { .unwrap() .document .unwrap(); + // 三个示例均使用 Window 表面;延伸由内部滚动容器实现,不能退回旧表面模式。 assert_eq!( document.frame.surface.mode, crate::page::Mode::Window, "{label}" ); + // 检查 scroll 上下文是否与当前入口预期一致,并要求整个帧没有 fault。 assert_eq!( document .frame @@ -8456,6 +8710,7 @@ fn bundled_start_page_uses_independent_layout_scroll_and_scene_viewports() { "{label}" ); assert!(document.frame.fault.is_none()); + // 以示例文档当前版本激活保存的首页标签,不复用进入示例前的旧布局版本。 let input = crate::events::Input { window: 1, session: document.frame.session, @@ -8468,6 +8723,7 @@ fn bundled_start_page_uses_independent_layout_scroll_and_scene_viewports() { tab_drop: None, screen_position: None, }; + // 返回首页后的新文档作为下一轮入口查找基准,覆盖连续来回切页的版本更新。 document = session.dispatch(&input).unwrap().document.unwrap(); } }