docs: explain world ui, scroll policies and native identity test paths

This commit is contained in:
2026-11-12 17:56:13 +08:00
parent 3ed148c880
commit d9c191ec00
+135
View File
@@ -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::<f32>()
.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<endor_core::CallSignature> {
// 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<endor_core::NativeResult, VmError> {
// 由 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::<crate::window::ManagedWindow>(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")