diff --git a/src/runtime/endor_host/tests.rs b/src/runtime/endor_host/tests.rs index 9e10a88..a5e1d36 100644 --- a/src/runtime/endor_host/tests.rs +++ b/src/runtime/endor_host/tests.rs @@ -5929,6 +5929,7 @@ fn scene3d_example_custom_geometry_and_cycle_diagnostics_use_real_source() { /// 旋转 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 { @@ -5963,7 +5964,9 @@ fn world_ui_panel_renders_live_endor_and_ray_hits_respect_mesh_occlusion() { 1, ) .unwrap(); + // 先验证发布文档,再定位携带 panel 投影上下文的可交互按钮,排除普通屏幕 UI。 initial.validate().unwrap(); + // 取得面板内真实 Mount;其逻辑宽高应按 UI 分辨率,而不是面板世界尺寸 2×2。 let button = initial .frame .mounts @@ -5971,12 +5974,14 @@ fn world_ui_panel_renders_live_endor_and_ray_hits_respect_mesh_occlusion() { .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!( @@ -5986,6 +5991,7 @@ fn world_ui_panel_renders_live_endor_and_ray_hits_respect_mesh_occlusion() { .value, "100" ); + // 从源 Mount.toLocal 反投影屏幕中心,数值解析后应近似恢复纹理坐标 50,与 toView 互相校验。 let local = session .execute("button.mounts().at(0).toLocal(100,100).x", "app.main") .unwrap() @@ -5993,7 +5999,9 @@ fn world_ui_panel_renders_live_endor_and_ray_hits_respect_mesh_occlusion() { .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, @@ -6002,11 +6010,13 @@ fn world_ui_panel_renders_live_endor_and_ray_hits_respect_mesh_occlusion() { ) .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, @@ -6019,16 +6029,19 @@ fn world_ui_panel_renders_live_endor_and_ray_hits_respect_mesh_occlusion() { 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(), @@ -6038,6 +6051,7 @@ fn world_ui_panel_renders_live_endor_and_ray_hits_respect_mesh_occlusion() { ) .unwrap(); assert_eq!(pixels[100 * 200 + 100], 0x00FF00); + // 在面板前方加入实体蓝盒,保持原 UI 仍挂载,单独检验深度遮挡对命中和像素的影响。 let blocked = session .execute( r##" @@ -6050,15 +6064,19 @@ fn world_ui_panel_renders_live_endor_and_ray_hits_respect_mesh_occlusion() { .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, @@ -6073,6 +6091,7 @@ fn world_ui_panel_renders_live_endor_and_ray_hits_respect_mesh_occlusion() { /// 把可交互的内层 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 { @@ -6097,17 +6116,22 @@ fn nested_world_ui_can_embed_another_3d_viewport_and_preserve_ray_dispatch() { 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, @@ -6116,6 +6140,7 @@ fn nested_world_ui_can_embed_another_3d_viewport_and_preserve_ray_dispatch() { ) .unwrap(); assert_eq!(pixels[100 * 200 + 100], 0x0000FF); + // 以外部屏幕坐标向内层 Mount 派发,宿主必须先反投影面板再执行内场景射线拾取。 let updated = session .dispatch(&crate::events::Input { window: 1, @@ -6132,6 +6157,7 @@ fn nested_world_ui_can_embed_another_3d_viewport_and_preserve_ray_dispatch() { .unwrap() .document .unwrap(); + // 回调改色后要求有效新文档,中心绿像素证明内 Mesh 更新经过两层表面正确呈现。 updated.validate().unwrap(); crate::software_renderer::SoftwareRenderer::render( &updated.display_list(1.).unwrap(), @@ -6147,6 +6173,7 @@ fn nested_world_ui_can_embed_another_3d_viewport_and_preserve_ray_dispatch() { /// 先让 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")) @@ -6161,20 +6188,28 @@ fn world_ui_cycles_and_surface_nesting_are_rejected_before_publication() { ) .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(); @@ -6188,6 +6223,7 @@ fn world_ui_cycles_and_surface_nesting_are_rejected_before_publication() { /// 同一按钮被两个世界面板共享,两个面板指定不同 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") @@ -6209,6 +6245,7 @@ fn one_ui_object_has_independent_mounts_and_texture_sizes_in_two_world_panels() 1, ) .unwrap(); + // 验证文档有效,再按出现索引核对共享对象的两个 Mount 及其独立宽高。 initial.validate().unwrap(); assert_eq!( session @@ -6245,28 +6282,34 @@ fn one_ui_object_has_independent_mounts_and_texture_sizes_in_two_world_panels() .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, @@ -6280,6 +6323,7 @@ fn one_ui_object_has_independent_mounts_and_texture_sizes_in_two_world_panels() /// 以实际有限窗口约束加载世界 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", @@ -6291,7 +6335,9 @@ fn world_ui_example_runs_with_window_constraints_and_programmable_actions() { }), ) .unwrap(); + // 先验证初始完整文档,再模拟重复原生尺寸通知和空闲任务轮询。 document.validate().unwrap(); + // 更新表与加载视口完全相同,相同尺寸不应让示例持续刷新。 let viewports = std::collections::BTreeMap::from([( crate::window::MAIN_WINDOW, crate::layout::Size { @@ -6299,7 +6345,9 @@ fn world_ui_example_runs_with_window_constraints_and_programmable_actions() { height: 800., }, )]); + // 重复三轮验证空闲稳定性;每轮 resize 与 poll 都应返回 None。 for _ in 0..3 { + // 相同视口必须保持空闲,后续任务轮询也不能凭空产生文档。 assert!( session .resize_windows_incremental(&viewports, true) @@ -6314,6 +6362,7 @@ fn world_ui_example_runs_with_window_constraints_and_programmable_actions() { .is_none() ); } + // 首帧必须实际包含面板内 CanvasViewport,防止示例加载了近似空页面而误通过。 assert!( document .frame @@ -6321,6 +6370,7 @@ fn world_ui_example_runs_with_window_constraints_and_programmable_actions() { .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() @@ -6339,7 +6389,9 @@ fn world_ui_example_runs_with_window_constraints_and_programmable_actions() { 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, @@ -6353,6 +6405,7 @@ fn world_ui_example_runs_with_window_constraints_and_programmable_actions() { /// 同一段落进入纵向/横向滚动容器,禁止轴的最大范围与偏移必须为 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", @@ -6360,44 +6413,54 @@ fn scroll_axes_wrap_shared_text_and_clamp_only_the_enabled_offsets() { 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(); @@ -6414,7 +6477,9 @@ fn scroll_axes_wrap_shared_text_and_clamp_only_the_enabled_offsets() { /// 未指定 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\"))", @@ -6425,6 +6490,7 @@ fn scroll_axis_defaults_to_both_and_rejects_unknown_policy() { session.execute("scroller.axis", "app.main").unwrap().value, "\"both\"" ); + // 独立加载把 axis 写为 diagonal 的已渲染容器,验证布局读取策略时明确拒绝未知值。 let error = ReplSession::load( r#" scroller = ScrollBlock(20, 10, TextBlock("bad")) @@ -6438,6 +6504,7 @@ fn scroll_axis_defaults_to_both_and_rejects_unknown_policy() { ) .err() .expect("invalid axis"); + // 诊断应列出 both/vertical/horizontal 允许集合,不得静默退回默认策略。 assert!( error .to_string() @@ -6450,6 +6517,7 @@ fn scroll_axis_defaults_to_both_and_rejects_unknown_policy() { /// 源 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 { @@ -6465,7 +6533,9 @@ fn scroll_axis_uses_source_allocated_viewport_instead_of_constructor_width() { 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 @@ -6474,6 +6544,7 @@ fn scroll_axis_uses_source_allocated_viewport_instead_of_constructor_width() { .value, "120" ); + // 从文字的具体 Mount 读最终宽度,避免只看到 scroller 改宽却漏掉内容未重测。 let width: f32 = session .execute("text.mounts().at(0).bounds().width", "app.main") .unwrap() @@ -6481,6 +6552,7 @@ fn scroll_axis_uses_source_allocated_viewport_instead_of_constructor_width() { .parse() .unwrap(); assert!(width <= 100.0); + // axis=vertical 必须同时保持 maxX=0,不能因为内容仍按构造宽度展开而产生横向范围。 assert_eq!( session .execute("scroller.mounts().at(0).scrollPosition().maxX", "app.main") @@ -6494,7 +6566,9 @@ fn scroll_axis_uses_source_allocated_viewport_instead_of_constructor_width() { /// 嵌套两个 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#" @@ -6514,10 +6588,13 @@ fn nested_scroll_into_view_and_source_paint_share_clipped_geometry() { .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") @@ -6532,6 +6609,7 @@ fn nested_scroll_into_view_and_source_paint_share_clipped_geometry() { .value, "148" ); + // 新屏幕点 (10,110) 应命中 last,核对偏移状态与最终输入几何一致。 assert_eq!( session .execute("last.mounts().at(0).hitTest(10,110)", "app.main") @@ -6545,7 +6623,9 @@ fn nested_scroll_into_view_and_source_paint_share_clipped_geometry() { /// 直接调用 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") @@ -6555,6 +6635,7 @@ returned = button.dispatch(dispatched)"#, "app.main", ) .unwrap(); + // 先验证 click 返回名义 Event 且 kind=click,再检查显式 dispatch 的对象身份。 assert_eq!( session .execute("ui.isInstance(clicked, Event)", "app.main") @@ -6566,6 +6647,7 @@ returned = button.dispatch(dispatched)"#, session.execute("clicked.kind", "app.main").unwrap().value, "\"click\"" ); + // 直接读 VM 全局字典里的 Value,比 repr 更严格地比较 returned 与 dispatched 是否为同一托管对象。 let globals = session.candidate.globals.unwrap(); assert_eq!( session @@ -6584,12 +6666,15 @@ returned = button.dispatch(dispatched)"#, #[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 在其正常调用栈/预算中处理参数和结果。 @@ -6598,6 +6683,7 @@ impl endor_core::NativeCallable for VisualRedirect { _: &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(), @@ -6609,9 +6695,13 @@ impl endor_core::NativeCallable for VisualRedirect { /// 从同一 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 @@ -6621,32 +6711,42 @@ fn native_visual_identity_accepts_shared_window_registries_only() { 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(); } @@ -6655,6 +6755,7 @@ fn native_visual_identity_accepts_shared_window_registries_only() { /// 替换 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) } @@ -6665,7 +6766,9 @@ fn replacing_base_visual_methods_changes_layout_and_same_identity_paint() { 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 @@ -6673,22 +6776,30 @@ fn replacing_base_visual_methods_changes_layout_and_same_identity_paint() { .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 @@ -6697,6 +6808,7 @@ fn replacing_base_visual_methods_changes_layout_and_same_identity_paint() { .value, "140" ); + // 取得原 Block.paint 的函数对象,接下来刻意保持其 ObjectRef 不变。 let original = session .candidate .vm @@ -6704,6 +6816,7 @@ fn replacing_base_visual_methods_changes_layout_and_same_identity_paint() { .lookup_method(base, "paint") .unwrap() .unwrap(); + // 沿用原 paint 签名,只更换 native payload,检验缓存是否跟踪实现版本而非仅句柄。 let signature = session .candidate .vm @@ -6711,6 +6824,7 @@ fn replacing_base_visual_methods_changes_layout_and_same_identity_paint() { .native_signature(original) .unwrap() .unwrap(); + // 转发器返回 Invoke 到 Hooks.paint,roots 中的 paint 保活目标;原函数身份仍相同。 session .candidate .vm @@ -6726,6 +6840,7 @@ fn replacing_base_visual_methods_changes_layout_and_same_identity_paint() { ), ) .unwrap(); + // 新实现返回空 DisplayList,刷新应发布空 commands,证明没有错误复用旧 intrinsic paint。 let updated = session.refresh_document().unwrap().unwrap(); assert!(updated.frame.commands.is_empty()); } @@ -6734,7 +6849,9 @@ fn replacing_base_visual_methods_changes_layout_and_same_identity_paint() { /// 保持 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#" @@ -6745,7 +6862,9 @@ fn same_identity_event_payload_changes_invalidate_interactivity_cache() { "app.main", ) .unwrap(); + // 从同一全局表取基类、备用 Hooks 与待检查矩形。 let globals = session.candidate.globals.unwrap(); + // 只读对象查询闭包供本测试局部复用,任何非对象目标都应立即失败。 let get = |name: &str| match session .candidate .vm @@ -6753,31 +6872,44 @@ fn same_identity_event_payload_changes_invalidate_interactivity_cache() { .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( @@ -6787,10 +6919,13 @@ fn same_identity_event_payload_changes_invalidate_interactivity_cache() { ), ) .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")