docs: explain world ui, scroll policies and native identity test paths
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user